- P2.9: VNCtalk video meeting — create/update meeting from event modal, 'Join Meeting' link in event detail. Admin config vnctalkServerUrl. - P2.10: Collabora online editing — 'Edit with Collabora' for office files, WOPI discovery + edit URL. Admin config collaboraServerUrl. - P2.11: Calendar enhancements — clickable links in descriptions, participant contact popover, Reply/Reply All from event, timezone picker, map links for locations. - P2.13: VNCdirectory IDP admin panel — Connection, SAML/IDP, LDAP, Authentication, Federated Apps configuration. Secret masking on display.
2174 lines
83 KiB
TypeScript
2174 lines
83 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
|
import { useTranslations } from "next-intl";
|
|
import {
|
|
Folder, File, Upload, FolderPlus, Download, Trash2,
|
|
Pencil, RefreshCw, Home, ChevronRight, MoreVertical,
|
|
Search, ArrowUp, ArrowDown, X, LayoutGrid, LayoutList,
|
|
Copy, Clipboard, Scissors, Info, Image as ImageIcon,
|
|
FilePlus, CopyPlus, FileText, FileAudio, FileVideo,
|
|
AlertCircle, Star, Clock, FolderUp,
|
|
FileArchive, FileSpreadsheet, Presentation, FileCode,
|
|
Box, PenTool, Terminal as TerminalIcon, Database, Type as TypeIcon,
|
|
Menu, Users, Share2, MailPlus, Paperclip, ExternalLink,
|
|
} from "lucide-react";
|
|
import { useIsDesktop } from "@/hooks/use-media-query";
|
|
import { Button } from "@/components/ui/button";
|
|
import { cn, formatFileSize } from "@/lib/utils";
|
|
import { NewFolderDialog } from "@/components/files/new-folder-dialog";
|
|
import { RenameDialog } from "@/components/files/rename-dialog";
|
|
import { FileUploadArea } from "@/components/files/file-upload-area";
|
|
import { loadFilesSettings } from "@/components/files/files-settings-dialog";
|
|
import type { FolderLayout } from "@/components/files/files-settings-dialog";
|
|
import { FolderTreeSidebar } from "@/components/files/folder-tree-sidebar";
|
|
import { ResizeHandle } from "@/components/layout/resize-handle";
|
|
import { Avatar } from "@/components/ui/avatar";
|
|
import { getDroppedFilesAndFolders } from "@/lib/webdav/drop-utils";
|
|
import type { FileResource } from "@/stores/file-store";
|
|
import { ShareCollectionDialog } from "@/components/settings/share-collection-dialog";
|
|
import { RadialMenu, type RadialMenuItem } from "@/components/ui/radial-menu";
|
|
import type { IJMAPClient } from "@/lib/jmap/client-interface";
|
|
import type { FileNodeRights } from "@/lib/jmap/types";
|
|
|
|
type SortKey = "name" | "size" | "modified";
|
|
type SortDir = "asc" | "desc";
|
|
type ViewMode = "list" | "grid";
|
|
|
|
interface ClipboardState {
|
|
mode: "cut" | "copy";
|
|
ids: string[];
|
|
names: string[];
|
|
sourceParentId: string | null;
|
|
}
|
|
|
|
export interface AccountFolderEntry {
|
|
accountId: string;
|
|
label: string;
|
|
email: string;
|
|
avatarColor: string;
|
|
}
|
|
|
|
interface FileBrowserProps {
|
|
currentPath: string;
|
|
resources: FileResource[];
|
|
isLoading: boolean;
|
|
error: string | null;
|
|
selectedResources: Set<string>;
|
|
uploadProgress: { name: string; loaded: number; total: number; current: number; totalFiles: number } | null;
|
|
clipboard: ClipboardState | null;
|
|
onNavigate: (path: string, resourceId?: string | null) => void;
|
|
onCreateFolder: (name: string) => Promise<void>;
|
|
onUploadFiles: (files: File[]) => Promise<void>;
|
|
onUploadFolder: (files: File[]) => Promise<void>;
|
|
onCancelUpload: () => void;
|
|
onDelete: (name: string) => Promise<void>;
|
|
onBatchDelete: (names: string[]) => Promise<void>;
|
|
onRename: (oldName: string, newName: string) => Promise<void>;
|
|
onDownload: (name: string) => Promise<void>;
|
|
onBatchDownload: (names: string[]) => Promise<void>;
|
|
onRefresh: () => Promise<void>;
|
|
onSelectResource: (name: string | null) => void;
|
|
onToggleSelect: (name: string) => void;
|
|
onSelectAll: () => void;
|
|
onClearSelection: () => void;
|
|
onSetSelection: (names: Set<string>) => void;
|
|
onCut: (names: string[]) => void;
|
|
onCopy: (names: string[]) => void;
|
|
onPaste: () => Promise<void>;
|
|
onMoveToFolder: (names: string[], targetFolder: string) => Promise<void>;
|
|
onMoveToParent: (names: string[]) => Promise<void>;
|
|
onPreviewImage: (name: string) => void;
|
|
onPreviewFile: (name: string) => void;
|
|
onShowDetails: (name: string) => void;
|
|
onCreateTextFile: (name: string) => Promise<void>;
|
|
onDuplicate: (name: string) => Promise<void>;
|
|
getImageUrl: (name: string) => Promise<string>;
|
|
listPath: (path: string) => Promise<FileResource[]>;
|
|
listByParentId: (parentId: string | null) => Promise<FileResource[]>;
|
|
favorites: string[];
|
|
recentFiles: { name: string; id: string; timestamp: number }[];
|
|
onToggleFavorite: (path: string) => void;
|
|
showDetails: boolean;
|
|
onToggleDetails: () => void;
|
|
detailResource: FileResource | null;
|
|
/** Pro shell only: all connected accounts surfaced as top-level folders at the root. */
|
|
accountFolders?: AccountFolderEntry[];
|
|
onSelectAccount?: (accountId: string) => void;
|
|
/** Pro shell only: when true, the root is a pure account picker - hide the file toolbar and don't render a regular listing. */
|
|
accountPickerMode?: boolean;
|
|
/** Pro shell only: label of the currently-attached account, shown as a breadcrumb segment after Home. */
|
|
accountLabel?: string | null;
|
|
/** JMAP client for the browsing account, used by the share dialog to list principals. */
|
|
client?: IJMAPClient | null;
|
|
/** Files account id of the browsing account; used to exclude self from the share picker. */
|
|
ownAccountId?: string | null;
|
|
/** True when the server supports JMAP Sharing (principals); gates the Share action. */
|
|
sharingEnabled?: boolean;
|
|
/** Add/update/remove a principal's share on a node. Set null rights to revoke. */
|
|
onShare?: (id: string, principalId: string, rights: FileNodeRights | null) => Promise<void>;
|
|
/** Send selected files as email attachments - opens the composer with files pre-attached. */
|
|
onSendAsAttachment?: (names: string[]) => void;
|
|
}
|
|
|
|
const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp", "ico", "avif"]);
|
|
|
|
function isImageFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return IMAGE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const TEXT_EXTENSIONS = new Set([
|
|
"txt", "md", "markdown", "json", "xml", "html", "htm", "css", "js", "ts",
|
|
"jsx", "tsx", "py", "rb", "java", "c", "cpp", "h", "hpp", "go", "rs",
|
|
"sh", "bash", "zsh", "yaml", "yml", "toml", "ini", "cfg", "conf", "env",
|
|
"log", "csv", "sql", "graphql", "vue", "svelte", "astro", "php", "pl",
|
|
"swift", "kt", "scala", "r", "lua", "vim",
|
|
]);
|
|
|
|
function isTextFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
const baseName = name.toLowerCase();
|
|
return TEXT_EXTENSIONS.has(ext) || ["dockerfile", "makefile", "readme", "license", "changelog"].includes(baseName);
|
|
}
|
|
|
|
const AUDIO_EXTENSIONS = new Set(["mp3", "wav", "ogg", "flac", "aac", "m4a", "wma", "opus"]);
|
|
function isAudioFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return AUDIO_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const VIDEO_EXTENSIONS = new Set(["mp4", "webm", "ogv", "mov", "avi", "mkv", "m4v"]);
|
|
function isVideoFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return VIDEO_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const PDF_EXTENSIONS = new Set(["pdf"]);
|
|
function isPdfFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return PDF_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const VECTOR_EXTENSIONS = new Set(["svg", "ai", "eps", "ps", "sketch", "fig", "xd", "gvdesign"]);
|
|
function isVectorFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return VECTOR_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const THREE_D_EXTENSIONS = new Set([
|
|
"obj", "fbx", "gltf", "glb", "stl", "3mf", "step", "stp", "iges", "igs",
|
|
"blend", "3ds", "dae", "usdz", "usd", "usda", "usdc", "ply", "wrl",
|
|
"c4d", "max", "ma", "mb", "dwg", "dxf",
|
|
]);
|
|
function is3DFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return THREE_D_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const EXECUTABLE_EXTENSIONS = new Set([
|
|
"exe", "msi", "dmg", "app", "appimage", "deb", "rpm", "snap", "flatpak",
|
|
"bat", "cmd", "com", "scr", "ps1", "apk", "ipa", "jar", "run",
|
|
]);
|
|
function isExecutableFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return EXECUTABLE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const ARCHIVE_EXTENSIONS = new Set([
|
|
"zip", "rar", "7z", "tar", "gz", "bz2", "xz", "zst", "lz", "lzma",
|
|
"tgz", "tbz2", "txz", "cab", "iso", "img",
|
|
]);
|
|
function isArchiveFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return ARCHIVE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const SPREADSHEET_EXTENSIONS = new Set(["xls", "xlsx", "ods", "numbers", "tsv"]);
|
|
function isSpreadsheetFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return SPREADSHEET_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const PRESENTATION_EXTENSIONS = new Set(["ppt", "pptx", "odp", "key"]);
|
|
function isPresentationFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return PRESENTATION_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const FONT_EXTENSIONS = new Set(["ttf", "otf", "woff", "woff2", "eot"]);
|
|
function isFontFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return FONT_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const DATABASE_EXTENSIONS = new Set(["db", "sqlite", "sqlite3", "mdb", "accdb"]);
|
|
function isDatabaseFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return DATABASE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
const OFFICE_EXTENSIONS = new Set([
|
|
"docx", "xlsx", "pptx", "odt", "ods", "odp", "doc", "xls", "ppt",
|
|
]);
|
|
function isOfficeFile(name: string): boolean {
|
|
const ext = name.split(".").pop()?.toLowerCase() || "";
|
|
return OFFICE_EXTENSIONS.has(ext);
|
|
}
|
|
|
|
function isPreviewable(name: string): boolean {
|
|
return isImageFile(name) || isTextFile(name) || isPdfFile(name) || isAudioFile(name) || isVideoFile(name);
|
|
}
|
|
|
|
function getFileIconByName(name: string, size: "sm" | "lg") {
|
|
const cls = size === "sm" ? "w-5 h-5" : "w-10 h-10";
|
|
if (isVectorFile(name)) return <PenTool className={`${cls} text-orange-500`} />;
|
|
if (is3DFile(name)) return <Box className={`${cls} text-cyan-500`} />;
|
|
if (isImageFile(name)) return <ImageIcon className={`${cls} text-emerald-500`} />;
|
|
if (isAudioFile(name)) return <FileAudio className={`${cls} text-purple-500`} />;
|
|
if (isVideoFile(name)) return <FileVideo className={`${cls} text-pink-500`} />;
|
|
if (isArchiveFile(name)) return <FileArchive className={`${cls} text-amber-600`} />;
|
|
if (isExecutableFile(name)) return <TerminalIcon className={`${cls} text-red-500`} />;
|
|
if (isSpreadsheetFile(name)) return <FileSpreadsheet className={`${cls} text-green-600`} />;
|
|
if (isPresentationFile(name)) return <Presentation className={`${cls} text-orange-600`} />;
|
|
if (isFontFile(name)) return <TypeIcon className={`${cls} text-indigo-500`} />;
|
|
if (isDatabaseFile(name)) return <Database className={`${cls} text-slate-500`} />;
|
|
if (isPdfFile(name)) return <FileText className={`${cls} text-red-600`} />;
|
|
if (isTextFile(name)) return <FileCode className={`${cls} text-yellow-600`} />;
|
|
return <File className={`${cls} text-muted-foreground`} />;
|
|
}
|
|
|
|
function getFileIcon(resource: FileResource) {
|
|
if (resource.isDirectory) {
|
|
return <Folder className="w-5 h-5 text-blue-500" />;
|
|
}
|
|
return getFileIconByName(resource.name, "sm");
|
|
}
|
|
|
|
function getGridIcon(resource: FileResource) {
|
|
if (resource.isDirectory) {
|
|
return <Folder className="w-10 h-10 text-blue-500" />;
|
|
}
|
|
return getFileIconByName(resource.name, "lg");
|
|
}
|
|
|
|
// Small inline indicator: a node shared out by the user (shareWith has entries)
|
|
// or a node shared *with* the user by another principal (isShared).
|
|
function ShareBadge({ resource, t }: { resource: FileResource; t: (key: string) => string }) {
|
|
const sharedOut = !!resource.shareWith && Object.keys(resource.shareWith).length > 0;
|
|
if (resource.isShared) {
|
|
return (
|
|
<Share2
|
|
className="w-3.5 h-3.5 text-primary shrink-0"
|
|
aria-label={t("shared_with_me")}
|
|
>
|
|
<title>{t("shared_with_me")}</title>
|
|
</Share2>
|
|
);
|
|
}
|
|
if (sharedOut) {
|
|
return (
|
|
<Users
|
|
className="w-3.5 h-3.5 text-primary shrink-0"
|
|
aria-label={t("shared")}
|
|
>
|
|
<title>{t("shared")}</title>
|
|
</Users>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function Thumbnail({ name, getImageUrl: fetchUrl, size = "sm" }: {
|
|
name: string;
|
|
getImageUrl: (n: string) => Promise<string>;
|
|
size?: "sm" | "lg";
|
|
}) {
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
const [failed, setFailed] = useState(false);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
fetchUrl(name).then(url => { if (!cancelled) setSrc(url); }).catch(() => { if (!cancelled) setFailed(true); });
|
|
return () => { cancelled = true; };
|
|
}, [name, fetchUrl]);
|
|
|
|
if (failed || !src) {
|
|
return size === "sm"
|
|
? <ImageIcon className="w-5 h-5 text-emerald-500" />
|
|
: <ImageIcon className="w-10 h-10 text-emerald-500" />;
|
|
}
|
|
|
|
const cls = size === "sm"
|
|
? "w-5 h-5 rounded object-cover"
|
|
: "w-10 h-10 rounded object-cover";
|
|
|
|
return <img src={src} alt={name} className={cls} loading="lazy" />;
|
|
}
|
|
|
|
function formatDate(dateString: string): string {
|
|
if (!dateString) return "";
|
|
try {
|
|
return new Date(dateString).toLocaleDateString(undefined, {
|
|
year: "numeric",
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
});
|
|
} catch {
|
|
return dateString;
|
|
}
|
|
}
|
|
|
|
function SkeletonRow() {
|
|
return (
|
|
<tr className="border-b border-border">
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-4 h-4 shrink-0" />
|
|
<div className="w-5 h-5 rounded bg-muted animate-pulse shrink-0" />
|
|
<div className="h-4 rounded bg-muted animate-pulse w-40" />
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5 hidden md:table-cell">
|
|
<div className="h-4 rounded bg-muted animate-pulse w-14" />
|
|
</td>
|
|
<td className="px-4 py-2.5 hidden lg:table-cell">
|
|
<div className="h-4 rounded bg-muted animate-pulse w-32" />
|
|
</td>
|
|
<td className="px-2 py-2.5">
|
|
<div className="h-4 w-4 rounded bg-muted animate-pulse" />
|
|
</td>
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function FileBrowser({
|
|
currentPath,
|
|
resources,
|
|
isLoading,
|
|
error,
|
|
selectedResources,
|
|
uploadProgress,
|
|
onNavigate,
|
|
onCreateFolder,
|
|
onUploadFiles,
|
|
onUploadFolder,
|
|
onCancelUpload,
|
|
onDelete,
|
|
onBatchDelete,
|
|
onRename,
|
|
onDownload,
|
|
onBatchDownload,
|
|
onRefresh,
|
|
onSelectResource,
|
|
onToggleSelect,
|
|
onSelectAll,
|
|
onClearSelection,
|
|
onSetSelection,
|
|
onCut,
|
|
onCopy,
|
|
onPaste,
|
|
onMoveToFolder,
|
|
onMoveToParent,
|
|
onPreviewImage,
|
|
onPreviewFile,
|
|
onShowDetails,
|
|
onCreateTextFile,
|
|
onDuplicate,
|
|
getImageUrl,
|
|
listPath,
|
|
listByParentId,
|
|
favorites,
|
|
recentFiles,
|
|
onToggleFavorite,
|
|
showDetails,
|
|
onToggleDetails,
|
|
detailResource,
|
|
clipboard,
|
|
accountFolders,
|
|
onSelectAccount,
|
|
accountPickerMode,
|
|
accountLabel,
|
|
client,
|
|
ownAccountId,
|
|
sharingEnabled,
|
|
onShare,
|
|
onSendAsAttachment,
|
|
}: FileBrowserProps) {
|
|
const t = useTranslations("files");
|
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
|
const [renameTarget, setRenameTarget] = useState<string | null>(null);
|
|
const [shareTargetId, setShareTargetId] = useState<string | null>(null);
|
|
const [isDraggingOver, setIsDraggingOver] = useState(false);
|
|
// The share dialog is bound to a node id (not name) so its shareWith stays
|
|
// live after a share refresh re-derives the resource list.
|
|
const shareTarget = shareTargetId ? resources.find(r => r.id === shareTargetId) ?? null : null;
|
|
// A node is shareable when the server supports JMAP Sharing, the viewer owns
|
|
// it (not a shared-with-me node), and holds the mayShare right (owned nodes
|
|
// report full rights; treat missing myRights as allowed).
|
|
const canShare = useCallback((r: FileResource | null | undefined): boolean =>
|
|
!!(sharingEnabled && onShare && client && r && !r.isShared && (r.myRights?.mayShare ?? true)),
|
|
[sharingEnabled, onShare, client]);
|
|
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
|
|
const [emptyContextMenu, setEmptyContextMenu] = useState<{ x: number; y: number } | null>(null);
|
|
|
|
// Radial menu state
|
|
const [radialMenuOpen, setRadialMenuOpen] = useState(false);
|
|
const [radialMenuPos, setRadialMenuPos] = useState({ x: 0, y: 0 });
|
|
const [radialMenuResourceName, setRadialMenuResourceName] = useState<string | null>(null);
|
|
|
|
const closeRadialMenu = useCallback(() => {
|
|
setRadialMenuOpen(false);
|
|
}, []);
|
|
|
|
const radialMenuItems = useMemo<RadialMenuItem[]>(() => {
|
|
if (!radialMenuResourceName) return [];
|
|
const name = radialMenuResourceName;
|
|
const resource = resources.find((r) => r.name === name);
|
|
const items: RadialMenuItem[] = [];
|
|
items.push({
|
|
id: "rename",
|
|
icon: <Pencil className="w-5 h-5" />,
|
|
label: t("rename"),
|
|
onClick: () => { setRenameTarget(name); },
|
|
});
|
|
items.push({
|
|
id: "delete",
|
|
icon: <Trash2 className="w-5 h-5" />,
|
|
label: t("delete"),
|
|
onClick: () => { onDelete(name); },
|
|
destructive: true,
|
|
});
|
|
if (resource && !resource.isDirectory) {
|
|
items.push({
|
|
id: "download",
|
|
icon: <Download className="w-5 h-5" />,
|
|
label: t("download"),
|
|
onClick: () => { onDownload(name); },
|
|
});
|
|
}
|
|
if (canShare(resource)) {
|
|
items.push({
|
|
id: "share",
|
|
icon: <Share2 className="w-5 h-5" />,
|
|
label: t("share"),
|
|
onClick: () => { if (resource?.id) setShareTargetId(resource.id); },
|
|
});
|
|
}
|
|
if (resource && !resource.isDirectory) {
|
|
items.push({
|
|
id: "send-as-attachment",
|
|
icon: <Paperclip className="w-5 h-5" />,
|
|
label: t("send_as_attachment"),
|
|
onClick: () => {},
|
|
});
|
|
}
|
|
return items;
|
|
}, [radialMenuResourceName, resources, t, onDelete, onDownload, canShare]);
|
|
const [showNewTextFile, setShowNewTextFile] = useState(false);
|
|
const [isUploading, setIsUploading] = useState(false);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [showSearch, setShowSearch] = useState(false);
|
|
const [sortKey, setSortKey] = useState<SortKey>("name");
|
|
const [sortDir, setSortDir] = useState<SortDir>("asc");
|
|
const [viewMode, setViewMode] = useState<ViewMode>(() => {
|
|
if (typeof window !== "undefined") {
|
|
return (localStorage.getItem("files-view-mode") as ViewMode) || "list";
|
|
}
|
|
return "list";
|
|
});
|
|
const [showThumbnails, setShowThumbnails] = useState(() => loadFilesSettings().showThumbnails);
|
|
const [folderLayout, setFolderLayout] = useState<FolderLayout>(() => loadFilesSettings().folderLayout);
|
|
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
|
if (typeof window !== "undefined") {
|
|
const saved = localStorage.getItem("files-sidebar-width");
|
|
if (saved) return Math.max(180, Math.min(400, Number(saved)));
|
|
}
|
|
return 256;
|
|
});
|
|
const [isResizing, setIsResizing] = useState(false);
|
|
const dragStartWidth = useRef(256);
|
|
const [dragTarget, setDragTarget] = useState<string | null>(null);
|
|
// Pane-aware: in a Pro split pane (or a narrow window) the folder tree
|
|
// sidebar collapses into a burger-toggled overlay so it doesn't crowd the
|
|
// file list.
|
|
const isDesktopPane = useIsDesktop();
|
|
const isNarrow = !isDesktopPane;
|
|
const [narrowSidebarOpen, setNarrowSidebarOpen] = useState(false);
|
|
useEffect(() => { if (!isNarrow) setNarrowSidebarOpen(false); }, [isNarrow]);
|
|
|
|
// Sync showThumbnails and folderLayout when settings change
|
|
useEffect(() => {
|
|
const reloadSettings = () => {
|
|
const s = loadFilesSettings();
|
|
setShowThumbnails(s.showThumbnails);
|
|
setFolderLayout(s.folderLayout);
|
|
};
|
|
const handleStorage = (e: StorageEvent) => {
|
|
if (e.key === "files-settings") reloadSettings();
|
|
};
|
|
// StorageEvent fires cross-tab, custom event fires same-tab
|
|
window.addEventListener("storage", handleStorage);
|
|
window.addEventListener("files-settings-changed", reloadSettings);
|
|
return () => {
|
|
window.removeEventListener("storage", handleStorage);
|
|
window.removeEventListener("files-settings-changed", reloadSettings);
|
|
};
|
|
}, []);
|
|
const [breadcrumbDropdown, setBreadcrumbDropdown] = useState<{
|
|
path: string;
|
|
folders: FileResource[];
|
|
x: number;
|
|
y: number;
|
|
} | null>(null);
|
|
const [marquee, setMarquee] = useState<{
|
|
startX: number;
|
|
startY: number;
|
|
currentX: number;
|
|
currentY: number;
|
|
} | null>(null);
|
|
const marqueeRef = useRef<{
|
|
additive: boolean;
|
|
initialSelection: Set<string>;
|
|
} | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const folderInputRef = useRef<HTMLInputElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Reset search when navigating
|
|
useEffect(() => {
|
|
setSearchQuery("");
|
|
}, [currentPath]);
|
|
|
|
// Focus search input when shown
|
|
useEffect(() => {
|
|
if (showSearch) searchInputRef.current?.focus();
|
|
}, [showSearch]);
|
|
|
|
// Persist view mode
|
|
const handleViewModeChange = useCallback((mode: ViewMode) => {
|
|
setViewMode(mode);
|
|
localStorage.setItem("files-view-mode", mode);
|
|
}, []);
|
|
|
|
// Filter and sort resources
|
|
const displayResources = useMemo(() => {
|
|
let filtered = resources;
|
|
// In sidebar mode, folders are shown in the sidebar tree - hide them from the main list
|
|
if (folderLayout === "sidebar") {
|
|
filtered = filtered.filter(r => !r.isDirectory);
|
|
}
|
|
if (searchQuery) {
|
|
const q = searchQuery.toLowerCase();
|
|
filtered = filtered.filter(r => r.name.toLowerCase().includes(q));
|
|
}
|
|
|
|
const sorted = [...filtered].sort((a, b) => {
|
|
// Directories always first
|
|
if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
|
|
|
|
let cmp = 0;
|
|
switch (sortKey) {
|
|
case "name":
|
|
cmp = a.name.localeCompare(b.name);
|
|
break;
|
|
case "size":
|
|
cmp = a.contentLength - b.contentLength;
|
|
break;
|
|
case "modified":
|
|
cmp = new Date(a.lastModified || 0).getTime() - new Date(b.lastModified || 0).getTime();
|
|
break;
|
|
}
|
|
return sortDir === "asc" ? cmp : -cmp;
|
|
});
|
|
return sorted;
|
|
}, [resources, searchQuery, sortKey, sortDir, folderLayout]);
|
|
|
|
// Build breadcrumb segments. In Pro mode an account is mounted "between"
|
|
// Home and the account's filesystem - surfaced as a non-clickable label
|
|
// (clicking the actual account again would be a no-op; Home detaches it).
|
|
const breadcrumbs: { name: string; path: string; isAccount?: boolean }[] = currentPath === '/'
|
|
? [{ name: t("breadcrumb_root"), path: '/' }]
|
|
: [
|
|
{ name: t("breadcrumb_root"), path: '/' },
|
|
...currentPath.split('/').filter(Boolean).map((segment, i, arr) => ({
|
|
name: segment,
|
|
path: '/' + arr.slice(0, i + 1).join('/'),
|
|
})),
|
|
];
|
|
if (accountLabel) {
|
|
breadcrumbs.splice(1, 0, { name: accountLabel, path: '', isAccount: true });
|
|
}
|
|
|
|
const handleNavigateUp = useCallback(() => {
|
|
if (currentPath === '/') return;
|
|
const segments = currentPath.split('/').filter(Boolean);
|
|
segments.pop();
|
|
const parentPath = segments.length === 0 ? '/' : '/' + segments.join('/');
|
|
// Pro shell: going up to root from a subfolder must land on the
|
|
// account's filesystem root, not detach back to the account picker.
|
|
// Home click (breadcrumb) still detaches.
|
|
if (parentPath === '/' && accountLabel) {
|
|
onNavigate('/', '__account_root__');
|
|
return;
|
|
}
|
|
onNavigate(parentPath);
|
|
}, [currentPath, onNavigate, accountLabel]);
|
|
|
|
const handleResourceClick = (resource: FileResource, e: React.MouseEvent) => {
|
|
if (resource.isDirectory) {
|
|
// Ctrl/Cmd+click on directories also toggles selection
|
|
if (e.ctrlKey || e.metaKey) {
|
|
onToggleSelect(resource.name);
|
|
return;
|
|
}
|
|
const newPath = currentPath === '/'
|
|
? `/${resource.name}`
|
|
: `${currentPath}/${resource.name}`;
|
|
onNavigate(newPath, resource.id);
|
|
} else {
|
|
if (e.ctrlKey || e.metaKey) {
|
|
onToggleSelect(resource.name);
|
|
} else if (e.shiftKey && resources.length > 0) {
|
|
// Shift+click range select
|
|
handleShiftSelect(resource.name);
|
|
} else {
|
|
onSelectResource(resource.name === [...selectedResources][0] && selectedResources.size === 1 ? null : resource.name);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleShiftSelect = (targetName: string) => {
|
|
const lastSelected = [...selectedResources].pop();
|
|
if (!lastSelected) {
|
|
onToggleSelect(targetName);
|
|
return;
|
|
}
|
|
const names = displayResources.map(r => r.name);
|
|
const startIdx = names.indexOf(lastSelected);
|
|
const endIdx = names.indexOf(targetName);
|
|
if (startIdx === -1 || endIdx === -1) return;
|
|
const from = Math.min(startIdx, endIdx);
|
|
const to = Math.max(startIdx, endIdx);
|
|
for (let i = from; i <= to; i++) {
|
|
if (!selectedResources.has(names[i])) {
|
|
onToggleSelect(names[i]);
|
|
}
|
|
}
|
|
};
|
|
|
|
// Marquee (rubber-band) selection
|
|
const handleMarqueeMouseDown = useCallback((e: React.MouseEvent) => {
|
|
if (e.button !== 0) return;
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest('[data-resource]') || target.closest('input') || target.closest('button') || target.closest('thead')) return;
|
|
|
|
const scrollArea = scrollAreaRef.current;
|
|
if (!scrollArea) return;
|
|
|
|
const rect = scrollArea.getBoundingClientRect();
|
|
const x = e.clientX - rect.left + scrollArea.scrollLeft;
|
|
const y = e.clientY - rect.top + scrollArea.scrollTop;
|
|
|
|
const additive = e.ctrlKey || e.metaKey;
|
|
marqueeRef.current = {
|
|
additive,
|
|
initialSelection: additive ? new Set(selectedResources) : new Set(),
|
|
};
|
|
|
|
setMarquee({ startX: x, startY: y, currentX: x, currentY: y });
|
|
|
|
if (!additive) {
|
|
onClearSelection();
|
|
}
|
|
|
|
e.preventDefault();
|
|
}, [selectedResources, onClearSelection]);
|
|
|
|
useEffect(() => {
|
|
if (!marquee) return;
|
|
|
|
const handleMouseMove = (e: MouseEvent) => {
|
|
const scrollArea = scrollAreaRef.current;
|
|
if (!scrollArea) return;
|
|
|
|
const rect = scrollArea.getBoundingClientRect();
|
|
const x = e.clientX - rect.left + scrollArea.scrollLeft;
|
|
const y = e.clientY - rect.top + scrollArea.scrollTop;
|
|
|
|
setMarquee(prev => prev ? { ...prev, currentX: x, currentY: y } : null);
|
|
|
|
// Calculate marquee rect
|
|
const info = marqueeRef.current;
|
|
if (!info) return;
|
|
|
|
const mx = Math.min(marquee.startX, x);
|
|
const my = Math.min(marquee.startY, y);
|
|
const mw = Math.abs(x - marquee.startX);
|
|
const mh = Math.abs(y - marquee.startY);
|
|
|
|
// Find intersecting items
|
|
const elements = scrollArea.querySelectorAll('[data-resource]');
|
|
const containerRect = scrollArea.getBoundingClientRect();
|
|
const newSelection = new Set(info.initialSelection);
|
|
|
|
elements.forEach(el => {
|
|
const name = el.getAttribute('data-resource');
|
|
if (!name) return;
|
|
const elRect = el.getBoundingClientRect();
|
|
const elX = elRect.left - containerRect.left + scrollArea.scrollLeft;
|
|
const elY = elRect.top - containerRect.top + scrollArea.scrollTop;
|
|
|
|
if (mx < elX + elRect.width && mx + mw > elX && my < elY + elRect.height && my + mh > elY) {
|
|
newSelection.add(name);
|
|
}
|
|
});
|
|
|
|
onSetSelection(newSelection);
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
setMarquee(null);
|
|
marqueeRef.current = null;
|
|
};
|
|
|
|
window.addEventListener('mousemove', handleMouseMove);
|
|
window.addEventListener('mouseup', handleMouseUp);
|
|
return () => {
|
|
window.removeEventListener('mousemove', handleMouseMove);
|
|
window.removeEventListener('mouseup', handleMouseUp);
|
|
};
|
|
}, [marquee, onSetSelection]);
|
|
|
|
const handleResourceDoubleClick = (resource: FileResource) => {
|
|
if (resource.isDirectory) {
|
|
const newPath = currentPath === '/'
|
|
? `/${resource.name}`
|
|
: `${currentPath}/${resource.name}`;
|
|
onNavigate(newPath, resource.id);
|
|
} else if (isPreviewable(resource.name)) {
|
|
if (isImageFile(resource.name)) {
|
|
onPreviewImage(resource.name);
|
|
} else {
|
|
onPreviewFile(resource.name);
|
|
}
|
|
} else {
|
|
onDownload(resource.name);
|
|
}
|
|
};
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
// Only show upload overlay for external file drags, not internal resource drags
|
|
if (e.dataTransfer.types.includes("Files")) {
|
|
setIsDraggingOver(true);
|
|
}
|
|
}, []);
|
|
|
|
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingOver(false);
|
|
}, []);
|
|
|
|
const handleDrop = useCallback(async (e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsDraggingOver(false);
|
|
|
|
setIsUploading(true);
|
|
try {
|
|
const { files, hasDirectories } = await getDroppedFilesAndFolders(e.dataTransfer);
|
|
if (files.length > 0) {
|
|
if (hasDirectories) {
|
|
await onUploadFolder(files);
|
|
} else {
|
|
await onUploadFiles(files);
|
|
}
|
|
}
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}, [onUploadFiles, onUploadFolder]);
|
|
|
|
const handleFileInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
setIsUploading(true);
|
|
try {
|
|
await onUploadFiles(files);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}
|
|
if (fileInputRef.current) {
|
|
fileInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const handleFolderInputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const files = Array.from(e.target.files || []);
|
|
if (files.length > 0) {
|
|
setIsUploading(true);
|
|
try {
|
|
await onUploadFolder(files);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}
|
|
if (folderInputRef.current) {
|
|
folderInputRef.current.value = '';
|
|
}
|
|
};
|
|
|
|
const contextMenuRef = useRef<HTMLDivElement>(null);
|
|
|
|
const handleContextMenu = (e: React.MouseEvent, name: string) => {
|
|
e.preventDefault();
|
|
setContextMenu({ x: e.clientX, y: e.clientY, name });
|
|
setRadialMenuPos({ x: e.clientX, y: e.clientY });
|
|
setRadialMenuResourceName(name);
|
|
setRadialMenuOpen(true);
|
|
};
|
|
|
|
// Adjust context menu position to stay within viewport
|
|
useEffect(() => {
|
|
if (contextMenu && contextMenuRef.current) {
|
|
const menu = contextMenuRef.current;
|
|
const rect = menu.getBoundingClientRect();
|
|
let { x, y } = contextMenu;
|
|
let adjusted = false;
|
|
|
|
if (x + rect.width > window.innerWidth) {
|
|
x = window.innerWidth - rect.width - 8;
|
|
adjusted = true;
|
|
}
|
|
if (y + rect.height > window.innerHeight) {
|
|
y = window.innerHeight - rect.height - 8;
|
|
adjusted = true;
|
|
}
|
|
|
|
if (adjusted) {
|
|
setContextMenu({ ...contextMenu, x, y });
|
|
}
|
|
}
|
|
}, [contextMenu]);
|
|
|
|
const handleContainerClick = () => {
|
|
if (contextMenu) setContextMenu(null);
|
|
if (emptyContextMenu) setEmptyContextMenu(null);
|
|
if (breadcrumbDropdown) setBreadcrumbDropdown(null);
|
|
};
|
|
|
|
const handleBreadcrumbRightClick = async (e: React.MouseEvent, crumbPath: string) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const parentPath = crumbPath === '/' ? '/' : '/' + crumbPath.split('/').filter(Boolean).slice(0, -1).join('/') || '/';
|
|
try {
|
|
const items = await listPath(parentPath === '/' ? '/' : parentPath);
|
|
const folders = items.filter(r => r.isDirectory);
|
|
setBreadcrumbDropdown({ path: parentPath, folders, x: e.clientX, y: e.clientY });
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const handleSortClick = (key: SortKey) => {
|
|
if (sortKey === key) {
|
|
setSortDir(d => d === "asc" ? "desc" : "asc");
|
|
} else {
|
|
setSortKey(key);
|
|
setSortDir("asc");
|
|
}
|
|
};
|
|
|
|
const SortIndicator = ({ column }: { column: SortKey }) => {
|
|
if (sortKey !== column) return null;
|
|
return sortDir === "asc"
|
|
? <ArrowUp className="w-3 h-3 inline ms-1" />
|
|
: <ArrowDown className="w-3 h-3 inline ms-1" />;
|
|
};
|
|
|
|
// Keyboard shortcuts
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
// Don't capture when typing in inputs or dialogs
|
|
const tag = (e.target as HTMLElement)?.tagName;
|
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
if (showNewFolder || renameTarget) return;
|
|
|
|
// Ctrl+F / Cmd+F: toggle search
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
e.preventDefault();
|
|
setShowSearch(v => !v);
|
|
return;
|
|
}
|
|
|
|
// Ctrl+A / Cmd+A: select all
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
|
|
e.preventDefault();
|
|
onSelectAll();
|
|
return;
|
|
}
|
|
|
|
// Ctrl+C / Cmd+C: copy selected
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'c') {
|
|
if (selectedResources.size > 0) {
|
|
e.preventDefault();
|
|
onCopy([...selectedResources]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+X / Cmd+X: cut selected
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'x') {
|
|
if (selectedResources.size > 0) {
|
|
e.preventDefault();
|
|
onCut([...selectedResources]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Ctrl+V / Cmd+V: paste
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'v') {
|
|
if (clipboard) {
|
|
e.preventDefault();
|
|
onPaste();
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Escape: clear selection, close search
|
|
if (e.key === 'Escape') {
|
|
if (showSearch) { setShowSearch(false); setSearchQuery(""); }
|
|
else if (selectedResources.size > 0) onClearSelection();
|
|
return;
|
|
}
|
|
|
|
// Delete: delete selected
|
|
if (e.key === 'Delete') {
|
|
if (selectedResources.size === 1) {
|
|
onDelete([...selectedResources][0]);
|
|
} else if (selectedResources.size > 1) {
|
|
onBatchDelete([...selectedResources]);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// F2: rename selected (single)
|
|
if (e.key === 'F2' && selectedResources.size === 1) {
|
|
setRenameTarget([...selectedResources][0]);
|
|
return;
|
|
}
|
|
|
|
// Enter: open selected directory or download selected file
|
|
if (e.key === 'Enter' && selectedResources.size === 1) {
|
|
const name = [...selectedResources][0];
|
|
const resource = resources.find(r => r.name === name);
|
|
if (resource?.isDirectory) {
|
|
const newPath = currentPath === '/'
|
|
? `/${resource.name}`
|
|
: `${currentPath}/${resource.name}`;
|
|
onNavigate(newPath, resource.id);
|
|
} else if (resource) {
|
|
onDownload(resource.name);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Backspace: navigate up
|
|
if (e.key === 'Backspace' && currentPath !== '/') {
|
|
handleNavigateUp();
|
|
return;
|
|
}
|
|
};
|
|
|
|
window.addEventListener('keydown', handler);
|
|
return () => window.removeEventListener('keydown', handler);
|
|
}, [selectedResources, resources, currentPath, showSearch, showNewFolder, renameTarget, onDelete, onBatchDelete, onSelectAll, onClearSelection, onNavigate, onDownload, onCut, onCopy, onPaste, clipboard, handleNavigateUp]);
|
|
|
|
const allSelected = resources.length > 0 && selectedResources.size === resources.length;
|
|
const someSelected = selectedResources.size > 0 && !allSelected;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="flex flex-col flex-1 min-h-0"
|
|
onClick={handleContainerClick}
|
|
onDragOver={handleDragOver}
|
|
onDragLeave={handleDragLeave}
|
|
onDrop={handleDrop}
|
|
tabIndex={-1}
|
|
>
|
|
{/* Toolbar */}
|
|
<div role="toolbar" aria-label={t("toolbar")} className="flex items-center gap-2 px-4 py-2 border-b border-border bg-background">
|
|
{isNarrow && folderLayout === "sidebar" && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 -ms-2"
|
|
onClick={() => setNarrowSidebarOpen((v) => !v)}
|
|
aria-label={t("open_folder_tree")}
|
|
>
|
|
<Menu className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
{/* Breadcrumbs */}
|
|
<nav aria-label={t("breadcrumb_root")} className="flex items-center gap-1 text-sm flex-1 min-w-0 overflow-x-auto">
|
|
{breadcrumbs.map((crumb, i) => (
|
|
<span key={`${i}:${crumb.path}`} className="flex items-center gap-1 shrink-0">
|
|
{i > 0 && <ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />}
|
|
<button
|
|
onClick={() => crumb.isAccount
|
|
? onNavigate('/', '__account_root__')
|
|
: onNavigate(crumb.path)}
|
|
onContextMenu={(e) => crumb.isAccount ? undefined : handleBreadcrumbRightClick(e, crumb.path)}
|
|
className={cn(
|
|
"px-1.5 py-0.5 rounded hover:bg-muted transition-colors",
|
|
i === breadcrumbs.length - 1
|
|
? "font-medium text-foreground"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
)}
|
|
>
|
|
{i === 0 ? <Home className="w-4 h-4" /> : crumb.name}
|
|
</button>
|
|
</span>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Action buttons */}
|
|
<div className="flex items-center gap-1 shrink-0">
|
|
{selectedResources.size > 0 && (() => {
|
|
const fileNames = [...selectedResources].filter(n => !resources.find(r => r.name === n)?.isDirectory);
|
|
const hasFiles = fileNames.length > 0;
|
|
const showBatch = selectedResources.size > 1;
|
|
if (!showBatch && !hasFiles) return null;
|
|
return (
|
|
<>
|
|
{showBatch && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => onBatchDownload(fileNames)}
|
|
>
|
|
<Download className="w-4 h-4 me-1" />
|
|
{t("download")} ({fileNames.length})
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8 text-destructive hover:text-destructive"
|
|
onClick={() => onBatchDelete([...selectedResources])}
|
|
>
|
|
<Trash2 className="w-4 h-4 me-1" />
|
|
{t("delete")} ({selectedResources.size})
|
|
</Button>
|
|
</>
|
|
)}
|
|
{hasFiles && onSendAsAttachment && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={() => onSendAsAttachment(fileNames)}
|
|
>
|
|
<MailPlus className="w-4 h-4 me-1" />
|
|
{t("send_as_attachment")} {fileNames.length > 1 && `(${fileNames.length})`}
|
|
</Button>
|
|
)}
|
|
{!showBatch && hasFiles && fileNames.length === 1 && isOfficeFile(fileNames[0]) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={async () => {
|
|
const file = resources.find((r) => r.name === fileNames[0]);
|
|
if (!file) return;
|
|
try {
|
|
const res = await fetch("/api/collabora/edit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
|
});
|
|
if (res.ok) {
|
|
const { url } = await res.json();
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
} catch (err) {
|
|
console.error("Collabora edit failed:", err);
|
|
}
|
|
}}
|
|
>
|
|
<Pencil className="w-4 h-4 me-1" />
|
|
Edit with Collabora
|
|
</Button>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
{clipboard && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-8"
|
|
onClick={onPaste}
|
|
>
|
|
<Clipboard className="w-4 h-4 me-1" />
|
|
{t("paste")} ({clipboard.names.length})
|
|
</Button>
|
|
)}
|
|
{!accountPickerMode && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setShowSearch(v => !v)}
|
|
title={t("search_placeholder")}
|
|
>
|
|
<Search className="w-4 h-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("h-8 w-8", viewMode === "grid" && "bg-muted")}
|
|
onClick={() => handleViewModeChange(viewMode === "list" ? "grid" : "list")}
|
|
title={viewMode === "list" ? t("grid_view") : t("list_view")}
|
|
>
|
|
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <LayoutList className="w-4 h-4" />}
|
|
</Button>
|
|
{!accountPickerMode && (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("h-8 w-8", showDetails && "bg-muted")}
|
|
onClick={onToggleDetails}
|
|
title={t("details")}
|
|
>
|
|
<Info className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className={cn("h-8 w-8", favorites.includes(currentPath) && "text-yellow-500")}
|
|
onClick={() => onToggleFavorite(currentPath)}
|
|
title={t("toggle_favorite")}
|
|
>
|
|
<Star className={cn("w-4 h-4", favorites.includes(currentPath) && "fill-current")} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
title={t("upload")}
|
|
disabled={isUploading}
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => folderInputRef.current?.click()}
|
|
title={t("upload_folder")}
|
|
disabled={isUploading}
|
|
>
|
|
<FolderUp className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setShowNewFolder(true)}
|
|
title={t("new_folder")}
|
|
>
|
|
<FolderPlus className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={() => setShowNewTextFile(true)}
|
|
title={t("new_text_file")}
|
|
>
|
|
<FilePlus className="w-4 h-4" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8"
|
|
onClick={onRefresh}
|
|
title={t("refresh")}
|
|
aria-label={t("refresh")}
|
|
disabled={isLoading}
|
|
>
|
|
<RefreshCw className={cn("w-4 h-4", isLoading && "animate-spin")} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Search bar */}
|
|
{showSearch && (
|
|
<div className="flex items-center gap-2 px-4 py-2 border-b border-border bg-muted/30">
|
|
<Search className="w-4 h-4 text-muted-foreground shrink-0" />
|
|
<input
|
|
ref={searchInputRef}
|
|
type="search"
|
|
role="searchbox"
|
|
aria-label={t("search_placeholder")}
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder={t("search_placeholder")}
|
|
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
setShowSearch(false);
|
|
setSearchQuery("");
|
|
}
|
|
}}
|
|
/>
|
|
{searchQuery && (
|
|
<button onClick={() => setSearchQuery("")} className="text-muted-foreground hover:text-foreground">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Hidden file input */}
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
multiple
|
|
className="hidden"
|
|
onChange={handleFileInputChange}
|
|
/>
|
|
{/* Hidden folder input */}
|
|
<input
|
|
ref={folderInputRef}
|
|
type="file"
|
|
className="hidden"
|
|
onChange={handleFolderInputChange}
|
|
{...({ webkitdirectory: '', directory: '' } as React.InputHTMLAttributes<HTMLInputElement>)}
|
|
/>
|
|
|
|
{/* Error display with retry */}
|
|
{error && (
|
|
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm border-b border-destructive/20 flex items-center gap-2">
|
|
<AlertCircle className="w-4 h-4 shrink-0" />
|
|
<span className="flex-1">{error}</span>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 text-destructive hover:text-destructive shrink-0"
|
|
onClick={onRefresh}
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5 me-1" />
|
|
{t("retry")}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Drag overlay */}
|
|
{isDraggingOver && (
|
|
<div className="absolute inset-0 z-50 bg-primary/5 border-2 border-dashed border-primary rounded-lg flex items-center justify-center pointer-events-none">
|
|
<div className="text-center">
|
|
<Upload className="w-12 h-12 text-primary mx-auto mb-2" />
|
|
<p className="text-sm font-medium text-primary">{t("drop_files_here")}</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload progress */}
|
|
{uploadProgress && (
|
|
<div className="px-4 py-2 border-b border-primary/20 bg-primary/5">
|
|
<div className="flex items-center gap-2 text-sm text-primary mb-1">
|
|
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
|
|
<span className="truncate">
|
|
{t("uploading")} {uploadProgress.name}
|
|
{uploadProgress.totalFiles > 1 && (
|
|
<span className="text-muted-foreground ms-1">
|
|
({uploadProgress.current}/{uploadProgress.totalFiles})
|
|
</span>
|
|
)}
|
|
</span>
|
|
<span className="ms-auto tabular-nums shrink-0 flex items-center gap-2">
|
|
{uploadProgress.total > 0
|
|
? `${Math.round((uploadProgress.loaded / uploadProgress.total) * 100)}%`
|
|
: "…"}
|
|
<button
|
|
onClick={onCancelUpload}
|
|
className="text-xs text-destructive hover:text-destructive/80 underline"
|
|
>
|
|
{t("cancel")}
|
|
</button>
|
|
</span>
|
|
</div>
|
|
<div className="h-1 bg-primary/20 rounded-full overflow-hidden">
|
|
<div
|
|
className="h-full bg-primary rounded-full transition-all duration-300"
|
|
style={{ width: uploadProgress.total > 0
|
|
? `${(uploadProgress.loaded / uploadProgress.total) * 100}%`
|
|
: '0%' }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* File list */}
|
|
<div className="flex-1 min-h-0 flex relative">
|
|
{/* Narrow-pane backdrop for the overlay folder tree */}
|
|
{folderLayout === "sidebar" && isNarrow && narrowSidebarOpen && (
|
|
<div
|
|
className="absolute inset-0 bg-black/50 z-40"
|
|
onClick={() => setNarrowSidebarOpen(false)}
|
|
/>
|
|
)}
|
|
{/* Folder tree sidebar (when layout is sidebar) */}
|
|
{folderLayout === "sidebar" && (
|
|
isNarrow ? (
|
|
<div
|
|
className={cn(
|
|
"absolute inset-y-0 left-0 z-50",
|
|
"transform transition-transform duration-300 ease-in-out",
|
|
!narrowSidebarOpen && "-translate-x-full"
|
|
)}
|
|
onClick={(e) => {
|
|
// Auto-close when the user taps a folder name. Chevrons stay
|
|
// open so they can expand/collapse without dismissing.
|
|
const target = e.target as HTMLElement;
|
|
const btn = target.closest('button');
|
|
if (btn && !btn.querySelector('svg.lucide-chevron-right, svg.lucide-chevron-down')) {
|
|
setNarrowSidebarOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
<FolderTreeSidebar
|
|
currentPath={currentPath}
|
|
onNavigate={onNavigate}
|
|
listByParentId={listByParentId}
|
|
width={288}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<FolderTreeSidebar
|
|
currentPath={currentPath}
|
|
onNavigate={onNavigate}
|
|
listByParentId={listByParentId}
|
|
width={sidebarWidth}
|
|
isResizing={isResizing}
|
|
/>
|
|
<ResizeHandle
|
|
onResizeStart={() => { dragStartWidth.current = sidebarWidth; setIsResizing(true); }}
|
|
onResize={(delta) => setSidebarWidth(Math.max(180, Math.min(400, dragStartWidth.current + delta)))}
|
|
onResizeEnd={() => {
|
|
setIsResizing(false);
|
|
localStorage.setItem("files-sidebar-width", String(sidebarWidth));
|
|
}}
|
|
onDoubleClick={() => { setSidebarWidth(256); localStorage.setItem("files-sidebar-width", "256"); }}
|
|
/>
|
|
</>
|
|
)
|
|
)}
|
|
{/* Favorites & Recent sidebar (when layout is inline) */}
|
|
{folderLayout === "inline" && (favorites.length > 0 || recentFiles.length > 0) && (
|
|
<div className="w-48 border-e border-border bg-background overflow-y-auto shrink-0 hidden lg:block">
|
|
{favorites.length > 0 && (
|
|
<div className="p-3">
|
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
|
|
<Star className="w-3 h-3" />
|
|
{t("favorites")}
|
|
</h4>
|
|
<div className="space-y-0.5">
|
|
{favorites.map((fav) => (
|
|
<button
|
|
key={fav}
|
|
onClick={() => onNavigate(fav)}
|
|
className={cn(
|
|
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start",
|
|
currentPath === fav && "bg-muted font-medium"
|
|
)}
|
|
>
|
|
<Folder className="w-3.5 h-3.5 text-blue-500 shrink-0" />
|
|
<span className="truncate">{fav === '/' ? t("breadcrumb_root") : fav.split('/').pop()}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{recentFiles.length > 0 && (
|
|
<div className="p-3 border-t border-border">
|
|
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2 flex items-center gap-1">
|
|
<Clock className="w-3 h-3" />
|
|
{t("recent")}
|
|
</h4>
|
|
<div className="space-y-0.5">
|
|
{recentFiles.slice(0, 10).map((recent) => (
|
|
<button
|
|
key={recent.id}
|
|
className="w-full flex items-center gap-2 px-2 py-1.5 rounded text-sm hover:bg-muted transition-colors text-start"
|
|
title={recent.name}
|
|
>
|
|
<File className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
|
<span className="truncate">{recent.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
<div
|
|
ref={scrollAreaRef}
|
|
className="flex-1 min-w-0 overflow-y-auto relative"
|
|
onMouseDown={handleMarqueeMouseDown}
|
|
>
|
|
{isLoading && resources.length === 0 ? (
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted/50 sticky top-0 z-10">
|
|
<tr className="border-b border-border">
|
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-4 h-4" />
|
|
{t("name")}
|
|
</div>
|
|
</th>
|
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">{t("size")}</th>
|
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">{t("modified")}</th>
|
|
<th className="w-10 px-2 py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<SkeletonRow />
|
|
<SkeletonRow />
|
|
<SkeletonRow />
|
|
<SkeletonRow />
|
|
<SkeletonRow />
|
|
<SkeletonRow />
|
|
</tbody>
|
|
</table>
|
|
) : accountPickerMode && accountFolders && accountFolders.length > 0 && onSelectAccount ? (
|
|
/* ======= ACCOUNT PICKER (Pro mode root) ======= */
|
|
<div className="p-4">
|
|
<div
|
|
className="grid gap-3"
|
|
style={{ gridTemplateColumns: 'repeat(auto-fill, minmax(11rem, 1fr))' }}
|
|
>
|
|
{accountFolders.map((acc) => (
|
|
<button
|
|
key={`__account__:${acc.accountId}`}
|
|
onClick={() => onSelectAccount(acc.accountId)}
|
|
title={acc.email}
|
|
className="flex items-center gap-3 p-3 rounded-lg border border-border hover:bg-muted/50 transition-colors text-start min-w-0"
|
|
>
|
|
<Avatar
|
|
name={acc.label}
|
|
email={acc.email}
|
|
size="md"
|
|
fallbackColor={acc.avatarColor}
|
|
className="shrink-0"
|
|
/>
|
|
<div className="min-w-0 flex flex-col">
|
|
<span className="truncate text-sm font-medium">{acc.label || acc.email}</span>
|
|
{acc.label && acc.label !== acc.email && (
|
|
<span className="truncate text-xs text-muted-foreground">{acc.email}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
) : accountPickerMode ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<p className="text-sm text-muted-foreground">{t("no_accounts")}</p>
|
|
</div>
|
|
) : resources.length === 0 && !searchQuery && currentPath === '/' ? (
|
|
<FileUploadArea
|
|
onUpload={async (files: File[]) => {
|
|
setIsUploading(true);
|
|
try {
|
|
await onUploadFiles(files);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}}
|
|
onUploadFolder={async (files: File[]) => {
|
|
setIsUploading(true);
|
|
try {
|
|
await onUploadFolder(files);
|
|
} finally {
|
|
setIsUploading(false);
|
|
}
|
|
}}
|
|
onCreateFolder={() => setShowNewFolder(true)}
|
|
onCreateTextFile={() => setShowNewTextFile(true)}
|
|
/>
|
|
) : viewMode === "grid" ? (
|
|
/* ======= GRID VIEW ======= */
|
|
<div className="p-4" role="grid" aria-label={t("file_list")}>
|
|
{currentPath !== '/' && !searchQuery && (
|
|
<div
|
|
className={cn(
|
|
"inline-flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors w-28",
|
|
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
|
|
)}
|
|
onClick={handleNavigateUp}
|
|
onDragOver={(e) => {
|
|
if (e.dataTransfer.types.includes("application/x-file-names")) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragTarget('..');
|
|
}
|
|
}}
|
|
onDragLeave={() => setDragTarget(null)}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
setDragTarget(null);
|
|
const raw = e.dataTransfer.getData("application/x-file-names");
|
|
if (!raw) return;
|
|
const names: string[] = JSON.parse(raw);
|
|
await onMoveToParent(names);
|
|
}}
|
|
>
|
|
<Folder className="w-10 h-10 text-muted-foreground" />
|
|
<span className="text-xs text-muted-foreground truncate w-full text-center">..</span>
|
|
</div>
|
|
)}
|
|
{displayResources.length === 0 && searchQuery ? (
|
|
<p className="px-4 py-8 text-center text-muted-foreground text-sm">{t("no_results")}</p>
|
|
) : (
|
|
<div
|
|
className="grid grid-cols-[repeat(auto-fill,minmax(7rem,1fr))] gap-2"
|
|
onContextMenu={(e) => {
|
|
if ((e.target as HTMLElement).closest('[data-resource]')) return;
|
|
e.preventDefault();
|
|
setEmptyContextMenu({ x: e.clientX, y: e.clientY });
|
|
}}
|
|
>
|
|
{displayResources.map((resource) => (
|
|
<div
|
|
key={resource.id}
|
|
data-resource={resource.name}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
|
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onDragOver={(e) => {
|
|
if (resource.isDirectory) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragTarget(resource.name);
|
|
}
|
|
}}
|
|
onDragLeave={() => setDragTarget(null)}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
setDragTarget(null);
|
|
if (!resource.isDirectory) return;
|
|
const raw = e.dataTransfer.getData("application/x-file-names");
|
|
if (!raw) return;
|
|
const names: string[] = JSON.parse(raw);
|
|
if (names.includes(resource.name)) return;
|
|
await onMoveToFolder(names, resource.name);
|
|
}}
|
|
className={cn(
|
|
"flex flex-col items-center gap-2 p-3 rounded-lg cursor-pointer transition-colors relative group",
|
|
selectedResources.has(resource.name)
|
|
? "bg-primary/10 ring-1 ring-primary/30"
|
|
: dragTarget === resource.name
|
|
? "bg-primary/5 ring-1 ring-primary/40"
|
|
: "hover:bg-muted/50",
|
|
clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50"
|
|
)}
|
|
onClick={(e) => handleResourceClick(resource, e)}
|
|
onDoubleClick={() => handleResourceDoubleClick(resource)}
|
|
onContextMenu={(e) => handleContextMenu(e, resource.name)}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedResources.has(resource.name)}
|
|
onChange={() => onToggleSelect(resource.name)}
|
|
className="w-3.5 h-3.5 rounded border-border accent-primary cursor-pointer absolute top-2 left-2 opacity-0 group-hover:opacity-100 data-[checked=true]:opacity-100"
|
|
data-checked={selectedResources.has(resource.name)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
{showThumbnails && isImageFile(resource.name)
|
|
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="lg" />
|
|
: getGridIcon(resource)}
|
|
<span className="text-xs truncate w-full text-center flex items-center justify-center gap-1" title={resource.name}>
|
|
<span className="truncate">{resource.name}</span>
|
|
<ShareBadge resource={resource} t={t} />
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* ======= LIST VIEW ======= */
|
|
<table
|
|
aria-label={t("file_list")}
|
|
className="w-full text-sm"
|
|
onContextMenu={(e) => {
|
|
if ((e.target as HTMLElement).closest('tr[data-resource]')) return;
|
|
e.preventDefault();
|
|
setEmptyContextMenu({ x: e.clientX, y: e.clientY });
|
|
}}
|
|
>
|
|
<thead className="bg-muted/50 sticky top-0 z-10">
|
|
<tr className="border-b border-border">
|
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground">
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
ref={(el) => { if (el) el.indeterminate = someSelected; }}
|
|
onChange={() => allSelected ? onClearSelection() : onSelectAll()}
|
|
className="w-4 h-4 rounded border-border accent-primary cursor-pointer"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
<button onClick={() => handleSortClick("name")} className="hover:text-foreground transition-colors">
|
|
{t("name")}
|
|
<SortIndicator column="name" />
|
|
</button>
|
|
</div>
|
|
</th>
|
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden md:table-cell w-24">
|
|
<button onClick={() => handleSortClick("size")} className="hover:text-foreground transition-colors">
|
|
{t("size")}
|
|
<SortIndicator column="size" />
|
|
</button>
|
|
</th>
|
|
<th className="text-start px-4 py-2 font-medium text-muted-foreground hidden lg:table-cell w-44">
|
|
<button onClick={() => handleSortClick("modified")} className="hover:text-foreground transition-colors">
|
|
{t("modified")}
|
|
<SortIndicator column="modified" />
|
|
</button>
|
|
</th>
|
|
<th className="w-10 px-2 py-2" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{currentPath !== '/' && !searchQuery && (
|
|
<tr
|
|
className={cn(
|
|
"border-b border-border cursor-pointer transition-colors",
|
|
dragTarget === '..' ? "bg-primary/5 ring-1 ring-primary/40" : "hover:bg-muted/50"
|
|
)}
|
|
onClick={handleNavigateUp}
|
|
onDragOver={(e) => {
|
|
if (e.dataTransfer.types.includes("application/x-file-names")) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragTarget('..');
|
|
}
|
|
}}
|
|
onDragLeave={() => setDragTarget(null)}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
setDragTarget(null);
|
|
const raw = e.dataTransfer.getData("application/x-file-names");
|
|
if (!raw) return;
|
|
const names: string[] = JSON.parse(raw);
|
|
await onMoveToParent(names);
|
|
}}
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-4 h-4" />
|
|
<Folder className="w-5 h-5 text-muted-foreground" />
|
|
<span className="text-muted-foreground">..</span>
|
|
</div>
|
|
</td>
|
|
<td className="hidden md:table-cell" />
|
|
<td className="hidden lg:table-cell" />
|
|
<td />
|
|
</tr>
|
|
)}
|
|
{displayResources.length === 0 && searchQuery ? (
|
|
<tr>
|
|
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground text-sm">
|
|
{t("no_results")}
|
|
</td>
|
|
</tr>
|
|
) : displayResources.map((resource) => (
|
|
<tr
|
|
key={resource.id}
|
|
data-resource={resource.name}
|
|
aria-selected={selectedResources.has(resource.name)}
|
|
draggable
|
|
onDragStart={(e) => {
|
|
const names = selectedResources.has(resource.name) ? [...selectedResources] : [resource.name];
|
|
e.dataTransfer.setData("application/x-file-names", JSON.stringify(names));
|
|
e.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onDragOver={(e) => {
|
|
if (resource.isDirectory) {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = "move";
|
|
setDragTarget(resource.name);
|
|
}
|
|
}}
|
|
onDragLeave={() => setDragTarget(null)}
|
|
onDrop={async (e) => {
|
|
e.preventDefault();
|
|
setDragTarget(null);
|
|
if (!resource.isDirectory) return;
|
|
const raw = e.dataTransfer.getData("application/x-file-names");
|
|
if (!raw) return;
|
|
const names: string[] = JSON.parse(raw);
|
|
if (names.includes(resource.name)) return;
|
|
await onMoveToFolder(names, resource.name);
|
|
}}
|
|
className={cn(
|
|
"border-b border-border cursor-pointer transition-colors",
|
|
selectedResources.has(resource.name)
|
|
? "bg-primary/10"
|
|
: dragTarget === resource.name
|
|
? "bg-primary/5 ring-1 ring-primary/40"
|
|
: "hover:bg-muted/50",
|
|
clipboard?.mode === "cut" && clipboard.names.includes(resource.name) && "opacity-50"
|
|
)}
|
|
onClick={(e) => handleResourceClick(resource, e)}
|
|
onDoubleClick={() => handleResourceDoubleClick(resource)}
|
|
onContextMenu={(e) => handleContextMenu(e, resource.name)}
|
|
>
|
|
<td className="px-4 py-2.5">
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={selectedResources.has(resource.name)}
|
|
onChange={() => onToggleSelect(resource.name)}
|
|
className="w-4 h-4 rounded border-border accent-primary cursor-pointer shrink-0"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
{showThumbnails && isImageFile(resource.name)
|
|
? <Thumbnail name={resource.name} getImageUrl={getImageUrl} size="sm" />
|
|
: getFileIcon(resource)}
|
|
<span className="truncate">{resource.name}</span>
|
|
<ShareBadge resource={resource} t={t} />
|
|
</div>
|
|
</td>
|
|
<td className="px-4 py-2.5 text-muted-foreground hidden md:table-cell tabular-nums">
|
|
{resource.isDirectory ? "-" : formatFileSize(resource.contentLength)}
|
|
</td>
|
|
<td className="px-4 py-2.5 text-muted-foreground hidden lg:table-cell tabular-nums">
|
|
{formatDate(resource.lastModified)}
|
|
</td>
|
|
<td className="px-2 py-2.5">
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleContextMenu(e, resource.name);
|
|
}}
|
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
<MoreVertical className="w-4 h-4" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
|
|
{/* Radial Action Menu */}
|
|
<RadialMenu
|
|
items={radialMenuItems}
|
|
isOpen={radialMenuOpen}
|
|
position={radialMenuPos}
|
|
onClose={closeRadialMenu}
|
|
/>
|
|
|
|
{/* Context menu */}
|
|
{contextMenu && (
|
|
<div
|
|
ref={contextMenuRef}
|
|
role="menu"
|
|
aria-label={t("context_menu")}
|
|
className="fixed z-50 bg-background border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
|
|
style={{ left: contextMenu.x, top: contextMenu.y }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isPreviewable(contextMenu.name) && (
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
if (isImageFile(contextMenu.name)) {
|
|
onPreviewImage(contextMenu.name);
|
|
} else {
|
|
onPreviewFile(contextMenu.name);
|
|
}
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<ImageIcon className="w-4 h-4" />
|
|
{t("preview")}
|
|
</button>
|
|
)}
|
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onDownload(contextMenu.name);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
{t("download")}
|
|
</button>
|
|
)}
|
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && isOfficeFile(contextMenu.name) && (
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={async () => {
|
|
const file = resources.find((r) => r.name === contextMenu.name);
|
|
if (!file) { setContextMenu(null); return; }
|
|
try {
|
|
const res = await fetch("/api/collabora/edit", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ fileId: file.id, fileName: file.name }),
|
|
});
|
|
if (res.ok) {
|
|
const { url } = await res.json();
|
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
}
|
|
} catch (err) {
|
|
console.error("Collabora edit failed:", err);
|
|
}
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<ExternalLink className="w-4 h-4" />
|
|
Edit with Collabora
|
|
</button>
|
|
)}
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onCut([contextMenu.name]);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Scissors className="w-4 h-4" />
|
|
{t("cut")}
|
|
</button>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onCopy([contextMenu.name]);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Copy className="w-4 h-4" />
|
|
{t("copy")}
|
|
</button>
|
|
{clipboard && (
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onPaste();
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Clipboard className="w-4 h-4" />
|
|
{t("paste")}
|
|
</button>
|
|
)}
|
|
{!resources.find(r => r.name === contextMenu.name)?.isDirectory && (
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onDuplicate(contextMenu.name);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<CopyPlus className="w-4 h-4" />
|
|
{t("duplicate")}
|
|
</button>
|
|
)}
|
|
{canShare(resources.find(r => r.name === contextMenu.name)) && (
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
const r = resources.find(res => res.name === contextMenu.name);
|
|
if (r) setShareTargetId(r.id);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Share2 className="w-4 h-4" />
|
|
{t("share")}
|
|
</button>
|
|
)}
|
|
<div className="h-px bg-border my-1" />
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onShowDetails(contextMenu.name);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Info className="w-4 h-4" />
|
|
{t("details")}
|
|
</button>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
setRenameTarget(contextMenu.name);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
{t("rename")}
|
|
</button>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted text-destructive transition-colors text-start"
|
|
onClick={() => {
|
|
onDelete(contextMenu.name);
|
|
setContextMenu(null);
|
|
}}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
{t("delete")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Empty-area context menu */}
|
|
{emptyContextMenu && (
|
|
<div
|
|
role="menu"
|
|
aria-label={t("context_menu")}
|
|
className="fixed z-50 bg-background border border-border rounded-lg shadow-lg py-1 min-w-[160px]"
|
|
style={{ left: emptyContextMenu.x, top: emptyContextMenu.y }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
setShowNewFolder(true);
|
|
setEmptyContextMenu(null);
|
|
}}
|
|
>
|
|
<FolderPlus className="w-4 h-4" />
|
|
{t("new_folder")}
|
|
</button>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
setShowNewTextFile(true);
|
|
setEmptyContextMenu(null);
|
|
}}
|
|
>
|
|
<FilePlus className="w-4 h-4" />
|
|
{t("new_text_file")}
|
|
</button>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
fileInputRef.current?.click();
|
|
setEmptyContextMenu(null);
|
|
}}
|
|
>
|
|
<Upload className="w-4 h-4" />
|
|
{t("upload")}
|
|
</button>
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
folderInputRef.current?.click();
|
|
setEmptyContextMenu(null);
|
|
}}
|
|
>
|
|
<FolderUp className="w-4 h-4" />
|
|
{t("upload_folder")}
|
|
</button>
|
|
{clipboard && (
|
|
<>
|
|
<div className="h-px bg-border my-1" />
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onPaste();
|
|
setEmptyContextMenu(null);
|
|
}}
|
|
>
|
|
<Clipboard className="w-4 h-4" />
|
|
{t("paste")}
|
|
</button>
|
|
</>
|
|
)}
|
|
<div className="h-px bg-border my-1" />
|
|
<button
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onRefresh();
|
|
setEmptyContextMenu(null);
|
|
}}
|
|
>
|
|
<RefreshCw className="w-4 h-4" />
|
|
{t("refresh")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Breadcrumb dropdown */}
|
|
{breadcrumbDropdown && (
|
|
<div
|
|
role="menu"
|
|
aria-label={t("breadcrumb_root")}
|
|
className="fixed z-50 bg-background border border-border rounded-lg shadow-lg py-1 min-w-[180px] max-h-64 overflow-y-auto"
|
|
style={{ left: breadcrumbDropdown.x, top: breadcrumbDropdown.y }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
{breadcrumbDropdown.folders.length === 0 ? (
|
|
<p className="px-3 py-2 text-sm text-muted-foreground">{t("no_results")}</p>
|
|
) : (
|
|
breadcrumbDropdown.folders.map((folder) => {
|
|
const folderPath = breadcrumbDropdown.path === '/'
|
|
? `/${folder.name}`
|
|
: `${breadcrumbDropdown.path}/${folder.name}`;
|
|
return (
|
|
<button
|
|
key={folder.id}
|
|
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-start"
|
|
onClick={() => {
|
|
onNavigate(folderPath, folder.id);
|
|
setBreadcrumbDropdown(null);
|
|
}}
|
|
>
|
|
<Folder className="w-4 h-4 text-blue-500 shrink-0" />
|
|
<span className="truncate">{folder.name}</span>
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Marquee selection rectangle */}
|
|
{marquee && (
|
|
<div
|
|
className="absolute border border-primary/50 bg-primary/10 pointer-events-none z-20"
|
|
style={{
|
|
left: Math.min(marquee.startX, marquee.currentX),
|
|
top: Math.min(marquee.startY, marquee.currentY),
|
|
width: Math.abs(marquee.currentX - marquee.startX),
|
|
height: Math.abs(marquee.currentY - marquee.startY),
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Details sidebar */}
|
|
{showDetails && detailResource && (
|
|
<div role="complementary" aria-label={t("details")} className="w-64 border-s border-border bg-background p-4 overflow-y-auto shrink-0 hidden md:block">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<h3 className="text-sm font-medium">{t("details")}</h3>
|
|
<button onClick={onToggleDetails} className="text-muted-foreground hover:text-foreground">
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
<div className="flex flex-col items-center gap-3 mb-4">
|
|
{detailResource.isDirectory
|
|
? <Folder className="w-12 h-12 text-blue-500" />
|
|
: getFileIconByName(detailResource.name, "lg")}
|
|
<p className="text-sm font-medium text-center break-all">{detailResource.name}</p>
|
|
</div>
|
|
<dl className="space-y-3 text-sm">
|
|
<div>
|
|
<dt className="text-muted-foreground text-xs">{t("type")}</dt>
|
|
<dd>{detailResource.isDirectory ? t("folder") : (detailResource.contentType || t("file"))}</dd>
|
|
</div>
|
|
{!detailResource.isDirectory && (
|
|
<div>
|
|
<dt className="text-muted-foreground text-xs">{t("size")}</dt>
|
|
<dd className="tabular-nums">{formatFileSize(detailResource.contentLength)}</dd>
|
|
</div>
|
|
)}
|
|
{detailResource.lastModified && (
|
|
<div>
|
|
<dt className="text-muted-foreground text-xs">{t("modified")}</dt>
|
|
<dd className="tabular-nums">{formatDate(detailResource.lastModified)}</dd>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<dt className="text-muted-foreground text-xs">{t("path")}</dt>
|
|
<dd className="text-xs break-all font-mono">
|
|
{currentPath === "/" ? `/${detailResource.name}` : `${currentPath}/${detailResource.name}`}
|
|
</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* New folder dialog */}
|
|
{showNewFolder && (
|
|
<NewFolderDialog
|
|
onConfirm={async (name: string) => {
|
|
await onCreateFolder(name);
|
|
setShowNewFolder(false);
|
|
}}
|
|
onCancel={() => setShowNewFolder(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* New text file dialog */}
|
|
{showNewTextFile && (
|
|
<RenameDialog
|
|
currentName="new-file.txt"
|
|
title={t("new_text_file")}
|
|
label={t("file_name")}
|
|
onConfirm={async (name: string) => {
|
|
await onCreateTextFile(name);
|
|
setShowNewTextFile(false);
|
|
}}
|
|
onCancel={() => setShowNewTextFile(false)}
|
|
/>
|
|
)}
|
|
|
|
{/* Rename dialog */}
|
|
{renameTarget && (
|
|
<RenameDialog
|
|
currentName={renameTarget}
|
|
onConfirm={async (newName: string) => {
|
|
await onRename(renameTarget, newName);
|
|
setRenameTarget(null);
|
|
}}
|
|
onCancel={() => setRenameTarget(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* Share dialog */}
|
|
{shareTarget && client && onShare && (
|
|
<ShareCollectionDialog
|
|
client={client}
|
|
kind="file"
|
|
collectionName={shareTarget.name}
|
|
shareWith={shareTarget.shareWith}
|
|
ownAccountId={ownAccountId || ""}
|
|
onShare={(principalId, rights) =>
|
|
onShare(shareTarget.id, principalId, rights as FileNodeRights | null)}
|
|
onClose={() => setShareTargetId(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|