feat(cloud-page) - wip - UI fixed, functionalities done, view/downlaod to be done

This commit is contained in:
2025-09-28 02:41:32 +05:30
parent 9a3c52bef5
commit 9090375789
10 changed files with 1822 additions and 908 deletions

View File

@@ -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<any> => {
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<any> => {
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");
}

View File

@@ -40,13 +40,16 @@ async function updateFolderTimestampsRecursively(folderId: number | null) {
export interface IStorage {
// Folders
getFolder(id: number): Promise<CloudFolder | null>;
listFoldersByParent(
parentId: number | null,
listRecentFolders(
limit: number,
offset: number
offset: number,
parentId?: number | null
): Promise<CloudFolder[]>;
countFoldersByParent(parentId: number | null): Promise<number>;
listRecentFolders(limit: number, offset: number): Promise<CloudFolder[]>;
countFolders(filter?: {
userId?: number;
nameContains?: string | null;
}): Promise<number>;
createFolder(
userId: number,
name: string,
@@ -57,10 +60,6 @@ export interface IStorage {
updates: Partial<{ name?: string; parentId?: number | null }>
): Promise<CloudFolder | null>;
deleteFolder(id: number): Promise<boolean>;
countFolders(filter?: {
userId?: number;
nameContains?: string | null;
}): Promise<number>;
// Files
getFile(id: number): Promise<CloudFile | null>;
@@ -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,

View File

@@ -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<HTMLDivElement | null>(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 (
<button
className={active ? activeChip : inactiveChip}
onClick={() => onNavigate(id)}
title={name ?? (id ? `Folder ${id}` : "My Cloud Storage")}
aria-current={active ? "page" : undefined}
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden
>
<path d="M3 7h18v10H3z" />
</svg>
<span className="truncate">
{name ?? (id ? `Folder ${id}` : "My Cloud Storage")}
</span>
</button>
);
}
// small slash separator (visible between chips)
const Slash = () => <li className="text-muted-foreground px-1">/</li>;
return (
// Card-like background for the entire breadcrumb strip
<nav className="bg-card p-3 rounded-md shadow-sm" aria-label="breadcrumb">
<ol className="flex items-center gap-2 flex-wrap">
{/* Root chip */}
<li>
<button
className={path.length === 0 ? activeChip : inactiveChip}
onClick={() => onNavigate(null)}
title="My Cloud Storage"
aria-current={path.length === 0 ? "page" : undefined}
>
<svg
className="h-4 w-4"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
aria-hidden
>
<path d="M3 11.5L12 4l9 7.5V20a1 1 0 0 1-1 1h-4v-6H8v6H4a1 1 0 0 1-1-1v-8.5z" />
</svg>
<span className="hidden sm:inline">My Cloud Storage</span>
</button>
</li>
{path.length > 0 && <Slash />}
{showAll ? (
// show all crumbs as chips with slashes between
path.map((p, idx) => (
<Fragment key={String(p.id ?? idx)}>
<li>
<Chip
id={p.id}
name={p.name}
active={idx === path.length - 1}
/>
</li>
{idx !== path.length - 1 && <Slash />}
</Fragment>
))
) : (
// collapsed view: first, ellipsis dropdown, last two (with slashes)
<>
{first && (
<>
<li>
<Chip id={first.id} name={first.name} active={false} />
</li>
<Slash />
</>
)}
<li>
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setOpenEllipsis((s) => !s)}
aria-expanded={openEllipsis}
className={inactiveChip}
title="Show hidden path"
>
</button>
{/* dropdown for middle items */}
{openEllipsis && (
<div className="absolute left-0 mt-2 w-56 bg-popover border rounded shadow z-50">
<ul className="p-2">
{middle.map((m) => (
<li key={String(m.id)}>
<button
className="w-full text-left px-2 py-1 rounded hover:bg-accent/5 text-sm text-muted-foreground"
onClick={() => {
setOpenEllipsis(false);
onNavigate(m.id);
}}
>
{m.name ?? `Folder ${m.id}`}
</button>
</li>
))}
{middle.length === 0 && (
<li>
<div className="px-2 py-1 text-sm text-muted-foreground">
No hidden folders
</div>
</li>
)}
</ul>
</div>
)}
</div>
</li>
<Slash />
{lastTwo.map((p, idx) => (
<Fragment key={String(p.id ?? `tail-${idx}`)}>
<li>
<Chip
id={p.id}
name={p.name}
active={idx === lastTwo.length - 1}
/>
</li>
{idx !== lastTwo.length - 1 && <Slash />}
</Fragment>
))}
</>
)}
</ol>
</nav>
);
}

View File

@@ -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 <FileIcon className="h-6 w-6" />;
if (mime.startsWith("image/")) return <ImageIcon className="h-6 w-6" />;
if (mime === "application/pdf" || mime.endsWith("/pdf"))
return <FileText className="h-6 w-6" />;
return <FileIcon className="h-6 w-6" />;
}
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<CloudFile[]>([]);
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<any>(null);
// rename/delete
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
const [renameInitial, setRenameInitial] = useState("");
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(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 (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Files</CardTitle>
<CardDescription>Manage Files in this folder</CardDescription>
</div>
<Button
variant="default"
className="inline-flex items-center px-4 py-2"
onClick={() => setIsUploadOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
Upload
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-6 text-center">Loading...</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{data.map((file) => (
<div
key={file.id}
className="p-3 rounded border hover:bg-gray-50 cursor-pointer"
onContextMenu={(e) => showMenu(e, file)}
onDoubleClick={() => onFileOpen?.(Number(file.id))}
>
<div className="flex flex-col items-center">
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
{fileIcon((file as any).mimeType)}
</div>
<div
className="text-sm truncate text-center"
style={{ maxWidth: 140 }}
>
<div title={file.name}>{file.name}</div>
<div className="text-xs text-gray-400">
{((file as any).fileSize ?? 0).toString()} bytes
</div>
</div>
</div>
</div>
))}
</div>
{/* pagination */}
{totalPages > 1 && (
<div className="mt-4 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem}{endItem} of {total} results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage((p) => Math.max(1, p - 1));
}}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map(
(page, idx) => (
<PaginationItem key={idx}>
{page === "..." ? (
<span className="px-2 text-gray-500">...</span>
) : (
<PaginationLink
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage(page as number);
}}
isActive={currentPage === page}
>
{page}
</PaginationLink>
)}
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage((p) => Math.min(totalPages, p + 1));
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)}
</>
)}
</CardContent>
{/* context menu */}
<Menu id="files-section-menu" animation="fade">
<Item onClick={({ props }: any) => openRename(props.file)}>
<span className="flex items-center gap-2">
<EditIcon className="h-4 w-4" /> Rename
</span>
</Item>
<Item onClick={({ props }: any) => handleDownload(props.file)}>
<span className="flex items-center gap-2">
<Download className="h-4 w-4" /> Download
</span>
</Item>
<Item onClick={({ props }: any) => openDelete(props.file)}>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4" /> Delete
</span>
</Item>
</Menu>
{/* upload modal using MultipleFileUploadZone (imperative handle) */}
{isUploadOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
<div className="bg-white p-6 rounded-md w-[90%] max-w-2xl">
<h3 className="text-lg font-semibold mb-4">Upload files</h3>
<MultipleFileUploadZone ref={uploadRef} />
<div className="mt-4 flex justify-end gap-2">
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
Cancel
</Button>
<Button onClick={handleUploadSubmit}>Upload</Button>
</div>
</div>
</div>
)}
{/* rename modal (reusing NewFolderModal for simplicity) */}
<NewFolderModal
isOpen={isRenameOpen}
initialName={renameInitial}
title="Rename File"
submitLabel="Rename"
onClose={() => {
setIsRenameOpen(false);
setRenameTargetId(null);
}}
onSubmit={submitRename}
/>
{/* delete confirm */}
<DeleteConfirmationDialog
isOpen={isDeleteOpen}
entityName={deleteTarget?.name}
onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}}
onConfirm={confirmDelete}
/>
</Card>
);
}

View File

@@ -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<number | null>(
folderId ?? null
);
const [path, setPath] = useState<FolderMeta[]>([]);
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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-semibold">
{currentFolderId == null
? "My Cloud Storage"
: `Folder ${currentFolderId}`}
</h2>
<div>
{onClose && (
<button
onClick={onClose}
className="inline-flex items-center px-3 py-1.5 rounded-md text-sm hover:bg-gray-100"
>
Close
</button>
)}
</div>
</div>
{/* Breadcrumb / path strip */}
<div>
{/* show breadcrumbs even if loading; breadcrumbs show 'My Cloud Storage' + path */}
<Breadcrumbs path={path} onNavigate={handleNavigateTo} />
</div>
{/* stacked vertically: folders on top, files below */}
<div className="flex flex-col gap-6">
<div className="w-full">
{/* pass onSelect so FolderSection can tell the panel to navigate into a child */}
<FolderSection
parentId={currentFolderId}
onSelect={handleChildSelect}
/>
</div>
<div className="w-full">
<FilesSection parentId={currentFolderId} />
</div>
</div>
</div>
);
}

View File

@@ -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<CloudFolder[]>([]);
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<number | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudFolder | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(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 (
<Card className={className}>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div>
<CardTitle>Folders</CardTitle>
<CardDescription>Manage all its Child folders</CardDescription>
</div>
<Button
variant="default"
className="inline-flex items-center px-4 py-2"
onClick={() => setIsNewOpen(true)}
>
<Plus className="h-4 w-4 mr-2" />
New Folder
</Button>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="py-6 text-center">Loading...</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-2">
{data.map((f) => {
const isSelected = selectedId === f.id;
return (
<div key={f.id} className="flex">
<div
role="button"
tabIndex={0}
onClick={() => 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" : "")
}
>
<FolderIcon className="h-6 w-6 text-yellow-500" />
<div className="text-sm truncate">{f.name}</div>
</div>
</div>
);
})}
</div>
{/* Pagination inside card */}
{totalPages > 1 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem}{endItem} of {total} results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage((p) => Math.max(1, p - 1));
}}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map(
(page, idx) => (
<PaginationItem key={idx}>
{page === "..." ? (
<span className="px-2 text-gray-500">...</span>
) : (
<PaginationLink
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage(page as number);
}}
isActive={currentPage === page}
>
{page}
</PaginationLink>
)}
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage((p) => Math.min(totalPages, p + 1));
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)}
</>
)}
</CardContent>
{/* react-contexify menu */}
<Menu id="folder-section-menu" animation="fade">
<Item onClick={({ props }: any) => openRename(props.folder)}>
<span className="flex items-center gap-2">
<EditIcon className="h-4 w-4" /> Rename
</span>
</Item>
<Item onClick={({ props }: any) => openDelete(props.folder)}>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4" /> Delete
</span>
</Item>
</Menu>
{/* Modals */}
<NewFolderModal
isOpen={isNewOpen}
onClose={() => setIsNewOpen(false)}
onSubmit={handleCreate}
/>
<NewFolderModal
isOpen={isRenameOpen}
initialName={renameInitial}
title="Rename Folder"
submitLabel="Rename"
onClose={() => {
setIsRenameOpen(false);
setRenameTargetId(null);
}}
onSubmit={submitRename}
/>
<DeleteConfirmationDialog
isOpen={isDeleteOpen}
entityName={deleteTarget?.name}
onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}}
onConfirm={confirmDelete}
/>
</Card>
);
}

View File

@@ -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> | 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 (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/40"
onClick={() => {
if (!isSubmitting) onClose();
}}
/>
<div className="relative w-full max-w-md mx-4 bg-white rounded-lg shadow-lg">
<div className="p-4 border-b">
<h3 className="text-lg font-medium">{title}</h3>
</div>
<form
onSubmit={async (e) => {
e.preventDefault();
if (!name.trim()) return;
try {
setIsSubmitting(true);
await onSubmit(name.trim());
} finally {
setIsSubmitting(false);
}
}}
>
<div className="p-4 space-y-3">
<label className="block text-sm font-medium">Folder name</label>
<input
autoFocus
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded-md border px-3 py-2"
placeholder="Enter folder name"
disabled={isSubmitting}
/>
</div>
<div className="flex items-center justify-end gap-2 p-4 border-t">
<Button
variant="ghost"
type="button"
onClick={() => !isSubmitting && onClose()}
>
Cancel
</Button>
<Button type="submit" disabled={isSubmitting || !name.trim()}>
{isSubmitting ? "Saving..." : submitLabel}
</Button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -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<number>(initialPage);
const [selectedFolderId, setSelectedFolderId] = useState<number | null>(null);
const [isRenameOpen, setIsRenameOpen] = useState(false);
const [renameInitialName, setRenameInitialName] = useState<string>("");
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CloudFolder | null>(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 (
<Card className={className}>
<CardHeader>
<CardTitle>Recent Folders</CardTitle>
<CardDescription>
Most recently updated top-level folders.
</CardDescription>
</CardHeader>
<CardContent className="py-3">
{isLoadingRecentFolders ? (
<div className="py-6 text-center">Loading...</div>
) : (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-5 gap-2">
{data.map((f) => {
const isSelected = selectedFolderId === Number(f.id);
return (
<div key={f.id} className="flex">
<div
role="button"
tabIndex={0}
onClick={() => 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 }}
>
<Folder className="h-8 w-8 text-yellow-500 flex-shrink-0" />
<div className="text-sm truncate">{f.name}</div>
</div>
</div>
);
})}
</div>
{totalPages > 1 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2">
<div className="text-sm text-muted-foreground whitespace-nowrap">
Showing {startItem}{endItem} of {totalCount} results
</div>
<Pagination>
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
onClick={(e: any) => {
e.preventDefault();
if (currentPage > 1)
setCurrentPage(currentPage - 1);
}}
className={
currentPage === 1
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
{getPageNumbers(currentPage, totalPages).map(
(page, idx) => (
<PaginationItem key={idx}>
{page === "..." ? (
<span className="px-2 text-gray-500">...</span>
) : (
<PaginationLink
href="#"
onClick={(e: any) => {
e.preventDefault();
setCurrentPage(page as number);
}}
isActive={currentPage === page}
>
{page}
</PaginationLink>
)}
</PaginationItem>
)
)}
<PaginationItem>
<PaginationNext
href="#"
onClick={(e: any) => {
e.preventDefault();
if (currentPage < totalPages)
setCurrentPage(currentPage + 1);
}}
className={
currentPage === totalPages
? "pointer-events-none opacity-50"
: ""
}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div>
)}
</>
)}
</CardContent>
{/* react-contexify Menu (single shared menu) */}
<Menu id="recent-folder-context-menu" animation="fade">
<Item
onClick={({ props }: any) => {
const folder: CloudFolder | undefined = props?.folder;
if (folder) openRename(folder);
}}
>
<span className="flex items-center gap-2">
<EditIcon className="h-4 w-4" /> Rename
</span>
</Item>
<Item
onClick={({ props }: any) => {
const folder: CloudFolder | undefined = props?.folder;
if (folder) openDelete(folder);
}}
>
<span className="flex items-center gap-2 text-red-600">
<Trash2 className="h-4 w-4" />
Delete
</span>
</Item>
</Menu>
{/* Rename modal (reuses NewFolderModal) */}
<NewFolderModal
isOpen={isRenameOpen}
initialName={renameInitialName}
title="Rename Folder"
submitLabel="Rename"
onClose={() => {
setIsRenameOpen(false);
setRenameTargetId(null);
}}
onSubmit={async (name) => {
await handleRenameSubmit(name);
}}
/>
{/* Delete confirmation */}
<DeleteConfirmationDialog
isOpen={isDeleteOpen}
entityName={deleteTarget?.name}
onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}}
onConfirm={handleDeleteConfirm}
/>
</Card>
);
}

View File

@@ -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<T> = {
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 <FileIcon className="h-10 w-10" />;
if (mime.startsWith("image/")) return <Image className="h-10 w-10" />;
if (mime === "application/pdf" || mime.endsWith("/pdf"))
return <FileText className="h-10 w-10" />;
return <FileIcon className="h-10 w-10" />;
}
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<CloudFolder | null>(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<number | null>(
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<number | null>(null); // which folder to upload into
const [uploadSelectedFiles, setUploadSelectedFiles] = useState<File[]>([]);
const uploadFileInputRef = useRef<HTMLInputElement | null>(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<CloudFolder[]>([]);
const [modalFiles, setModalFiles] = useState<CloudFile[]>([]);
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<CloudFolder[]>;
},
});
/* ---------- 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<CloudFolder[]>;
}
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<CloudFile[]>;
}
/* ---------- 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 (
<div>
<div className="container mx-auto space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Cloud Storage</h1>
<p className="text-muted-foreground">
Recent top-level folders click any folder to open it.
</p>
</div>
<div className="flex gap-2 items-center">
<Button
variant="ghost"
onClick={() => {
/* search omitted */
}}
>
<SearchIcon className="h-4 w-4 mr-2" /> Search
</Button>
{/* MAIN PAGE New Folder: open Add Folder modal (parent null) */}
<Button
onClick={() => {
setAddFolderParentId(null);
setAddFolderName("");
setIsAddFolderModalOpen(true);
}}
>
<FolderPlus className="h-4 w-4 mr-2" /> New Folder (root)
</Button>
</div>
<div className="container mx-auto space-y-6">
{/* Header / actions */}
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold tracking-tight">Cloud Storage</h1>
<p className="text-muted-foreground">
Manage Files and Folders in Cloud Storage.
</p>
</div>
{/* Recent Folders (top-level only) */}
<Card>
<CardHeader>
<CardTitle>Recent Folders</CardTitle>
<CardDescription>
Most recently updated top-level folders
</CardDescription>
</CardHeader>
<CardContent>
{isLoadingRecentFolders ? (
<div className="py-6 text-center">Loading...</div>
) : (
<>
<div className="flex gap-3 overflow-x-auto py-2">
{(recentFoldersData?.data ?? []).map((f) => (
<div key={f.id} className="flex-shrink-0">
<div
onClick={() => openFolderModal(f)}
className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer"
style={{ minWidth: 120 }}
>
<Folder className="h-10 w-10 text-yellow-500 mb-1" />
<div
className="text-sm max-w-[140px] text-center truncate"
title={f.name}
>
{f.name}
</div>
</div>
</div>
))}
</div>
<div className="flex gap-2 items-center">
<Button
variant="ghost"
onClick={() => {
/* search flow omitted - wire your search UI here */
}}
>
<SearchIcon className="h-4 w-4 mr-2" /> Search
</Button>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Showing {(recentFoldersData?.data ?? []).length} recent
folders
</div>
<div className="flex gap-2 items-center">
<Button
size="sm"
variant="outline"
onClick={() =>
setRecentOffset(
Math.max(0, recentOffset - RECENT_LIMIT)
)
}
disabled={recentOffset === 0}
>
Prev
</Button>
<Button
size="sm"
variant="outline"
onClick={() =>
setRecentOffset(recentOffset + RECENT_LIMIT)
}
disabled={
!recentFoldersData?.data?.length ||
recentFoldersData!.data.length < RECENT_LIMIT
}
>
Next
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
<Button onClick={() => setIsNewFolderOpen(true)}>
<FolderIcon className="h-4 w-4 mr-2" /> New Folder
</Button>
</div>
</div>
{/* Main Modal: spacing so not flush top/bottom */}
<Dialog
open={isFolderModalOpen}
onOpenChange={(v) => {
setIsFolderModalOpen(v);
if (!v) {
setModalFolder(null);
setModalPath([{ id: null, name: "My Cloud Storage" }]);
setModalFolders([]);
setModalFiles([]);
setUploadSelectedFiles([]);
}
{/* Recent folders card (delegated component) */}
<RecentTopLevelFoldersCard
key={recentKey}
pageSize={10}
initialPage={1}
onSelect={(folderId) => {
setPanelInitialFolderId(folderId);
setPanelOpen(true);
}}
>
<DialogContent className="max-w-6xl w-full my-8">
<DialogHeader>
<div className="flex items-center justify-between w-full">
<div className="w-full">
{/* breadcrumb inside modal */}
<Breadcrumb>
<BreadcrumbList>
{modalPath.map((p, idx) => (
<div
key={String(p.id) + idx}
className="flex items-center"
>
{idx > 0 && <BreadcrumbSeparator />}
<BreadcrumbItem>
{idx === modalPath.length - 1 ? (
<BreadcrumbPage>{p.name}</BreadcrumbPage>
) : (
<BreadcrumbLink
className="cursor-pointer hover:text-primary"
onClick={() => handleModalBreadcrumbClick(idx)}
>
{p.name}
</BreadcrumbLink>
)}
</BreadcrumbItem>
</div>
))}
</BreadcrumbList>
</Breadcrumb>
</div>
/>
<div className="ml-4">
<Button
size="sm"
variant="ghost"
onClick={() => setIsFolderModalOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DialogHeader>
{/* FolderPanel lives in page so it can be reused with other UI */}
{panelOpen && (
<FolderPanel
folderId={panelInitialFolderId}
onClose={() => 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);
<div className="space-y-6 p-4">
{/* ----- Folders row ----- */}
<Card>
<CardHeader>
<CardTitle>Folders</CardTitle>
<CardDescription>
Child folders (page size {FOLDERS_LIMIT})
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 overflow-x-auto py-2">
{/* Add Folder tile: opens Add Folder modal (parent = current modal parent) */}
<div className="flex-shrink-0">
<div
className="p-3 rounded-lg hover:bg-gray-50 cursor-pointer border border-dashed border-gray-200 flex items-center justify-center"
onClick={() => {
setAddFolderParentId(
modalPath[modalPath.length - 1]?.id ?? null
);
setAddFolderName("");
setIsAddFolderModalOpen(true);
}}
style={{ minWidth: 120 }}
>
<Plus className="h-10 w-10" />
</div>
</div>
// clear the panel initial id and close the panel so child folder/file sections hide
setPanelInitialFolderId(null);
setPanelOpen(false);
}
}}
/>
)}
{modalFolders.map((f) => (
<div key={f.id} className="flex-shrink-0">
<div
onClick={() => openFolderModal(f)}
className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer"
style={{ minWidth: 120 }}
>
<Folder className="h-10 w-10 text-yellow-500 mb-1" />
<div
className="text-sm truncate text-center"
style={{ maxWidth: 120 }}
title={f.name}
>
{f.name}
</div>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Showing {modalFolders.length} of {foldersTotal}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleFoldersPage("prev")}
disabled={foldersOffset === 0}
>
Prev
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleFoldersPage("next")}
disabled={foldersOffset + FOLDERS_LIMIT >= foldersTotal}
>
Next
</Button>
</div>
</div>
</CardContent>
</Card>
{/* ----- Files section (below folders) ----- */}
<Card>
<CardHeader>
<CardTitle>Files</CardTitle>
<CardDescription>
Files in this folder (page size {FILES_LIMIT})
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-sm text-gray-500">
Target: {modalPath[modalPath.length - 1]?.name}
</div>
</div>
<div className="flex items-center gap-2">
{/* ADD FILE tile (only + icon) */}
<div
className="p-3 rounded-lg hover:bg-gray-50 cursor-pointer border border-dashed border-gray-200 flex items-center justify-center"
onClick={() => {
setUploadParentId(
modalPath[modalPath.length - 1]?.id ?? null
);
setUploadSelectedFiles([]);
setIsUploadModalOpen(true);
}}
style={{ minWidth: 120 }}
>
<Plus className="h-10 w-10" />
</div>
</div>
</div>
{isLoadingModalItems ? (
<div className="py-6 text-center">Loading...</div>
) : (
<>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
{modalFiles.map((file) => (
<div
key={file.id}
className="p-3 rounded hover:bg-gray-50 border"
title={file.name}
>
<div className="flex flex-col items-center">
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
{fileIcon((file as any).mimeType)}
</div>
<div
className="text-sm truncate text-center"
style={{ maxWidth: 140 }}
>
<div title={file.name}>
{truncateName(file.name, 28)}
</div>
<div className="text-xs text-gray-400">
{((file as any).fileSize ?? 0).toString()} bytes
</div>
</div>
</div>
</div>
))}
</div>
<div className="flex items-center justify-between mt-4">
<div className="text-sm text-gray-500">
Showing {modalFiles.length} of {filesTotal}
</div>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleFilesPage("prev")}
disabled={filesOffset === 0}
>
Prev
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleFilesPage("next")}
disabled={filesOffset + FILES_LIMIT >= filesTotal}
>
Next
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsFolderModalOpen(false)}
>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Add Folder Modal (simple name/cancel/confirm) */}
<Dialog
open={isAddFolderModalOpen}
onOpenChange={(v) => {
setIsAddFolderModalOpen(v);
if (!v) {
setAddFolderName("");
setAddFolderParentId(null);
}
}}
>
<DialogContent className="max-w-md w-full my-8">
<DialogHeader>
<div className="flex items-center justify-between w-full">
<div>
<h3 className="text-lg font-semibold">Create Folder</h3>
<div className="text-sm text-muted-foreground">
Parent:{" "}
{addFolderParentId == null
? "Root"
: `id ${addFolderParentId}`}
</div>
</div>
<div>
<Button
size="sm"
variant="ghost"
onClick={() => setIsAddFolderModalOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DialogHeader>
<div className="p-4">
<Input
placeholder="Folder name"
value={addFolderName}
onChange={(e) => setAddFolderName(e.target.value)}
/>
<div className="flex gap-2 mt-3">
<Button onClick={() => createFolder.mutate(addFolderName.trim())}>
Create
</Button>
<Button
variant="ghost"
onClick={() => {
setIsAddFolderModalOpen(false);
setAddFolderName("");
}}
>
Cancel
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* Upload Modal */}
<Dialog
open={isUploadModalOpen}
onOpenChange={(v) => {
setIsUploadModalOpen(v);
if (!v) {
setUploadSelectedFiles([]);
setUploadParentId(null);
}
}}
>
<DialogContent className="max-w-md w-full my-8">
<DialogHeader>
<div className="flex items-center justify-between w-full">
<div>
<h3 className="text-lg font-semibold">Upload Files</h3>
<div className="text-sm text-muted-foreground">
Target:{" "}
{uploadParentId == null ? "Root" : `id ${uploadParentId}`}
</div>
</div>
<div>
<Button
size="sm"
variant="ghost"
onClick={() => setIsUploadModalOpen(false)}
>
<X className="h-4 w-4" />
</Button>
</div>
</div>
</DialogHeader>
<div className="p-4">
<Label>Choose files (multiple)</Label>
<input
ref={uploadFileInputRef}
type="file"
multiple
onChange={(e) => handleUploadFileSelection(e.target.files)}
/>
<div className="mt-2">
{uploadSelectedFiles.length ? (
<div className="text-sm mb-2">
{uploadSelectedFiles.map((f) => f.name).join(", ")}
</div>
) : (
<div className="text-sm text-gray-500 mb-2">
No files selected
</div>
)}
<div className="flex gap-2">
<Button onClick={() => uploadFileInputRef.current?.click()}>
Pick files
</Button>
<Button
onClick={startUploadFromModal}
disabled={!uploadSelectedFiles.length}
>
Upload
</Button>
<Button
variant="ghost"
onClick={() => {
setIsUploadModalOpen(false);
setUploadSelectedFiles([]);
}}
>
Cancel
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
{/* New folder modal (reusable) */}
<NewFolderModal
isOpen={isNewFolderOpen}
onClose={() => setIsNewFolderOpen(false)}
onSubmit={handleCreateFolder}
/>
</div>
);
}

View File

@@ -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;
}