Files
DentalManagementE/apps/Frontend/src/components/cloud-storage/files-section.tsx

535 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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";
export type FilesSectionProps = {
parentId: number | null;
pageSize?: number;
className?: string;
onFileOpen?: (fileId: number) => void;
};
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],
});
} 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 (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],
});
} 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);
}}
/>
{/* delete confirm */}
<DeleteConfirmationDialog
isOpen={isDeleteOpen}
entityName={deleteTarget?.name}
onCancel={() => {
setIsDeleteOpen(false);
setDeleteTarget(null);
}}
onConfirm={confirmDelete}
/>
</Card>
);
}