From 9090375789dc9edf1be09505f661c9c3a04693e1 Mon Sep 17 00:00:00 2001 From: Potenz Date: Sun, 28 Sep 2025 02:41:32 +0530 Subject: [PATCH] feat(cloud-page) - wip - UI fixed, functionalities done, view/downlaod to be done --- apps/Backend/src/routes/cloud-storage.ts | 52 +- .../src/storage/cloudStorage-storage.ts | 44 +- .../components/cloud-storage/bread-crumb.tsx | 203 ++++ .../cloud-storage/files-section.tsx | 444 ++++++++ .../components/cloud-storage/folder-panel.tsx | 156 +++ .../cloud-storage/folder-section.tsx | 400 ++++++++ .../cloud-storage/new-folder-modal.tsx | 100 ++ .../recent-top-level-folder-modal.tsx | 353 +++++++ .../Frontend/src/pages/cloud-storage-page.tsx | 958 ++---------------- .../Frontend/src/utils/pageNumberGenerator.ts | 20 + 10 files changed, 1822 insertions(+), 908 deletions(-) create mode 100644 apps/Frontend/src/components/cloud-storage/bread-crumb.tsx create mode 100644 apps/Frontend/src/components/cloud-storage/files-section.tsx create mode 100644 apps/Frontend/src/components/cloud-storage/folder-panel.tsx create mode 100644 apps/Frontend/src/components/cloud-storage/folder-section.tsx create mode 100644 apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx create mode 100644 apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx create mode 100644 apps/Frontend/src/utils/pageNumberGenerator.ts diff --git a/apps/Backend/src/routes/cloud-storage.ts b/apps/Backend/src/routes/cloud-storage.ts index 171dfb9..78786b9 100644 --- a/apps/Backend/src/routes/cloud-storage.ts +++ b/apps/Backend/src/routes/cloud-storage.ts @@ -69,9 +69,7 @@ router.get( return res.json({ error: false, data: paged, - total: folders.length, - limit, - offset, + totalCount: folders.length, }); } catch (err) { return sendError(res, 500, "Failed to load child folders", err); @@ -103,9 +101,9 @@ router.get( try { const files = await storage.listFilesInFolder(parentId, limit, offset); - const total = await storage.countFilesInFolder(parentId); + const totalCount = await storage.countFilesInFolder(parentId); const serialized = files.map(serializeFile); - return res.json({ error: false, data: serialized, total, limit, offset }); + return res.json({ error: false, data: serialized, totalCount }); } catch (err) { return sendError(res, 500, "Failed to load files for folder", err); } @@ -121,20 +119,40 @@ router.get( const limit = parsePositiveInt(req.query.limit, 50); const offset = parsePositiveInt(req.query.offset, 0); try { - const folders = await storage.listRecentFolders(limit, offset); - const total = await storage.countFolders(); - return res.json({ error: false, data: folders, total, limit, offset }); + // Always request top-level folders (parentId = null) + const parentId: number | null = null; + const folders = await storage.listRecentFolders(limit, offset, parentId); + const totalCount = await storage.countFoldersByParent(parentId); + + return res.json({ + error: false, + data: folders, + totalCount, + }); } catch (err) { return sendError(res, 500, "Failed to load recent folders"); } } ); -/* ---------- Folder CRUD ---------- - POST /folders { userId, name, parentId? } - PUT /folders/:id { name?, parentId? } - DELETE /folders/:id -*/ +// ---------- Folder CRUD ---------- +router.get( + "/folders/:id", + async (req: Request, res: Response): Promise => { + const id = Number.parseInt(req.params.id ?? "", 10); + if (!Number.isInteger(id) || id <= 0) + return sendError(res, 400, "Invalid folder id"); + + try { + const folder = await storage.getFolder(id); + if (!folder) return sendError(res, 404, "Folder not found"); + return res.json({ error: false, data: folder }); + } catch (err) { + return sendError(res, 500, "Failed to load folder"); + } + } +); + router.post("/folders", async (req: Request, res: Response): Promise => { const { userId, name, parentId } = req.body; if (!userId || typeof name !== "string" || !name.trim()) { @@ -210,9 +228,9 @@ router.get( try { const files = await storage.listFilesInFolder(folderId, limit, offset); - const total = await storage.countFilesInFolder(folderId); + const totalCount = await storage.countFilesInFolder(folderId); const serialized = files.map(serializeFile); - return res.json({ error: false, data: serialized, total, limit, offset }); + return res.json({ error: false, data: serialized, totalCount }); } catch (err) { return sendError(res, 500, "Failed to list files for folder"); } @@ -415,7 +433,7 @@ router.get( try { const { data, total } = await storage.searchFolders(q, limit, offset); - return res.json({ error: false, data, total, limit, offset }); + return res.json({ error: false, data, totalCount: total }); } catch (err) { return sendError(res, 500, "Folder search failed"); } @@ -440,7 +458,7 @@ router.get( try { const { data, total } = await storage.searchFiles(q, type, limit, offset); const serialized = data.map(serializeFile); - return res.json({ error: false, data: serialized, total, limit, offset }); + return res.json({ error: false, data: serialized, totalCount: total }); } catch (err) { return sendError(res, 500, "File search failed"); } diff --git a/apps/Backend/src/storage/cloudStorage-storage.ts b/apps/Backend/src/storage/cloudStorage-storage.ts index c86a755..87daa2b 100644 --- a/apps/Backend/src/storage/cloudStorage-storage.ts +++ b/apps/Backend/src/storage/cloudStorage-storage.ts @@ -40,13 +40,16 @@ async function updateFolderTimestampsRecursively(folderId: number | null) { export interface IStorage { // Folders getFolder(id: number): Promise; - listFoldersByParent( - parentId: number | null, + listRecentFolders( limit: number, - offset: number + offset: number, + parentId?: number | null ): Promise; countFoldersByParent(parentId: number | null): Promise; - listRecentFolders(limit: number, offset: number): Promise; + countFolders(filter?: { + userId?: number; + nameContains?: string | null; + }): Promise; createFolder( userId: number, name: string, @@ -57,10 +60,6 @@ export interface IStorage { updates: Partial<{ name?: string; parentId?: number | null }> ): Promise; deleteFolder(id: number): Promise; - countFolders(filter?: { - userId?: number; - nameContains?: string | null; - }): Promise; // Files getFile(id: number): Promise; @@ -120,17 +119,23 @@ export const cloudStorage: IStorage = { return (folder as unknown as CloudFolder) ?? null; }, - async listFoldersByParent( - parentId: number | null = null, - limit = 50, - offset = 0 - ) { + async listRecentFolders(limit = 50, offset = 0, parentId?: number | null) { + const where: any = {}; + + // parentId === undefined → no filter (global recent) + // parentId === null → top-level folders (parent IS NULL) + // parentId === number → children of that folder + if (parentId !== undefined) { + where.parentId = parentId; + } + const folders = await db.cloudFolder.findMany({ - where: { parentId }, - orderBy: { name: "asc" }, + where, + orderBy: { updatedAt: "desc" }, skip: offset, take: limit, }); + return folders as unknown as CloudFolder[]; }, @@ -138,15 +143,6 @@ export const cloudStorage: IStorage = { return db.cloudFolder.count({ where: { parentId } }); }, - async listRecentFolders(limit = 50, offset = 0) { - const folders = await db.cloudFolder.findMany({ - orderBy: { updatedAt: "desc" }, - skip: offset, - take: limit, - }); - return folders as unknown as CloudFolder[]; - }, - async createFolder( userId: number, name: string, diff --git a/apps/Frontend/src/components/cloud-storage/bread-crumb.tsx b/apps/Frontend/src/components/cloud-storage/bread-crumb.tsx new file mode 100644 index 0000000..ac75d9b --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/bread-crumb.tsx @@ -0,0 +1,203 @@ +import { Fragment, useEffect, useRef, useState } from "react"; + +/** + * Improved Breadcrumbs helper component + * - Renders a pill-style path with chevrons + * - Collapses middle items when path is long and exposes them via an ellipsis dropdown + * - Clickable items, accessible, responsive truncation + */ + +export type FolderMeta = { + id: number | null; + name: string | null; + parentId: number | null; +}; + +export function Breadcrumbs({ + path, + onNavigate, +}: { + path: FolderMeta[]; + onNavigate: (id: number | null) => void; +}) { + const [openEllipsis, setOpenEllipsis] = useState(false); + const dropdownRef = useRef(null); + + // close dropdown on outside click + useEffect(() => { + function onDocClick(e: MouseEvent) { + if (!dropdownRef.current) return; + if (!dropdownRef.current.contains(e.target as Node)) { + setOpenEllipsis(false); + } + } + if (openEllipsis) { + document.addEventListener("mousedown", onDocClick); + } + return () => document.removeEventListener("mousedown", onDocClick); + }, [openEllipsis]); + + // Render strategy: if path.length <= 4 show all; else show: first, ellipsis, last 2 + const showAll = path.length <= 4; + const first = path[0]; + const lastTwo = path.slice(Math.max(0, path.length - 2)); + const middle = path.slice(1, Math.max(1, path.length - 2)); + + // utility classes + const inactiveChip = + "inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm truncate max-w-[220px] bg-muted hover:bg-muted/80 text-muted-foreground"; + const activeChip = + "inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm truncate max-w-[220px] bg-primary/10 text-primary ring-1 ring-primary/20"; + + // render a chip with optional active flag + function Chip({ + id, + name, + active, + }: { + id: number | null; + name: string | null; + active?: boolean; + }) { + return ( + + ); + } + + // small slash separator (visible between chips) + const Slash = () =>
  • /
  • ; + + return ( + // Card-like background for the entire breadcrumb strip + + ); +} diff --git a/apps/Frontend/src/components/cloud-storage/files-section.tsx b/apps/Frontend/src/components/cloud-storage/files-section.tsx new file mode 100644 index 0000000..79ca7f1 --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/files-section.tsx @@ -0,0 +1,444 @@ +import React, { useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Plus, + File as FileIcon, + FileText, + Image as ImageIcon, + Trash2, + Download, + Edit3 as EditIcon, +} from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import type { CloudFile } from "@repo/db/types"; +import { MultipleFileUploadZone } from "@/components/file-upload/multiple-file-upload-zone"; +import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; +import { Menu, Item, contextMenu } from "react-contexify"; +import "react-contexify/dist/ReactContexify.css"; +import { useAuth } from "@/hooks/use-auth"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationPrevious, + PaginationLink, + PaginationNext, +} from "@/components/ui/pagination"; +import { getPageNumbers } from "@/utils/pageNumberGenerator"; +import { NewFolderModal } from "./new-folder-modal"; +import { toast } from "@/hooks/use-toast"; +import { Description } from "@radix-ui/react-toast"; + +export type FilesSectionProps = { + parentId: number | null; + pageSize?: number; + className?: string; + onFileOpen?: (fileId: number) => void; +}; + +const FILES_LIMIT_DEFAULT = 20; + +function fileIcon(mime?: string) { + if (!mime) return ; + if (mime.startsWith("image/")) return ; + if (mime === "application/pdf" || mime.endsWith("/pdf")) + return ; + return ; +} + +export default function FilesSection({ + parentId, + pageSize = FILES_LIMIT_DEFAULT, + className, + onFileOpen, +}: FilesSectionProps) { + const qc = useQueryClient(); + const { user } = useAuth(); + const userId = user?.id; + + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + // upload modal and ref + const [isUploadOpen, setIsUploadOpen] = useState(false); + const uploadRef = useRef(null); + + // rename/delete + const [isRenameOpen, setIsRenameOpen] = useState(false); + const [renameTargetId, setRenameTargetId] = useState(null); + const [renameInitial, setRenameInitial] = useState(""); + + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + useEffect(() => { + loadPage(currentPage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parentId, currentPage]); + + async function loadPage(page: number) { + setIsLoading(true); + try { + const offset = (page - 1) * pageSize; + const fid = parentId === null ? "null" : String(parentId); + const res = await apiRequest( + "GET", + `/api/cloud-storage/items/files?parentId=${encodeURIComponent( + fid + )}&limit=${pageSize}&offset=${offset}` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Failed to load files"); + const rows: CloudFile[] = Array.isArray(json.data) ? json.data : []; + setData(rows); + const t = + typeof json.totalCount === "number" + ? json.totalCount + : typeof json.total === "number" + ? json.total + : rows.length; + setTotal(t); + } catch (err: any) { + setData([]); + setTotal(0); + toast({ + title: "Failed to load files", + description: err?.message ?? String(err), + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + } + + function showMenu(e: React.MouseEvent, file: CloudFile) { + e.preventDefault(); + e.stopPropagation(); + contextMenu.show({ + id: "files-section-menu", + event: e.nativeEvent, + props: { file }, + }); + } + + // rename + function openRename(file: CloudFile) { + setRenameTargetId(Number(file.id)); + setRenameInitial(file.name ?? ""); + setIsRenameOpen(true); + contextMenu.hideAll(); + } + async function submitRename(newName: string) { + if (!renameTargetId) return; + try { + const res = await apiRequest( + "PUT", + `/api/cloud-storage/files/${renameTargetId}`, + { name: newName } + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Rename failed"); + setIsRenameOpen(false); + setRenameTargetId(null); + toast({ title: "File renamed" }); + + loadPage(currentPage); + qc.invalidateQueries({ + queryKey: ["/api/cloud-storage/folders/recent", 1], + }); + } catch (err: any) { + toast({ + title: "Rename failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + // delete + function openDelete(file: CloudFile) { + setDeleteTarget(file); + setIsDeleteOpen(true); + contextMenu.hideAll(); + } + async function confirmDelete() { + if (!deleteTarget) return; + try { + const res = await apiRequest( + "DELETE", + `/api/cloud-storage/files/${deleteTarget.id}` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Delete failed"); + setIsDeleteOpen(false); + setDeleteTarget(null); + toast({ title: "File deleted" }); + + // reload current page (ensure page index valid) + loadPage(currentPage); + qc.invalidateQueries({ + queryKey: ["/api/cloud-storage/folders/recent", 1], + }); + } catch (err: any) { + toast({ + title: "Delete failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + // download + function handleDownload(file: CloudFile) { + // Open download endpoint in new tab so the browser handles attachment headers + const url = `/api/cloud-storage/files/${file.id}/download`; + window.open(url, "_blank"); + contextMenu.hideAll(); + } + + // upload: get files from MultipleFileUploadZone (imperative handle) + async function handleUploadSubmit() { + const files: File[] = uploadRef.current?.getFiles?.() ?? []; + if (!files.length) return; + try { + for (const f of files) { + const fid = parentId === null ? "null" : String(parentId); + const initRes = await apiRequest( + "POST", + `/api/cloud-storage/folders/${encodeURIComponent(fid)}/files`, + { + userId, + name: f.name, + mimeType: f.type || null, + expectedSize: f.size, + totalChunks: 1, + } + ); + const initJson = await initRes.json(); + const created = initJson?.data; + if (!created || typeof created.id !== "number") + throw new Error("Init failed"); + const raw = await f.arrayBuffer(); + // upload chunk + await apiRequest( + "POST", + `/api/cloud-storage/files/${created.id}/chunks?seq=0`, + raw + ); + // finalize + await apiRequest( + "POST", + `/api/cloud-storage/files/${created.id}/complete`, + {} + ); + toast({ title: "Upload complete", description: f.name }); + } + setIsUploadOpen(false); + loadPage(currentPage); + qc.invalidateQueries({ + queryKey: ["/api/cloud-storage/folders/recent", 1], + }); + } catch (err: any) { + toast({ + title: "Upload failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const endItem = Math.min(total, currentPage * pageSize); + + return ( + + +
    + Files + Manage Files in this folder +
    + + +
    + + + {isLoading ? ( +
    Loading...
    + ) : ( + <> +
    + {data.map((file) => ( +
    showMenu(e, file)} + onDoubleClick={() => onFileOpen?.(Number(file.id))} + > +
    +
    + {fileIcon((file as any).mimeType)} +
    +
    +
    {file.name}
    +
    + {((file as any).fileSize ?? 0).toString()} bytes +
    +
    +
    +
    + ))} +
    + + {/* pagination */} + {totalPages > 1 && ( +
    +
    +
    + Showing {startItem}–{endItem} of {total} results +
    + + + + + { + e.preventDefault(); + setCurrentPage((p) => Math.max(1, p - 1)); + }} + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "" + } + /> + + + {getPageNumbers(currentPage, totalPages).map( + (page, idx) => ( + + {page === "..." ? ( + ... + ) : ( + { + e.preventDefault(); + setCurrentPage(page as number); + }} + isActive={currentPage === page} + > + {page} + + )} + + ) + )} + + + { + e.preventDefault(); + setCurrentPage((p) => Math.min(totalPages, p + 1)); + }} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + +
    +
    + )} + + )} +
    + + {/* context menu */} + + openRename(props.file)}> + + Rename + + + + handleDownload(props.file)}> + + Download + + + + openDelete(props.file)}> + + Delete + + + + + {/* upload modal using MultipleFileUploadZone (imperative handle) */} + {isUploadOpen && ( +
    +
    +

    Upload files

    + +
    + + +
    +
    +
    + )} + + {/* rename modal (reusing NewFolderModal for simplicity) */} + { + setIsRenameOpen(false); + setRenameTargetId(null); + }} + onSubmit={submitRename} + /> + + {/* delete confirm */} + { + setIsDeleteOpen(false); + setDeleteTarget(null); + }} + onConfirm={confirmDelete} + /> +
    + ); +} diff --git a/apps/Frontend/src/components/cloud-storage/folder-panel.tsx b/apps/Frontend/src/components/cloud-storage/folder-panel.tsx new file mode 100644 index 0000000..08ca445 --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/folder-panel.tsx @@ -0,0 +1,156 @@ +// Updated components to support: 1) navigating into child folders (FolderSection -> FolderPanel) +// 2) a breadcrumb / path strip in FolderPanel so user can see & click parent folders + +import React, { useEffect, useRef, useState } from "react"; +import FolderSection from "@/components/cloud-storage/folder-section"; +import FilesSection from "@/components/cloud-storage/files-section"; +import { apiRequest } from "@/lib/queryClient"; +import { Breadcrumbs, FolderMeta } from "./bread-crumb"; + +type Props = { + folderId: number | null; + onClose?: () => void; + onViewChange?: (id: number | null) => void; +}; + +export default function FolderPanel({ + folderId, + onClose, + onViewChange, +}: Props) { + const [currentFolderId, setCurrentFolderId] = useState( + folderId ?? null + ); + const [path, setPath] = useState([]); + const [isLoadingPath, setIsLoadingPath] = useState(false); + + // When the panel opens to a different initial folder, sync and notify parent + useEffect(() => { + setCurrentFolderId(folderId ?? null); + onViewChange?.(folderId ?? null); + }, [folderId, onViewChange]); + + // notify parent when viewed folder changes + useEffect(() => { + onViewChange?.(currentFolderId); + }, [currentFolderId, onViewChange]); + + // whenever currentFolderId changes we load the ancestor path + useEffect(() => { + let mounted = true; + async function buildPath(fid: number | null) { + setIsLoadingPath(true); + try { + // We'll build path from root -> ... -> current. Since we don't know + // if backend provides a single endpoint for ancestry, we'll fetch + // current folder and walk parents until null. If fid is null then path is empty. + if (fid == null) { + if (mounted) setPath([]); + return; + } + + const collected: FolderMeta[] = []; + let cursor: number | null = fid; + + // keep a safety cap to avoid infinite loop in case of cycles + const MAX_DEPTH = 50; + let depth = 0; + + while (cursor != null && depth < MAX_DEPTH) { + const res = await apiRequest( + "GET", + `/api/cloud-storage/folders/${cursor}` + ); + const json = await res.json(); + if (!res.ok) + throw new Error(json?.message || "Failed to fetch folder"); + + const folder = json?.data ?? json ?? null; + // normalize + const meta: FolderMeta = { + id: folder?.id ?? null, + name: folder?.name ?? null, + parentId: folder?.parentId ?? null, + }; + + // prepend (we are walking up) then continue with parent + collected.push(meta); + cursor = meta.parentId; + depth += 1; + } + + // collected currently top-down from current -> root. We need root->...->current + const rootToCurrent = collected.slice().reverse(); + // we don't include the root (null) as an explicit item; Breadcrumbs shows "My Cloud Storage" + if (mounted) setPath(rootToCurrent); + } catch (err) { + console.error("buildPath error", err); + if (mounted) setPath([]); + } finally { + if (mounted) setIsLoadingPath(false); + } + } + + buildPath(currentFolderId); + return () => { + mounted = false; + }; + }, [currentFolderId]); + + // handler when child folder is clicked inside FolderSection + function handleChildSelect(childFolderId: number | null) { + // if user re-clicks current folder id as toggle, we still want to navigate into it. + setCurrentFolderId(childFolderId); + onViewChange?.(childFolderId); // keep page in sync + } + + // navigate via breadcrumb (id may be null for root) + function handleNavigateTo(id: number | null) { + setCurrentFolderId(id); + onViewChange?.(id); + } + + return ( +
    +
    +

    + {currentFolderId == null + ? "My Cloud Storage" + : `Folder ${currentFolderId}`} +

    + +
    + {onClose && ( + + )} +
    +
    + + {/* Breadcrumb / path strip */} +
    + {/* show breadcrumbs even if loading; breadcrumbs show 'My Cloud Storage' + path */} + +
    + + {/* stacked vertically: folders on top, files below */} +
    +
    + {/* pass onSelect so FolderSection can tell the panel to navigate into a child */} + +
    + +
    + +
    +
    +
    + ); +} diff --git a/apps/Frontend/src/components/cloud-storage/folder-section.tsx b/apps/Frontend/src/components/cloud-storage/folder-section.tsx new file mode 100644 index 0000000..419823f --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/folder-section.tsx @@ -0,0 +1,400 @@ +import React, { useEffect, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { + Folder as FolderIcon, + Plus, + Trash2, + Edit3 as EditIcon, +} from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import type { CloudFolder } from "@repo/db/types"; +import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal"; +import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; +import { Menu, Item, contextMenu } from "react-contexify"; +import "react-contexify/dist/ReactContexify.css"; +import { useAuth } from "@/hooks/use-auth"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationPrevious, + PaginationLink, + PaginationNext, +} from "@/components/ui/pagination"; +import { getPageNumbers } from "@/utils/pageNumberGenerator"; +import { recentTopLevelFoldersQueryKey } from "./recent-top-level-folder-modal"; +import { toast } from "@/hooks/use-toast"; + +export type FolderSectionProps = { + parentId: number | null; + pageSize?: number; + className?: string; + onSelect?: (folderId: number | null) => void; +}; + +export default function FolderSection({ + parentId, + pageSize = 10, + className, + onSelect, +}: FolderSectionProps) { + const qc = useQueryClient(); + const { user } = useAuth(); + const userId = user?.id; + + const [data, setData] = useState([]); + const [total, setTotal] = useState(0); + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(false); + + const [isNewOpen, setIsNewOpen] = useState(false); + const [isRenameOpen, setIsRenameOpen] = useState(false); + const [renameInitial, setRenameInitial] = useState(""); + const [renameTargetId, setRenameTargetId] = useState(null); + + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const [selectedId, setSelectedId] = useState(null); + + // reset selectedId and page when parent changes + useEffect(() => { + setSelectedId(null); + setCurrentPage(1); + }, [parentId]); + + // load page + useEffect(() => { + loadPage(currentPage); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [parentId, currentPage]); + + async function loadPage(page: number) { + setIsLoading(true); + try { + const offset = (page - 1) * pageSize; + const pid = parentId === null ? "null" : String(parentId); + const res = await apiRequest( + "GET", + `/api/cloud-storage/items/folders?parentId=${encodeURIComponent( + pid + )}&limit=${pageSize}&offset=${offset}` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Failed to load folders"); + const rows: CloudFolder[] = Array.isArray(json.data) ? json.data : []; + setData(rows); + const t = + typeof json.total === "number" + ? json.total + : typeof json.totalCount === "number" + ? json.totalCount + : rows.length; + setTotal(t); + } catch (err: any) { + setData([]); + setTotal(0); + toast({ + title: "Failed to load folders", + description: err?.message ?? String(err), + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + } + + // tile click toggles selection + function handleTileClick(id: number) { + const next = selectedId === id ? null : id; + setSelectedId(next); + onSelect?.(next); + contextMenu.hideAll(); + } + + // right-click menu via react-contexify + function showMenu(e: React.MouseEvent, folder: CloudFolder) { + e.preventDefault(); + e.stopPropagation(); + contextMenu.show({ + id: `folder-section-menu`, + event: e.nativeEvent, + props: { folder }, + }); + } + + // create folder + async function handleCreate(name: string) { + if (!userId) { + toast({ + title: "Not signed in", + description: "Please sign in to create folders.", + variant: "destructive", + }); + return; // caller should ensure auth + } + try { + const res = await apiRequest("POST", "/api/cloud-storage/folders", { + userId, + name, + parentId, + }); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Create failed"); + setIsNewOpen(false); + toast({ title: "Folder created" }); + // refresh this page and top-level recent + loadPage(1); + setCurrentPage(1); + qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) }); + } catch (err: any) { + toast({ + title: "Create failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + // rename + function openRename(folder: CloudFolder) { + setRenameTargetId(Number(folder.id)); + setRenameInitial(folder.name ?? ""); + setIsRenameOpen(true); + contextMenu.hideAll(); + } + async function submitRename(newName: string) { + if (!renameTargetId) return; + try { + const res = await apiRequest( + "PUT", + `/api/cloud-storage/folders/${renameTargetId}`, + { name: newName } + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Rename failed"); + setIsRenameOpen(false); + setRenameTargetId(null); + toast({ title: "Folder renamed" }); + + loadPage(currentPage); + qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) }); + } catch (err: any) { + toast({ + title: "Rename failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + // delete + function openDelete(folder: CloudFolder) { + setDeleteTarget(folder); + setIsDeleteOpen(true); + contextMenu.hideAll(); + } + async function confirmDelete() { + if (!deleteTarget) return; + try { + const res = await apiRequest( + "DELETE", + `/api/cloud-storage/folders/${deleteTarget.id}` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Delete failed"); + // deselect if needed + if (selectedId === deleteTarget.id) { + setSelectedId(null); + onSelect?.(null); + } + setIsDeleteOpen(false); + setDeleteTarget(null); + toast({ title: "Folder deleted" }); + + // reload current page (if empty page and not first, move back) + const maybePage = Math.max(1, currentPage); + loadPage(maybePage); + qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) }); + } catch (err: any) { + toast({ + title: "Delete failed", + description: err?.message ?? String(err), + variant: "destructive", + }); + } + } + + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const endItem = Math.min(total, currentPage * pageSize); + + return ( + + +
    + Folders + Manage all its Child folders +
    + + +
    + + + {isLoading ? ( +
    Loading...
    + ) : ( + <> +
    + {data.map((f) => { + const isSelected = selectedId === f.id; + return ( +
    +
    handleTileClick(Number(f.id))} + onContextMenu={(e) => showMenu(e, f)} + className={ + "w-full flex items-center gap-3 p-2 rounded-lg hover:bg-gray-100 cursor-pointer " + + (isSelected ? "ring-2 ring-blue-400 bg-blue-50" : "") + } + > + +
    {f.name}
    +
    +
    + ); + })} +
    + + {/* Pagination inside card */} + {totalPages > 1 && ( +
    +
    +
    + Showing {startItem}–{endItem} of {total} results +
    + + + + + { + e.preventDefault(); + setCurrentPage((p) => Math.max(1, p - 1)); + }} + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "" + } + /> + + + {getPageNumbers(currentPage, totalPages).map( + (page, idx) => ( + + {page === "..." ? ( + ... + ) : ( + { + e.preventDefault(); + setCurrentPage(page as number); + }} + isActive={currentPage === page} + > + {page} + + )} + + ) + )} + + + { + e.preventDefault(); + setCurrentPage((p) => Math.min(totalPages, p + 1)); + }} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + +
    +
    + )} + + )} +
    + + {/* react-contexify menu */} + + openRename(props.folder)}> + + Rename + + + + openDelete(props.folder)}> + + Delete + + + + + {/* Modals */} + setIsNewOpen(false)} + onSubmit={handleCreate} + /> + + { + setIsRenameOpen(false); + setRenameTargetId(null); + }} + onSubmit={submitRename} + /> + + { + setIsDeleteOpen(false); + setDeleteTarget(null); + }} + onConfirm={confirmDelete} + /> +
    + ); +} diff --git a/apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx b/apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx new file mode 100644 index 0000000..f3987c7 --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx @@ -0,0 +1,100 @@ +import React, { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Folder, Search as SearchIcon } from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import type { CloudFolder } from "@repo/db/types"; +import FolderPanel from "@/components/cloud-storage/folder-panel"; + +// ----------------------------- +// Reusable NewFolderModal +// ----------------------------- +export type NewFolderModalProps = { + isOpen: boolean; + initialName?: string; + title?: string; + submitLabel?: string; + onClose: () => void; + onSubmit: (name: string) => Promise | void; +}; + +export function NewFolderModal({ + isOpen, + initialName = "", + title = "New Folder", + submitLabel = "Create", + onClose, + onSubmit, +}: NewFolderModalProps) { + const [name, setName] = useState(initialName); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + setName(initialName); + }, [initialName, isOpen]); + + if (!isOpen) return null; + + return ( +
    +
    { + if (!isSubmitting) onClose(); + }} + /> + +
    +
    +

    {title}

    +
    + +
    { + e.preventDefault(); + if (!name.trim()) return; + try { + setIsSubmitting(true); + await onSubmit(name.trim()); + } finally { + setIsSubmitting(false); + } + }} + > +
    + + setName(e.target.value)} + className="w-full rounded-md border px-3 py-2" + placeholder="Enter folder name" + disabled={isSubmitting} + /> +
    + +
    + + +
    +
    +
    +
    + ); +} diff --git a/apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx b/apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx new file mode 100644 index 0000000..b7e9090 --- /dev/null +++ b/apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx @@ -0,0 +1,353 @@ +import React, { useState } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { EditIcon, Folder, Trash2 } from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; +import type { CloudFolder } from "@repo/db/types"; +import { getPageNumbers } from "@/utils/pageNumberGenerator"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationPrevious, + PaginationLink, + PaginationNext, +} from "@/components/ui/pagination"; +import type { QueryKey } from "@tanstack/react-query"; +import { useToast } from "@/hooks/use-toast"; +import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal"; +import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog"; +import { Menu, Item, contextMenu } from "react-contexify"; +import "react-contexify/dist/ReactContexify.css"; + +export const recentTopLevelFoldersQueryKey = (page: number): QueryKey => [ + "/api/cloud-storage/folders/recent", + page, +]; + +export type RecentTopLevelFoldersCardProps = { + pageSize?: number; + initialPage?: number; + className?: string; + onSelect?: (folderId: number | null) => void; +}; + +export default function RecentTopLevelFoldersCard({ + pageSize = 10, + initialPage = 1, + className, + onSelect, +}: RecentTopLevelFoldersCardProps) { + const [currentPage, setCurrentPage] = useState(initialPage); + const [selectedFolderId, setSelectedFolderId] = useState(null); + + const [isRenameOpen, setIsRenameOpen] = useState(false); + const [renameInitialName, setRenameInitialName] = useState(""); + const [renameTargetId, setRenameTargetId] = useState(null); + + const [isDeleteOpen, setIsDeleteOpen] = useState(false); + const [deleteTarget, setDeleteTarget] = useState(null); + + const qc = useQueryClient(); + const { toast } = useToast(); + + const { + data: recentFoldersData, + isLoading: isLoadingRecentFolders, + refetch, + } = useQuery({ + queryKey: recentTopLevelFoldersQueryKey(currentPage), + queryFn: async () => { + const offset = (currentPage - 1) * pageSize; + const res = await apiRequest( + "GET", + `/api/cloud-storage/folders/recent?limit=${pageSize}&offset=${offset}` + ); + const json = await res.json(); + if (!res.ok) + throw new Error(json?.message || "Failed to load recent folders"); + + const data: CloudFolder[] = Array.isArray(json.data) ? json.data : []; + const totalCount = + typeof json.totalCount === "number" + ? json.totalCount + : typeof json.total === "number" + ? json.total + : data.length; + + return { data, totalCount }; + }, + }); + + const data = recentFoldersData?.data ?? []; + const totalCount = recentFoldersData?.totalCount ?? data.length; + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const startItem = totalCount === 0 ? 0 : (currentPage - 1) * pageSize + 1; + const endItem = Math.min(totalCount, currentPage * pageSize); + + // toggle selection: select if different, deselect if same + function handleTileClick(id: number) { + if (selectedFolderId === id) { + setSelectedFolderId(null); + onSelect?.(null); + } else { + setSelectedFolderId(id); + onSelect?.(id); + } + // close any open context menu + contextMenu.hideAll(); + } + + // show react-contexify menu on right-click + function handleContextMenu(e: React.MouseEvent, folder: CloudFolder) { + e.preventDefault(); + e.stopPropagation(); + contextMenu.show({ + id: "recent-folder-context-menu", + event: e.nativeEvent, + props: { folder }, + }); + } + + // rename flow + function openRename(folder: CloudFolder) { + setRenameTargetId(Number(folder.id)); + setRenameInitialName(folder.name ?? ""); + setIsRenameOpen(true); + contextMenu.hideAll(); + } + + async function handleRenameSubmit(newName: string) { + if (!renameTargetId) return; + try { + const res = await apiRequest( + "PUT", + `/api/cloud-storage/folders/${renameTargetId}`, + { + name: newName, + } + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Failed to rename folder"); + toast({ title: "Folder renamed" }); + setIsRenameOpen(false); + setRenameTargetId(null); + // refresh current page & first page + qc.invalidateQueries({ + queryKey: recentTopLevelFoldersQueryKey(currentPage), + }); + qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) }); + await refetch(); + } catch (err: any) { + toast({ title: "Error", description: err?.message || String(err) }); + } + } + + // delete flow + function openDelete(folder: CloudFolder) { + setDeleteTarget(folder); + setIsDeleteOpen(true); + contextMenu.hideAll(); + } + + async function handleDeleteConfirm() { + if (!deleteTarget) return; + const id = deleteTarget.id; + try { + const res = await apiRequest( + "DELETE", + `/api/cloud-storage/folders/${id}` + ); + const json = await res.json(); + if (!res.ok) throw new Error(json?.message || "Failed to delete folder"); + toast({ title: "Folder deleted" }); + setIsDeleteOpen(false); + setDeleteTarget(null); + // if the deleted folder was selected, deselect it and notify parent + if (selectedFolderId === id) { + setSelectedFolderId(null); + onSelect?.(null); + } + // refresh pages + qc.invalidateQueries({ + queryKey: recentTopLevelFoldersQueryKey(currentPage), + }); + qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) }); + await refetch(); + } catch (err: any) { + toast({ title: "Error", description: err?.message || String(err) }); + } + } + + return ( + + + Recent Folders + + Most recently updated top-level folders. + + + + + {isLoadingRecentFolders ? ( +
    Loading...
    + ) : ( + <> +
    + {data.map((f) => { + const isSelected = selectedFolderId === Number(f.id); + return ( +
    +
    handleTileClick(Number(f.id))} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") + handleTileClick(Number(f.id)); + }} + onContextMenu={(e) => handleContextMenu(e, f)} + className={ + "w-full flex items-center gap-3 p-2 rounded-lg hover:bg-gray-100 cursor-pointer focus:outline-none " + + (isSelected ? "ring-2 ring-blue-400 bg-blue-50" : "") + } + style={{ minHeight: 44 }} + > + +
    {f.name}
    +
    +
    + ); + })} +
    + + {totalPages > 1 && ( +
    +
    +
    + Showing {startItem}–{endItem} of {totalCount} results +
    + + + + + { + e.preventDefault(); + if (currentPage > 1) + setCurrentPage(currentPage - 1); + }} + className={ + currentPage === 1 + ? "pointer-events-none opacity-50" + : "" + } + /> + + + {getPageNumbers(currentPage, totalPages).map( + (page, idx) => ( + + {page === "..." ? ( + ... + ) : ( + { + e.preventDefault(); + setCurrentPage(page as number); + }} + isActive={currentPage === page} + > + {page} + + )} + + ) + )} + + + { + e.preventDefault(); + if (currentPage < totalPages) + setCurrentPage(currentPage + 1); + }} + className={ + currentPage === totalPages + ? "pointer-events-none opacity-50" + : "" + } + /> + + + +
    +
    + )} + + )} +
    + + {/* react-contexify Menu (single shared menu) */} + + { + const folder: CloudFolder | undefined = props?.folder; + if (folder) openRename(folder); + }} + > + + Rename + + + + { + const folder: CloudFolder | undefined = props?.folder; + if (folder) openDelete(folder); + }} + > + + + Delete + + + + + {/* Rename modal (reuses NewFolderModal) */} + { + setIsRenameOpen(false); + setRenameTargetId(null); + }} + onSubmit={async (name) => { + await handleRenameSubmit(name); + }} + /> + + {/* Delete confirmation */} + { + setIsDeleteOpen(false); + setDeleteTarget(null); + }} + onConfirm={handleDeleteConfirm} + /> +
    + ); +} diff --git a/apps/Frontend/src/pages/cloud-storage-page.tsx b/apps/Frontend/src/pages/cloud-storage-page.tsx index 70655fd..aae97ab 100644 --- a/apps/Frontend/src/pages/cloud-storage-page.tsx +++ b/apps/Frontend/src/pages/cloud-storage-page.tsx @@ -1,903 +1,127 @@ -// src/pages/cloud-storage.tsx -import { useEffect, useRef, useState } from "react"; -import { useQuery, useMutation } from "@tanstack/react-query"; -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; +import { Folder as FolderIcon, Search as SearchIcon } from "lucide-react"; +import { apiRequest } from "@/lib/queryClient"; import { useToast } from "@/hooks/use-toast"; -import { apiRequest, queryClient } from "@/lib/queryClient"; -import { - Folder, - FolderPlus, - Search as SearchIcon, - X, - Plus, - File as FileIcon, - FileText, - Image as ImageIcon, - Image, -} from "lucide-react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogFooter, -} from "@/components/ui/dialog"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; +import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal"; +import FolderPanel from "@/components/cloud-storage/folder-panel"; import { useAuth } from "@/hooks/use-auth"; - -import type { CloudFolder, CloudFile } from "@repo/db/types"; - -type ApiListResponse = { - error: boolean; - data: T; - total?: number; - limit?: number; - offset?: number; -}; - -function truncateName(name: string, len = 28) { - if (!name) return ""; - return name.length > len ? name.slice(0, len - 1) + "…" : name; -} - -function fileIcon(mime?: string) { - if (!mime) return ; - if (mime.startsWith("image/")) return ; - if (mime === "application/pdf" || mime.endsWith("/pdf")) - return ; - return ; -} +import RecentTopLevelFoldersCard, { + recentTopLevelFoldersQueryKey, +} from "@/components/cloud-storage/recent-top-level-folder-modal"; export default function CloudStoragePage() { const { toast } = useToast(); const { user } = useAuth(); - const CURRENT_USER_ID = user?.id; + const qc = useQueryClient(); - // modal state - const [isFolderModalOpen, setIsFolderModalOpen] = useState(false); - const [modalFolder, setModalFolder] = useState(null); + // panel open + initial folder id to show when opening + const [panelOpen, setPanelOpen] = useState(false); + const [panelInitialFolderId, setPanelInitialFolderId] = useState< + number | null + >(null); - // Add-folder modal (simple name/cancel/confirm) - used both from main page and inside folder - const [isAddFolderModalOpen, setIsAddFolderModalOpen] = useState(false); - const [addFolderParentId, setAddFolderParentId] = useState( - null - ); // which parent to create in - const [addFolderName, setAddFolderName] = useState(""); + // key to remount recent card to clear its internal selection when needed + const [recentKey, setRecentKey] = useState(0); - // Upload modal (simple file picker + confirm) - const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); - const [uploadParentId, setUploadParentId] = useState(null); // which folder to upload into - const [uploadSelectedFiles, setUploadSelectedFiles] = useState([]); - const uploadFileInputRef = useRef(null); + // New folder modal + const [isNewFolderOpen, setIsNewFolderOpen] = useState(false); - // breadcrumb inside modal: array of {id: number|null, name} - const [modalPath, setModalPath] = useState< - { id: number | null; name: string }[] - >([{ id: null, name: "My Cloud Storage" }]); - - // pagination state for folders/files in modal - const [foldersOffset, setFoldersOffset] = useState(0); - const [filesOffset, setFilesOffset] = useState(0); - const FOLDERS_LIMIT = 10; - const FILES_LIMIT = 20; - - const [modalFolders, setModalFolders] = useState([]); - const [modalFiles, setModalFiles] = useState([]); - const [foldersTotal, setFoldersTotal] = useState(0); - const [filesTotal, setFilesTotal] = useState(0); - const [isLoadingModalItems, setIsLoadingModalItems] = useState(false); - - // recent folders (main page) - show only top-level (parentId === null) - const RECENT_LIMIT = 10; - const [recentOffset, setRecentOffset] = useState(0); - const { - data: recentFoldersData, - isLoading: isLoadingRecentFolders, - refetch: refetchRecentFolders, - } = useQuery({ - queryKey: ["/api/cloud-storage/folders/recent", recentOffset], - queryFn: async () => { - const res = await apiRequest( - "GET", - `/api/cloud-storage/folders/recent?limit=${RECENT_LIMIT}&offset=${recentOffset}` - ); - const json = await res.json(); - if (!res.ok) - throw new Error(json?.message || "Failed to load recent folders"); - // filter to top-level only (parentId === null) - const filtered = (json.data || []).filter((f: any) => f.parentId == null); - return { ...json, data: filtered } as ApiListResponse; - }, - }); - - /* ---------- Server fetch functions (paginated) ---------- */ - - async function fetchModalFolders( - parentId: number | null, - limit = FOLDERS_LIMIT, - offset = 0 - ) { - const pid = parentId === null ? "null" : String(parentId); - const res = await apiRequest( - "GET", - `/api/cloud-storage/items/folders?parentId=${encodeURIComponent(pid)}&limit=${limit}&offset=${offset}` - ); - const json = await res.json().catch(() => null); - if (!res.ok || !json) - throw new Error(json?.message || "Failed to fetch folders"); - return json as ApiListResponse; - } - - async function fetchModalFiles( - folderId: number | null, - limit = FILES_LIMIT, - offset = 0 - ) { - const fid = folderId === null ? "null" : String(folderId); - const res = await apiRequest( - "GET", - `/api/cloud-storage/folders/${encodeURIComponent(fid)}/files?limit=${limit}&offset=${offset}` - ); - const json = await res.json().catch(() => null); - if (!res.ok || !json) - throw new Error(json?.message || "Failed to fetch files"); - return json as ApiListResponse; - } - - /* ---------- load modal items (folders + files) ---------- */ - - const loadModalItems = async ( - parentId: number | null, - foldersOffsetArg = 0, - filesOffsetArg = 0 - ) => { - setIsLoadingModalItems(true); + // create folder handler (page-level) + async function handleCreateFolder(name: string) { try { - const [foldersResp, filesResp] = await Promise.all([ - fetchModalFolders(parentId, FOLDERS_LIMIT, foldersOffsetArg), - fetchModalFiles(parentId, FILES_LIMIT, filesOffsetArg), - ]); - setModalFolders(foldersResp.data ?? []); - setFoldersTotal(foldersResp.total ?? foldersResp.data?.length ?? 0); - setModalFiles(filesResp.data ?? []); - setFilesTotal(filesResp.total ?? filesResp.data?.length ?? 0); - } catch (err: any) { - toast({ - title: "Error", - description: err?.message || "Failed to load items", - variant: "destructive", - }); - setModalFolders([]); - setModalFiles([]); - setFoldersTotal(0); - setFilesTotal(0); - } finally { - setIsLoadingModalItems(false); - } - }; - - /* ---------- Open modal (single-click from recent or elsewhere) ---------- */ - const openFolderModal = async (folder: CloudFolder | null) => { - const fid: number | null = - folder && typeof (folder as any).id === "number" - ? (folder as any).id - : null; - - setModalPath((prev) => { - if (!folder) return [{ id: null, name: "My Cloud Storage" }]; - const idx = prev.findIndex((p) => p.id === fid); - if (idx >= 0) return prev.slice(0, idx + 1); - const last = prev[prev.length - 1]; - if (last && last.id === (folder as any).parentId) { - return [...prev, { id: fid, name: folder.name }]; + const userId = user?.id; + if (!userId) { + toast({ + title: "Sign in required", + description: "Please sign in to create a folder.", + }); + return; } - return [ - { id: null, name: "My Cloud Storage" }, - { id: fid, name: folder.name }, - ]; - }); - setModalFolder(folder ?? null); - setFoldersOffset(0); - setFilesOffset(0); - setUploadSelectedFiles([]); - setIsFolderModalOpen(true); - await loadModalItems(fid, 0, 0); - }; - - /* ---------- breadcrumb click inside modal ---------- */ - const handleModalBreadcrumbClick = async (index: number) => { - if (!Number.isInteger(index) || index < 0) return; - - const newPath = modalPath.slice(0, index + 1); - if (newPath.length === 0) { - setModalPath([{ id: null, name: "My Cloud Storage" }]); - setModalFolder(null); - setFoldersOffset(0); - setFilesOffset(0); - await loadModalItems(null, 0, 0); - return; - } - - setModalPath(newPath); - - const target = newPath[newPath.length - 1]; - const targetId: number | null = - target && typeof target.id === "number" ? target.id : null; - - setModalFolder(null); - setFoldersOffset(0); - setFilesOffset(0); - await loadModalItems(targetId, 0, 0); - }; - - /* ---------- create folder (via Add Folder modal) ---------- */ - const createFolder = useMutation({ - mutationFn: async (name: string) => { - const body = { - userId: CURRENT_USER_ID, + const res = await apiRequest("POST", `/api/cloud-storage/folders`, { + userId, name, - parentId: addFolderParentId ?? null, - }; - const res = await apiRequest("POST", "/api/cloud-storage/folders", body); + parentId: null, + }); const json = await res.json(); if (!res.ok) throw new Error(json?.message || "Failed to create folder"); - return json; - }, - onSuccess: async () => { - toast({ title: "Folder created", description: "Folder created." }); - setIsAddFolderModalOpen(false); - setAddFolderName(""); - // refresh modal view if we're in same parent - await loadModalItems( - addFolderParentId ?? null, - foldersOffset, - filesOffset - ); - queryClient.invalidateQueries({ - queryKey: ["/api/cloud-storage/folders/recent"], - }); - }, - onError: () => - toast({ - title: "Error", - description: "Failed to create folder", - variant: "destructive", - }), - }); - /* ---------- Upload logic (via upload modal) ---------- - Use arrayBuffer() and send as raw bytes to POST /files/:id/chunks?seq=..., - then POST /files/:id/complete - */ - const uploadSingleFile = async ( - file: File, - targetFolderId: number | null - ) => { - const folderParam = - targetFolderId === null ? "null" : String(targetFolderId); - const body = { - userId: CURRENT_USER_ID, - name: file.name, - mimeType: file.type || null, - expectedSize: file.size, - totalChunks: 1, - }; + toast({ title: "Folder created" }); - const initRes = await apiRequest( - "POST", - `/api/cloud-storage/folders/${encodeURIComponent(folderParam)}/files`, - body - ); - const initJson = await initRes.json().catch(() => null); - if (!initRes.ok || !initJson) - throw new Error( - initJson?.message ?? `Init failed (status ${initRes.status})` - ); - const created: CloudFile = initJson.data; - if (!created || typeof created.id !== "number") - throw new Error("Invalid response from init: missing file id"); + // close modal + setIsNewFolderOpen(false); - // prepare raw bytes - const raw = await file.arrayBuffer(); - const chunkUrl = `/api/cloud-storage/files/${created.id}/chunks?seq=0`; - - try { - await apiRequest("POST", chunkUrl, raw); + // Invalidate recent folders page 1 so RecentFoldersCard will refresh. + qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) }); } catch (err: any) { - try { - await apiRequest("DELETE", `/api/cloud-storage/files/${created.id}`); - } catch (_) {} - throw new Error(`Chunk upload failed: ${err?.message ?? String(err)}`); + toast({ title: "Error", description: err?.message || String(err) }); } - - const completeRes = await apiRequest( - "POST", - `/api/cloud-storage/files/${created.id}/complete`, - {} - ); - const completeJson = await completeRes.json().catch(() => null); - if (!completeRes.ok || !completeJson) - throw new Error( - completeJson?.message ?? - `Finalize failed (status ${completeRes.status})` - ); - return completeJson; - }; - - const uploadFilesMutation = useMutation({ - mutationFn: async (files: File[]) => { - const targetFolderId = - uploadParentId ?? modalPath[modalPath.length - 1]?.id ?? null; - const results = []; - for (const f of files) { - results.push(await uploadSingleFile(f, targetFolderId)); - } - return results; - }, - onSuccess: async () => { - toast({ title: "Upload complete", description: "Files uploaded." }); - setUploadSelectedFiles([]); - setIsUploadModalOpen(false); - await loadModalItems( - modalPath[modalPath.length - 1]?.id ?? null, - foldersOffset, - filesOffset - ); - queryClient.invalidateQueries({ - queryKey: ["/api/cloud-storage/folders/recent"], - }); - }, - onError: (err: any) => { - toast({ - title: "Upload failed", - description: err?.message ?? "Upload failed", - variant: "destructive", - }); - }, - }); - - /* ---------- handlers ---------- */ - - const handleUploadFileSelection = (filesList: FileList | null) => { - setUploadSelectedFiles(filesList ? Array.from(filesList) : []); - }; - - const startUploadFromModal = () => { - if (!uploadSelectedFiles.length) { - toast({ - title: "No files", - description: "Select files to upload", - variant: "destructive", - }); - return; - } - uploadFilesMutation.mutate(uploadSelectedFiles); - }; - - const handleFoldersPage = async (dir: "next" | "prev") => { - const newOffset = - dir === "next" - ? foldersOffset + FOLDERS_LIMIT - : Math.max(0, foldersOffset - FOLDERS_LIMIT); - setFoldersOffset(newOffset); - await loadModalItems( - modalPath[modalPath.length - 1]?.id ?? null, - newOffset, - filesOffset - ); - }; - - const handleFilesPage = async (dir: "next" | "prev") => { - const newOffset = - dir === "next" - ? filesOffset + FILES_LIMIT - : Math.max(0, filesOffset - FILES_LIMIT); - setFilesOffset(newOffset); - await loadModalItems( - modalPath[modalPath.length - 1]?.id ?? null, - foldersOffset, - newOffset - ); - }; - - useEffect(() => { - refetchRecentFolders(); - }, [recentOffset]); - - /* ---------- Render ---------- */ + } return ( -
    -
    -
    -
    -

    Cloud Storage

    -

    - Recent top-level folders — click any folder to open it. -

    -
    - -
    - - - {/* MAIN PAGE New Folder: open Add Folder modal (parent null) */} - -
    +
    + {/* Header / actions */} +
    +
    +

    Cloud Storage

    +

    + Manage Files and Folders in Cloud Storage. +

    - {/* Recent Folders (top-level only) */} - - - Recent Folders - - Most recently updated top-level folders - - - - {isLoadingRecentFolders ? ( -
    Loading...
    - ) : ( - <> -
    - {(recentFoldersData?.data ?? []).map((f) => ( -
    -
    openFolderModal(f)} - className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer" - style={{ minWidth: 120 }} - > - -
    - {f.name} -
    -
    -
    - ))} -
    +
    + -
    -
    - Showing {(recentFoldersData?.data ?? []).length} recent - folders -
    -
    - - -
    -
    - - )} - - + +
    - {/* Main Modal: spacing so not flush top/bottom */} - { - setIsFolderModalOpen(v); - if (!v) { - setModalFolder(null); - setModalPath([{ id: null, name: "My Cloud Storage" }]); - setModalFolders([]); - setModalFiles([]); - setUploadSelectedFiles([]); - } + {/* Recent folders card (delegated component) */} + { + setPanelInitialFolderId(folderId); + setPanelOpen(true); }} - > - - -
    -
    - {/* breadcrumb inside modal */} - - - {modalPath.map((p, idx) => ( -
    - {idx > 0 && } - - {idx === modalPath.length - 1 ? ( - {p.name} - ) : ( - handleModalBreadcrumbClick(idx)} - > - {p.name} - - )} - -
    - ))} -
    -
    -
    + /> -
    - -
    -
    -
    + {/* FolderPanel lives in page so it can be reused with other UI */} + {panelOpen && ( + setPanelOpen(false)} + onViewChange={(viewedId: any) => { + // If the panel navigates back to root, clear recent card selection by remounting it + if (viewedId === null) { + setRecentKey((k) => k + 1); -
    - {/* ----- Folders row ----- */} - - - Folders - - Child folders (page size {FOLDERS_LIMIT}) - - - -
    - {/* Add Folder tile: opens Add Folder modal (parent = current modal parent) */} -
    -
    { - setAddFolderParentId( - modalPath[modalPath.length - 1]?.id ?? null - ); - setAddFolderName(""); - setIsAddFolderModalOpen(true); - }} - style={{ minWidth: 120 }} - > - -
    -
    + // clear the panel initial id and close the panel so child folder/file sections hide + setPanelInitialFolderId(null); + setPanelOpen(false); + } + }} + /> + )} - {modalFolders.map((f) => ( -
    -
    openFolderModal(f)} - className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer" - style={{ minWidth: 120 }} - > - -
    - {f.name} -
    -
    -
    - ))} -
    - -
    -
    - Showing {modalFolders.length} of {foldersTotal} -
    -
    - - -
    -
    -
    -
    - - {/* ----- Files section (below folders) ----- */} - - - Files - - Files in this folder (page size {FILES_LIMIT}) - - - -
    -
    -
    - Target: {modalPath[modalPath.length - 1]?.name} -
    -
    - -
    - {/* ADD FILE tile (only + icon) */} -
    { - setUploadParentId( - modalPath[modalPath.length - 1]?.id ?? null - ); - setUploadSelectedFiles([]); - setIsUploadModalOpen(true); - }} - style={{ minWidth: 120 }} - > - -
    -
    -
    - - {isLoadingModalItems ? ( -
    Loading...
    - ) : ( - <> -
    - {modalFiles.map((file) => ( -
    -
    -
    - {fileIcon((file as any).mimeType)} -
    -
    -
    - {truncateName(file.name, 28)} -
    -
    - {((file as any).fileSize ?? 0).toString()} bytes -
    -
    -
    -
    - ))} -
    - -
    -
    - Showing {modalFiles.length} of {filesTotal} -
    -
    - - -
    -
    - - )} -
    -
    -
    - - - - -
    -
    - - {/* Add Folder Modal (simple name/cancel/confirm) */} - { - setIsAddFolderModalOpen(v); - if (!v) { - setAddFolderName(""); - setAddFolderParentId(null); - } - }} - > - - -
    -
    -

    Create Folder

    -
    - Parent:{" "} - {addFolderParentId == null - ? "Root" - : `id ${addFolderParentId}`} -
    -
    -
    - -
    -
    -
    - -
    - setAddFolderName(e.target.value)} - /> -
    - - -
    -
    -
    -
    - - {/* Upload Modal */} - { - setIsUploadModalOpen(v); - if (!v) { - setUploadSelectedFiles([]); - setUploadParentId(null); - } - }} - > - - -
    -
    -

    Upload Files

    -
    - Target:{" "} - {uploadParentId == null ? "Root" : `id ${uploadParentId}`} -
    -
    -
    - -
    -
    -
    - -
    - - handleUploadFileSelection(e.target.files)} - /> -
    - {uploadSelectedFiles.length ? ( -
    - {uploadSelectedFiles.map((f) => f.name).join(", ")} -
    - ) : ( -
    - No files selected -
    - )} -
    - - - -
    -
    -
    -
    -
    + {/* New folder modal (reusable) */} + setIsNewFolderOpen(false)} + onSubmit={handleCreateFolder} + />
    ); } diff --git a/apps/Frontend/src/utils/pageNumberGenerator.ts b/apps/Frontend/src/utils/pageNumberGenerator.ts new file mode 100644 index 0000000..0309e15 --- /dev/null +++ b/apps/Frontend/src/utils/pageNumberGenerator.ts @@ -0,0 +1,20 @@ +export function getPageNumbers(current: number, total: number, maxButtons = 7) { + const pages: (number | "...")[] = []; + if (total <= maxButtons) { + for (let i = 1; i <= total; i++) pages.push(i); + return pages; + } + + const half = Math.floor(maxButtons / 2); + let start = Math.max(1, current - half); + let end = Math.min(total, current + half); + + if (start === 1) end = Math.min(total, maxButtons); + if (end === total) start = Math.max(1, total - maxButtons + 1); + + if (start > 1) pages.push(1, "..."); + for (let i = start; i <= end; i++) pages.push(i); + if (end < total) pages.push("...", total); + + return pages; +}