initial commit

This commit is contained in:
2026-04-04 22:13:55 -04:00
commit 5d77e207c9
10181 changed files with 522212 additions and 0 deletions

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,284 @@
import React, { useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { toast } from "@/hooks/use-toast";
import { Download, Maximize2, Minimize2, Trash2, X } from "lucide-react";
import { DeleteConfirmationDialog } from "../ui/deleteDialog";
import { QueryClient } from "@tanstack/react-query";
import { cloudFilesQueryKeyRoot } from "./files-section";
type Props = {
fileId: number | null;
isOpen: boolean;
onClose: () => void;
onDeleted?: () => void;
};
export default function FilePreviewModal({
fileId,
isOpen,
onClose,
onDeleted,
}: Props) {
const [loading, setLoading] = useState(false);
const [meta, setMeta] = useState<any | null>(null);
const [blobUrl, setBlobUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [deleting, setDeleting] = useState(false);
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
useEffect(() => {
if (!isOpen || !fileId) return;
let cancelled = false;
let createdUrl: string | null = null;
async function load() {
setLoading(true);
setError(null);
setMeta(null);
setBlobUrl(null);
try {
const metaRes = await apiRequest(
"GET",
`/api/cloud-storage/files/${fileId}`
);
const metaJson = await metaRes.json();
if (!metaRes.ok) {
throw new Error(metaJson?.message || "Failed to load file metadata");
}
if (cancelled) return;
setMeta(metaJson.data);
const contentRes = await apiRequest(
"GET",
`/api/cloud-storage/files/${fileId}/content`
);
if (!contentRes.ok) {
let msg = `Preview request failed (${contentRes.status})`;
try {
const j = await contentRes.json();
msg = j?.message ?? msg;
} catch (e) {}
throw new Error(msg);
}
const blob = await contentRes.blob();
if (cancelled) return;
createdUrl = URL.createObjectURL(blob);
setBlobUrl(createdUrl);
} catch (err: any) {
if (!cancelled) setError(err?.message ?? String(err));
} finally {
if (!cancelled) setLoading(false);
}
}
load();
return () => {
cancelled = true;
if (createdUrl) {
URL.revokeObjectURL(createdUrl);
}
};
}, [isOpen, fileId]);
if (!isOpen) return null;
const mime = meta?.mimeType ?? "";
async function handleDownload() {
if (!fileId) return;
try {
const res = await apiRequest(
"GET",
`/api/cloud-storage/files/${fileId}/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 = meta?.name ?? `file-${fileId}`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
} catch (err: any) {
toast({
title: "Download failed",
description: err?.message ?? String(err),
variant: "destructive",
});
}
}
async function confirmDelete() {
if (!fileId) return;
setIsDeleteOpen(false);
setDeleting(true);
try {
const res = await apiRequest(
"DELETE",
`/api/cloud-storage/files/${fileId}`
);
const json = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(json?.message || `Delete failed (${res.status})`);
}
toast({
title: "Deleted",
description: `File "${meta?.name ?? `file-${fileId}`}" deleted.`,
});
// notify parent to refresh lists if they provided callback
if (typeof onDeleted === "function") {
try {
onDeleted();
} catch (e) {
// ignore parent errors
}
}
// close modal
onClose();
} catch (err: any) {
toast({
title: "Delete failed",
description: err?.message ?? String(err),
variant: "destructive",
});
} finally {
setDeleting(false);
}
}
// container sizing classes
const containerBase =
"bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl";
const sizeClass = isFullscreen
? "w-[95vw] h-[95vh]"
: "w-[min(1200px,95vw)] h-[85vh]";
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
<div className={`${containerBase} ${sizeClass} max-w-full max-h-full`}>
{/* header */}
<div className="flex items-start justify-between gap-3 pb-2 border-b">
<div className="min-w-0">
<h3 className="text-lg font-semibold truncate">
{meta?.name ?? "Preview"}
</h3>
<div className="text-sm text-gray-500 truncate">
{meta?.mimeType ?? ""}
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => setIsFullscreen((s) => !s)}
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
className="p-2 rounded hover:bg-gray-100"
aria-label="Toggle fullscreen"
>
{isFullscreen ? (
<Minimize2 className="w-4 h-4" />
) : (
<Maximize2 className="w-4 h-4" />
)}
</button>
<Button variant="ghost" onClick={handleDownload}>
<Download className="w-4 h-4" />
</Button>
<Button
variant="destructive"
onClick={() => setIsDeleteOpen(true)}
disabled={deleting}
>
<Trash2 className="w-4 h-4" />
</Button>
<Button onClick={onClose}>
{" "}
<X className="w-4 h-4" />
</Button>
</div>
</div>
{/* body */}
<div className="flex-1 overflow-auto mt-3">
{/* loading / error */}
{loading && (
<div className="w-full h-full flex items-center justify-center">
Loading preview
</div>
)}
{error && <div className="text-red-600">{error}</div>}
{/* image */}
{!loading && !error && blobUrl && mime.startsWith("image/") && (
<div className="flex items-center justify-center w-full h-full">
<img
src={blobUrl}
alt={meta?.name}
className="max-w-full max-h-full object-contain"
style={{ maxHeight: "calc(100vh - 200px)" }}
/>
</div>
)}
{/* pdf */}
{!loading &&
!error &&
blobUrl &&
(mime === "application/pdf" || mime.endsWith("/pdf")) && (
<div className="w-full h-full">
<iframe
src={blobUrl}
title={meta?.name}
className="w-full h-full border-0"
style={{ minHeight: 400 }}
/>
</div>
)}
{/* fallback */}
{!loading &&
!error &&
blobUrl &&
!mime.startsWith("image/") &&
!mime.includes("pdf") && (
<div className="p-4">
<p>Preview not available for this file type.</p>
<p className="mt-2">
<a
href={blobUrl}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
Open raw
</a>
</p>
</div>
)}
</div>
</div>
<DeleteConfirmationDialog
isOpen={isDeleteOpen}
entityName={meta?.name ?? undefined}
onCancel={() => setIsDeleteOpen(false)}
onConfirm={confirmDelete}
/>
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,152 @@
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 : ${path[path.length - 1]?.name ?? 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

@@ -0,0 +1,426 @@
import React, { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Folder as FolderIcon,
File as FileIcon,
Search as SearchIcon,
Clock as ClockIcon,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
/**
* Canonical query keys
*/
export const cloudSearchQueryKeyRoot = ["cloud-search"];
export const cloudSearchQueryKeyBase = (
q: string,
searchTarget: "filename" | "foldername" | "both",
typeFilter: "any" | "images" | "pdf" | "video" | "audio",
page: number
) => ["cloud-search", q, searchTarget, typeFilter, page];
type ResultRow = {
id: number;
name: string;
mimeType?: string | null;
folderId?: number | null;
isComplete?: boolean;
kind: "file" | "folder";
fileSize?: string | number | null;
createdAt?: string;
};
export default function CloudSearchBar({
onOpenFolder = (id: number | null) => {},
onSelectFile = (fileId: number) => {},
}: {
onOpenFolder?: (id: number | null) => void;
onSelectFile?: (fileId: number) => void;
}) {
const [q, setQ] = useState("");
const [searchTarget, setSearchTarget] = useState<
"filename" | "foldername" | "both"
>("filename"); // default filename
const [typeFilter, setTypeFilter] = useState<
"any" | "images" | "pdf" | "video" | "audio"
>("any");
const [page, setPage] = useState(1);
const [limit] = useState(10);
const debounceMs = 600;
const [debouncedQ, setDebouncedQ] = useState(q);
// debounce input
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q.trim()), debounceMs);
return () => clearTimeout(t);
}, [q, debounceMs]);
function typeParamFromFilter(filter: string) {
if (filter === "any") return undefined;
if (filter === "images") return "image";
if (filter === "pdf") return "application/pdf";
return filter;
}
// fetcher used by useQuery
async function fetchSearch(): Promise<{
results: ResultRow[];
total: number;
}> {
const query = debouncedQ ?? "";
if (!query) return { results: [], total: 0 };
const offset = (page - 1) * limit;
const typeParam = typeParamFromFilter(typeFilter as string);
// helper: call files endpoint
async function callFiles() {
const tQuery = typeParam ? `&type=${encodeURIComponent(typeParam)}` : "";
const res = await apiRequest(
"GET",
`/api/cloud-storage/search/files?q=${encodeURIComponent(query)}${tQuery}&limit=${limit}&offset=${offset}`
);
const json = await res.json();
if (!res.ok) throw new Error(json?.message || "File search failed");
const mapped: ResultRow[] = (json.data || []).map((d: any) => ({
id: d.id,
name: d.name,
kind: "file",
mimeType: d.mimeType,
fileSize: d.fileSize,
folderId: d.folderId ?? null,
createdAt: d.createdAt,
}));
return { mapped, total: json.totalCount ?? mapped.length };
}
// helper: call folders endpoint
async function callFolders() {
const res = await apiRequest(
"GET",
`/api/cloud-storage/search/folders?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`
);
const json = await res.json();
if (!res.ok) throw new Error(json?.message || "Folder search failed");
const mapped: ResultRow[] = (json.data || []).map((d: any) => ({
id: d.id,
name: d.name,
kind: "folder",
folderId: d.parentId ?? null,
}));
// enforce top-level folders only when searching folders specifically
// (if the API already filters, this is harmless)
return { mapped, total: json.totalCount ?? mapped.length };
}
// Decide which endpoints to call
if (searchTarget === "filename") {
const f = await callFiles();
return { results: f.mapped, total: f.total };
} else if (searchTarget === "foldername") {
const fo = await callFolders();
// filter top-level only (parentId === null)
const topLevel = fo.mapped.filter((r) => r.folderId == null);
return { results: topLevel, total: fo.total };
} else {
// both: call both and combine (folders first, then files), but keep page limit
const [filesRes, foldersRes] = await Promise.all([
callFiles(),
callFolders(),
]);
// folders restrict to top-level
const foldersTop = foldersRes.mapped.filter((r) => r.folderId == null);
const combined = [...foldersTop, ...filesRes.mapped].slice(0, limit);
const combinedTotal = foldersRes.total + filesRes.total;
return { results: combined, total: combinedTotal };
}
}
// react-query: key depends on debouncedQ, searchTarget, typeFilter, page
const queryKey = useMemo(
() => cloudSearchQueryKeyBase(debouncedQ, searchTarget, typeFilter, page),
[debouncedQ, searchTarget, typeFilter, page]
);
const { data, isFetching, error } = useQuery({
queryKey,
queryFn: fetchSearch,
enabled: debouncedQ.length > 0,
staleTime: 0,
});
// sync local UI state with query data
const results = data?.results ?? [];
const total = data?.total ?? 0;
const loading = isFetching;
const errMsg = error ? ((error as any)?.message ?? String(error)) : null;
// persist recent terms & matches when new results arrive
useEffect(() => {
if (!debouncedQ) return;
// recent terms
try {
const raw = localStorage.getItem("cloud_search_recent_terms");
const prev: string[] = raw ? JSON.parse(raw) : [];
const term = debouncedQ;
const copy = [term, ...prev.filter((t) => t !== term)].slice(0, 10);
localStorage.setItem("cloud_search_recent_terms", JSON.stringify(copy));
} catch {}
// recent matches snapshot
try {
const rawMatches = localStorage.getItem("cloud_search_recent_matches");
const prevMatches: Record<string, ResultRow[]> = rawMatches
? JSON.parse(rawMatches)
: {};
const snapshot = results;
const copy = { ...prevMatches, [debouncedQ]: snapshot };
localStorage.setItem("cloud_search_recent_matches", JSON.stringify(copy));
} catch {}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [data, debouncedQ]);
// load recentTerms & recentMatches from storage for initial UI
const [recentTerms, setRecentTerms] = useState<string[]>(() => {
try {
const raw = localStorage.getItem("cloud_search_recent_terms");
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
});
const [recentMatches, setRecentMatches] = useState<
Record<string, ResultRow[]>
>(() => {
try {
const raw = localStorage.getItem("cloud_search_recent_matches");
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
});
// update recentTerms/recentMatches UI copies whenever localStorage changes (best-effort)
useEffect(() => {
try {
const raw = localStorage.getItem("cloud_search_recent_terms");
setRecentTerms(raw ? JSON.parse(raw) : []);
} catch {}
try {
const raw = localStorage.getItem("cloud_search_recent_matches");
setRecentMatches(raw ? JSON.parse(raw) : {});
} catch {}
}, [data]); // refresh small UX cache when new data arrives
// reset page when q or filters change (like before)
useEffect(() => setPage(1), [debouncedQ, searchTarget, typeFilter]);
const totalPages = useMemo(
() => Math.max(1, Math.ceil(total / limit)),
[total, limit]
);
function onClear() {
setQ("");
// the query will auto-disable when debouncedQ is empty
}
return (
<div className="bg-card p-4 rounded-2xl shadow-sm">
<div className="flex flex-col md:flex-row gap-3 md:items-center">
<div className="flex items-center gap-2 flex-1">
<SearchIcon className="h-5 w-5 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search files and folders..."
aria-label="Search files and folders"
className="flex-1"
/>
<Button variant="ghost" onClick={() => onClear()}>
Clear
</Button>
</div>
<div className="flex items-center gap-2">
<Select
onValueChange={(v) => setSearchTarget(v as any)}
value={searchTarget}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Search target" />
</SelectTrigger>
<SelectContent>
<SelectItem value="filename">Filename (default)</SelectItem>
<SelectItem value="foldername">
Folder name (top-level)
</SelectItem>
<SelectItem value="both">Both</SelectItem>
</SelectContent>
</Select>
<Select
onValueChange={(v) => setTypeFilter(v as any)}
value={typeFilter}
>
<SelectTrigger className="w-40">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="any">Any type</SelectItem>
<SelectItem value="images">Images</SelectItem>
<SelectItem value="pdf">PDFs</SelectItem>
<SelectItem value="video">Videos</SelectItem>
<SelectItem value="audio">Audio</SelectItem>
</SelectContent>
</Select>
<Button onClick={() => setPage((p) => p)}>Search</Button>
</div>
</div>
<div className="mt-4 grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="mb-2 text-sm font-semibold flex items-center gap-2">
<ClockIcon className="h-4 w-4" /> Recent searches
</h4>
<div className="flex flex-wrap gap-2">
{recentTerms.length ? (
recentTerms.map((t) => (
<motion.button
key={t}
whileHover={{ scale: 1.03 }}
whileTap={{ scale: 0.98 }}
className="px-3 py-1 rounded-full bg-muted text-sm"
onClick={() => setQ(t)}
>
{t}
</motion.button>
))
) : (
<div className="text-sm text-muted-foreground">
No recent searches
</div>
)}
</div>
</div>
<div>
<h4 className="mb-2 text-sm font-semibold">Results</h4>
<div className="bg-background rounded-md p-2 max-h-72 overflow-auto">
<AnimatePresence>
{loading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="p-4 text-center text-sm"
>
Searching...
</motion.div>
)}
{!loading && errMsg && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="p-4 text-sm text-destructive"
>
{errMsg}
</motion.div>
)}
{!loading && !results.length && debouncedQ && !errMsg && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="p-4 text-sm text-muted-foreground"
>
No results for "{debouncedQ}"
</motion.div>
)}
{!loading &&
results.map((r) => (
<motion.div
key={`${r.kind}-${r.id}`}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
className="p-2 rounded hover:bg-muted/50 flex items-center gap-3 cursor-pointer"
onClick={() => {
if (r.kind === "folder") onOpenFolder(r.id);
else onSelectFile(r.id);
}}
>
<div className="w-8 h-8 flex items-center justify-center rounded bg-muted">
{r.kind === "folder" ? (
<FolderIcon className="h-4 w-4" />
) : (
<FileIcon className="h-4 w-4" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="truncate font-medium">{r.name}</div>
<div className="text-xs text-muted-foreground truncate">
{r.kind === "file" ? (r.mimeType ?? "file") : "Folder"}
</div>
</div>
{r.kind === "file" && r.fileSize != null && (
<div className="text-xs text-muted-foreground">
{String(r.fileSize)}
</div>
)}
</motion.div>
))}
</AnimatePresence>
</div>
<div className="mt-3 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{total} result(s)
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="text-sm">
{page} / {totalPages}
</div>
<Button
size="sm"
variant="ghost"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</div>
);
}