diff --git a/app/(pages)/dashboard/buckets/[bucket-name]/page.tsx b/app/(pages)/dashboard/buckets/[bucket-name]/page.tsx deleted file mode 100644 index e63d32a..0000000 --- a/app/(pages)/dashboard/buckets/[bucket-name]/page.tsx +++ /dev/null @@ -1,277 +0,0 @@ -"use client"; - -import React from "react"; -import { Button } from "@/components/ui/button"; -import { Upload, Check, ChevronsUpDown } from "lucide-react"; -import dynamic from "next/dynamic"; - -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { cn } from "@/lib/utils"; -import { S3File, S3Root } from "@/types/S3Objects"; - -type FileType = - | "document" - | "compressed" - | "image" - | "audio" - | "video" - | "unknown" - | "folder"; - -const mockFileData: S3Root = { - folders: [ - { - folders: [], - name: "compressed", - files: [ - { - extension: "rar", - etag: '"dd9bb762731449c0e06a2c869b739db1"', - size: "1.56 MB", - type: "compressed", - name: "lab.rar", - location: "compressed/lab.rar", - lastModified: "2025-03-08T05:15:12Z", - }, - ], - location: "compressed/", - type: "folder", - itemCount: 1, - }, - { - folders: [], - name: "documents", - files: [ - { - extension: "pdf", - etag: '"52e419c0508aaeea9213f513b84e8d73"', - size: "9.17 KB", - type: "document", - name: "rptTimeTableStudent.pdf", - location: "documents/rptTimeTableStudent.pdf", - lastModified: "2025-03-08T05:15:11Z", - }, - ], - location: "documents/", - type: "folder", - itemCount: 1, - }, - { - folders: [], - name: "videos", - files: [ - { - extension: "mp4", - etag: '"447b7f6a669fc87a1e5c1d506474a7cb"', - size: "1.12 MB", - type: "video", - name: "VID_4556446.mp4", - location: "videos/VID_4556446.mp4", - lastModified: "2025-03-08T05:15:12Z", - }, - { - extension: "mp4", - etag: '"4aa28625598a7adfb7ec3fef5e44453b-2"', - size: "31.48 MB", - type: "video", - name: "VID_5468553.mp4", - location: "videos/VID_5468553.mp4", - lastModified: "2025-03-08T05:15:11Z", - }, - ], - location: "videos/", - type: "folder", - itemCount: 2, - }, - ], - name: "root", - files: [ - { - extension: "jpg", - etag: '"8aa445cbeca5119ec77c4e4bb760e12e"', - size: "130.04 KB", - type: "image", - name: "IMG_2135545.jpg", - location: "IMG_2135545.jpg", - lastModified: "2025-03-08T05:15:13Z", - }, - { - extension: "jpg", - etag: '"3ee4b3bb7fe145c77a883db5b201a172"', - size: "93.66 KB", - type: "image", - name: "IMG_2135546.jpg", - location: "IMG_2135546.jpg", - lastModified: "2025-03-08T05:15:13Z", - }, - ], - location: "", - type: "folder", - itemCount: 5, -}; - -const fileTypes = [ - { value: "all", label: "All Files" }, - { value: "document", label: "Documents" }, - { value: "compressed", label: "Compressed" }, - { value: "image", label: "Images" }, - { value: "audio", label: "Audio" }, - { value: "video", label: "Video" }, - { value: "folder", label: "Folders" }, -]; - -const Files = dynamic(() => import("@/components/files-component/files")); -const SearchBar = dynamic(() => import("@/components/bucket-component/search")); -const FilterButtons = dynamic( - () => import("@/components/bucket-component/filter") -); - -export default function Page() { - const [searchTerm, setSearchTerm] = React.useState(""); - const [activeFilter, setActiveFilter] = React.useState< - "size" | "date" | null - >(null); - const [open, setOpen] = React.useState(false); - const [selectedType, setSelectedType] = React.useState("all"); - - const filteredAndSortedData = React.useMemo(() => { - const filterItems = (items: S3File[] | S3Root["folders"]) => { - return items.filter((item) => - item.name.toLowerCase().includes(searchTerm.toLowerCase()) - ); - }; - - let filteredFiles = filterItems(mockFileData.files); - const filteredFolders = filterItems(mockFileData.folders); - - // Apply type filter - if (selectedType !== "all") { - filteredFiles = filteredFiles.filter((file) => file.type === selectedType); - } - - // Apply date or size filter only to files - if (activeFilter === "date") { - filteredFiles = [...filteredFiles].sort((a, b) => { - if ('lastModified' in a && 'lastModified' in b) { - return new Date(b.lastModified).getTime() - new Date(a.lastModified).getTime(); - } - return 0; - }); - } else if (activeFilter === "size") { - const convertToBytes = (size: string) => { - const num = parseFloat(size); - if (size.includes("GB")) return num * 1024 * 1024 * 1024; - if (size.includes("MB")) return num * 1024 * 1024; - if (size.includes("KB")) return num * 1024; - return num; - }; - filteredFiles = [...filteredFiles].sort((a, b) => { - if ('size' in a && 'size' in b) { - return convertToBytes(b.size) - convertToBytes(a.size); - } - return 0; - }); - } - - return { - files: filteredFiles, - folders: filteredFolders, - }; - }, [searchTerm, activeFilter, selectedType]); - - - - return ( -
-
-
- -
-
- - - - - - - - - - - No results found. - - - {fileTypes.map((type) => ( - { - setSelectedType(currentValue); - setOpen(false); - }} - className="dark:text-gray-100 dark:hover:bg-gray-700" - > - {type.label} - - - ))} - - - - - - -
-
-
- {filteredAndSortedData.folders.map((folder) => ( - - ))} - {filteredAndSortedData.files.map((file) => ( - - ))} -
-
- ); -} diff --git a/app/(pages)/dashboard/buckets/[bucketName]/page.tsx b/app/(pages)/dashboard/buckets/[bucketName]/page.tsx new file mode 100644 index 0000000..37ed119 --- /dev/null +++ b/app/(pages)/dashboard/buckets/[bucketName]/page.tsx @@ -0,0 +1,213 @@ +"use client"; + +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Upload } from "lucide-react"; +import dynamic from "next/dynamic"; +import { S3File, S3Folder, S3Root } from "@/types/S3Objects"; +import Breadcrumb from "@/components/bucket-component/breadcrumb"; +import LoadingSkeleton from "@/components/bucket-component/loading-skeleton"; +import FileTypeFilter from "@/components/bucket-component/file-type-filter"; +import { s3Service } from "@/services/s3.service"; + +type FileType = + | "document" + | "compressed" + | "image" + | "audio" + | "video" + | "unknown" + | "folder"; + +const Files = dynamic(() => import("@/components/files-component/files")); +const SearchBar = dynamic(() => import("@/components/bucket-component/search")); +const FilterButtons = dynamic( + () => import("@/components/bucket-component/filter") +); + +interface PageProps { + params: Promise<{ + bucketName: string; + }>; +} + +interface FileData { + readonly name: string; + readonly itemCount?: number; + readonly size?: string; + readonly extension?: string; + readonly key?: string; +} + +export default function Page({ params }: PageProps) { + const resolvedParams = React.use(params); + const [searchTerm, setSearchTerm] = React.useState(""); + const [activeFilter, setActiveFilter] = React.useState< + "size" | "date" | null + >(null); + const [selectedType, setSelectedType] = React.useState("all"); + const [currentPath, setCurrentPath] = React.useState(""); + const [isLoading, setIsLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [fileData, setFileData] = React.useState(null); + + const fetchFiles = React.useCallback(async () => { + try { + setIsLoading(true); + setError(null); + + const response = await s3Service.listObjects({ + bucketName: resolvedParams.bucketName, + objectPrefix: currentPath, + }); + + setFileData(response.data); + } catch (err) { + if (err instanceof Error) { + console.error("Error fetching files:", err); + setError(err.message || "Failed to fetch files"); + } + } finally { + setIsLoading(false); + } + }, [resolvedParams.bucketName, currentPath]); + + React.useEffect(() => { + fetchFiles(); + }, [fetchFiles]); + + const handleFolderClick = (folder: S3Folder) => { + setCurrentPath(folder.key); + }; + + const filteredAndSortedData = React.useMemo(() => { + if (!fileData) return { files: [], folders: [] }; + + const filterFiles = (files: S3File[] = []) => { + return files.filter((file) => { + const fileName = file.key.split("/").pop() || ""; + return fileName.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }; + + const filterFolders = (folders: S3Folder[] = []) => { + return folders.filter((folder) => { + const folderName = folder.key.replace(/\/$/, ""); + return folderName.toLowerCase().includes(searchTerm.toLowerCase()); + }); + }; + + let filteredFiles = filterFiles(fileData.files || []); + const filteredFolders = filterFolders(fileData.folders || []); + + if (selectedType !== "all") { + filteredFiles = filteredFiles.filter( + (file) => file.fileType.toLowerCase() === selectedType + ); + } + + if (activeFilter === "date") { + filteredFiles = [...filteredFiles].sort((a, b) => { + return ( + new Date(b.lastModified).getTime() - + new Date(a.lastModified).getTime() + ); + }); + } else if (activeFilter === "size") { + const convertToBytes = (size: string) => { + const num = parseFloat(size); + if (size.includes("GB")) return num * 1024 * 1024 * 1024; + if (size.includes("MB")) return num * 1024 * 1024; + if (size.includes("KB")) return num * 1024; + return num; + }; + filteredFiles = [...filteredFiles].sort((a, b) => { + return convertToBytes(b.size) - convertToBytes(a.size); + }); + } + + return { + files: filteredFiles, + folders: filteredFolders, + }; + }, [fileData, searchTerm, activeFilter, selectedType]); + + const mapS3FileToFileData = (file: S3File): FileData => ({ + name: file.key.split("/").pop() || "", + size: file.size, + extension: file.extension, + key: file.key, + }); + + const mapS3FolderToFileData = (folder: S3Folder): FileData => ({ + name: folder.key.replace(/\/$/, ""), + itemCount: 0, + key: folder.key, + }); + + if (error) { + return ( +
+
Error: {error}
+
+ ); + } + + return ( +
+
+
+ +
+
+ + + +
+
+ + + +
+ {isLoading ? ( + + ) : ( +
+ {filteredAndSortedData.folders.map((folder) => ( +
+ handleFolderClick(folder)} + /> +
+ ))} + {filteredAndSortedData.files.map((file) => ( +
+ +
+ ))} +
+ )} +
+
+ ); +} diff --git a/app/(pages)/dashboard/buckets/page.tsx b/app/(pages)/dashboard/buckets/page.tsx index dff3b7f..d37cb88 100644 --- a/app/(pages)/dashboard/buckets/page.tsx +++ b/app/(pages)/dashboard/buckets/page.tsx @@ -1,36 +1,24 @@ "use client"; -import { useState, useMemo } from "react"; -import dynamic from "next/dynamic"; -import { CreateBucket } from "@/components/bucket-component/create-bkt"; +import { useState } from "react"; import useBuckets from "@/hooks/useBuckets"; +import { useBucketFiltering } from "@/hooks/useBucketFiltering"; +import dynamic from "next/dynamic"; -// Dynamically imported components with loading placeholders -const SearchBar = dynamic( - () => import("@/components/bucket-component/search"), - { - loading: () => ( -
- ), - } -); -const FilterButtons = dynamic( - () => import("@/components/bucket-component/filter"), - { - loading: () =>
, - } +const BucketToolbar = dynamic(() => + import("@/components/bucket-component/bucket-toolbar").then( + (mod) => mod.default + ) ); -const BucketCard = dynamic( - () => import("@/components/bucket-component/bucket-card"), - { - loading: () =>
, - } + +const BucketGrid = dynamic(() => + import("@/components/bucket-component/bucket-grid").then((mod) => mod.default) ); -const SelectRegions = dynamic( - () => import("@/components/bucket-component/regions"), - { - loading: () =>
, - } + +const BucketGridSkeleton = dynamic(() => + import("@/components/bucket-component/bucket-grid-skeleton").then( + (mod) => mod.default + ) ); export default function Page() { @@ -40,69 +28,36 @@ export default function Page() { ); const [searchTerm, setSearchTerm] = useState(""); - // Compute the displayed buckets based on the search term and active filter - const displayedBuckets = useMemo(() => { - let data = [...buckets]; + const filteredBuckets = useBucketFiltering({ + buckets, + searchTerm, + activeFilter, + }); - if (searchTerm) { - data = data.filter((bucket) => - bucket.bucketName.toLowerCase().includes(searchTerm.toLowerCase()) - ); - } - if (activeFilter === "size") { - data = data.sort((a, b) => { - const sizeToBytes = (size: string) => { - const [value, unit] = size.split(" "); - const numValue = parseFloat(value); - switch (unit?.toUpperCase()) { - case "KB": - return numValue * 1024; - case "MB": - return numValue * 1024 * 1024; - case "GB": - return numValue * 1024 * 1024 * 1024; - case "TB": - return numValue * 1024 * 1024 * 1024 * 1024; - default: - return numValue; - } - }; - return sizeToBytes(b.size || "0") - sizeToBytes(a.size || "0"); - }); - } else if (activeFilter === "date") { - data = data.sort( - (a, b) => - new Date(b.createdOn).getTime() - new Date(a.createdOn).getTime() - ); - } - return data; - }, [buckets, searchTerm, activeFilter]); + if (isError) { + return ( +
+
+ Error: {isError} +
+
+ ); + } return (
-
- - - - -
+ + {isLoading ? ( -
- {[...Array(4)].map((_, index) => ( -
- ))} -
- ) : isError ? ( -

{isError}

+ ) : ( -
- {displayedBuckets.map((bucket) => ( - - ))} -
+ )}
); diff --git a/app/(pages)/login/components/login-form.tsx b/app/(pages)/login/components/login-form.tsx index 9775cd2..a9f3052 100644 --- a/app/(pages)/login/components/login-form.tsx +++ b/app/(pages)/login/components/login-form.tsx @@ -9,12 +9,20 @@ import { useRouter } from "next/navigation"; import { toast } from "sonner"; import SpinnerIcon from "@/icons/spinner-icon"; import Redirecting from "@/skeleton/redirecting"; +import { useAuth } from "@/hooks/useAuth"; -export default function LoginForm({ - className, - ...props -}: React.ComponentPropsWithoutRef<"form">) { - const [credentials, setCredentials] = useState({ +interface LoginCredentials { + accessKey: string; + secretKey: string; + region: string; +} + +interface LoginFormProps extends React.ComponentPropsWithoutRef<"form"> { + className?: string; +} + +export default function LoginForm({ className, ...props }: LoginFormProps) { + const [credentials, setCredentials] = useState({ accessKey: "", secretKey: "", region: "ap-south-1", @@ -22,37 +30,31 @@ export default function LoginForm({ const [loading, setLoading] = useState(false); const [isRedirecting, setIsRedirecting] = useState(false); const router = useRouter(); + const { login } = useAuth(); + + const handleInputChange = + (field: keyof LoginCredentials) => + (e: React.ChangeEvent) => { + setCredentials((prev) => ({ + ...prev, + [field]: e.target.value, + })); + }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); try { - const response = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/login`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(credentials), - } - ); - - const data = await response.json(); - - if (response.ok && data.status === "SUCCESS" && data.data.sessionToken) { - document.cookie = `sessionToken=${data.data.sessionToken}; path=/; Secure; SameSite=Strict`; - toast.success("Login successful!"); + const success = await login(credentials); + if (success) { setIsRedirecting(true); setTimeout(() => { router.push("/dashboard/buckets"); }, 200); - } else { - toast.error(data.message || "Invalid credentials"); } } catch (error) { - console.error("Error:", error); + console.error("Login error:", error); toast.error("Failed to validate credentials"); } finally { setLoading(false); @@ -88,12 +90,7 @@ export default function LoginForm({ maxLength={264} value={credentials.accessKey} className="dark:bg-gray-800 dark:text-white dark:border-gray-700 h-12" - onChange={(e) => - setCredentials((prev) => ({ - ...prev, - accessKey: e.target.value, - })) - } + onChange={handleInputChange("accessKey")} />
@@ -109,12 +106,7 @@ export default function LoginForm({ disabled={loading} value={credentials.secretKey} className="dark:bg-gray-800 dark:text-white dark:border-gray-700 h-12" - onChange={(e) => - setCredentials((prev) => ({ - ...prev, - secretKey: e.target.value, - })) - } + onChange={handleInputChange("secretKey")} />
+ {pathSegments.map((segment, index) => { + const path = pathSegments.slice(0, index + 1).join("/") + "/"; + const isLast = index === pathSegments.length - 1; + + return ( +
+ + +
+ ); + })} +
+ ); +} diff --git a/components/bucket-component/bucket-grid-skeleton.tsx b/components/bucket-component/bucket-grid-skeleton.tsx new file mode 100644 index 0000000..84a9d2b --- /dev/null +++ b/components/bucket-component/bucket-grid-skeleton.tsx @@ -0,0 +1,69 @@ +import { cn } from "@/lib/utils"; + +interface BucketGridSkeletonProps { + count?: number; + className?: string; + columnCount?: { + sm?: number; + md?: number; + lg?: number; + xl?: number; + "2xl"?: number; + }; +} + +export default function BucketGridSkeleton({ + count = 4, + className, + columnCount = { + sm: 2, + md: 2, + lg: 3, + xl: 4, + "2xl": 5, + }, +}: BucketGridSkeletonProps) { + // Generate dynamic grid columns class + const gridColsClass = Object.entries(columnCount) + .map(([breakpoint, count]) => { + if (breakpoint === "sm") return `sm:grid-cols-${count}`; + if (breakpoint === "md") return `md:grid-cols-${count}`; + if (breakpoint === "lg") return `lg:grid-cols-${count}`; + if (breakpoint === "xl") return `xl:grid-cols-${count}`; + if (breakpoint === "2xl") return `2xl:grid-cols-${count}`; + return ""; + }) + .filter(Boolean) + .join(" "); + + return ( +
+ {[...Array(count)].map((_, index) => ( +
+ {/* Menu button skeleton */} +
+
+
+ +
+ {/* Region and size badges skeleton */} +
+
+
+
+
+ + {/* Bucket name skeleton */} +
+ + {/* Creation date skeleton */} +
+
+
+ ))} +
+ ); +} diff --git a/components/bucket-component/bucket-grid.tsx b/components/bucket-component/bucket-grid.tsx new file mode 100644 index 0000000..63e0062 --- /dev/null +++ b/components/bucket-component/bucket-grid.tsx @@ -0,0 +1,33 @@ +import { Bucket } from "@/types/bucket"; +import dynamic from "next/dynamic"; + +const BucketCard = dynamic( + () => import("@/components/bucket-component/bucket-card"), + { + loading: () => ( +
+ ), + } +); + +interface BucketGridProps { + buckets: Bucket[]; + onBucketDelete: () => void; +} + +export default function BucketGrid({ + buckets, + onBucketDelete, +}: BucketGridProps) { + return ( +
+ {buckets.map((bucket) => ( + + ))} +
+ ); +} diff --git a/components/bucket-component/bucket-toolbar.tsx b/components/bucket-component/bucket-toolbar.tsx new file mode 100644 index 0000000..8f59d13 --- /dev/null +++ b/components/bucket-component/bucket-toolbar.tsx @@ -0,0 +1,61 @@ +import dynamic from "next/dynamic"; +import { CreateBucket } from "@/components/bucket-component/create-bkt"; + +const SearchBar = dynamic( + () => import("@/components/bucket-component/search"), + { + loading: () => ( +
+ ), + } +); + +const FilterButtons = dynamic( + () => import("@/components/bucket-component/filter"), + { + loading: () => ( +
+ ), + } +); + +const SelectRegions = dynamic( + () => import("@/components/bucket-component/regions"), + { + loading: () => ( +
+ ), + } +); + +interface BucketToolbarProps { + searchTerm: string; + setSearchTerm: (term: string) => void; + activeFilter: "size" | "date" | null; + setActiveFilter: (filter: "size" | "date" | null) => void; + onBucketCreated: () => void; +} + +export default function BucketToolbar({ + searchTerm, + setSearchTerm, + activeFilter, + setActiveFilter, + onBucketCreated, +}: BucketToolbarProps) { + return ( +
+
+ +
+
+ + + +
+
+ ); +} diff --git a/components/bucket-component/file-type-filter.tsx b/components/bucket-component/file-type-filter.tsx new file mode 100644 index 0000000..c0d442c --- /dev/null +++ b/components/bucket-component/file-type-filter.tsx @@ -0,0 +1,83 @@ +import React from "react"; +import { Button } from "@/components/ui/button"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; + +const fileTypes = [ + { value: "all", label: "All Files" }, + { value: "document", label: "Documents" }, + { value: "compressed", label: "Compressed" }, + { value: "image", label: "Images" }, + { value: "audio", label: "Audio" }, + { value: "video", label: "Video" }, + { value: "folder", label: "Folders" }, +]; + +interface FileTypeFilterProps { + selectedType: string; + setSelectedType: (type: string) => void; +} + +export default function FileTypeFilter({ + selectedType, + setSelectedType, +}: FileTypeFilterProps) { + const [open, setOpen] = React.useState(false); + + return ( + + + + + + + + + No results found. + + {fileTypes.map((type) => ( + { + setSelectedType(currentValue); + setOpen(false); + }} + > + {type.label} + + + ))} + + + + + + ); +} diff --git a/components/bucket-component/loading-skeleton.tsx b/components/bucket-component/loading-skeleton.tsx new file mode 100644 index 0000000..55c737f --- /dev/null +++ b/components/bucket-component/loading-skeleton.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +export default function LoadingSkeleton() { + return ( +
+ {[...Array(4)].map((_, index) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ); +} diff --git a/components/files-component/files.tsx b/components/files-component/files.tsx index a3706d6..01009e9 100644 --- a/components/files-component/files.tsx +++ b/components/files-component/files.tsx @@ -1,4 +1,3 @@ -import Link from "next/link"; import React from "react"; import { ContextMenu, @@ -9,144 +8,194 @@ import { ContextMenuTrigger, } from "@/components/ui/context-menu"; import { Card, CardContent } from "@/components/ui/card"; +import { + FileText, + FileArchive, + FileImage, + FileAudio, + FileVideo, + Folder, + FileQuestion, +} from "lucide-react"; +import dynamic from "next/dynamic"; +import { s3Service } from "@/services/s3.service"; +import Cookies from "js-cookie"; + +const MediaPreview = dynamic(() => import("./media-preview")); interface FileData { - name: string; - itemCount?: number; - size?: string; - extension?: string; + readonly name: string; + readonly itemCount?: number; + readonly size?: string; + readonly extension?: string; + readonly key?: string; } interface FileProps { - type: "document" | "compressed" | "image" | "audio" | "video" | "folder" | "unknown"; - data: FileData; + readonly type: + | "document" + | "compressed" + | "image" + | "audio" + | "video" + | "folder" + | "unknown"; + readonly data: FileData; + readonly onClick?: () => void; + readonly icon?: React.ElementType; + readonly bucketName?: string; } -export default function Files({ type, data }: FileProps) { - const getIconProperties = () => { +export default function Files({ + type, + data, + onClick, + icon: Icon, + bucketName, +}: FileProps) { + const [isPreviewOpen, setIsPreviewOpen] = React.useState(false); + const [previewUrl, setPreviewUrl] = React.useState(null); + const [error, setError] = React.useState(null); + + const getIconColor = () => { + return "text-gray-700 dark:text-gray-300"; + }; + + const getDefaultIcon = () => { switch (type) { case "document": - return { - color: "text-blue-500 dark:text-blue-400", - path: "M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z", - }; + return FileText; case "compressed": - return { - color: "text-green-500 dark:text-green-400", - path: "M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8M10 12h4", - }; + return FileArchive; case "image": - return { - color: "text-purple-500 dark:text-purple-400", - path: "M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z", - }; + return FileImage; case "audio": - return { - color: "text-red-500 dark:text-red-400", - path: "M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3", - }; + return FileAudio; case "video": - return { - color: "text-yellow-500 dark:text-yellow-400", - path: "M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z", - }; + return FileVideo; case "folder": - return { - color: "text-orange-500 dark:text-orange-400", - path: "M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z", - }; + return Folder; + default: + return FileQuestion; + } + }; + + const handleClick = async () => { + if (type === "folder" && onClick) { + onClick(); + return; + } + + if ((type === "image" || type === "video") && bucketName && data.key) { + try { + const sessionToken = Cookies.get("sessionToken"); + if (!sessionToken) { + throw new Error("No session token found. Please log in again."); + } + + console.log("Fetching presigned URL for:", { + bucketName, + objectKey: data.key, + type, + hasSessionToken: !!sessionToken, + }); + + const url = await s3Service.getPresignedUrl(bucketName, data.key); + + if (!url) { + throw new Error("No URL received from presigned URL request"); + } + + setPreviewUrl(url); + setIsPreviewOpen(true); + setError(null); + } catch (error) { + console.error("Error getting presigned URL:", error); + setError( + error instanceof Error ? error.message : "Failed to load preview" + ); + } + } else { + console.log("Click conditions not met:", { + type, + bucketName, + hasKey: !!data.key, + data, + }); } }; - const icon = getIconProperties(); + const IconComponent = Icon || getDefaultIcon(); + const iconColor = getIconColor(); return ( - - - - -
- - + + + + +
+ - -
-
-
- 13 ? "group-hover:animate-marquee" : "" - } inline-block`} - > - {data.name} -
-

- {(data.extension ?? "").toUpperCase()}{" "} - {type === "folder" - ? `${data.itemCount} items` - : `• ${data.size}`} -

-
- - - - - - Open - ⌘O - - - Download - ⌘D - - - Share - ⌘S - - - - Copy URL - ⌘C - - - Rename - ⌘R - - - - Delete - - - - +
+
+ 13 ? "group-hover:animate-marquee" : "" + } inline-block`} + > + {data.name} + +
+ {data.size && ( +

+ {data.size} +

+ )} + {data.itemCount !== undefined && ( +

+ {data.itemCount} items +

+ )} +
+ + + + + + Download + ⌘D + + + Share + ⌘S + + + + Delete + ⌘⌫ + + + + + {previewUrl && isPreviewOpen && ( + setIsPreviewOpen(false)} + title={data.name} + mediaUrl={previewUrl} + type={type} + /> + )} + {error && ( +
+ {error} +
+ )} + ); } diff --git a/components/files-component/media-preview.tsx b/components/files-component/media-preview.tsx new file mode 100644 index 0000000..2f20ee9 --- /dev/null +++ b/components/files-component/media-preview.tsx @@ -0,0 +1,373 @@ +import React, { + useEffect, + useState, + MouseEvent, + WheelEvent, + useRef, + useCallback, +} from "react"; +import Image from "next/image"; +import { + X, + Download, + ZoomIn, + ZoomOut, + RotateCw, + ChevronLeft, + ChevronRight, +} from "lucide-react"; + +interface MediaPreviewProps { + readonly onClose: () => void; + readonly title: string; + readonly mediaUrl: string; + readonly type: + | "document" + | "compressed" + | "image" + | "audio" + | "video" + | "folder" + | "unknown"; + readonly onNext?: () => void; + readonly onPrev?: () => void; + readonly hasNext?: boolean; + readonly hasPrev?: boolean; +} + +interface Position { + x: number; + y: number; +} + +interface ToolbarProps { + readonly title: string; + readonly onClose: () => void; + readonly onZoomIn: () => void; + readonly onZoomOut: () => void; + readonly onRotate: () => void; + readonly onDownload: () => void; + readonly scale: number; +} + +function Toolbar({ + title, + onClose, + onZoomIn, + onZoomOut, + onRotate, + onDownload, + scale, +}: ToolbarProps) { + return ( +
+
+ +
+

+ {title} +

+
+
+ + + +
+ +
+
+ ); +} + +export default function MediaPreview({ + onClose, + title, + mediaUrl, + type, + onNext, + onPrev, + hasNext = false, + hasPrev = false, +}: MediaPreviewProps) { + const [state, setState] = useState({ + isLoading: true, + scale: 1, + rotation: 0, + position: { x: 0, y: 0 } as Position, + isDragging: false, + dragStart: { x: 0, y: 0 } as Position, + error: null as string | null, + }); + + const containerRef = useRef(null); + const imageRef = useRef(null); + + const getBoundedPosition = useCallback( + (newX: number, newY: number): Position => { + if (!containerRef.current || !imageRef.current) + return { x: newX, y: newY }; + + const container = containerRef.current.getBoundingClientRect(); + const image = imageRef.current.getBoundingClientRect(); + const scaledWidth = image.width * state.scale; + const scaledHeight = image.height * state.scale; + const maxX = Math.max(0, (scaledWidth - container.width) / 2); + const maxY = Math.max(0, (scaledHeight - container.height) / 2); + + return { + x: Math.min(Math.max(newX, -maxX), maxX), + y: Math.min(Math.max(newY, -maxY), maxY), + }; + }, + [state.scale] + ); + + const handleZoom = useCallback( + (delta: number) => { + setState((prev) => { + const newScale = Math.min(Math.max(prev.scale + delta, 0.5), 2); + const deltaScale = newScale - prev.scale; + const newPos = { + x: prev.position.x - prev.position.x * deltaScale, + y: prev.position.y - prev.position.y * deltaScale, + }; + const boundedPos = getBoundedPosition(newPos.x, newPos.y); + return { ...prev, scale: newScale, position: boundedPos }; + }); + }, + [getBoundedPosition] + ); + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + handleZoom(e.deltaY < 0 ? 0.25 : -0.25); + }; + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + setState((prev) => ({ + ...prev, + isDragging: true, + dragStart: { + x: e.clientX - prev.position.x, + y: e.clientY - prev.position.y, + }, + })); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!state.isDragging) return; + e.preventDefault(); + + requestAnimationFrame(() => { + const newX = e.clientX - state.dragStart.x; + const newY = e.clientY - state.dragStart.y; + const boundedPosition = getBoundedPosition(newX, newY); + setState((prev) => ({ ...prev, position: boundedPosition })); + }); + }; + + const handleMouseUp = () => { + setState((prev) => ({ ...prev, isDragging: false })); + }; + + useEffect(() => { + setState((prev) => ({ + ...prev, + position: { x: 0, y: 0 }, + scale: 1, + rotation: 0, + isLoading: true, + error: null, + })); + }, [mediaUrl]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowRight" && hasNext && onNext) onNext(); + else if (e.key === "ArrowLeft" && hasPrev && onPrev) onPrev(); + else if ((e.key === "+" || e.key === "=") && state.scale < 2) + handleZoom(0.25); + else if (e.key === "-" && state.scale > 0.5) handleZoom(-0.25); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onNext, onPrev, hasNext, hasPrev, state.scale, handleZoom]); + + const handleError = (error: string) => { + setState((prev) => ({ ...prev, isLoading: false, error })); + }; + + const renderMedia = () => { + switch (type) { + case "image": + return ( + {title} setState((prev) => ({ ...prev, isLoading: false }))} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onMouseLeave={handleMouseUp} + onWheel={handleWheel} + onError={() => handleError("Failed to load image")} + unoptimized + /> + ); + case "video": + return ( + + ); + case "audio": + return ( + + ); + default: + return ( +
+

Preview not available for this file type

+
+ ); + } + }; + + return ( +
+ handleZoom(0.25)} + onZoomOut={() => handleZoom(-0.25)} + onRotate={() => + setState((prev) => ({ + ...prev, + rotation: (prev.rotation + 90) % 360, + position: { x: 0, y: 0 }, + })) + } + onDownload={() => window.open(mediaUrl, "_blank")} + scale={state.scale} + /> + + +
+ ) : ( + renderMedia() + )} +
+ + + {hasPrev && ( + + )} + {hasNext && ( + + )} +
+ ); +} diff --git a/components/files-component/media-preview/image-viewer.tsx b/components/files-component/media-preview/image-viewer.tsx new file mode 100644 index 0000000..ebb3d81 --- /dev/null +++ b/components/files-component/media-preview/image-viewer.tsx @@ -0,0 +1,76 @@ +import Image from "next/image"; +import React, { useRef, MouseEvent, WheelEvent } from "react"; + +interface Position { + x: number; + y: number; +} + +interface ImageViewerProps { + readonly title: string; + readonly mediaUrl: string; + readonly isLoading: boolean; + readonly scale: number; + readonly rotation: number; + readonly position: Position; + readonly isDragging: boolean; + onLoad: () => void; + readonly onMouseDown: (e: MouseEvent) => void; + readonly onMouseMove: (e: MouseEvent) => void; + readonly onMouseUp: () => void; + readonly onWheel: (e: WheelEvent) => void; +} + +export function ImageViewer({ + title, + mediaUrl, + isLoading, + scale, + rotation, + position, + isDragging, + onLoad, + onMouseDown, + onMouseMove, + onMouseUp, + onWheel, +}: ImageViewerProps) { + const containerRef = useRef(null); + const imageRef = useRef(null); + + return ( + + ); +} diff --git a/components/files-component/media-preview/index.tsx b/components/files-component/media-preview/index.tsx new file mode 100644 index 0000000..3bd63cb --- /dev/null +++ b/components/files-component/media-preview/index.tsx @@ -0,0 +1,170 @@ +import React, { + useEffect, + useState, + MouseEvent, + WheelEvent, + useCallback, +} from "react"; +import dynamic from "next/dynamic"; +import { MediaPreviewProps, PreviewState } from "../../../types/media"; + +// Dynamically import components +const Toolbar = dynamic(() => + import("./toolbar").then((mod) => ({ default: mod.Toolbar })) +); +const Navigation = dynamic(() => + import("./navigation").then((mod) => ({ default: mod.Navigation })) +); +const ImageViewer = dynamic(() => + import("./image-viewer").then((mod) => ({ default: mod.ImageViewer })) +); + +export default function MediaPreview({ + onClose, + title, + mediaUrl, + onNext, + onPrev, + hasNext = false, + hasPrev = false, +}: MediaPreviewProps) { + const [state, setState] = useState({ + isLoading: true, + scale: 1, + rotation: 0, + position: { x: 0, y: 0 }, + isDragging: false, + dragStart: { x: 0, y: 0 }, + }); + + const getBoundedPosition = (newX: number, newY: number) => { + const container = document.querySelector(".image-container"); + const image = document.querySelector(".preview-image"); + if (!container || !image) return { x: newX, y: newY }; + + const containerRect = container.getBoundingClientRect(); + const imageRect = image.getBoundingClientRect(); + const scaledWidth = imageRect.width * state.scale; + const scaledHeight = imageRect.height * state.scale; + const maxX = Math.max(0, (scaledWidth - containerRect.width) / 2); + const maxY = Math.max(0, (scaledHeight - containerRect.height) / 2); + + return { + x: Math.min(Math.max(newX, -maxX), maxX), + y: Math.min(Math.max(newY, -maxY), maxY), + }; + }; + + const handleZoom = useCallback( + (delta: number) => { + setState((prev) => { + const newScale = Math.min(Math.max(prev.scale + delta, 0.5), 2); + const deltaScale = newScale - prev.scale; + const newPos = { + x: prev.position.x - prev.position.x * deltaScale, + y: prev.position.y - prev.position.y * deltaScale, + }; + const boundedPos = getBoundedPosition(newPos.x, newPos.y); + return { ...prev, scale: newScale, position: boundedPos }; + }); + }, + [getBoundedPosition] + ); + + const handleWheel = (e: WheelEvent) => { + e.preventDefault(); + handleZoom(e.deltaY < 0 ? 0.25 : -0.25); + }; + + const handleMouseDown = (e: MouseEvent) => { + e.preventDefault(); + setState((prev) => ({ + ...prev, + isDragging: true, + dragStart: { + x: e.clientX - prev.position.x, + y: e.clientY - prev.position.y, + }, + })); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (!state.isDragging) return; + e.preventDefault(); + + requestAnimationFrame(() => { + const newX = e.clientX - state.dragStart.x; + const newY = e.clientY - state.dragStart.y; + const boundedPosition = getBoundedPosition(newX, newY); + setState((prev) => ({ ...prev, position: boundedPosition })); + }); + }; + + const handleMouseUp = () => { + setState((prev) => ({ ...prev, isDragging: false })); + }; + + useEffect(() => { + setState((prev) => ({ + ...prev, + position: { x: 0, y: 0 }, + scale: 1, + rotation: 0, + })); + }, [mediaUrl]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "ArrowRight" && hasNext && onNext) onNext(); + else if (e.key === "ArrowLeft" && hasPrev && onPrev) onPrev(); + else if ((e.key === "+" || e.key === "=") && state.scale < 2) + handleZoom(0.25); + else if (e.key === "-" && state.scale > 0.5) handleZoom(-0.25); + }; + + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [onNext, onPrev, hasNext, hasPrev, state.scale, handleZoom]); + + return ( +
+ handleZoom(0.25)} + onZoomOut={() => handleZoom(-0.25)} + onRotate={() => + setState((prev) => ({ + ...prev, + rotation: (prev.rotation + 90) % 360, + position: { x: 0, y: 0 }, + })) + } + onDownload={() => window.open(mediaUrl, "_blank")} + scale={state.scale} + /> + + setState((prev) => ({ ...prev, isLoading: false }))} + onMouseDown={handleMouseDown} + onMouseMove={handleMouseMove} + onMouseUp={handleMouseUp} + onWheel={handleWheel} + /> + + +
+ ); +} diff --git a/components/files-component/media-preview/navigation.tsx b/components/files-component/media-preview/navigation.tsx new file mode 100644 index 0000000..c76fad1 --- /dev/null +++ b/components/files-component/media-preview/navigation.tsx @@ -0,0 +1,39 @@ +import React from "react"; +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface NavigationProps { + readonly onNext?: () => void; + readonly onPrev?: () => void; + readonly hasNext?: boolean; + readonly hasPrev?: boolean; +} + +export function Navigation({ + onNext, + onPrev, + hasNext = false, + hasPrev = false, +}: NavigationProps) { + return ( + <> + {hasPrev && ( + + )} + {hasNext && ( + + )} + + ); +} diff --git a/components/files-component/media-preview/toolbar.tsx b/components/files-component/media-preview/toolbar.tsx new file mode 100644 index 0000000..1e48974 --- /dev/null +++ b/components/files-component/media-preview/toolbar.tsx @@ -0,0 +1,74 @@ +import React from "react"; +import { X, Download, ZoomIn, ZoomOut, RotateCw } from "lucide-react"; + +interface ToolbarProps { + readonly title: string; + readonly onClose: () => void; + readonly onZoomIn: () => void; + readonly onZoomOut: () => void; + readonly onRotate: () => void; + readonly onDownload: () => void; + readonly scale: number; +} + +export function Toolbar({ + title, + onClose, + onZoomIn, + onZoomOut, + onRotate, + onDownload, + scale, +}: ToolbarProps) { + return ( +
+
+ +
+

+ {title} +

+
+
+ + + +
+ +
+
+ ); +} diff --git a/components/files.tsx b/components/files.tsx new file mode 100644 index 0000000..808a4ed --- /dev/null +++ b/components/files.tsx @@ -0,0 +1,150 @@ +import React, { useState, useEffect } from "react"; +import { s3Service } from "@/services/s3.service"; +import { S3File, S3Folder } from "@/types/S3Objects"; +import MediaPreview from "./files-component/media-preview"; + +interface FilesProps { + bucketName: string; + prefix?: string; +} + +export const Files: React.FC = ({ bucketName, prefix = "" }) => { + const [files, setFiles] = useState([]); + const [folders, setFolders] = useState([]); + const [selectedFile, setSelectedFile] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + + useEffect(() => { + const fetchObjects = async () => { + try { + setIsLoading(true); + setError(null); + const response = await s3Service.listObjects({ + bucketName, + objectPrefix: prefix, + }); + setFiles(response.data.files); + setFolders(response.data.folders); + } catch (err) { + console.error("Error fetching objects:", err); + setError(err instanceof Error ? err.message : "Failed to load files"); + } finally { + setIsLoading(false); + } + }; + + fetchObjects(); + }, [bucketName, prefix]); + + const handleFileClick = async (file: S3File) => { + try { + setSelectedFile(file); + const url = await s3Service.getPresignedUrl(bucketName, file.key); + setPreviewUrl(url); + } catch (err) { + console.error("Error getting presigned URL:", err); + setError(err instanceof Error ? err.message : "Failed to load preview"); + } + }; + + const getFileType = ( + key: string + ): + | "document" + | "compressed" + | "image" + | "audio" + | "video" + | "folder" + | "unknown" => { + if (key.endsWith("/")) return "folder"; + const ext = key.split(".").pop()?.toLowerCase(); + if (["jpg", "jpeg", "png", "gif", "webp"].includes(ext ?? "")) + return "image"; + if (["mp4", "webm", "ogg"].includes(ext ?? "")) return "video"; + if (["mp3", "wav", "ogg"].includes(ext ?? "")) return "audio"; + if (["zip", "rar", "7z"].includes(ext ?? "")) return "compressed"; + if (["pdf", "doc", "docx", "txt"].includes(ext ?? "")) return "document"; + return "unknown"; + }; + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (error) { + return ( +
+

Error: {error}

+
+ ); + } + + return ( +
+
+

Files

+
+ {folders.map((folder) => ( + + ))} + {files.map((file) => ( + + ))} +
+
+
+ {selectedFile && previewUrl ? ( +
+

{selectedFile.key}

+
+ setPreviewUrl(null)} + title={selectedFile.key} + mediaUrl={previewUrl} + type={getFileType(selectedFile.key)} + /> +
+
+ ) : ( +
Select a file to preview
+ )} +
+
+ ); +}; diff --git a/components/health-check.tsx b/components/health-check.tsx index 468fa13..e8ef648 100644 --- a/components/health-check.tsx +++ b/components/health-check.tsx @@ -2,14 +2,59 @@ import { cn } from "@/lib/utils"; import { healthCheck } from "@/services/bucketService"; import React, { useEffect, useState } from "react"; +import { + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, +} from "@/components/ui/sidebar"; -interface HealthCheckProps { - className?: string; -} - -export default function HealthCheck({ className = "" }: HealthCheckProps) { +export default function HealthCheck() { const [status, setStatus] = useState("loading"); + const renderStatusIcon = () => { + if (status === "loading") { + return ( + + + + + ); + } + + if (status === "SUCCESS") { + return ( + + + + ); + } + + return ( + + + + ); + }; + useEffect(() => { const checkHealth = async () => { try { @@ -29,58 +74,28 @@ export default function HealthCheck({ className = "" }: HealthCheckProps) { }, []); return ( -
- {status === "loading" ? ( - - + + +
- - - - Connecting... - - ) : status === "SUCCESS" ? ( - - - - - Connected to server - - ) : ( - - - - - Connection failed - - )} -
+ {renderStatusIcon()} +
+ + {status === "loading" && "Connecting..."} + {status === "SUCCESS" && "Connected to server"} + {status === "FAILED" && "Connection failed"} + + + + ); } diff --git a/components/ui/loading-spinner.tsx b/components/ui/loading-spinner.tsx new file mode 100644 index 0000000..f848429 --- /dev/null +++ b/components/ui/loading-spinner.tsx @@ -0,0 +1,9 @@ +import React from "react"; + +export default function LoadingSpinner() { + return ( +
+
+
+ ); +} diff --git a/hooks/useAuth.ts b/hooks/useAuth.ts new file mode 100644 index 0000000..62d6c83 --- /dev/null +++ b/hooks/useAuth.ts @@ -0,0 +1,50 @@ +import { toast } from "sonner"; + +interface LoginCredentials { + accessKey: string; + secretKey: string; + region: string; +} + +export function useAuth() { + const login = async (credentials: LoginCredentials): Promise => { + try { + const response = await fetch( + `${process.env.NEXT_PUBLIC_API_BASE_URL}/auth/login`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(credentials), + } + ); + + const data = await response.json(); + + if (response.ok && data.status === "SUCCESS" && data.data.sessionToken) { + document.cookie = `sessionToken=${data.data.sessionToken}; path=/; Secure; SameSite=Strict`; + toast.success("Login successful!"); + return true; + } else { + toast.error(data.message || "Invalid credentials"); + return false; + } + } catch (error) { + console.error("Auth error:", error); + toast.error("Failed to validate credentials"); + return false; + } + }; + + const logout = () => { + document.cookie = + "sessionToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT"; + window.location.href = "/login"; + }; + + return { + login, + logout, + }; +} diff --git a/hooks/useBucketFiltering.ts b/hooks/useBucketFiltering.ts new file mode 100644 index 0000000..ac3bdac --- /dev/null +++ b/hooks/useBucketFiltering.ts @@ -0,0 +1,57 @@ +import { useMemo } from "react"; +import { Bucket } from "@/types/bucket"; + +interface UseBucketFilteringProps { + buckets: Bucket[]; + searchTerm: string; + activeFilter: "size" | "date" | null; +} + +export function useBucketFiltering({ + buckets, + searchTerm, + activeFilter, +}: UseBucketFilteringProps) { + const filteredBuckets = useMemo(() => { + let data = [...buckets]; + + // Apply search filter + if (searchTerm) { + data = data.filter((bucket) => + bucket.bucketName.toLowerCase().includes(searchTerm.toLowerCase()) + ); + } + + // Apply sorting + if (activeFilter === "size") { + data = data.sort((a, b) => { + const sizeToBytes = (size: string) => { + const [value, unit] = size.split(" "); + const numValue = parseFloat(value); + switch (unit?.toUpperCase()) { + case "KB": + return numValue * 1024; + case "MB": + return numValue * 1024 * 1024; + case "GB": + return numValue * 1024 * 1024 * 1024; + case "TB": + return numValue * 1024 * 1024 * 1024 * 1024; + default: + return numValue; + } + }; + return sizeToBytes(b.size || "0") - sizeToBytes(a.size || "0"); + }); + } else if (activeFilter === "date") { + data = data.sort( + (a, b) => + new Date(b.createdOn).getTime() - new Date(a.createdOn).getTime() + ); + } + + return data; + }, [buckets, searchTerm, activeFilter]); + + return filteredBuckets; +} diff --git a/hooks/useFiles.ts b/hooks/useFiles.ts new file mode 100644 index 0000000..82decd5 --- /dev/null +++ b/hooks/useFiles.ts @@ -0,0 +1,148 @@ +import { useState, useEffect, useMemo } from "react"; +import { FileType, FilterType } from "@/types/fileTypes"; + +interface File { + name: string; + type: FileType; + size: number; + lastModified: Date; + path: string; +} + +interface Folder { + name: string; + path: string; +} + +interface UseFilesProps { + bucketName: string; + path?: string; +} + +interface S3Object { + name: string; + type: FileType; + size: number; + lastModified: string; + path: string; +} + +export const useFiles = ({ bucketName, path = "" }: UseFilesProps) => { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [files, setFiles] = useState([]); + const [folders, setFolders] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const [activeFilter, setActiveFilter] = useState(null); + const [selectedType, setSelectedType] = useState("all"); + + const fetchFiles = async () => { + try { + setIsLoading(true); + setError(null); + const response = await fetch("http://localhost:8080/s3/list-objects", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + bucketName, + prefix: path, + }), + }); + + if (!response.ok) { + throw new Error("Failed to fetch files"); + } + + const data = (await response.json()) as { objects: S3Object[] }; + + // Process files and folders from the response + const newFiles: File[] = []; + const newFolders: Folder[] = []; + + data.objects.forEach((obj: S3Object) => { + if (obj.type === "folder") { + newFolders.push({ + name: obj.name, + path: obj.path, + }); + } else { + newFiles.push({ + name: obj.name, + type: obj.type, + size: obj.size, + lastModified: new Date(obj.lastModified), + path: obj.path, + }); + } + }); + + setFiles(newFiles); + setFolders(newFolders); + } catch (err) { + setError(err instanceof Error ? err.message : "An error occurred"); + } finally { + setIsLoading(false); + } + }; + + useEffect(() => { + fetchFiles(); + }, [bucketName, path]); + + const filteredAndSortedData = useMemo(() => { + let filteredFiles = [...files]; + let filteredFolders = [...folders]; + + // Apply search filter + if (searchTerm) { + const term = searchTerm.toLowerCase(); + filteredFiles = filteredFiles.filter((file) => + file.name.toLowerCase().includes(term) + ); + filteredFolders = filteredFolders.filter((folder) => + folder.name.toLowerCase().includes(term) + ); + } + + // Apply type filter + if (selectedType !== "all") { + filteredFiles = filteredFiles.filter( + (file) => file.type === selectedType + ); + } + + // Apply sorting + if (activeFilter) { + filteredFiles.sort((a, b) => { + switch (activeFilter) { + case "size": + return b.size - a.size; + case "date": + return b.lastModified.getTime() - a.lastModified.getTime(); + default: + return 0; + } + }); + } + + return { + files: filteredFiles, + folders: filteredFolders, + }; + }, [files, folders, searchTerm, activeFilter, selectedType]); + + return { + ...filteredAndSortedData, + isLoading, + error, + searchTerm, + setSearchTerm, + activeFilter, + setActiveFilter, + selectedType, + setSelectedType, + refresh: fetchFiles, + }; +}; diff --git a/package-lock.json b/package-lock.json index c5a56a8..543d0e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,9 +22,11 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-toast": "^1.2.5", "@radix-ui/react-tooltip": "^1.1.7", + "@types/js-cookie": "^3.0.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "next": "15.1.6", "next-themes": "^0.4.4", @@ -2610,6 +2612,12 @@ "tslib": "^2.8.0" } }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "license": "MIT" + }, "node_modules/@types/json5": { "version": "0.0.29", "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", @@ -5885,6 +5893,15 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/package.json b/package.json index c2e7d50..38733df 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-toast": "^1.2.5", "@radix-ui/react-tooltip": "^1.1.7", + "@types/js-cookie": "^3.0.6", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.0.0", + "js-cookie": "^3.0.5", "lucide-react": "^0.474.0", "next": "15.1.6", "next-themes": "^0.4.4", diff --git a/services/base.service.ts b/services/base.service.ts new file mode 100644 index 0000000..302e8e1 --- /dev/null +++ b/services/base.service.ts @@ -0,0 +1,68 @@ +import Cookies from "js-cookie"; + +export interface RequestConfig extends RequestInit {} + +export class BaseService { + constructor(protected readonly baseURL: string) {} + + protected getHeaders() { + const sessionToken = Cookies.get("sessionToken"); + if (!sessionToken) { + throw new Error("No authentication token found. Please log in again."); + } + + return { + "Content-Type": "application/json", + sessionToken, + }; + } + + protected async handleResponse(response: Response): Promise { + if (response.status === 401) { + Cookies.remove("sessionToken"); + window.location.href = "/login"; + throw new Error("Session expired. Please log in again."); + } + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + console.log("Raw response data:", data); + + // Handle different response formats + if (data.status === "SUCCESS") { + return data; + } else if (data.url) { + // Handle presigned URL response format + return data; + } else if (data.data) { + // Handle response with data wrapper + return data.data; + } + + throw new Error(data.message || "Request failed"); + } + + protected async request( + endpoint: string, + config: RequestConfig = {}, + requestId: string + ): Promise { + try { + const response = await fetch(`${this.baseURL}${endpoint}`, { + ...config, + headers: { + ...this.getHeaders(), + ...config.headers, + }, + }); + + return this.handleResponse(response); + } catch (error) { + console.error("Request error:", error); + throw error; + } + } +} diff --git a/services/s3.service.ts b/services/s3.service.ts new file mode 100644 index 0000000..eceba00 --- /dev/null +++ b/services/s3.service.ts @@ -0,0 +1,76 @@ +import { ListObjectsRequest, ListObjectsResponse } from "@/types/api"; +import { BaseService } from "./base.service"; +import Cookies from "js-cookie"; + +const API_BASE_URL = "http://localhost:8080"; + +interface PresignedUrlResponse { + success: boolean; + message: string; + data: string; +} + +class S3Service extends BaseService { + constructor() { + super(API_BASE_URL); + } + + async listObjects(request: ListObjectsRequest): Promise { + return this.request( + "/s3/list-objects", + { + method: "POST", + body: JSON.stringify(request), + }, + `list-objects-${request.bucketName}-${request.objectPrefix}` + ); + } + + async getPresignedUrl( + bucketName: string, + objectKey: string + ): Promise { + const sessionToken = Cookies.get("sessionToken"); + if (!sessionToken) { + throw new Error("No session token found. Please log in again."); + } + + console.log("Making presigned URL request with:", { + bucketName, + objectKey, + sessionToken: sessionToken.substring(0, 10) + "...", // Log partial token for debugging + }); + + try { + const response = await this.request( + "/s3/presigned-url", + { + method: "POST", + headers: { + "Content-Type": "application/json", + sessionToken: sessionToken, + }, + body: JSON.stringify({ + bucketName, + objectPrefix: objectKey, + }), + }, + `presigned-url-${bucketName}-${objectKey}` + ); + + console.log("Raw response from presigned URL request:", response); + + if (!response?.data) { + console.error("Invalid response format:", response); + throw new Error("Invalid response format from server"); + } + + return response.data; + } catch (error) { + console.error("Error in getPresignedUrl:", error); + throw error; + } + } +} + +export const s3Service = new S3Service(); diff --git a/static/files.ts b/static/files.ts deleted file mode 100644 index b12b52d..0000000 --- a/static/files.ts +++ /dev/null @@ -1,49 +0,0 @@ -export const files = [ - { - name: "root", - location: "", - folders: [ - { - name: "Important Docs", - location: "Important Docs/", - folders: [ - { - name: "Academic Docs", - location: "Important Docs/Academic Docs/", - folders: [], - files: [ - { - name: "Adhaar Card.jpg", - location: "Important Docs/Academic Docs/Adhaar Card.jpg", - size: 217267, - lastModified: "2023-08-19T13:23:44Z", - extension: "jpg", - etag: "93b0647a51bffcce370ef6eba91e791c", - }, - { - name: "Class X Marksheet (Digilocker).pdf", - location: - "Important Docs/Academic Docs/Class X Marksheet (Digilocker).pdf", - size: 128659, - lastModified: "2023-08-19T13:23:46Z", - extension: "pdf", - etag: "797704ac2c66f0efb6974549c195c1f5", - }, - ], - }, - ], - files: [], - }, - ], - files: [ - { - name: "CCNA-Cert.pdf", - location: "CCNA-Cert.pdf", - size: 130155, - lastModified: "2025-02-23T02:21:17Z", - extension: "pdf", - etag: "c9c7a4f58629b9f7cfc908815a158038", - }, - ], - }, -]; diff --git a/types/S3Objects.ts b/types/S3Objects.ts index 83a8627..5b48437 100644 --- a/types/S3Objects.ts +++ b/types/S3Objects.ts @@ -1,29 +1,27 @@ -interface S3File { - name: string; - location: string; - size: string; - lastModified: string; +export interface S3File { extension: string; + size: string; etag: string; - type: "document" | "compressed" | "image" | "audio" | "video" | "unknown"; + lastModified: string; + key: string; + fileType: string; + name?: string; + type?: string; } -interface S3Folder { - name: string; - location: string; - type: "folder"; - itemCount: number; - folders: S3Folder[]; - files: S3File[]; +export interface S3Folder { + etag: string | null; + lastModified: string | null; + key: string; + itemCount?: number; } -interface S3Root { - name: string; - location: string; - type: "folder"; - itemCount: number; +export interface S3Root { + filesCount: number; folders: S3Folder[]; files: S3File[]; + currentFolder: string; + etag: string | null; + lastModified: string | null; + foldersCount: number; } - -export type { S3File, S3Folder, S3Root }; diff --git a/types/api.ts b/types/api.ts new file mode 100644 index 0000000..73c9d0e --- /dev/null +++ b/types/api.ts @@ -0,0 +1,22 @@ +import { S3Root } from "./S3Objects"; + +export interface ApiResponse { + status: string; + message: string; + data: T; + statusCode: number; + timestamp: string; +} + +export interface ListObjectsRequest { + bucketName: string; + objectPrefix: string; +} + +export interface ListObjectsResponse { + status: string; + message: string; + data: S3Root; + statusCode: number; + timestamp: string; +} diff --git a/types/fileTypes.ts b/types/fileTypes.ts new file mode 100644 index 0000000..a7cabab --- /dev/null +++ b/types/fileTypes.ts @@ -0,0 +1,25 @@ +export type FileType = + | "document" + | "compressed" + | "image" + | "audio" + | "video" + | "unknown" + | "folder"; + +export type FilterType = "size" | "date" | null; + +export interface FileTypeOption { + value: string; + label: string; +} + +export const FILE_TYPES: FileTypeOption[] = [ + { value: "all", label: "All Files" }, + { value: "document", label: "Documents" }, + { value: "compressed", label: "Compressed" }, + { value: "image", label: "Images" }, + { value: "audio", label: "Audio" }, + { value: "video", label: "Video" }, + { value: "folder", label: "Folders" }, +]; diff --git a/types/media.ts b/types/media.ts new file mode 100644 index 0000000..c7ae1f6 --- /dev/null +++ b/types/media.ts @@ -0,0 +1,23 @@ +export interface Position { + x: number; + y: number; +} + +export interface MediaPreviewProps { + readonly onClose: () => void; + readonly title: string; + readonly mediaUrl: string; + readonly onNext?: () => void; + readonly onPrev?: () => void; + readonly hasNext?: boolean; + readonly hasPrev?: boolean; +} + +export interface PreviewState { + isLoading: boolean; + scale: number; + rotation: number; + position: Position; + isDragging: boolean; + dragStart: Position; +}