feat(cloud-page) - wip - UI fixed, functionalities done, view/downlaod to be done
This commit is contained in:
@@ -69,9 +69,7 @@ router.get(
|
|||||||
return res.json({
|
return res.json({
|
||||||
error: false,
|
error: false,
|
||||||
data: paged,
|
data: paged,
|
||||||
total: folders.length,
|
totalCount: folders.length,
|
||||||
limit,
|
|
||||||
offset,
|
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, 500, "Failed to load child folders", err);
|
return sendError(res, 500, "Failed to load child folders", err);
|
||||||
@@ -103,9 +101,9 @@ router.get(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await storage.listFilesInFolder(parentId, limit, offset);
|
const files = await storage.listFilesInFolder(parentId, limit, offset);
|
||||||
const total = await storage.countFilesInFolder(parentId);
|
const totalCount = await storage.countFilesInFolder(parentId);
|
||||||
const serialized = files.map(serializeFile);
|
const serialized = files.map(serializeFile);
|
||||||
return res.json({ error: false, data: serialized, total, limit, offset });
|
return res.json({ error: false, data: serialized, totalCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, 500, "Failed to load files for folder", err);
|
return sendError(res, 500, "Failed to load files for folder", err);
|
||||||
}
|
}
|
||||||
@@ -121,20 +119,40 @@ router.get(
|
|||||||
const limit = parsePositiveInt(req.query.limit, 50);
|
const limit = parsePositiveInt(req.query.limit, 50);
|
||||||
const offset = parsePositiveInt(req.query.offset, 0);
|
const offset = parsePositiveInt(req.query.offset, 0);
|
||||||
try {
|
try {
|
||||||
const folders = await storage.listRecentFolders(limit, offset);
|
// Always request top-level folders (parentId = null)
|
||||||
const total = await storage.countFolders();
|
const parentId: number | null = null;
|
||||||
return res.json({ error: false, data: folders, total, limit, offset });
|
const folders = await storage.listRecentFolders(limit, offset, parentId);
|
||||||
|
const totalCount = await storage.countFoldersByParent(parentId);
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
error: false,
|
||||||
|
data: folders,
|
||||||
|
totalCount,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, 500, "Failed to load recent folders");
|
return sendError(res, 500, "Failed to load recent folders");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
/* ---------- Folder CRUD ----------
|
// ---------- Folder CRUD ----------
|
||||||
POST /folders { userId, name, parentId? }
|
router.get(
|
||||||
PUT /folders/:id { name?, parentId? }
|
"/folders/:id",
|
||||||
DELETE /folders/:id
|
async (req: Request, res: Response): Promise<any> => {
|
||||||
*/
|
const id = Number.parseInt(req.params.id ?? "", 10);
|
||||||
|
if (!Number.isInteger(id) || id <= 0)
|
||||||
|
return sendError(res, 400, "Invalid folder id");
|
||||||
|
|
||||||
|
try {
|
||||||
|
const folder = await storage.getFolder(id);
|
||||||
|
if (!folder) return sendError(res, 404, "Folder not found");
|
||||||
|
return res.json({ error: false, data: folder });
|
||||||
|
} catch (err) {
|
||||||
|
return sendError(res, 500, "Failed to load folder");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
router.post("/folders", async (req: Request, res: Response): Promise<any> => {
|
router.post("/folders", async (req: Request, res: Response): Promise<any> => {
|
||||||
const { userId, name, parentId } = req.body;
|
const { userId, name, parentId } = req.body;
|
||||||
if (!userId || typeof name !== "string" || !name.trim()) {
|
if (!userId || typeof name !== "string" || !name.trim()) {
|
||||||
@@ -210,9 +228,9 @@ router.get(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const files = await storage.listFilesInFolder(folderId, limit, offset);
|
const files = await storage.listFilesInFolder(folderId, limit, offset);
|
||||||
const total = await storage.countFilesInFolder(folderId);
|
const totalCount = await storage.countFilesInFolder(folderId);
|
||||||
const serialized = files.map(serializeFile);
|
const serialized = files.map(serializeFile);
|
||||||
return res.json({ error: false, data: serialized, total, limit, offset });
|
return res.json({ error: false, data: serialized, totalCount });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, 500, "Failed to list files for folder");
|
return sendError(res, 500, "Failed to list files for folder");
|
||||||
}
|
}
|
||||||
@@ -415,7 +433,7 @@ router.get(
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const { data, total } = await storage.searchFolders(q, limit, offset);
|
const { data, total } = await storage.searchFolders(q, limit, offset);
|
||||||
return res.json({ error: false, data, total, limit, offset });
|
return res.json({ error: false, data, totalCount: total });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, 500, "Folder search failed");
|
return sendError(res, 500, "Folder search failed");
|
||||||
}
|
}
|
||||||
@@ -440,7 +458,7 @@ router.get(
|
|||||||
try {
|
try {
|
||||||
const { data, total } = await storage.searchFiles(q, type, limit, offset);
|
const { data, total } = await storage.searchFiles(q, type, limit, offset);
|
||||||
const serialized = data.map(serializeFile);
|
const serialized = data.map(serializeFile);
|
||||||
return res.json({ error: false, data: serialized, total, limit, offset });
|
return res.json({ error: false, data: serialized, totalCount: total });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return sendError(res, 500, "File search failed");
|
return sendError(res, 500, "File search failed");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,13 +40,16 @@ async function updateFolderTimestampsRecursively(folderId: number | null) {
|
|||||||
export interface IStorage {
|
export interface IStorage {
|
||||||
// Folders
|
// Folders
|
||||||
getFolder(id: number): Promise<CloudFolder | null>;
|
getFolder(id: number): Promise<CloudFolder | null>;
|
||||||
listFoldersByParent(
|
listRecentFolders(
|
||||||
parentId: number | null,
|
|
||||||
limit: number,
|
limit: number,
|
||||||
offset: number
|
offset: number,
|
||||||
|
parentId?: number | null
|
||||||
): Promise<CloudFolder[]>;
|
): Promise<CloudFolder[]>;
|
||||||
countFoldersByParent(parentId: number | null): Promise<number>;
|
countFoldersByParent(parentId: number | null): Promise<number>;
|
||||||
listRecentFolders(limit: number, offset: number): Promise<CloudFolder[]>;
|
countFolders(filter?: {
|
||||||
|
userId?: number;
|
||||||
|
nameContains?: string | null;
|
||||||
|
}): Promise<number>;
|
||||||
createFolder(
|
createFolder(
|
||||||
userId: number,
|
userId: number,
|
||||||
name: string,
|
name: string,
|
||||||
@@ -57,10 +60,6 @@ export interface IStorage {
|
|||||||
updates: Partial<{ name?: string; parentId?: number | null }>
|
updates: Partial<{ name?: string; parentId?: number | null }>
|
||||||
): Promise<CloudFolder | null>;
|
): Promise<CloudFolder | null>;
|
||||||
deleteFolder(id: number): Promise<boolean>;
|
deleteFolder(id: number): Promise<boolean>;
|
||||||
countFolders(filter?: {
|
|
||||||
userId?: number;
|
|
||||||
nameContains?: string | null;
|
|
||||||
}): Promise<number>;
|
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
getFile(id: number): Promise<CloudFile | null>;
|
getFile(id: number): Promise<CloudFile | null>;
|
||||||
@@ -120,17 +119,23 @@ export const cloudStorage: IStorage = {
|
|||||||
return (folder as unknown as CloudFolder) ?? null;
|
return (folder as unknown as CloudFolder) ?? null;
|
||||||
},
|
},
|
||||||
|
|
||||||
async listFoldersByParent(
|
async listRecentFolders(limit = 50, offset = 0, parentId?: number | null) {
|
||||||
parentId: number | null = null,
|
const where: any = {};
|
||||||
limit = 50,
|
|
||||||
offset = 0
|
// parentId === undefined → no filter (global recent)
|
||||||
) {
|
// parentId === null → top-level folders (parent IS NULL)
|
||||||
|
// parentId === number → children of that folder
|
||||||
|
if (parentId !== undefined) {
|
||||||
|
where.parentId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
const folders = await db.cloudFolder.findMany({
|
const folders = await db.cloudFolder.findMany({
|
||||||
where: { parentId },
|
where,
|
||||||
orderBy: { name: "asc" },
|
orderBy: { updatedAt: "desc" },
|
||||||
skip: offset,
|
skip: offset,
|
||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
return folders as unknown as CloudFolder[];
|
return folders as unknown as CloudFolder[];
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -138,15 +143,6 @@ export const cloudStorage: IStorage = {
|
|||||||
return db.cloudFolder.count({ where: { parentId } });
|
return db.cloudFolder.count({ where: { parentId } });
|
||||||
},
|
},
|
||||||
|
|
||||||
async listRecentFolders(limit = 50, offset = 0) {
|
|
||||||
const folders = await db.cloudFolder.findMany({
|
|
||||||
orderBy: { updatedAt: "desc" },
|
|
||||||
skip: offset,
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
return folders as unknown as CloudFolder[];
|
|
||||||
},
|
|
||||||
|
|
||||||
async createFolder(
|
async createFolder(
|
||||||
userId: number,
|
userId: number,
|
||||||
name: string,
|
name: string,
|
||||||
|
|||||||
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Normal file
203
apps/Frontend/src/components/cloud-storage/bread-crumb.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
444
apps/Frontend/src/components/cloud-storage/files-section.tsx
Normal file
444
apps/Frontend/src/components/cloud-storage/files-section.tsx
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
File as FileIcon,
|
||||||
|
FileText,
|
||||||
|
Image as ImageIcon,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
Edit3 as EditIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import type { CloudFile } from "@repo/db/types";
|
||||||
|
import { MultipleFileUploadZone } from "@/components/file-upload/multiple-file-upload-zone";
|
||||||
|
import { DeleteConfirmationDialog } from "@/components/ui/deleteDialog";
|
||||||
|
import { Menu, Item, contextMenu } from "react-contexify";
|
||||||
|
import "react-contexify/dist/ReactContexify.css";
|
||||||
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import {
|
||||||
|
Pagination,
|
||||||
|
PaginationContent,
|
||||||
|
PaginationItem,
|
||||||
|
PaginationPrevious,
|
||||||
|
PaginationLink,
|
||||||
|
PaginationNext,
|
||||||
|
} from "@/components/ui/pagination";
|
||||||
|
import { getPageNumbers } from "@/utils/pageNumberGenerator";
|
||||||
|
import { NewFolderModal } from "./new-folder-modal";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { Description } from "@radix-ui/react-toast";
|
||||||
|
|
||||||
|
export type FilesSectionProps = {
|
||||||
|
parentId: number | null;
|
||||||
|
pageSize?: number;
|
||||||
|
className?: string;
|
||||||
|
onFileOpen?: (fileId: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FILES_LIMIT_DEFAULT = 20;
|
||||||
|
|
||||||
|
function fileIcon(mime?: string) {
|
||||||
|
if (!mime) return <FileIcon className="h-6 w-6" />;
|
||||||
|
if (mime.startsWith("image/")) return <ImageIcon className="h-6 w-6" />;
|
||||||
|
if (mime === "application/pdf" || mime.endsWith("/pdf"))
|
||||||
|
return <FileText className="h-6 w-6" />;
|
||||||
|
return <FileIcon className="h-6 w-6" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilesSection({
|
||||||
|
parentId,
|
||||||
|
pageSize = FILES_LIMIT_DEFAULT,
|
||||||
|
className,
|
||||||
|
onFileOpen,
|
||||||
|
}: FilesSectionProps) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const { user } = useAuth();
|
||||||
|
const userId = user?.id;
|
||||||
|
|
||||||
|
const [data, setData] = useState<CloudFile[]>([]);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
// upload modal and ref
|
||||||
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||||
|
const uploadRef = useRef<any>(null);
|
||||||
|
|
||||||
|
// rename/delete
|
||||||
|
const [isRenameOpen, setIsRenameOpen] = useState(false);
|
||||||
|
const [renameTargetId, setRenameTargetId] = useState<number | null>(null);
|
||||||
|
const [renameInitial, setRenameInitial] = useState("");
|
||||||
|
|
||||||
|
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<CloudFile | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadPage(currentPage);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [parentId, currentPage]);
|
||||||
|
|
||||||
|
async function loadPage(page: number) {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const fid = parentId === null ? "null" : String(parentId);
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/cloud-storage/items/files?parentId=${encodeURIComponent(
|
||||||
|
fid
|
||||||
|
)}&limit=${pageSize}&offset=${offset}`
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json?.message || "Failed to load files");
|
||||||
|
const rows: CloudFile[] = Array.isArray(json.data) ? json.data : [];
|
||||||
|
setData(rows);
|
||||||
|
const t =
|
||||||
|
typeof json.totalCount === "number"
|
||||||
|
? json.totalCount
|
||||||
|
: typeof json.total === "number"
|
||||||
|
? json.total
|
||||||
|
: rows.length;
|
||||||
|
setTotal(t);
|
||||||
|
} catch (err: any) {
|
||||||
|
setData([]);
|
||||||
|
setTotal(0);
|
||||||
|
toast({
|
||||||
|
title: "Failed to load files",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMenu(e: React.MouseEvent, file: CloudFile) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
contextMenu.show({
|
||||||
|
id: "files-section-menu",
|
||||||
|
event: e.nativeEvent,
|
||||||
|
props: { file },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// rename
|
||||||
|
function openRename(file: CloudFile) {
|
||||||
|
setRenameTargetId(Number(file.id));
|
||||||
|
setRenameInitial(file.name ?? "");
|
||||||
|
setIsRenameOpen(true);
|
||||||
|
contextMenu.hideAll();
|
||||||
|
}
|
||||||
|
async function submitRename(newName: string) {
|
||||||
|
if (!renameTargetId) return;
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"PUT",
|
||||||
|
`/api/cloud-storage/files/${renameTargetId}`,
|
||||||
|
{ name: newName }
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json?.message || "Rename failed");
|
||||||
|
setIsRenameOpen(false);
|
||||||
|
setRenameTargetId(null);
|
||||||
|
toast({ title: "File renamed" });
|
||||||
|
|
||||||
|
loadPage(currentPage);
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Rename failed",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// delete
|
||||||
|
function openDelete(file: CloudFile) {
|
||||||
|
setDeleteTarget(file);
|
||||||
|
setIsDeleteOpen(true);
|
||||||
|
contextMenu.hideAll();
|
||||||
|
}
|
||||||
|
async function confirmDelete() {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
try {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"DELETE",
|
||||||
|
`/api/cloud-storage/files/${deleteTarget.id}`
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok) throw new Error(json?.message || "Delete failed");
|
||||||
|
setIsDeleteOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
toast({ title: "File deleted" });
|
||||||
|
|
||||||
|
// reload current page (ensure page index valid)
|
||||||
|
loadPage(currentPage);
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Delete failed",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// download
|
||||||
|
function handleDownload(file: CloudFile) {
|
||||||
|
// Open download endpoint in new tab so the browser handles attachment headers
|
||||||
|
const url = `/api/cloud-storage/files/${file.id}/download`;
|
||||||
|
window.open(url, "_blank");
|
||||||
|
contextMenu.hideAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload: get files from MultipleFileUploadZone (imperative handle)
|
||||||
|
async function handleUploadSubmit() {
|
||||||
|
const files: File[] = uploadRef.current?.getFiles?.() ?? [];
|
||||||
|
if (!files.length) return;
|
||||||
|
try {
|
||||||
|
for (const f of files) {
|
||||||
|
const fid = parentId === null ? "null" : String(parentId);
|
||||||
|
const initRes = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
`/api/cloud-storage/folders/${encodeURIComponent(fid)}/files`,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
name: f.name,
|
||||||
|
mimeType: f.type || null,
|
||||||
|
expectedSize: f.size,
|
||||||
|
totalChunks: 1,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const initJson = await initRes.json();
|
||||||
|
const created = initJson?.data;
|
||||||
|
if (!created || typeof created.id !== "number")
|
||||||
|
throw new Error("Init failed");
|
||||||
|
const raw = await f.arrayBuffer();
|
||||||
|
// upload chunk
|
||||||
|
await apiRequest(
|
||||||
|
"POST",
|
||||||
|
`/api/cloud-storage/files/${created.id}/chunks?seq=0`,
|
||||||
|
raw
|
||||||
|
);
|
||||||
|
// finalize
|
||||||
|
await apiRequest(
|
||||||
|
"POST",
|
||||||
|
`/api/cloud-storage/files/${created.id}/complete`,
|
||||||
|
{}
|
||||||
|
);
|
||||||
|
toast({ title: "Upload complete", description: f.name });
|
||||||
|
}
|
||||||
|
setIsUploadOpen(false);
|
||||||
|
loadPage(currentPage);
|
||||||
|
qc.invalidateQueries({
|
||||||
|
queryKey: ["/api/cloud-storage/folders/recent", 1],
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Upload failed",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const startItem = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||||
|
const endItem = Math.min(total, currentPage * pageSize);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Files</CardTitle>
|
||||||
|
<CardDescription>Manage Files in this folder</CardDescription>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
className="inline-flex items-center px-4 py-2"
|
||||||
|
onClick={() => setIsUploadOpen(true)}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
Upload
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
|
<CardContent>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="py-6 text-center">Loading...</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
{data.map((file) => (
|
||||||
|
<div
|
||||||
|
key={file.id}
|
||||||
|
className="p-3 rounded border hover:bg-gray-50 cursor-pointer"
|
||||||
|
onContextMenu={(e) => showMenu(e, file)}
|
||||||
|
onDoubleClick={() => onFileOpen?.(Number(file.id))}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
|
||||||
|
{fileIcon((file as any).mimeType)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="text-sm truncate text-center"
|
||||||
|
style={{ maxWidth: 140 }}
|
||||||
|
>
|
||||||
|
<div title={file.name}>{file.name}</div>
|
||||||
|
<div className="text-xs text-gray-400">
|
||||||
|
{((file as any).fileSize ?? 0).toString()} bytes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="mt-4 pt-3 border-t border-gray-100">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
Showing {startItem}–{endItem} of {total} results
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination>
|
||||||
|
<PaginationContent>
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationPrevious
|
||||||
|
href="#"
|
||||||
|
onClick={(e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCurrentPage((p) => Math.max(1, p - 1));
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === 1
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
|
||||||
|
{getPageNumbers(currentPage, totalPages).map(
|
||||||
|
(page, idx) => (
|
||||||
|
<PaginationItem key={idx}>
|
||||||
|
{page === "..." ? (
|
||||||
|
<span className="px-2 text-gray-500">...</span>
|
||||||
|
) : (
|
||||||
|
<PaginationLink
|
||||||
|
href="#"
|
||||||
|
onClick={(e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCurrentPage(page as number);
|
||||||
|
}}
|
||||||
|
isActive={currentPage === page}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</PaginationLink>
|
||||||
|
)}
|
||||||
|
</PaginationItem>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaginationItem>
|
||||||
|
<PaginationNext
|
||||||
|
href="#"
|
||||||
|
onClick={(e: any) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setCurrentPage((p) => Math.min(totalPages, p + 1));
|
||||||
|
}}
|
||||||
|
className={
|
||||||
|
currentPage === totalPages
|
||||||
|
? "pointer-events-none opacity-50"
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</PaginationItem>
|
||||||
|
</PaginationContent>
|
||||||
|
</Pagination>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
|
||||||
|
{/* context menu */}
|
||||||
|
<Menu id="files-section-menu" animation="fade">
|
||||||
|
<Item onClick={({ props }: any) => openRename(props.file)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<EditIcon className="h-4 w-4" /> Rename
|
||||||
|
</span>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<Item onClick={({ props }: any) => handleDownload(props.file)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Download className="h-4 w-4" /> Download
|
||||||
|
</span>
|
||||||
|
</Item>
|
||||||
|
|
||||||
|
<Item onClick={({ props }: any) => openDelete(props.file)}>
|
||||||
|
<span className="flex items-center gap-2 text-red-600">
|
||||||
|
<Trash2 className="h-4 w-4" /> Delete
|
||||||
|
</span>
|
||||||
|
</Item>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
{/* upload modal using MultipleFileUploadZone (imperative handle) */}
|
||||||
|
{isUploadOpen && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-40">
|
||||||
|
<div className="bg-white p-6 rounded-md w-[90%] max-w-2xl">
|
||||||
|
<h3 className="text-lg font-semibold mb-4">Upload files</h3>
|
||||||
|
<MultipleFileUploadZone ref={uploadRef} />
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button variant="ghost" onClick={() => setIsUploadOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleUploadSubmit}>Upload</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* rename modal (reusing NewFolderModal for simplicity) */}
|
||||||
|
<NewFolderModal
|
||||||
|
isOpen={isRenameOpen}
|
||||||
|
initialName={renameInitial}
|
||||||
|
title="Rename File"
|
||||||
|
submitLabel="Rename"
|
||||||
|
onClose={() => {
|
||||||
|
setIsRenameOpen(false);
|
||||||
|
setRenameTargetId(null);
|
||||||
|
}}
|
||||||
|
onSubmit={submitRename}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* delete confirm */}
|
||||||
|
<DeleteConfirmationDialog
|
||||||
|
isOpen={isDeleteOpen}
|
||||||
|
entityName={deleteTarget?.name}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsDeleteOpen(false);
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}}
|
||||||
|
onConfirm={confirmDelete}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
156
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Normal file
156
apps/Frontend/src/components/cloud-storage/folder-panel.tsx
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
// Updated components to support: 1) navigating into child folders (FolderSection -> FolderPanel)
|
||||||
|
// 2) a breadcrumb / path strip in FolderPanel so user can see & click parent folders
|
||||||
|
|
||||||
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
|
import FolderSection from "@/components/cloud-storage/folder-section";
|
||||||
|
import FilesSection from "@/components/cloud-storage/files-section";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { Breadcrumbs, FolderMeta } from "./bread-crumb";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
folderId: number | null;
|
||||||
|
onClose?: () => void;
|
||||||
|
onViewChange?: (id: number | null) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function FolderPanel({
|
||||||
|
folderId,
|
||||||
|
onClose,
|
||||||
|
onViewChange,
|
||||||
|
}: Props) {
|
||||||
|
const [currentFolderId, setCurrentFolderId] = useState<number | null>(
|
||||||
|
folderId ?? null
|
||||||
|
);
|
||||||
|
const [path, setPath] = useState<FolderMeta[]>([]);
|
||||||
|
const [isLoadingPath, setIsLoadingPath] = useState(false);
|
||||||
|
|
||||||
|
// When the panel opens to a different initial folder, sync and notify parent
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentFolderId(folderId ?? null);
|
||||||
|
onViewChange?.(folderId ?? null);
|
||||||
|
}, [folderId, onViewChange]);
|
||||||
|
|
||||||
|
// notify parent when viewed folder changes
|
||||||
|
useEffect(() => {
|
||||||
|
onViewChange?.(currentFolderId);
|
||||||
|
}, [currentFolderId, onViewChange]);
|
||||||
|
|
||||||
|
// whenever currentFolderId changes we load the ancestor path
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
async function buildPath(fid: number | null) {
|
||||||
|
setIsLoadingPath(true);
|
||||||
|
try {
|
||||||
|
// We'll build path from root -> ... -> current. Since we don't know
|
||||||
|
// if backend provides a single endpoint for ancestry, we'll fetch
|
||||||
|
// current folder and walk parents until null. If fid is null then path is empty.
|
||||||
|
if (fid == null) {
|
||||||
|
if (mounted) setPath([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const collected: FolderMeta[] = [];
|
||||||
|
let cursor: number | null = fid;
|
||||||
|
|
||||||
|
// keep a safety cap to avoid infinite loop in case of cycles
|
||||||
|
const MAX_DEPTH = 50;
|
||||||
|
let depth = 0;
|
||||||
|
|
||||||
|
while (cursor != null && depth < MAX_DEPTH) {
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/cloud-storage/folders/${cursor}`
|
||||||
|
);
|
||||||
|
const json = await res.json();
|
||||||
|
if (!res.ok)
|
||||||
|
throw new Error(json?.message || "Failed to fetch folder");
|
||||||
|
|
||||||
|
const folder = json?.data ?? json ?? null;
|
||||||
|
// normalize
|
||||||
|
const meta: FolderMeta = {
|
||||||
|
id: folder?.id ?? null,
|
||||||
|
name: folder?.name ?? null,
|
||||||
|
parentId: folder?.parentId ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
// prepend (we are walking up) then continue with parent
|
||||||
|
collected.push(meta);
|
||||||
|
cursor = meta.parentId;
|
||||||
|
depth += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// collected currently top-down from current -> root. We need root->...->current
|
||||||
|
const rootToCurrent = collected.slice().reverse();
|
||||||
|
// we don't include the root (null) as an explicit item; Breadcrumbs shows "My Cloud Storage"
|
||||||
|
if (mounted) setPath(rootToCurrent);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("buildPath error", err);
|
||||||
|
if (mounted) setPath([]);
|
||||||
|
} finally {
|
||||||
|
if (mounted) setIsLoadingPath(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildPath(currentFolderId);
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [currentFolderId]);
|
||||||
|
|
||||||
|
// handler when child folder is clicked inside FolderSection
|
||||||
|
function handleChildSelect(childFolderId: number | null) {
|
||||||
|
// if user re-clicks current folder id as toggle, we still want to navigate into it.
|
||||||
|
setCurrentFolderId(childFolderId);
|
||||||
|
onViewChange?.(childFolderId); // keep page in sync
|
||||||
|
}
|
||||||
|
|
||||||
|
// navigate via breadcrumb (id may be null for root)
|
||||||
|
function handleNavigateTo(id: number | null) {
|
||||||
|
setCurrentFolderId(id);
|
||||||
|
onViewChange?.(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-2xl font-semibold">
|
||||||
|
{currentFolderId == null
|
||||||
|
? "My Cloud Storage"
|
||||||
|
: `Folder ${currentFolderId}`}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{onClose && (
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="inline-flex items-center px-3 py-1.5 rounded-md text-sm hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breadcrumb / path strip */}
|
||||||
|
<div>
|
||||||
|
{/* show breadcrumbs even if loading; breadcrumbs show 'My Cloud Storage' + path */}
|
||||||
|
<Breadcrumbs path={path} onNavigate={handleNavigateTo} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* stacked vertically: folders on top, files below */}
|
||||||
|
<div className="flex flex-col gap-6">
|
||||||
|
<div className="w-full">
|
||||||
|
{/* pass onSelect so FolderSection can tell the panel to navigate into a child */}
|
||||||
|
<FolderSection
|
||||||
|
parentId={currentFolderId}
|
||||||
|
onSelect={handleChildSelect}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-full">
|
||||||
|
<FilesSection parentId={currentFolderId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Normal file
400
apps/Frontend/src/components/cloud-storage/folder-section.tsx
Normal 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
Normal file
100
apps/Frontend/src/components/cloud-storage/new-folder-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,435 +1,73 @@
|
|||||||
// src/pages/cloud-storage.tsx
|
import { useState } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardContent,
|
|
||||||
CardDescription,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Folder as FolderIcon, Search as SearchIcon } from "lucide-react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
import { apiRequest, queryClient } from "@/lib/queryClient";
|
import { NewFolderModal } from "@/components/cloud-storage/new-folder-modal";
|
||||||
import {
|
import FolderPanel from "@/components/cloud-storage/folder-panel";
|
||||||
Folder,
|
|
||||||
FolderPlus,
|
|
||||||
Search as SearchIcon,
|
|
||||||
X,
|
|
||||||
Plus,
|
|
||||||
File as FileIcon,
|
|
||||||
FileText,
|
|
||||||
Image as ImageIcon,
|
|
||||||
Image,
|
|
||||||
} from "lucide-react";
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogFooter,
|
|
||||||
} from "@/components/ui/dialog";
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb";
|
|
||||||
import { useAuth } from "@/hooks/use-auth";
|
import { useAuth } from "@/hooks/use-auth";
|
||||||
|
import RecentTopLevelFoldersCard, {
|
||||||
import type { CloudFolder, CloudFile } from "@repo/db/types";
|
recentTopLevelFoldersQueryKey,
|
||||||
|
} from "@/components/cloud-storage/recent-top-level-folder-modal";
|
||||||
type ApiListResponse<T> = {
|
|
||||||
error: boolean;
|
|
||||||
data: T;
|
|
||||||
total?: number;
|
|
||||||
limit?: number;
|
|
||||||
offset?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
function truncateName(name: string, len = 28) {
|
|
||||||
if (!name) return "";
|
|
||||||
return name.length > len ? name.slice(0, len - 1) + "…" : name;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fileIcon(mime?: string) {
|
|
||||||
if (!mime) return <FileIcon className="h-10 w-10" />;
|
|
||||||
if (mime.startsWith("image/")) return <Image className="h-10 w-10" />;
|
|
||||||
if (mime === "application/pdf" || mime.endsWith("/pdf"))
|
|
||||||
return <FileText className="h-10 w-10" />;
|
|
||||||
return <FileIcon className="h-10 w-10" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CloudStoragePage() {
|
export default function CloudStoragePage() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const CURRENT_USER_ID = user?.id;
|
const qc = useQueryClient();
|
||||||
|
|
||||||
// modal state
|
// panel open + initial folder id to show when opening
|
||||||
const [isFolderModalOpen, setIsFolderModalOpen] = useState(false);
|
const [panelOpen, setPanelOpen] = useState(false);
|
||||||
const [modalFolder, setModalFolder] = useState<CloudFolder | null>(null);
|
const [panelInitialFolderId, setPanelInitialFolderId] = useState<
|
||||||
|
number | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
// Add-folder modal (simple name/cancel/confirm) - used both from main page and inside folder
|
// key to remount recent card to clear its internal selection when needed
|
||||||
const [isAddFolderModalOpen, setIsAddFolderModalOpen] = useState(false);
|
const [recentKey, setRecentKey] = useState(0);
|
||||||
const [addFolderParentId, setAddFolderParentId] = useState<number | null>(
|
|
||||||
null
|
|
||||||
); // which parent to create in
|
|
||||||
const [addFolderName, setAddFolderName] = useState("");
|
|
||||||
|
|
||||||
// Upload modal (simple file picker + confirm)
|
// New folder modal
|
||||||
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
|
const [isNewFolderOpen, setIsNewFolderOpen] = useState(false);
|
||||||
const [uploadParentId, setUploadParentId] = useState<number | null>(null); // which folder to upload into
|
|
||||||
const [uploadSelectedFiles, setUploadSelectedFiles] = useState<File[]>([]);
|
|
||||||
const uploadFileInputRef = useRef<HTMLInputElement | null>(null);
|
|
||||||
|
|
||||||
// breadcrumb inside modal: array of {id: number|null, name}
|
// create folder handler (page-level)
|
||||||
const [modalPath, setModalPath] = useState<
|
async function handleCreateFolder(name: string) {
|
||||||
{ id: number | null; name: string }[]
|
|
||||||
>([{ id: null, name: "My Cloud Storage" }]);
|
|
||||||
|
|
||||||
// pagination state for folders/files in modal
|
|
||||||
const [foldersOffset, setFoldersOffset] = useState(0);
|
|
||||||
const [filesOffset, setFilesOffset] = useState(0);
|
|
||||||
const FOLDERS_LIMIT = 10;
|
|
||||||
const FILES_LIMIT = 20;
|
|
||||||
|
|
||||||
const [modalFolders, setModalFolders] = useState<CloudFolder[]>([]);
|
|
||||||
const [modalFiles, setModalFiles] = useState<CloudFile[]>([]);
|
|
||||||
const [foldersTotal, setFoldersTotal] = useState(0);
|
|
||||||
const [filesTotal, setFilesTotal] = useState(0);
|
|
||||||
const [isLoadingModalItems, setIsLoadingModalItems] = useState(false);
|
|
||||||
|
|
||||||
// recent folders (main page) - show only top-level (parentId === null)
|
|
||||||
const RECENT_LIMIT = 10;
|
|
||||||
const [recentOffset, setRecentOffset] = useState(0);
|
|
||||||
const {
|
|
||||||
data: recentFoldersData,
|
|
||||||
isLoading: isLoadingRecentFolders,
|
|
||||||
refetch: refetchRecentFolders,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ["/api/cloud-storage/folders/recent", recentOffset],
|
|
||||||
queryFn: async () => {
|
|
||||||
const res = await apiRequest(
|
|
||||||
"GET",
|
|
||||||
`/api/cloud-storage/folders/recent?limit=${RECENT_LIMIT}&offset=${recentOffset}`
|
|
||||||
);
|
|
||||||
const json = await res.json();
|
|
||||||
if (!res.ok)
|
|
||||||
throw new Error(json?.message || "Failed to load recent folders");
|
|
||||||
// filter to top-level only (parentId === null)
|
|
||||||
const filtered = (json.data || []).filter((f: any) => f.parentId == null);
|
|
||||||
return { ...json, data: filtered } as ApiListResponse<CloudFolder[]>;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- Server fetch functions (paginated) ---------- */
|
|
||||||
|
|
||||||
async function fetchModalFolders(
|
|
||||||
parentId: number | null,
|
|
||||||
limit = FOLDERS_LIMIT,
|
|
||||||
offset = 0
|
|
||||||
) {
|
|
||||||
const pid = parentId === null ? "null" : String(parentId);
|
|
||||||
const res = await apiRequest(
|
|
||||||
"GET",
|
|
||||||
`/api/cloud-storage/items/folders?parentId=${encodeURIComponent(pid)}&limit=${limit}&offset=${offset}`
|
|
||||||
);
|
|
||||||
const json = await res.json().catch(() => null);
|
|
||||||
if (!res.ok || !json)
|
|
||||||
throw new Error(json?.message || "Failed to fetch folders");
|
|
||||||
return json as ApiListResponse<CloudFolder[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchModalFiles(
|
|
||||||
folderId: number | null,
|
|
||||||
limit = FILES_LIMIT,
|
|
||||||
offset = 0
|
|
||||||
) {
|
|
||||||
const fid = folderId === null ? "null" : String(folderId);
|
|
||||||
const res = await apiRequest(
|
|
||||||
"GET",
|
|
||||||
`/api/cloud-storage/folders/${encodeURIComponent(fid)}/files?limit=${limit}&offset=${offset}`
|
|
||||||
);
|
|
||||||
const json = await res.json().catch(() => null);
|
|
||||||
if (!res.ok || !json)
|
|
||||||
throw new Error(json?.message || "Failed to fetch files");
|
|
||||||
return json as ApiListResponse<CloudFile[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---------- load modal items (folders + files) ---------- */
|
|
||||||
|
|
||||||
const loadModalItems = async (
|
|
||||||
parentId: number | null,
|
|
||||||
foldersOffsetArg = 0,
|
|
||||||
filesOffsetArg = 0
|
|
||||||
) => {
|
|
||||||
setIsLoadingModalItems(true);
|
|
||||||
try {
|
try {
|
||||||
const [foldersResp, filesResp] = await Promise.all([
|
const userId = user?.id;
|
||||||
fetchModalFolders(parentId, FOLDERS_LIMIT, foldersOffsetArg),
|
if (!userId) {
|
||||||
fetchModalFiles(parentId, FILES_LIMIT, filesOffsetArg),
|
|
||||||
]);
|
|
||||||
setModalFolders(foldersResp.data ?? []);
|
|
||||||
setFoldersTotal(foldersResp.total ?? foldersResp.data?.length ?? 0);
|
|
||||||
setModalFiles(filesResp.data ?? []);
|
|
||||||
setFilesTotal(filesResp.total ?? filesResp.data?.length ?? 0);
|
|
||||||
} catch (err: any) {
|
|
||||||
toast({
|
toast({
|
||||||
title: "Error",
|
title: "Sign in required",
|
||||||
description: err?.message || "Failed to load items",
|
description: "Please sign in to create a folder.",
|
||||||
variant: "destructive",
|
|
||||||
});
|
});
|
||||||
setModalFolders([]);
|
|
||||||
setModalFiles([]);
|
|
||||||
setFoldersTotal(0);
|
|
||||||
setFilesTotal(0);
|
|
||||||
} finally {
|
|
||||||
setIsLoadingModalItems(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- Open modal (single-click from recent or elsewhere) ---------- */
|
|
||||||
const openFolderModal = async (folder: CloudFolder | null) => {
|
|
||||||
const fid: number | null =
|
|
||||||
folder && typeof (folder as any).id === "number"
|
|
||||||
? (folder as any).id
|
|
||||||
: null;
|
|
||||||
|
|
||||||
setModalPath((prev) => {
|
|
||||||
if (!folder) return [{ id: null, name: "My Cloud Storage" }];
|
|
||||||
const idx = prev.findIndex((p) => p.id === fid);
|
|
||||||
if (idx >= 0) return prev.slice(0, idx + 1);
|
|
||||||
const last = prev[prev.length - 1];
|
|
||||||
if (last && last.id === (folder as any).parentId) {
|
|
||||||
return [...prev, { id: fid, name: folder.name }];
|
|
||||||
}
|
|
||||||
return [
|
|
||||||
{ id: null, name: "My Cloud Storage" },
|
|
||||||
{ id: fid, name: folder.name },
|
|
||||||
];
|
|
||||||
});
|
|
||||||
|
|
||||||
setModalFolder(folder ?? null);
|
|
||||||
setFoldersOffset(0);
|
|
||||||
setFilesOffset(0);
|
|
||||||
setUploadSelectedFiles([]);
|
|
||||||
setIsFolderModalOpen(true);
|
|
||||||
await loadModalItems(fid, 0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- breadcrumb click inside modal ---------- */
|
|
||||||
const handleModalBreadcrumbClick = async (index: number) => {
|
|
||||||
if (!Number.isInteger(index) || index < 0) return;
|
|
||||||
|
|
||||||
const newPath = modalPath.slice(0, index + 1);
|
|
||||||
if (newPath.length === 0) {
|
|
||||||
setModalPath([{ id: null, name: "My Cloud Storage" }]);
|
|
||||||
setModalFolder(null);
|
|
||||||
setFoldersOffset(0);
|
|
||||||
setFilesOffset(0);
|
|
||||||
await loadModalItems(null, 0, 0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setModalPath(newPath);
|
const res = await apiRequest("POST", `/api/cloud-storage/folders`, {
|
||||||
|
userId,
|
||||||
const target = newPath[newPath.length - 1];
|
|
||||||
const targetId: number | null =
|
|
||||||
target && typeof target.id === "number" ? target.id : null;
|
|
||||||
|
|
||||||
setModalFolder(null);
|
|
||||||
setFoldersOffset(0);
|
|
||||||
setFilesOffset(0);
|
|
||||||
await loadModalItems(targetId, 0, 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
/* ---------- create folder (via Add Folder modal) ---------- */
|
|
||||||
const createFolder = useMutation({
|
|
||||||
mutationFn: async (name: string) => {
|
|
||||||
const body = {
|
|
||||||
userId: CURRENT_USER_ID,
|
|
||||||
name,
|
name,
|
||||||
parentId: addFolderParentId ?? null,
|
parentId: null,
|
||||||
};
|
});
|
||||||
const res = await apiRequest("POST", "/api/cloud-storage/folders", body);
|
|
||||||
const json = await res.json();
|
const json = await res.json();
|
||||||
if (!res.ok) throw new Error(json?.message || "Failed to create folder");
|
if (!res.ok) throw new Error(json?.message || "Failed to create folder");
|
||||||
return json;
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast({ title: "Folder created", description: "Folder created." });
|
|
||||||
setIsAddFolderModalOpen(false);
|
|
||||||
setAddFolderName("");
|
|
||||||
// refresh modal view if we're in same parent
|
|
||||||
await loadModalItems(
|
|
||||||
addFolderParentId ?? null,
|
|
||||||
foldersOffset,
|
|
||||||
filesOffset
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["/api/cloud-storage/folders/recent"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: () =>
|
|
||||||
toast({
|
|
||||||
title: "Error",
|
|
||||||
description: "Failed to create folder",
|
|
||||||
variant: "destructive",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- Upload logic (via upload modal) ----------
|
toast({ title: "Folder created" });
|
||||||
Use arrayBuffer() and send as raw bytes to POST /files/:id/chunks?seq=...,
|
|
||||||
then POST /files/:id/complete
|
|
||||||
*/
|
|
||||||
const uploadSingleFile = async (
|
|
||||||
file: File,
|
|
||||||
targetFolderId: number | null
|
|
||||||
) => {
|
|
||||||
const folderParam =
|
|
||||||
targetFolderId === null ? "null" : String(targetFolderId);
|
|
||||||
const body = {
|
|
||||||
userId: CURRENT_USER_ID,
|
|
||||||
name: file.name,
|
|
||||||
mimeType: file.type || null,
|
|
||||||
expectedSize: file.size,
|
|
||||||
totalChunks: 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
const initRes = await apiRequest(
|
// close modal
|
||||||
"POST",
|
setIsNewFolderOpen(false);
|
||||||
`/api/cloud-storage/folders/${encodeURIComponent(folderParam)}/files`,
|
|
||||||
body
|
|
||||||
);
|
|
||||||
const initJson = await initRes.json().catch(() => null);
|
|
||||||
if (!initRes.ok || !initJson)
|
|
||||||
throw new Error(
|
|
||||||
initJson?.message ?? `Init failed (status ${initRes.status})`
|
|
||||||
);
|
|
||||||
const created: CloudFile = initJson.data;
|
|
||||||
if (!created || typeof created.id !== "number")
|
|
||||||
throw new Error("Invalid response from init: missing file id");
|
|
||||||
|
|
||||||
// prepare raw bytes
|
// Invalidate recent folders page 1 so RecentFoldersCard will refresh.
|
||||||
const raw = await file.arrayBuffer();
|
qc.invalidateQueries({ queryKey: recentTopLevelFoldersQueryKey(1) });
|
||||||
const chunkUrl = `/api/cloud-storage/files/${created.id}/chunks?seq=0`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await apiRequest("POST", chunkUrl, raw);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
try {
|
toast({ title: "Error", description: err?.message || String(err) });
|
||||||
await apiRequest("DELETE", `/api/cloud-storage/files/${created.id}`);
|
|
||||||
} catch (_) {}
|
|
||||||
throw new Error(`Chunk upload failed: ${err?.message ?? String(err)}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const completeRes = await apiRequest(
|
|
||||||
"POST",
|
|
||||||
`/api/cloud-storage/files/${created.id}/complete`,
|
|
||||||
{}
|
|
||||||
);
|
|
||||||
const completeJson = await completeRes.json().catch(() => null);
|
|
||||||
if (!completeRes.ok || !completeJson)
|
|
||||||
throw new Error(
|
|
||||||
completeJson?.message ??
|
|
||||||
`Finalize failed (status ${completeRes.status})`
|
|
||||||
);
|
|
||||||
return completeJson;
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFilesMutation = useMutation({
|
|
||||||
mutationFn: async (files: File[]) => {
|
|
||||||
const targetFolderId =
|
|
||||||
uploadParentId ?? modalPath[modalPath.length - 1]?.id ?? null;
|
|
||||||
const results = [];
|
|
||||||
for (const f of files) {
|
|
||||||
results.push(await uploadSingleFile(f, targetFolderId));
|
|
||||||
}
|
}
|
||||||
return results;
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast({ title: "Upload complete", description: "Files uploaded." });
|
|
||||||
setUploadSelectedFiles([]);
|
|
||||||
setIsUploadModalOpen(false);
|
|
||||||
await loadModalItems(
|
|
||||||
modalPath[modalPath.length - 1]?.id ?? null,
|
|
||||||
foldersOffset,
|
|
||||||
filesOffset
|
|
||||||
);
|
|
||||||
queryClient.invalidateQueries({
|
|
||||||
queryKey: ["/api/cloud-storage/folders/recent"],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (err: any) => {
|
|
||||||
toast({
|
|
||||||
title: "Upload failed",
|
|
||||||
description: err?.message ?? "Upload failed",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
/* ---------- handlers ---------- */
|
|
||||||
|
|
||||||
const handleUploadFileSelection = (filesList: FileList | null) => {
|
|
||||||
setUploadSelectedFiles(filesList ? Array.from(filesList) : []);
|
|
||||||
};
|
|
||||||
|
|
||||||
const startUploadFromModal = () => {
|
|
||||||
if (!uploadSelectedFiles.length) {
|
|
||||||
toast({
|
|
||||||
title: "No files",
|
|
||||||
description: "Select files to upload",
|
|
||||||
variant: "destructive",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploadFilesMutation.mutate(uploadSelectedFiles);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFoldersPage = async (dir: "next" | "prev") => {
|
|
||||||
const newOffset =
|
|
||||||
dir === "next"
|
|
||||||
? foldersOffset + FOLDERS_LIMIT
|
|
||||||
: Math.max(0, foldersOffset - FOLDERS_LIMIT);
|
|
||||||
setFoldersOffset(newOffset);
|
|
||||||
await loadModalItems(
|
|
||||||
modalPath[modalPath.length - 1]?.id ?? null,
|
|
||||||
newOffset,
|
|
||||||
filesOffset
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFilesPage = async (dir: "next" | "prev") => {
|
|
||||||
const newOffset =
|
|
||||||
dir === "next"
|
|
||||||
? filesOffset + FILES_LIMIT
|
|
||||||
: Math.max(0, filesOffset - FILES_LIMIT);
|
|
||||||
setFilesOffset(newOffset);
|
|
||||||
await loadModalItems(
|
|
||||||
modalPath[modalPath.length - 1]?.id ?? null,
|
|
||||||
foldersOffset,
|
|
||||||
newOffset
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
refetchRecentFolders();
|
|
||||||
}, [recentOffset]);
|
|
||||||
|
|
||||||
/* ---------- Render ---------- */
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
|
||||||
<div className="container mx-auto space-y-6">
|
<div className="container mx-auto space-y-6">
|
||||||
|
{/* Header / actions */}
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold tracking-tight">Cloud Storage</h1>
|
<h1 className="text-3xl font-bold tracking-tight">Cloud Storage</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Recent top-level folders — click any folder to open it.
|
Manage Files and Folders in Cloud Storage.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -437,467 +75,53 @@ export default function CloudStoragePage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
/* search omitted */
|
/* search flow omitted - wire your search UI here */
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SearchIcon className="h-4 w-4 mr-2" /> Search
|
<SearchIcon className="h-4 w-4 mr-2" /> Search
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* MAIN PAGE New Folder: open Add Folder modal (parent null) */}
|
<Button onClick={() => setIsNewFolderOpen(true)}>
|
||||||
<Button
|
<FolderIcon className="h-4 w-4 mr-2" /> New Folder
|
||||||
onClick={() => {
|
</Button>
|
||||||
setAddFolderParentId(null);
|
</div>
|
||||||
setAddFolderName("");
|
</div>
|
||||||
setIsAddFolderModalOpen(true);
|
|
||||||
|
{/* Recent folders card (delegated component) */}
|
||||||
|
<RecentTopLevelFoldersCard
|
||||||
|
key={recentKey}
|
||||||
|
pageSize={10}
|
||||||
|
initialPage={1}
|
||||||
|
onSelect={(folderId) => {
|
||||||
|
setPanelInitialFolderId(folderId);
|
||||||
|
setPanelOpen(true);
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<FolderPlus className="h-4 w-4 mr-2" /> New Folder (root)
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Recent Folders (top-level only) */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Recent Folders</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Most recently updated top-level folders
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoadingRecentFolders ? (
|
|
||||||
<div className="py-6 text-center">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex gap-3 overflow-x-auto py-2">
|
|
||||||
{(recentFoldersData?.data ?? []).map((f) => (
|
|
||||||
<div key={f.id} className="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
onClick={() => openFolderModal(f)}
|
|
||||||
className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer"
|
|
||||||
style={{ minWidth: 120 }}
|
|
||||||
>
|
|
||||||
<Folder className="h-10 w-10 text-yellow-500 mb-1" />
|
|
||||||
<div
|
|
||||||
className="text-sm max-w-[140px] text-center truncate"
|
|
||||||
title={f.name}
|
|
||||||
>
|
|
||||||
{f.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Showing {(recentFoldersData?.data ?? []).length} recent
|
|
||||||
folders
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setRecentOffset(
|
|
||||||
Math.max(0, recentOffset - RECENT_LIMIT)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
disabled={recentOffset === 0}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() =>
|
|
||||||
setRecentOffset(recentOffset + RECENT_LIMIT)
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
!recentFoldersData?.data?.length ||
|
|
||||||
recentFoldersData!.data.length < RECENT_LIMIT
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Modal: spacing so not flush top/bottom */}
|
|
||||||
<Dialog
|
|
||||||
open={isFolderModalOpen}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
setIsFolderModalOpen(v);
|
|
||||||
if (!v) {
|
|
||||||
setModalFolder(null);
|
|
||||||
setModalPath([{ id: null, name: "My Cloud Storage" }]);
|
|
||||||
setModalFolders([]);
|
|
||||||
setModalFiles([]);
|
|
||||||
setUploadSelectedFiles([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-6xl w-full my-8">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div className="w-full">
|
|
||||||
{/* breadcrumb inside modal */}
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
{modalPath.map((p, idx) => (
|
|
||||||
<div
|
|
||||||
key={String(p.id) + idx}
|
|
||||||
className="flex items-center"
|
|
||||||
>
|
|
||||||
{idx > 0 && <BreadcrumbSeparator />}
|
|
||||||
<BreadcrumbItem>
|
|
||||||
{idx === modalPath.length - 1 ? (
|
|
||||||
<BreadcrumbPage>{p.name}</BreadcrumbPage>
|
|
||||||
) : (
|
|
||||||
<BreadcrumbLink
|
|
||||||
className="cursor-pointer hover:text-primary"
|
|
||||||
onClick={() => handleModalBreadcrumbClick(idx)}
|
|
||||||
>
|
|
||||||
{p.name}
|
|
||||||
</BreadcrumbLink>
|
|
||||||
)}
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="ml-4">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsFolderModalOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-6 p-4">
|
|
||||||
{/* ----- Folders row ----- */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Folders</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Child folders (page size {FOLDERS_LIMIT})
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center gap-3 overflow-x-auto py-2">
|
|
||||||
{/* Add Folder tile: opens Add Folder modal (parent = current modal parent) */}
|
|
||||||
<div className="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
className="p-3 rounded-lg hover:bg-gray-50 cursor-pointer border border-dashed border-gray-200 flex items-center justify-center"
|
|
||||||
onClick={() => {
|
|
||||||
setAddFolderParentId(
|
|
||||||
modalPath[modalPath.length - 1]?.id ?? null
|
|
||||||
);
|
|
||||||
setAddFolderName("");
|
|
||||||
setIsAddFolderModalOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ minWidth: 120 }}
|
|
||||||
>
|
|
||||||
<Plus className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{modalFolders.map((f) => (
|
|
||||||
<div key={f.id} className="flex-shrink-0">
|
|
||||||
<div
|
|
||||||
onClick={() => openFolderModal(f)}
|
|
||||||
className="flex flex-col items-center p-3 rounded-lg hover:bg-gray-100 cursor-pointer"
|
|
||||||
style={{ minWidth: 120 }}
|
|
||||||
>
|
|
||||||
<Folder className="h-10 w-10 text-yellow-500 mb-1" />
|
|
||||||
<div
|
|
||||||
className="text-sm truncate text-center"
|
|
||||||
style={{ maxWidth: 120 }}
|
|
||||||
title={f.name}
|
|
||||||
>
|
|
||||||
{f.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Showing {modalFolders.length} of {foldersTotal}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleFoldersPage("prev")}
|
|
||||||
disabled={foldersOffset === 0}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleFoldersPage("next")}
|
|
||||||
disabled={foldersOffset + FOLDERS_LIMIT >= foldersTotal}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* ----- Files section (below folders) ----- */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>Files</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
Files in this folder (page size {FILES_LIMIT})
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="flex items-center justify-between mb-4">
|
|
||||||
<div>
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Target: {modalPath[modalPath.length - 1]?.name}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{/* ADD FILE tile (only + icon) */}
|
|
||||||
<div
|
|
||||||
className="p-3 rounded-lg hover:bg-gray-50 cursor-pointer border border-dashed border-gray-200 flex items-center justify-center"
|
|
||||||
onClick={() => {
|
|
||||||
setUploadParentId(
|
|
||||||
modalPath[modalPath.length - 1]?.id ?? null
|
|
||||||
);
|
|
||||||
setUploadSelectedFiles([]);
|
|
||||||
setIsUploadModalOpen(true);
|
|
||||||
}}
|
|
||||||
style={{ minWidth: 120 }}
|
|
||||||
>
|
|
||||||
<Plus className="h-10 w-10" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isLoadingModalItems ? (
|
|
||||||
<div className="py-6 text-center">Loading...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{modalFiles.map((file) => (
|
|
||||||
<div
|
|
||||||
key={file.id}
|
|
||||||
className="p-3 rounded hover:bg-gray-50 border"
|
|
||||||
title={file.name}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col items-center">
|
|
||||||
<div className="h-10 w-10 text-gray-500 mb-2 flex items-center justify-center">
|
|
||||||
{fileIcon((file as any).mimeType)}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className="text-sm truncate text-center"
|
|
||||||
style={{ maxWidth: 140 }}
|
|
||||||
>
|
|
||||||
<div title={file.name}>
|
|
||||||
{truncateName(file.name, 28)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-400">
|
|
||||||
{((file as any).fileSize ?? 0).toString()} bytes
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between mt-4">
|
|
||||||
<div className="text-sm text-gray-500">
|
|
||||||
Showing {modalFiles.length} of {filesTotal}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleFilesPage("prev")}
|
|
||||||
disabled={filesOffset === 0}
|
|
||||||
>
|
|
||||||
Prev
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleFilesPage("next")}
|
|
||||||
disabled={filesOffset + FILES_LIMIT >= filesTotal}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => setIsFolderModalOpen(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Add Folder Modal (simple name/cancel/confirm) */}
|
|
||||||
<Dialog
|
|
||||||
open={isAddFolderModalOpen}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
setIsAddFolderModalOpen(v);
|
|
||||||
if (!v) {
|
|
||||||
setAddFolderName("");
|
|
||||||
setAddFolderParentId(null);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md w-full my-8">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Create Folder</h3>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Parent:{" "}
|
|
||||||
{addFolderParentId == null
|
|
||||||
? "Root"
|
|
||||||
: `id ${addFolderParentId}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsAddFolderModalOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<Input
|
|
||||||
placeholder="Folder name"
|
|
||||||
value={addFolderName}
|
|
||||||
onChange={(e) => setAddFolderName(e.target.value)}
|
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-2 mt-3">
|
|
||||||
<Button onClick={() => createFolder.mutate(addFolderName.trim())}>
|
|
||||||
Create
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setIsAddFolderModalOpen(false);
|
|
||||||
setAddFolderName("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Upload Modal */}
|
{/* FolderPanel lives in page so it can be reused with other UI */}
|
||||||
<Dialog
|
{panelOpen && (
|
||||||
open={isUploadModalOpen}
|
<FolderPanel
|
||||||
onOpenChange={(v) => {
|
folderId={panelInitialFolderId}
|
||||||
setIsUploadModalOpen(v);
|
onClose={() => setPanelOpen(false)}
|
||||||
if (!v) {
|
onViewChange={(viewedId: any) => {
|
||||||
setUploadSelectedFiles([]);
|
// If the panel navigates back to root, clear recent card selection by remounting it
|
||||||
setUploadParentId(null);
|
if (viewedId === null) {
|
||||||
|
setRecentKey((k) => k + 1);
|
||||||
|
|
||||||
|
// clear the panel initial id and close the panel so child folder/file sections hide
|
||||||
|
setPanelInitialFolderId(null);
|
||||||
|
setPanelOpen(false);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
|
||||||
<DialogContent className="max-w-md w-full my-8">
|
|
||||||
<DialogHeader>
|
|
||||||
<div className="flex items-center justify-between w-full">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-semibold">Upload Files</h3>
|
|
||||||
<div className="text-sm text-muted-foreground">
|
|
||||||
Target:{" "}
|
|
||||||
{uploadParentId == null ? "Root" : `id ${uploadParentId}`}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => setIsUploadModalOpen(false)}
|
|
||||||
>
|
|
||||||
<X className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="p-4">
|
|
||||||
<Label>Choose files (multiple)</Label>
|
|
||||||
<input
|
|
||||||
ref={uploadFileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
onChange={(e) => handleUploadFileSelection(e.target.files)}
|
|
||||||
/>
|
/>
|
||||||
<div className="mt-2">
|
|
||||||
{uploadSelectedFiles.length ? (
|
|
||||||
<div className="text-sm mb-2">
|
|
||||||
{uploadSelectedFiles.map((f) => f.name).join(", ")}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-gray-500 mb-2">
|
|
||||||
No files selected
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={() => uploadFileInputRef.current?.click()}>
|
{/* New folder modal (reusable) */}
|
||||||
Pick files
|
<NewFolderModal
|
||||||
</Button>
|
isOpen={isNewFolderOpen}
|
||||||
<Button
|
onClose={() => setIsNewFolderOpen(false)}
|
||||||
onClick={startUploadFromModal}
|
onSubmit={handleCreateFolder}
|
||||||
disabled={!uploadSelectedFiles.length}
|
/>
|
||||||
>
|
|
||||||
Upload
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setIsUploadModalOpen(false);
|
|
||||||
setUploadSelectedFiles([]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
20
apps/Frontend/src/utils/pageNumberGenerator.ts
Normal file
20
apps/Frontend/src/utils/pageNumberGenerator.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export function getPageNumbers(current: number, total: number, maxButtons = 7) {
|
||||||
|
const pages: (number | "...")[] = [];
|
||||||
|
if (total <= maxButtons) {
|
||||||
|
for (let i = 1; i <= total; i++) pages.push(i);
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const half = Math.floor(maxButtons / 2);
|
||||||
|
let start = Math.max(1, current - half);
|
||||||
|
let end = Math.min(total, current + half);
|
||||||
|
|
||||||
|
if (start === 1) end = Math.min(total, maxButtons);
|
||||||
|
if (end === total) start = Math.max(1, total - maxButtons + 1);
|
||||||
|
|
||||||
|
if (start > 1) pages.push(1, "...");
|
||||||
|
for (let i = start; i <= end; i++) pages.push(i);
|
||||||
|
if (end < total) pages.push("...", total);
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user