diff --git a/apps/Backend/src/routes/cloud-storage.ts b/apps/Backend/src/routes/cloud-storage.ts new file mode 100644 index 0000000..097a001 --- /dev/null +++ b/apps/Backend/src/routes/cloud-storage.ts @@ -0,0 +1,7 @@ +import { Router } from "express"; + +const router = Router(); + + +export default router; + \ No newline at end of file diff --git a/apps/Backend/src/routes/index.ts b/apps/Backend/src/routes/index.ts index 1a986cc..53d99fb 100644 --- a/apps/Backend/src/routes/index.ts +++ b/apps/Backend/src/routes/index.ts @@ -12,6 +12,7 @@ import paymentsRoutes from "./payments"; import databaseManagementRoutes from "./database-management"; import notificationsRoutes from "./notifications"; import paymentOcrRoutes from "./paymentOcrExtraction"; +import cloudStorageRoutes from "./cloud-storage"; const router = Router(); @@ -28,5 +29,6 @@ router.use("/payments", paymentsRoutes); router.use("/database-management", databaseManagementRoutes); router.use("/notifications", notificationsRoutes); router.use("/payment-ocr", paymentOcrRoutes); +router.use("/cloud-storage", cloudStorageRoutes); export default router; diff --git a/apps/Backend/src/utils/prismaFileUtils.ts b/apps/Backend/src/utils/prismaFileUtils.ts new file mode 100644 index 0000000..e89abf3 --- /dev/null +++ b/apps/Backend/src/utils/prismaFileUtils.ts @@ -0,0 +1,12 @@ +/** + * Helper: convert Prisma CloudFile result to JSON-friendly object. + */ +export function serializeFile(f: any) { + if (!f) return null; + return { + ...f, + fileSize: typeof f.fileSize === "bigint" ? f.fileSize.toString() : f.fileSize, + createdAt: f.createdAt?.toISOString?.(), + updatedAt: f.updatedAt?.toISOString?.(), + }; +} diff --git a/apps/Frontend/src/App.tsx b/apps/Frontend/src/App.tsx index 8ffc24c..45c5c4a 100644 --- a/apps/Frontend/src/App.tsx +++ b/apps/Frontend/src/App.tsx @@ -25,6 +25,7 @@ const DatabaseManagementPage = lazy( () => import("./pages/database-management-page") ); const ReportsPage = lazy(() => import("./pages/reports-page")); +const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page")); const NotFound = lazy(() => import("./pages/not-found")); function Router() { @@ -50,7 +51,8 @@ function Router() { path="/database-management" component={() => } /> - } /> + } /> + } /> } /> } /> diff --git a/apps/Frontend/src/components/layout/sidebar.tsx b/apps/Frontend/src/components/layout/sidebar.tsx index 1fff026..c526526 100644 --- a/apps/Frontend/src/components/layout/sidebar.tsx +++ b/apps/Frontend/src/components/layout/sidebar.tsx @@ -10,6 +10,7 @@ import { FolderOpen, Database, FileText, + Cloud, } from "lucide-react"; import { cn } from "@/lib/utils"; import { useMemo } from "react"; @@ -61,6 +62,11 @@ export function Sidebar() { path: "/reports", icon: , }, + { + name: "Cloud storage", + path: "/cloud-storage", + icon: , + }, { name: "Backup Database", path: "/database-management", diff --git a/apps/Frontend/src/pages/cloud-storage-page.tsx b/apps/Frontend/src/pages/cloud-storage-page.tsx new file mode 100644 index 0000000..c54e418 --- /dev/null +++ b/apps/Frontend/src/pages/cloud-storage-page.tsx @@ -0,0 +1,831 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@tanstack/react-query"; +import { TopAppBar } from "@/components/layout/top-app-bar"; +import { Sidebar } from "@/components/layout/sidebar"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useToast } from "@/hooks/use-toast"; +import { apiRequest, queryClient } from "@/lib/queryClient"; +import { + Upload, + Download, + Folder, + Trash2, + FolderPlus, + Edit2, + MoreVertical, + FileText, + Image, + FileCode, + FileArchive, + FileAudio, + FileVideo, + Eye, + X, +} from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb"; +import { CloudFolder, CloudFile } from "@shared/schema"; + +interface CloudStorageItem { + folders: CloudFolder[]; + files: CloudFile[]; +} + +export default function CloudStoragePage() { + const { toast } = useToast(); + const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); + const [currentFolderId, setCurrentFolderId] = useState(null); + const [folderPath, setFolderPath] = useState< + { id: number | null; name: string }[] + >([{ id: null, name: "My Cloud Storage" }]); + + // Dialog states + const [isCreateFolderOpen, setIsCreateFolderOpen] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [isUploadOpen, setIsUploadOpen] = useState(false); + + // Form states + const [newFolderName, setNewFolderName] = useState(""); + const [newItemName, setNewItemName] = useState(""); + const [selectedItem, setSelectedItem] = useState<{ + type: "folder" | "file"; + item: CloudFolder | CloudFile; + } | null>(null); + const [selectedFile, setSelectedFile] = useState(null); + const [viewingFile, setViewingFile] = useState(null); + const [fileViewUrl, setFileViewUrl] = useState(""); + const [isViewerOpen, setIsViewerOpen] = useState(false); + + // Fetch folders and files + const { + data: storageItems, + isLoading, + refetch, + } = useQuery({ + queryKey: ["/api/cloud-storage/items", currentFolderId], + queryFn: async () => { + const params = currentFolderId + ? `?parentId=${currentFolderId}` + : "?parentId="; + const res = await fetch(`/api/cloud-storage/items${params}`, { + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to fetch items"); + return res.json(); + }, + }); + + // Create folder mutation + const createFolderMutation = useMutation({ + mutationFn: async (name: string) => { + const res = await apiRequest("POST", "/api/cloud-storage/folders", { + name, + parentId: currentFolderId, + }); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); + setIsCreateFolderOpen(false); + setNewFolderName(""); + toast({ + title: "Folder created", + description: "Your folder has been created successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: "Failed to create folder. Please try again.", + variant: "destructive", + }); + }, + }); + + // Rename folder mutation + const renameFolderMutation = useMutation({ + mutationFn: async ({ id, name }: { id: number; name: string }) => { + const res = await apiRequest( + "PATCH", + `/api/cloud-storage/folders/${id}`, + { name } + ); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); + setIsRenameOpen(false); + setNewItemName(""); + setSelectedItem(null); + toast({ + title: "Folder renamed", + description: "Your folder has been renamed successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: "Failed to rename folder. Please try again.", + variant: "destructive", + }); + }, + }); + + // Rename file mutation + const renameFileMutation = useMutation({ + mutationFn: async ({ id, name }: { id: number; name: string }) => { + const res = await apiRequest("PATCH", `/api/cloud-storage/files/${id}`, { + name, + }); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); + setIsRenameOpen(false); + setNewItemName(""); + setSelectedItem(null); + toast({ + title: "File renamed", + description: "Your file has been renamed successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: "Failed to rename file. Please try again.", + variant: "destructive", + }); + }, + }); + + // Delete folder mutation + const deleteFolderMutation = useMutation({ + mutationFn: async (id: number) => { + const res = await apiRequest( + "DELETE", + `/api/cloud-storage/folders/${id}` + ); + if (!res.ok) throw new Error("Failed to delete folder"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); + setIsDeleteOpen(false); + setSelectedItem(null); + toast({ + title: "Folder deleted", + description: "Your folder has been deleted successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: "Failed to delete folder. Please try again.", + variant: "destructive", + }); + }, + }); + + // Delete file mutation + const deleteFileMutation = useMutation({ + mutationFn: async (id: number) => { + const res = await apiRequest("DELETE", `/api/cloud-storage/files/${id}`); + if (!res.ok) throw new Error("Failed to delete file"); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); + setIsDeleteOpen(false); + setSelectedItem(null); + toast({ + title: "File deleted", + description: "Your file has been deleted successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: "Failed to delete file. Please try again.", + variant: "destructive", + }); + }, + }); + + // Upload file mutation + const uploadFileMutation = useMutation({ + mutationFn: async (file: File) => { + // Get upload URL + const uploadUrlRes = await apiRequest( + "POST", + "/api/cloud-storage/upload-url", + { + fileName: file.name, + } + ); + const { uploadURL, storagePath } = await uploadUrlRes.json(); + + // Upload file to object storage + const uploadRes = await fetch(uploadURL, { + method: "PUT", + body: file, + headers: { + "Content-Type": file.type || "application/octet-stream", + }, + }); + + if (!uploadRes.ok) { + throw new Error("Failed to upload file to storage"); + } + + // Save file metadata with the storage path + const res = await apiRequest("POST", "/api/cloud-storage/files", { + name: file.name, + folderId: currentFolderId, + fileUrl: storagePath, + fileSize: file.size, + mimeType: file.type, + }); + return res.json(); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["/api/cloud-storage/items"] }); + setIsUploadOpen(false); + setSelectedFile(null); + toast({ + title: "File uploaded", + description: "Your file has been uploaded successfully.", + }); + }, + onError: (error) => { + toast({ + title: "Error", + description: "Failed to upload file. Please try again.", + variant: "destructive", + }); + }, + }); + + const handleFolderClick = (folder: CloudFolder) => { + setCurrentFolderId(folder.id); + setFolderPath([...folderPath, { id: folder.id, name: folder.name }]); + }; + + const handleBreadcrumbClick = (index: number) => { + const newPath = folderPath.slice(0, index + 1); + setFolderPath(newPath); + setCurrentFolderId(newPath[newPath.length - 1]!.id); + }; + + const handleCreateFolder = () => { + if (newFolderName.trim()) { + createFolderMutation.mutate(newFolderName.trim()); + } + }; + + const handleRename = () => { + if (selectedItem && newItemName.trim()) { + if (selectedItem.type === "folder") { + renameFolderMutation.mutate({ + id: (selectedItem.item as CloudFolder).id, + name: newItemName.trim(), + }); + } else { + renameFileMutation.mutate({ + id: (selectedItem.item as CloudFile).id, + name: newItemName.trim(), + }); + } + } + }; + + const handleDelete = () => { + if (selectedItem) { + if (selectedItem.type === "folder") { + deleteFolderMutation.mutate((selectedItem.item as CloudFolder).id); + } else { + deleteFileMutation.mutate((selectedItem.item as CloudFile).id); + } + } + }; + + const handleFileUpload = () => { + if (selectedFile) { + uploadFileMutation.mutate(selectedFile); + } + }; + + const handleViewFile = async (file: CloudFile) => { + try { + const res = await fetch(`/api/cloud-storage/files/${file.id}/url`, { + credentials: "include", + }); + if (!res.ok) throw new Error("Failed to get file URL"); + const { url, mimeType } = await res.json(); + + setViewingFile(file); + setFileViewUrl(url); + setIsViewerOpen(true); + } catch (error) { + toast({ + title: "Error", + description: "Failed to open file. Please try again.", + variant: "destructive", + }); + } + }; + + const getFileIcon = (mimeType?: string) => { + if (!mimeType) return ; + + if (mimeType.startsWith("image/")) + return ; + if (mimeType.startsWith("video/")) + return ; + if (mimeType.startsWith("audio/")) + return ; + if (mimeType.includes("zip") || mimeType.includes("tar")) + return ; + if ( + mimeType.includes("javascript") || + mimeType.includes("typescript") || + mimeType.includes("json") + ) + return ; + + return ; + }; + + const formatFileSize = (bytes: number) => { + if (bytes === 0) return "0 Bytes"; + const k = 1024; + const sizes = ["Bytes", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; + }; + + return ( +
+
+
+
+

Cloud Storage

+

+ View and manage files and folder at cloud storage. +

+
+
+
+ + +
+
+ + {/* Breadcrumb */} + + + + + {folderPath.map((item, index) => ( +
+ {index > 0 && } + + {index === folderPath.length - 1 ? ( + {item.name} + ) : ( + handleBreadcrumbClick(index)} + > + {item.name} + + )} + +
+ ))} +
+
+
+
+ + {/* Storage Content */} + + + Files and Folders + + Manage your files and folders in the cloud + + + + {isLoading ? ( +
Loading...
+ ) : ( +
+ {/* Folders */} + {storageItems?.folders.map((folder) => ( +
handleFolderClick(folder)} + > +
+ + + {folder.name} + +
+ + + + + + { + setSelectedItem({ type: "folder", item: folder }); + setNewItemName(folder.name); + setIsRenameOpen(true); + }} + > + + Rename + + + { + setSelectedItem({ type: "folder", item: folder }); + setIsDeleteOpen(true); + }} + className="text-red-600" + > + + Delete + + + +
+ ))} + + {/* Files */} + {storageItems?.files.map((file) => ( +
+
+ {getFileIcon(file.mimeType || undefined)} + + {file.name} + + + {formatFileSize(file.fileSize)} + +
+ + + + + + handleViewFile(file)}> + + View + + { + try { + const res = await fetch( + `/api/cloud-storage/files/${file.id}/url`, + { + credentials: "include", + } + ); + if (!res.ok) + throw new Error("Failed to get file URL"); + const { url } = await res.json(); + window.open(url, "_blank"); + } catch (error) { + toast({ + title: "Error", + description: "Failed to download file.", + variant: "destructive", + }); + } + }} + > + + Download + + { + setSelectedItem({ type: "file", item: file }); + setNewItemName(file.name); + setIsRenameOpen(true); + }} + > + + Rename + + + { + setSelectedItem({ type: "file", item: file }); + setIsDeleteOpen(true); + }} + className="text-red-600" + > + + Delete + + + +
+ ))} + + {/* Empty state */} + {!storageItems?.folders.length && !storageItems?.files.length && ( +
+ +

This folder is empty

+

+ Create a new folder or upload files to get started +

+
+ )} +
+ )} +
+
+ + {/* Create Folder Dialog */} + + + + Create New Folder + + Enter a name for your new folder + + +
+
+ + setNewFolderName(e.target.value)} + placeholder="My Folder" + /> +
+
+ + + + +
+
+ + {/* Rename Dialog */} + + + + + Rename {selectedItem?.type === "folder" ? "Folder" : "File"} + + + Enter a new name for this {selectedItem?.type} + + +
+
+ + setNewItemName(e.target.value)} + placeholder="New name" + /> +
+
+ + + + +
+
+ + {/* Delete Confirmation Dialog */} + + + + + Delete {selectedItem?.type === "folder" ? "Folder" : "File"} + + + Are you sure you want to delete "{selectedItem?.item.name}"? This + action cannot be undone. + + + + + + + + + + {/* Upload File Dialog */} + + + + Upload File + + Select a file to upload to your cloud storage + + +
+
+ + setSelectedFile(e.target.files?.[0] || null)} + /> +
+ {selectedFile && ( +
+

File: {selectedFile.name}

+

Size: {formatFileSize(selectedFile.size)}

+
+ )} +
+ + + + +
+
+ + {/* File Viewer Dialog */} + + +
+
+

{viewingFile?.name}

+ +
+
+ {viewingFile && fileViewUrl && ( + <> + {/* PDF Viewer */} + {viewingFile.mimeType === "application/pdf" && ( +
+