mirror of
https://github.com/kyantech/Palmr.git
synced 2026-01-09 06:02:28 +08:00
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:
parent
25b1a62d5f
commit
18700d7e72
@ -1947,5 +1947,9 @@
|
||||
"directLinkDescription": "عنوان URL مباشر لملف الصورة",
|
||||
"htmlDescription": "استخدم هذا الكود لتضمين الصورة في صفحات HTML",
|
||||
"bbcodeDescription": "استخدم هذا الكود لتضمين الصورة في المنتديات التي تدعم BBCode"
|
||||
},
|
||||
"contextMenu": {
|
||||
"newFolder": "مجلد جديد",
|
||||
"uploadFile": "رفع ملف"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1945,5 +1945,9 @@
|
||||
"directLinkDescription": "छवि फ़ाइल का सीधा URL",
|
||||
"htmlDescription": "HTML पेजों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें",
|
||||
"bbcodeDescription": "BBCode का समर्थन करने वाले मंचों में छवि एम्बेड करने के लिए इस कोड का उपयोग करें"
|
||||
},
|
||||
"contextMenu": {
|
||||
"newFolder": "नया फ़ोल्डर",
|
||||
"uploadFile": "फ़ाइल अपलोड करें"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1945,5 +1945,9 @@
|
||||
"directLinkDescription": "画像ファイルへの直接URL",
|
||||
"htmlDescription": "このコードを使用してHTMLページに画像を埋め込みます",
|
||||
"bbcodeDescription": "BBCodeをサポートするフォーラムに画像を埋め込むには、このコードを使用します"
|
||||
},
|
||||
"contextMenu": {
|
||||
"newFolder": "新規フォルダ",
|
||||
"uploadFile": "ファイルをアップロード"
|
||||
}
|
||||
}
|
||||
@ -1945,5 +1945,9 @@
|
||||
"directLinkDescription": "이미지 파일에 대한 직접 URL",
|
||||
"htmlDescription": "이 코드를 사용하여 HTML 페이지에 이미지를 삽입하세요",
|
||||
"bbcodeDescription": "BBCode를 지원하는 포럼에 이미지를 삽입하려면 이 코드를 사용하세요"
|
||||
},
|
||||
"contextMenu": {
|
||||
"newFolder": "새 폴더",
|
||||
"uploadFile": "파일 업로드"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -1945,5 +1945,9 @@
|
||||
"directLinkDescription": "Прямой URL-адрес файла изображения",
|
||||
"htmlDescription": "Используйте этот код для встраивания изображения в HTML-страницы",
|
||||
"bbcodeDescription": "Используйте этот код для встраивания изображения на форумах, поддерживающих BBCode"
|
||||
},
|
||||
"contextMenu": {
|
||||
"newFolder": "Новая папка",
|
||||
"uploadFile": "Загрузить файл"
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
@ -1945,5 +1945,9 @@
|
||||
"directLinkDescription": "图片文件的直接URL",
|
||||
"htmlDescription": "使用此代码将图片嵌入HTML页面",
|
||||
"bbcodeDescription": "使用此代码将图片嵌入支持BBCode的论坛"
|
||||
},
|
||||
"contextMenu": {
|
||||
"newFolder": "新建文件夹",
|
||||
"uploadFile": "上传文件"
|
||||
}
|
||||
}
|
||||
@ -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
4996
apps/web/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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 />
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
42
apps/web/src/components/skeletons/files-grid-skeleton.tsx
Normal file
42
apps/web/src/components/skeletons/files-grid-skeleton.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
58
apps/web/src/components/skeletons/files-table-skeleton.tsx
Normal file
58
apps/web/src/components/skeletons/files-table-skeleton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
2
apps/web/src/components/skeletons/index.ts
Normal file
2
apps/web/src/components/skeletons/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FilesGridSkeleton } from "./files-grid-skeleton";
|
||||
export { FilesTableSkeleton } from "./files-table-skeleton";
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
|
||||
252
apps/web/src/components/ui/context-menu.tsx
Normal file
252
apps/web/src/components/ui/context-menu.tsx
Normal 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,
|
||||
}
|
||||
@ -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");
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user