initial commit
This commit is contained in:
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Executable file
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
284
apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx
Executable file
284
apps/Frontend/src/components/cloud-storage/file-preview-modal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
152
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Executable file
152
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Executable file
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Executable file
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
353
apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx
Executable file
353
apps/Frontend/src/components/cloud-storage/recent-top-level-folder-modal.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
426
apps/Frontend/src/components/cloud-storage/search-bar.tsx
Executable file
426
apps/Frontend/src/components/cloud-storage/search-bar.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user