initial commit
This commit is contained in:
584
apps/Frontend/src/components/cloud-storage/files-section.tsx
Executable file
584
apps/Frontend/src/components/cloud-storage/files-section.tsx
Executable file
@@ -0,0 +1,584 @@
|
||||
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 FilePreviewModal from "./file-preview-modal";
|
||||
import { cloudSearchQueryKeyRoot } from "./search-bar";
|
||||
|
||||
export type FilesSectionProps = {
|
||||
parentId: number | null;
|
||||
pageSize?: number;
|
||||
className?: string;
|
||||
onFileOpen?: (fileId: number) => void;
|
||||
};
|
||||
|
||||
// canonical root key for files list queries (per-parent)
|
||||
export const cloudFilesQueryKeyRoot = ["cloud-files"];
|
||||
/**
|
||||
* Build a full query key for files list under a parent folder with page parameters.
|
||||
* Example usage:
|
||||
* cloudFilesQueryKeyBase(parentId, page, pageSize)
|
||||
*/
|
||||
export const cloudFilesQueryKeyBase = (
|
||||
parentId: number | null,
|
||||
page: number,
|
||||
pageSize: number
|
||||
) => [
|
||||
"cloud-files",
|
||||
parentId === null ? "null" : String(parentId),
|
||||
page,
|
||||
pageSize,
|
||||
];
|
||||
|
||||
const FILES_LIMIT_DEFAULT = 20;
|
||||
const MAX_FILE_MB = 10;
|
||||
const MAX_FILE_BYTES = MAX_FILE_MB * 1024 * 1024;
|
||||
|
||||
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 [uploading, setUploading] = useState(false);
|
||||
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("");
|
||||
|
||||
// delete dialog
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(null);
|
||||
|
||||
// preview modal
|
||||
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||
const [previewFileId, setPreviewFileId] = useState<number | 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],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
|
||||
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
|
||||
} 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],
|
||||
});
|
||||
// invalidate any cloud-files lists (so file lists refresh)
|
||||
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
|
||||
// invalidate any cloud-search queries so search results refresh
|
||||
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Delete failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// download (context menu) - (fetch bytes from backend host via wrapper)
|
||||
async function handleDownload(file: CloudFile) {
|
||||
try {
|
||||
const res = await apiRequest(
|
||||
"GET",
|
||||
`/api/cloud-storage/files/${file.id}/download`
|
||||
);
|
||||
if (!res.ok) {
|
||||
const j = await res.json().catch(() => ({}));
|
||||
throw new Error(j?.message || `Download failed (${res.status})`);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = file.name ?? `file-${file.id}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
|
||||
// revoke after a bit
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
contextMenu.hideAll();
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Download failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// upload: get files from MultipleFileUploadZone (imperative handle)
|
||||
async function handleUploadSubmit() {
|
||||
const files: File[] = uploadRef.current?.getFiles?.() ?? [];
|
||||
if (!files.length) {
|
||||
toast({
|
||||
title: "No files selected",
|
||||
description: "Please choose files to upload before clicking Upload.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setUploading(true);
|
||||
|
||||
// pre-check all files and show errors / skip too-large files
|
||||
const oversized = files.filter((f) => f.size > MAX_FILE_BYTES);
|
||||
if (oversized.length) {
|
||||
oversized.slice(0, 5).forEach((f) =>
|
||||
toast({
|
||||
title: "File too large",
|
||||
description: `${f.name} is ${Math.round(f.size / 1024 / 1024)} MB — max ${MAX_FILE_MB} MB allowed.`,
|
||||
variant: "destructive",
|
||||
})
|
||||
);
|
||||
// Remove oversized files from the upload list (upload the rest)
|
||||
}
|
||||
|
||||
const toUpload = files.filter((f) => f.size <= MAX_FILE_BYTES);
|
||||
if (toUpload.length === 0) {
|
||||
// nothing to upload
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const f of toUpload) {
|
||||
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],
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: cloudFilesQueryKeyRoot, exact: false });
|
||||
qc.invalidateQueries({ queryKey: cloudSearchQueryKeyRoot, exact: false });
|
||||
} catch (err: any) {
|
||||
toast({
|
||||
title: "Upload failed",
|
||||
description: err?.message ?? String(err),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Pagination
|
||||
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);
|
||||
|
||||
// open preview (single click)
|
||||
function openPreview(file: CloudFile) {
|
||||
setPreviewFileId(Number(file.id));
|
||||
setIsPreviewOpen(true);
|
||||
contextMenu.hideAll();
|
||||
}
|
||||
|
||||
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)}
|
||||
onClick={() => openPreview(file)}
|
||||
>
|
||||
<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}
|
||||
acceptedFileTypes="application/pdf,image/*"
|
||||
maxFiles={10}
|
||||
maxFileSizeMB={10}
|
||||
maxFileSizeByType={{ "application/pdf": 10, "image/*": 5 }}
|
||||
isUploading={uploading}
|
||||
/>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUploadSubmit} disabled={uploading}>
|
||||
{uploading ? "Uploading..." : "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}
|
||||
/>
|
||||
|
||||
{/* FIle Preview Modal */}
|
||||
<FilePreviewModal
|
||||
fileId={previewFileId}
|
||||
isOpen={isPreviewOpen}
|
||||
onClose={() => {
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewFileId(null);
|
||||
}}
|
||||
onDeleted={() => {
|
||||
// close preview
|
||||
setIsPreviewOpen(false);
|
||||
setPreviewFileId(null);
|
||||
|
||||
// reload this folder page
|
||||
loadPage(currentPage);
|
||||
|
||||
// invalidate caches
|
||||
qc.invalidateQueries({
|
||||
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: cloudFilesQueryKeyRoot,
|
||||
exact: false,
|
||||
});
|
||||
qc.invalidateQueries({
|
||||
queryKey: cloudSearchQueryKeyRoot,
|
||||
exact: false,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* delete confirm */}
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={isDeleteOpen}
|
||||
entityName={deleteTarget?.name}
|
||||
onCancel={() => {
|
||||
setIsDeleteOpen(false);
|
||||
setDeleteTarget(null);
|
||||
}}
|
||||
onConfirm={confirmDelete}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user