feat: enhance file management with context menu and skeleton loading states

- Added a context menu for file and folder actions, including options to create new folders and upload files.
- Implemented skeleton loading components for files grid and table views to improve user experience during data loading.
- Updated file and folder components to support new context menu interactions.
- Refactored drag-and-drop functionality to integrate with the new context menu features.

These changes improve the usability and responsiveness of the file management interface.
This commit is contained in:
Daniel Luiz Alves 2025-10-23 23:44:38 -03:00
parent 25b1a62d5f
commit 18700d7e72
28 changed files with 3421 additions and 3070 deletions

View File

@ -1947,5 +1947,9 @@
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
},
"contextMenu": {
"newFolder": "مجلد جديد",
"uploadFile": "رفع ملف"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "Direkte URL zur Bilddatei",
"htmlDescription": "Verwenden Sie diesen Code, um das Bild in HTML-Seiten einzubetten",
"bbcodeDescription": "Verwenden Sie diesen Code, um das Bild in Foren einzubetten, die BBCode unterstützen"
},
"contextMenu": {
"newFolder": "Neuer Ordner",
"uploadFile": "Datei hochladen"
}
}

View File

@ -8,6 +8,10 @@
"auth_failed": "Authentication failed. Please try again."
}
},
"contextMenu": {
"newFolder": "New folder",
"uploadFile": "Upload file"
},
"authProviders": {
"title": "Authentication Providers",
"description": "Configure external authentication providers for SSO",

View File

@ -8,6 +8,10 @@
"auth_failed": "Error de autenticación. Por favor, inténtelo de nuevo."
}
},
"contextMenu": {
"newFolder": "Nueva carpeta",
"uploadFile": "Subir archivo"
},
"authProviders": {
"title": "Proveedores de Autenticación",
"description": "Configurar proveedores de autenticación externos para SSO",

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "URL directe vers le fichier image",
"htmlDescription": "Utilisez ce code pour intégrer l'image dans des pages HTML",
"bbcodeDescription": "Utilisez ce code pour intégrer l'image dans des forums prenant en charge BBCode"
},
"contextMenu": {
"newFolder": "Nouveau dossier",
"uploadFile": "Télécharger un fichier"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
},
"contextMenu": {
"newFolder": "नया फ़ोल्डर",
"uploadFile": "फ़ाइल अपलोड करें"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "URL diretto al file immagine",
"htmlDescription": "Usa questo codice per incorporare l'immagine nelle pagine HTML",
"bbcodeDescription": "Usa questo codice per incorporare l'immagine nei forum che supportano BBCode"
},
"contextMenu": {
"newFolder": "Nuova cartella",
"uploadFile": "Carica file"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "画像ファイルへの直接URL",
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
},
"contextMenu": {
"newFolder": "新規フォルダ",
"uploadFile": "ファイルをアップロード"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "이미지 파일에 대한 직접 URL",
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
},
"contextMenu": {
"newFolder": "새 폴더",
"uploadFile": "파일 업로드"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "Directe URL naar het afbeeldingsbestand",
"htmlDescription": "Gebruik deze code om de afbeelding in te sluiten in HTML-pagina's",
"bbcodeDescription": "Gebruik deze code om de afbeelding in te sluiten in forums die BBCode ondersteunen"
},
"contextMenu": {
"newFolder": "Nieuwe map",
"uploadFile": "Bestand uploaden"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "Bezpośredni adres URL pliku obrazu",
"htmlDescription": "Użyj tego kodu, aby osadzić obraz na stronach HTML",
"bbcodeDescription": "Użyj tego kodu, aby osadzić obraz na forach obsługujących BBCode"
},
"contextMenu": {
"newFolder": "Nowy folder",
"uploadFile": "Prześlij plik"
}
}

View File

@ -8,6 +8,10 @@
"auth_failed": "Falha na autenticação. Tente novamente."
}
},
"contextMenu": {
"newFolder": "Nova pasta",
"uploadFile": "Enviar arquivo"
},
"authProviders": {
"title": "Provedores de autenticação",
"description": "Configure provedores de autenticação externos para SSO",

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "Прямой URL-адрес файла изображения",
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
},
"contextMenu": {
"newFolder": "Новая папка",
"uploadFile": "Загрузить файл"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "Resim dosyasının doğrudan URL'si",
"htmlDescription": "Resmi HTML sayfalarına yerleştirmek için bu kodu kullanın",
"bbcodeDescription": "BBCode destekleyen forumlara resmi yerleştirmek için bu kodu kullanın"
},
"contextMenu": {
"newFolder": "Yeni klasör",
"uploadFile": "Dosya yükle"
}
}

View File

@ -1945,5 +1945,9 @@
"directLinkDescription": "图片文件的直接URL",
"htmlDescription": "使用此代码将图片嵌入HTML页面",
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
},
"contextMenu": {
"newFolder": "新建文件夹",
"uploadFile": "上传文件"
}
}

View File

@ -36,6 +36,7 @@
"@radix-ui/react-avatar": "^1.1.4",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-dropdown-menu": "^2.1.6",
"@radix-ui/react-label": "^2.1.2",

4996
apps/web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
import { IconLayoutGrid, IconSearch, IconTable } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { FilesGridSkeleton, FilesTableSkeleton } from "@/components/skeletons";
import { FilesGrid } from "@/components/tables/files-grid";
import { FilesTable } from "@/components/tables/files-table";
import { Button } from "@/components/ui/button";
@ -47,6 +48,8 @@ interface FilesViewManagerProps {
isLoading?: boolean;
emptyStateComponent?: React.ComponentType;
isShareMode?: boolean;
onCreateFolder?: () => void;
onUpload?: () => void;
onDeleteFolder?: (folder: Folder) => void;
onImmediateUpdate?: (itemId: string, itemType: "file" | "folder", newParentId: string | null) => void;
onRefresh?: () => Promise<void>;
@ -85,6 +88,8 @@ export function FilesViewManager({
isLoading = false,
emptyStateComponent: EmptyStateComponent,
isShareMode = false,
onCreateFolder,
onUpload,
onDeleteFolder,
onRenameFolder,
onMoveFolder,
@ -128,6 +133,8 @@ export function FilesViewManager({
files,
folders: folders || [],
onNavigateToFolder,
onCreateFolder: isShareMode ? undefined : onCreateFolder,
onUpload: isShareMode ? undefined : onUpload,
onDeleteFolder: isShareMode ? undefined : onDeleteFolder,
onRenameFolder: isShareMode ? undefined : onRenameFolder,
onMoveFolder: isShareMode ? undefined : onMoveFolder,
@ -200,10 +207,11 @@ export function FilesViewManager({
</div>
{isLoading ? (
<div className="text-center py-8">
<div className="animate-spin h-8 w-8 border-2 border-current border-t-transparent rounded-full mx-auto mb-4" />
<p className="text-muted-foreground">Loading...</p>
</div>
viewMode === "table" ? (
<FilesTableSkeleton rowCount={10} />
) : (
<FilesGridSkeleton itemCount={12} />
)
) : showEmptyState ? (
EmptyStateComponent ? (
<EmptyStateComponent />

View File

@ -69,8 +69,9 @@ export function useFileBrowser() {
const [isUploadModalOpen, setIsUploadModalOpen] = useState(false);
const [clearSelectionCallback, setClearSelectionCallbackState] = useState<(() => void) | undefined>();
const [dataLoaded, setDataLoaded] = useState(false);
const [forceUpdate, setForceUpdate] = useState(0);
const [forceUpdate] = useState(0);
const isNavigatingRef = useRef(false);
const loadFilesRef = useRef<(() => Promise<void>) | null>(null);
const urlFolderSlug = searchParams.get("folder") || null;
const [currentFolderId, setCurrentFolderId] = useState<string | null>(null);
@ -172,7 +173,11 @@ export function useFileBrowser() {
if (dataLoaded && allFiles.length > 0) {
isNavigatingRef.current = true;
navigateToFolderDirect(targetFolderId);
setTimeout(() => {
// Refresh data when navigating to ensure we have latest state
setTimeout(async () => {
if (loadFilesRef.current) {
await loadFilesRef.current();
}
isNavigatingRef.current = false;
}, 0);
} else {
@ -247,12 +252,13 @@ export function useFileBrowser() {
}
}, [urlFolderSlug, buildBreadcrumbPath, t, getFolderIdFromPathSlug]);
const fileManager = useEnhancedFileManager(loadFiles, clearSelectionCallback);
const handleImmediateUpdate = useCallback(
(itemId: string, itemType: "file" | "folder", newParentId: string | null) => {
// Use requestAnimationFrame for smoother updates
requestAnimationFrame(() => {
// Check if this is a delete operation
const isDelete = newParentId === ("__DELETE__" as any);
if (itemType === "file") {
setFiles((prevFiles) => {
return prevFiles.filter((file) => file.id !== itemId);
@ -260,6 +266,9 @@ export function useFileBrowser() {
// Update allFiles to keep state consistent
setAllFiles((prevAllFiles) => {
if (isDelete) {
return prevAllFiles.filter((file) => file.id !== itemId);
}
return prevAllFiles.map((file) => (file.id === itemId ? { ...file, folderId: newParentId } : file));
});
} else if (itemType === "folder") {
@ -269,6 +278,9 @@ export function useFileBrowser() {
// Update allFolders to keep state consistent
setAllFolders((prevAllFolders) => {
if (isDelete) {
return prevAllFolders.filter((folder) => folder.id !== itemId);
}
return prevAllFolders.map((folder) =>
folder.id === itemId ? { ...folder, parentId: newParentId } : folder
);
@ -279,6 +291,8 @@ export function useFileBrowser() {
[]
);
const fileManager = useEnhancedFileManager(loadFiles, clearSelectionCallback, handleImmediateUpdate);
const getImmediateChildFoldersWithMatches = useCallback(() => {
if (!searchQuery) return [];
@ -338,12 +352,17 @@ export function useFileBrowser() {
const filteredFolders = searchQuery ? getImmediateChildFoldersWithMatches() : folders;
// Update loadFilesRef whenever loadFiles changes
useEffect(() => {
if (!isNavigatingRef.current) {
loadFiles();
}
loadFilesRef.current = loadFiles;
}, [loadFiles]);
// Load files only on mount or when explicitly called
useEffect(() => {
loadFiles();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Empty dependency array - load only on mount
return {
isLoading,
files,

View File

@ -139,11 +139,63 @@ export default function FilesPage() {
onSearch={handleSearch}
onDownload={fileManager.handleDownload}
isLoading={isLoading}
onCreateFolder={() => fileManager.setCreateFolderModalOpen(true)}
onUpload={modals.onOpenUploadModal}
breadcrumbs={
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem>
<BreadcrumbLink className="flex items-center gap-1 cursor-pointer" onClick={navigateToRoot}>
<BreadcrumbLink
className="flex items-center gap-1.5 cursor-pointer transition-colors p-0.5 rounded-md"
onClick={navigateToRoot}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add("bg-primary/10", "text-primary");
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
try {
const itemData = e.dataTransfer.getData("text/plain");
const items = JSON.parse(itemData);
// Update UI immediately
items.forEach((item: any) => {
handleImmediateUpdate(item.id, item.type, null);
});
// Move all items in parallel
const movePromises = items.map((item: any) => {
if (item.type === "file") {
return moveFile(item.id, { folderId: null });
} else if (item.type === "folder") {
return moveFolder(item.id, { parentId: null });
}
return Promise.resolve();
});
await Promise.all(movePromises);
if (items.length === 1) {
toast.success(
`${items[0].type === "folder" ? "Folder" : "File"} "${items[0].name}" moved to root folder`
);
} else {
toast.success(`${items.length} items moved to root folder`);
}
} catch (error) {
console.error("Error moving items:", error);
toast.error("Failed to move items");
await loadFiles();
}
}}
>
<IconFolderOpen size={16} />
{t("folderActions.rootFolder")}
</BreadcrumbLink>
@ -154,9 +206,75 @@ export default function FilesPage() {
<BreadcrumbSeparator />
<BreadcrumbItem>
{index === currentPath.length - 1 ? (
<BreadcrumbPage>{folder.name}</BreadcrumbPage>
<BreadcrumbPage className="flex items-center gap-1.5">
<IconFolderOpen size={16} />
{folder.name}
</BreadcrumbPage>
) : (
<BreadcrumbLink className="cursor-pointer" onClick={() => navigateToFolder(folder.id)}>
<BreadcrumbLink
className="flex items-center gap-1 cursor-pointer transition-colors p-0.5 rounded-md"
onClick={() => navigateToFolder(folder.id)}
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.add("bg-primary/10", "text-primary");
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
e.currentTarget.classList.remove("bg-primary/10", "text-primary");
try {
const itemData = e.dataTransfer.getData("text/plain");
const items = JSON.parse(itemData);
// Filter out invalid moves
const validItems = items.filter((item: any) => {
if (item.id === folder.id) return false;
if (item.type === "folder" && item.id === folder.id) return false;
return true;
});
if (validItems.length === 0) {
toast.error("Cannot move items to this location");
return;
}
// Update UI immediately
validItems.forEach((item: any) => {
handleImmediateUpdate(item.id, item.type, folder.id);
});
// Move all items in parallel
const movePromises = validItems.map((item: any) => {
if (item.type === "file") {
return moveFile(item.id, { folderId: folder.id });
} else if (item.type === "folder") {
return moveFolder(item.id, { parentId: folder.id });
}
return Promise.resolve();
});
await Promise.all(movePromises);
if (validItems.length === 1) {
toast.success(
`${validItems[0].type === "folder" ? "Folder" : "File"} "${validItems[0].name}" moved to "${folder.name}"`
);
} else {
toast.success(`${validItems.length} items moved to "${folder.name}"`);
}
} catch (error) {
console.error("Error moving items:", error);
toast.error("Failed to move items");
await loadFiles();
}
}}
>
<IconFolderOpen size={16} />
{folder.name}
</BreadcrumbLink>
)}

View File

@ -0,0 +1,42 @@
import { Skeleton } from "@/components/ui/skeleton";
interface FilesGridSkeletonProps {
itemCount?: number;
}
export function FilesGridSkeleton({ itemCount = 12 }: FilesGridSkeletonProps) {
return (
<>
{/* Select All Checkbox Skeleton */}
<div className="flex items-center gap-2 px-2 mb-4">
<Skeleton className="h-4 w-4 rounded" />
<Skeleton className="h-4 w-24" />
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{Array.from({ length: itemCount }).map((_, index) => (
<div key={index} className="border rounded-lg p-3 space-y-3">
{/* Icon/Preview skeleton */}
<div className="flex flex-col items-center space-y-3">
<Skeleton className="w-16 h-16 rounded-lg" />
{/* File name skeleton */}
<div className="w-full space-y-1">
<Skeleton className="h-4 w-full" />
{/* Description skeleton (optional, 50% chance) */}
{index % 2 === 0 && <Skeleton className="h-3 w-3/4" />}
{/* Size and date skeleton */}
<div className="space-y-1">
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-24" />
</div>
</div>
</div>
</div>
))}
</div>
</>
);
}

View File

@ -0,0 +1,58 @@
import { Skeleton } from "@/components/ui/skeleton";
interface FilesTableSkeletonProps {
rowCount?: number;
}
export function FilesTableSkeleton({ rowCount = 10 }: FilesTableSkeletonProps) {
return (
<div className="rounded-md border">
<div className="w-full">
{/* Table Header */}
<div className="border-b bg-muted/50">
<div className="grid grid-cols-[auto_1fr_120px_120px_80px] gap-4 p-4">
<Skeleton className="h-4 w-4" /> {/* Checkbox */}
<Skeleton className="h-4 w-24" /> {/* Name */}
<Skeleton className="h-4 w-16" /> {/* Size */}
<Skeleton className="h-4 w-20" /> {/* Modified */}
<Skeleton className="h-4 w-12" /> {/* Actions */}
</div>
</div>
{/* Table Rows */}
<div className="divide-y">
{Array.from({ length: rowCount }).map((_, index) => (
<div key={index} className="grid grid-cols-[auto_1fr_120px_120px_80px] gap-4 p-4 hover:bg-muted/50">
{/* Checkbox */}
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-4 w-4" />
</div>
{/* Name column with icon */}
<div className="flex items-center gap-2 min-w-0">
<Skeleton className="h-6 w-6 rounded flex-shrink-0" />
<Skeleton className="h-4 w-full max-w-[200px]" />
</div>
{/* Size */}
<div className="flex items-center">
<Skeleton className="h-4 w-16" />
</div>
{/* Modified date */}
<div className="flex items-center">
<Skeleton className="h-4 w-24" />
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
<Skeleton className="h-6 w-6 rounded" />
<Skeleton className="h-6 w-6 rounded" />
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { FilesGridSkeleton } from "./files-grid-skeleton";
export { FilesTableSkeleton } from "./files-table-skeleton";

View File

@ -2,11 +2,13 @@ import { useEffect, useMemo, useRef, useState } from "react";
import {
IconArrowsMove,
IconChevronDown,
IconCloudUpload,
IconDotsVertical,
IconDownload,
IconEdit,
IconEye,
IconFolder,
IconFolderPlus,
IconShare,
IconTrash,
} from "@tabler/icons-react";
@ -14,6 +16,7 @@ import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from "@/components/ui/context-menu";
import {
DropdownMenu,
DropdownMenuContent,
@ -62,7 +65,8 @@ interface FilesGridProps {
folders?: Folder[];
onPreview?: (file: File) => void;
onRename?: (file: File) => void;
onCreateFolder?: () => void;
onUpload?: () => void;
onDownload: (objectName: string, fileName: string) => void;
onShare?: (file: File) => void;
onDelete?: (file: File) => void;
@ -89,6 +93,8 @@ export function FilesGrid({
folders = [],
onPreview,
onRename,
onCreateFolder,
onUpload,
onDownload,
onShare,
onDelete,
@ -365,361 +371,547 @@ export function FilesGrid({
<span className="text-sm text-muted-foreground">{t("filesTable.selectAll")}</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{/* Render folders first */}
{folders.map((folder) => {
const isSelected = selectedFolders.has(folder.id);
const isDragOver = dragOverTarget?.id === folder.id;
const isDraggedOver = draggedItem?.id === folder.id;
<ContextMenu modal={false}>
<ContextMenuTrigger asChild>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4 ">
{/* Render folders first */}
{folders.map((folder) => {
const isSelected = selectedFolders.has(folder.id);
const isDragOver = dragOverTarget?.id === folder.id;
const isDraggedOver = draggedItem?.id === folder.id;
// Check if this folder is part of the dragged items (optimized with memoized Set)
const isBeingDragged = draggedItemIds.has(folder.id);
const isAnySelectedItemDragged = isDragging && isSelected && draggedItems.length > 1;
// Check if this folder is part of the dragged items (optimized with memoized Set)
const isBeingDragged = draggedItemIds.has(folder.id);
const isAnySelectedItemDragged = isDragging && isSelected && draggedItems.length > 1;
return (
<div
key={`folder-${folder.id}`}
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-all duration-200 cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
} ${isDragOver && !isBeingDragged ? "ring-2 ring-primary bg-primary/10 scale-105" : ""} ${
isDraggedOver ? "opacity-50" : ""
} ${
isBeingDragged || isAnySelectedItemDragged
? "opacity-40 scale-95 transform rotate-2 border-2 border-primary/50 shadow-lg"
: ""
}`}
style={{
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
willChange: isDragging ? "transform, opacity" : "auto",
}}
onClick={() => onNavigateToFolder?.(folder.id)}
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(e, { id: folder.id, type: "folder", name: folder.name });
}}
onDragEnd={handleDragEnd}
onDragOver={(e) => {
e.stopPropagation();
handleDragOver(e, { id: folder.id, type: "folder", name: folder.name });
}}
onDragLeave={handleDragLeave}
onDrop={(e) => {
e.stopPropagation();
handleDrop(e, { id: folder.id, type: "folder", name: folder.name });
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
const newSelected = new Set(selectedFolders);
if (checked) {
newSelected.add(folder.id);
} else {
newSelected.delete(folder.id);
}
setSelectedFolders(newSelected);
}}
aria-label={`Select folder ${folder.name}`}
className="bg-background border-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
onDownloadFolder && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
const folderContextMenu = !isShareMode && (
<ContextMenuContent className="w-[200px]">
{onRenameFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</ContextMenuItem>
)}
{onMoveFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</ContextMenuItem>
)}
{onShareFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</ContextMenuItem>
)}
{onDownloadFolder && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onRenameFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onShareFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDownloadFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
)}
{onDeleteFolder && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
<IconFolder className="h-10 w-10 text-primary" />
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={folder.name}>
{folder.name}
</p>
{folder.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={folder.description}>
{folder.description}
</p>
{t("filesTable.actions.download")}
</ContextMenuItem>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{folder.totalSize ? formatFileSize(Number(folder.totalSize)) : "—"}</p>
<p>{formatDateTime(folder.createdAt)}</p>
</div>
</div>
</div>
</div>
);
})}
{onDeleteFolder && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
variant="destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</ContextMenuItem>
)}
</ContextMenuContent>
);
{/* Render files */}
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const isSelected = selectedFiles.has(file.id);
const isImage = isImageFile(file.name);
const previewUrl = filePreviewUrls[file.id];
const isDraggedOver = draggedItem?.id === file.id;
return (
<ContextMenu key={`folder-${folder.id}`} modal={false}>
<ContextMenuTrigger asChild>
<div
data-card="true"
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-all duration-200 cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
} ${isDragOver && !isBeingDragged ? "ring-2 ring-primary bg-primary/10 scale-105" : ""} ${
isDraggedOver ? "opacity-50" : ""
} ${
isBeingDragged || isAnySelectedItemDragged
? "opacity-40 scale-95 transform rotate-2 border-2 border-primary/50 shadow-lg"
: ""
}`}
style={{
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
willChange: isDragging ? "transform, opacity" : "auto",
}}
onClick={() => onNavigateToFolder?.(folder.id)}
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(e, { id: folder.id, type: "folder", name: folder.name });
}}
onDragEnd={handleDragEnd}
onDragOver={(e) => {
e.stopPropagation();
handleDragOver(e, { id: folder.id, type: "folder", name: folder.name });
}}
onDragLeave={handleDragLeave}
onDrop={(e) => {
e.stopPropagation();
handleDrop(e, { id: folder.id, type: "folder", name: folder.name });
}}
onContextMenu={(e) => {
e.stopPropagation();
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
const newSelected = new Set(selectedFolders);
if (checked) {
newSelected.add(folder.id);
} else {
newSelected.delete(folder.id);
}
setSelectedFolders(newSelected);
}}
aria-label={`Select folder ${folder.name}`}
className="bg-background border-2"
onClick={(e) => e.stopPropagation()}
/>
</div>
// Check if this file is part of the dragged items (optimized with memoized Set)
const isBeingDragged = draggedItemIds.has(file.id);
const isAnySelectedItemDragged = isDragging && isSelected && draggedItems.length > 1;
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
onDownloadFolder && (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
)
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
{onRenameFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRenameFolder(folder);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
{onMoveFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFolder(folder);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onShareFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShareFolder(folder);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onDownloadFolder && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownloadFolder(folder.id, folder.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
)}
{onDeleteFolder && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(folder);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
return (
<div
key={file.id}
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-all duration-200 cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
} ${isDraggedOver ? "opacity-50 scale-95" : ""} ${
isBeingDragged || isAnySelectedItemDragged
? "opacity-40 scale-95 transform rotate-2 border-2 border-primary/50 shadow-lg"
: ""
}`}
style={{
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
willChange: isDragging ? "transform, opacity" : "auto",
}}
onClick={(e) => {
if (
(e.target as HTMLElement).closest(".checkbox-wrapper") ||
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest('[role="menuitem"]')
) {
return;
}
if (onPreview) {
onPreview(file);
}
}}
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(e, { id: file.id, type: "file", name: file.name });
}}
onDragEnd={handleDragEnd}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
handleSelectFile({ stopPropagation: () => {} } as React.MouseEvent, file.id, checked);
}}
aria-label={t("filesTable.selectFile", { fileName: file.name })}
className="bg-background border-2"
/>
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
<IconFolder className="h-10 w-10 text-primary" />
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={folder.name}>
{folder.name}
</p>
{folder.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={folder.description}>
{folder.description}
</p>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{folder.totalSize ? formatFileSize(Number(folder.totalSize)) : "—"}</p>
<p>{formatDateTime(folder.createdAt)}</p>
</div>
</div>
</div>
</div>
</ContextMenuTrigger>
{folderContextMenu}
</ContextMenu>
);
})}
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
{/* Render files */}
{files.map((file) => {
const { icon: FileIcon, color } = getFileIcon(file.name);
const isSelected = selectedFiles.has(file.id);
const isImage = isImageFile(file.name);
const previewUrl = filePreviewUrls[file.id];
const isDraggedOver = draggedItem?.id === file.id;
// Check if this file is part of the dragged items (optimized with memoized Set)
const isBeingDragged = draggedItemIds.has(file.id);
const isAnySelectedItemDragged = isDragging && isSelected && draggedItems.length > 1;
const fileContextMenu = !isShareMode && (
<ContextMenuContent className="w-[200px]">
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</ContextMenuItem>
{onRename && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</ContextMenuItem>
)}
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={(e) => e.stopPropagation()}>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
{onRename && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onMoveFile && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
{isImage && previewUrl ? (
<img src={previewUrl} alt={file.name} className="object-cover w-full h-full" />
) : (
<FileIcon className={`h-10 w-10 ${color}`} />
{t("filesTable.actions.download")}
</ContextMenuItem>
{onShare && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</ContextMenuItem>
)}
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={file.name}>
{file.name}
</p>
{file.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={file.description}>
{file.description}
</p>
{onMoveFile && (
<ContextMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</ContextMenuItem>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{formatFileSize(file.size)}</p>
<p>{formatDateTime(file.createdAt)}</p>
</div>
</div>
</div>
</div>
);
})}
</div>
{onDelete && (
<ContextMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
variant="destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</ContextMenuItem>
)}
</ContextMenuContent>
);
return (
<ContextMenu key={file.id} modal={false}>
<ContextMenuTrigger asChild>
<div
data-card="true"
className={`relative group border rounded-lg p-3 hover:bg-muted/50 transition-all duration-200 cursor-pointer ${
isSelected ? "ring-2 ring-primary bg-muted/50" : ""
} ${isDraggedOver ? "opacity-50 scale-95" : ""} ${
isBeingDragged || isAnySelectedItemDragged
? "opacity-40 scale-95 transform rotate-2 border-2 border-primary/50 shadow-lg"
: ""
}`}
style={{
transition: "all 0.2s cubic-bezier(0.4, 0, 0.2, 1)",
willChange: isDragging ? "transform, opacity" : "auto",
}}
onClick={(e) => {
if (
(e.target as HTMLElement).closest(".checkbox-wrapper") ||
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest('[role="menuitem"]')
) {
return;
}
if (onPreview) {
onPreview(file);
}
}}
draggable
onDragStart={(e) => {
e.stopPropagation();
handleDragStart(e, { id: file.id, type: "file", name: file.name });
}}
onDragEnd={handleDragEnd}
onContextMenu={(e) => {
e.stopPropagation();
}}
>
<div className="absolute top-2 left-2 z-10 checkbox-wrapper">
<Checkbox
checked={isSelected}
onCheckedChange={(checked: boolean) => {
handleSelectFile({ stopPropagation: () => {} } as React.MouseEvent, file.id, checked);
}}
aria-label={t("filesTable.selectFile", { fileName: file.name })}
className="bg-background border-2"
/>
</div>
<div className="absolute top-2 right-2 z-10">
{isShareMode ? (
<Button
size="icon"
variant="ghost"
className="h-8 w-8 hover:bg-background/80"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.download")}</span>
</Button>
) : (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => e.stopPropagation()}
>
<IconDotsVertical className="h-4 w-4" />
<span className="sr-only">{t("filesTable.actions.menu")}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[200px]">
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onPreview?.(file);
}}
>
<IconEye className="h-4 w-4" />
{t("filesTable.actions.preview")}
</DropdownMenuItem>
{onRename && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onRename?.(file);
}}
>
<IconEdit className="h-4 w-4" />
{t("filesTable.actions.edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onDownload(file.objectName, file.name);
}}
>
<IconDownload className="h-4 w-4" />
{t("filesTable.actions.download")}
</DropdownMenuItem>
{onShare && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onShare?.(file);
}}
>
<IconShare className="h-4 w-4" />
{t("filesTable.actions.share")}
</DropdownMenuItem>
)}
{onMoveFile && (
<DropdownMenuItem
className="cursor-pointer py-2"
onClick={(e) => {
e.stopPropagation();
onMoveFile?.(file);
}}
>
<IconArrowsMove className="h-4 w-4" />
{t("common.move")}
</DropdownMenuItem>
)}
{onDelete && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onDelete?.(file);
}}
className="cursor-pointer py-2 text-destructive focus:text-destructive"
>
<IconTrash className="h-4 w-4" />
{t("filesTable.actions.delete")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="flex flex-col items-center space-y-3">
<div className="w-16 h-16 flex items-center justify-center bg-muted/30 rounded-lg overflow-hidden">
{isImage && previewUrl ? (
<img src={previewUrl} alt={file.name} className="object-cover w-full h-full" />
) : (
<FileIcon className={`h-10 w-10 ${color}`} />
)}
</div>
<div className="w-full space-y-1">
<p className="text-sm font-medium truncate text-left" title={file.name}>
{file.name}
</p>
{file.description && (
<p className="text-xs text-muted-foreground truncate text-left" title={file.description}>
{file.description}
</p>
)}
<div className="text-xs text-muted-foreground space-y-1 text-left">
<p>{formatFileSize(file.size)}</p>
<p>{formatDateTime(file.createdAt)}</p>
</div>
</div>
</div>
</div>
</ContextMenuTrigger>
{fileContextMenu}
</ContextMenu>
);
})}
</div>
</ContextMenuTrigger>
{!isShareMode && (onCreateFolder || onUpload) && (
<ContextMenuContent className="w-[200px]">
{onCreateFolder && (
<ContextMenuItem onClick={onCreateFolder} className="cursor-pointer py-2">
<IconFolderPlus className="h-4 w-4" />
{t("contextMenu.newFolder")}
</ContextMenuItem>
)}
{onUpload && (
<ContextMenuItem onClick={onUpload} className="cursor-pointer py-2">
<IconCloudUpload className="h-4 w-4" />
{t("contextMenu.uploadFile")}
</ContextMenuItem>
)}
</ContextMenuContent>
)}
</ContextMenu>
</div>
);
}

View File

@ -13,7 +13,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
"text-muted-foreground flex flex-wrap items-center gap-1 text-sm break-words sm:gap-2.5",
className
)}
{...props}
@ -22,7 +22,7 @@ function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1.5", className)} {...props} />;
return <li data-slot="breadcrumb-item" className={cn("inline-flex items-center gap-1", className)} {...props} />;
}
function BreadcrumbLink({

View File

@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}

View File

@ -65,7 +65,7 @@ export function useDragDrop({
const itemsToShow = items.slice(0, 3);
const remaining = items.length - itemsToShow.length;
itemsToShow.forEach((item, index) => {
itemsToShow.forEach((item) => {
const itemDiv = document.createElement("div");
itemDiv.style.display = "flex";
itemDiv.style.alignItems = "center";
@ -230,13 +230,6 @@ export function useDragDrop({
} else {
toast.success(`${validItems.length} items moved to "${target.name}"`);
}
// Refresh data after successful move with slight delay for smooth transition
if (onRefresh) {
setTimeout(async () => {
await onRefresh();
}, 150);
}
} catch (error) {
console.error("Error moving items:", error);
toast.error("Failed to move items");

View File

@ -135,7 +135,11 @@ export interface EnhancedFileManagerHook {
setClearSelectionCallback?: (callback: () => void) => void;
}
export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSelection?: () => void) {
export function useEnhancedFileManager(
onRefresh: () => Promise<void>,
clearSelection?: () => void,
handleImmediateUpdate?: (itemId: string, itemType: "file" | "folder", newParentId: string | null) => void
) {
const t = useTranslations();
const [previewFile, setPreviewFile] = useState<PreviewFile | null>(null);
@ -188,7 +192,6 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
name: newName,
description: description || null,
});
await onRefresh();
toast.success(t("files.updateSuccess"));
setFileToRename(null);
} catch (error) {
@ -199,8 +202,12 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
const handleDelete = async (fileId: string) => {
try {
// Optimistic update - remove from UI immediately
if (handleImmediateUpdate) {
handleImmediateUpdate(fileId, "file", "__DELETE__" as any);
}
await deleteFile(fileId);
await onRefresh();
toast.success(t("files.deleteSuccess"));
setFileToDelete(null);
} catch (error) {
@ -299,6 +306,16 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
if (!filesToDelete && !foldersToDelete) return;
try {
// Optimistic update - remove all items from UI immediately
if (handleImmediateUpdate) {
filesToDelete?.forEach((file) => {
handleImmediateUpdate(file.id, "file", "__DELETE__" as any);
});
foldersToDelete?.forEach((folder) => {
handleImmediateUpdate(folder.id, "folder", "__DELETE__" as any);
});
}
const deletePromises = [];
if (filesToDelete) {
@ -315,7 +332,6 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
toast.success(t("files.bulkDeleteSuccess", { count: totalCount }));
setFilesToDelete(null);
setFoldersToDelete(null);
onRefresh();
} catch (error) {
console.error("Failed to delete items:", error);
toast.error(t("files.bulkDeleteError"));
@ -333,7 +349,6 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
await registerFolder(folderData);
toast.success(t("folderActions.folderCreated"));
await onRefresh();
setCreateFolderModalOpen(false);
} catch (error) {
console.error("Error creating folder:", error);
@ -346,7 +361,6 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
try {
await updateFolder(folderId, { name: newName, description });
toast.success(t("folderActions.folderRenamed"));
await onRefresh();
setFolderToRename(null);
} catch (error) {
console.error("Error renaming folder:", error);
@ -356,9 +370,13 @@ export function useEnhancedFileManager(onRefresh: () => Promise<void>, clearSele
const handleFolderDelete = async (folderId: string) => {
try {
// Optimistic update - remove from UI immediately
if (handleImmediateUpdate) {
handleImmediateUpdate(folderId, "folder", "__DELETE__" as any);
}
await deleteFolder(folderId);
toast.success(t("folderActions.folderDeleted"));
await onRefresh();
setFolderToDelete(null);
if (clearSelectionCallback) {
clearSelectionCallback();