Files
platform/src/apps/officer-web/Screens/Dashboard/Files/Screen/FileItem.tsx
T

483 lines
15 KiB
TypeScript

import { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { Folder, Trash2, Pencil, MoreVertical, MessageSquare, Scissors, Copy, Check, Play, Download } from 'lucide-react';
import { getIcon } from 'material-file-icons';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { cardStyle } from '@/components/Card';
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
export type FileItemProps = {
entry: DirEntry;
viewMode: 'grid' | 'list';
selected: boolean;
anySelected: boolean;
selectedCount: number;
isCut: boolean;
onOpen: (entry: DirEntry) => void;
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onDownload: (entry: DirEntry) => void;
onSelect: (entry: DirEntry, ev: React.MouseEvent) => void;
onCut: () => void;
onCopy: () => void;
forceRename: boolean;
onRenamingChange: (name: string | null) => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
};
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function formatDate(ms: number): string {
return new Date(ms).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
});
}
type MenuItemsProps = {
entry: DirEntry;
multiSelected: boolean;
onDelete: (e: DirEntry) => void;
onStartRename: () => void;
onChat: (e: DirEntry) => void;
onDownload: (e: DirEntry) => void;
onCut: () => void;
onCopy: () => void;
matchingTasks: TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
};
const DropdownMenuItems = ({
entry,
multiSelected,
onDelete,
onStartRename,
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
onRunTask,
}: MenuItemsProps) => (
<>
<DropdownMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</DropdownMenuItem>
{matchingTasks.length > 0 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="z-[600]">
{matchingTasks.map((task) => (
<DropdownMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</DropdownMenuItem>
<DropdownMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</DropdownMenuItem>
{!multiSelected && (
<DropdownMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</DropdownMenuItem>
</>
);
const ContextMenuItems = ({
entry,
multiSelected,
onDelete,
onStartRename,
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
onRunTask,
}: MenuItemsProps) => (
<>
<ContextMenuItem onClick={() => onChat(entry)} className="cursor-pointer">
<MessageSquare className="mr-2 h-4 w-4" />
Chat...
</ContextMenuItem>
<ContextMenuItem onClick={() => onDownload(entry)} className="cursor-pointer">
<Download className="mr-2 h-4 w-4" />
Download
</ContextMenuItem>
{matchingTasks.length > 0 && (
<ContextMenuSub>
<ContextMenuSubTrigger className="cursor-pointer">
<Play className="mr-2 h-4 w-4" />
Run Task
</ContextMenuSubTrigger>
<ContextMenuSubContent className="z-[600]">
{matchingTasks.map((task) => (
<ContextMenuItem key={task.dirName} onClick={() => onRunTask(task, entry)} className="cursor-pointer">
{task.name}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={onCut} className="cursor-pointer">
<Scissors className="mr-2 h-4 w-4" />
Cut
</ContextMenuItem>
<ContextMenuItem onClick={onCopy} className="cursor-pointer">
<Copy className="mr-2 h-4 w-4" />
Copy
</ContextMenuItem>
{!multiSelected && (
<ContextMenuItem onClick={onStartRename} className="cursor-pointer">
<Pencil className="mr-2 h-4 w-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuSeparator />
<ContextMenuItem onClick={() => onDelete(entry)} className="text-red-600 cursor-pointer">
<Trash2 className="mr-2 h-4 w-4" />
Delete
</ContextMenuItem>
</>
);
const EllipsisMenu = (props: MenuItemsProps) => (
<div onClick={(ev) => ev.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-1 rounded hover:bg-duck-dark/10 cursor-pointer">
<MoreVertical className="h-4 w-4 text-duck-dark/60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="z-[600]" onCloseAutoFocus={(ev) => ev.preventDefault()}>
<DropdownMenuItems {...props} />
</DropdownMenuContent>
</DropdownMenu>
</div>
);
const InlineRenameInput = ({
initialName,
onCommit,
onCancel,
}: {
initialName: string;
onCommit: (name: string) => void;
onCancel: () => void;
}) => {
const [value, setValue] = useState(initialName);
const mountRef = useCallback((node: HTMLInputElement | null) => {
if (!node) return;
requestAnimationFrame(() => {
node.focus();
const dotIndex = initialName.lastIndexOf('.');
if (dotIndex > 0) {
node.setSelectionRange(0, dotIndex);
} else {
node.select();
}
});
}, []);
const commit = () => {
const trimmed = value.trim();
if (trimmed && trimmed !== initialName) {
onCommit(trimmed);
} else {
onCancel();
}
};
return (
<input
ref={mountRef}
value={value}
onChange={(ev) => setValue(ev.target.value)}
onBlur={commit}
onKeyDown={(ev) => {
if (ev.key === 'Enter') commit();
if (ev.key === 'Escape') onCancel();
}}
onClick={(ev) => ev.stopPropagation()}
className="text-sm font-medium text-duck-dark bg-background border border-duck-teal/50 rounded px-1 py-0.5 outline-none w-full text-center"
/>
);
};
const Checkbox = ({
checked,
anySelected,
onClick,
}: {
checked: boolean;
anySelected: boolean;
onClick: (ev: React.MouseEvent) => void;
}) => (
<button
onClick={(ev) => {
ev.stopPropagation();
onClick(ev);
}}
className={`flex items-center justify-center h-5 w-5 rounded border-2 transition-all cursor-pointer ${
checked ? 'bg-duck-teal border-duck-teal text-white' : 'border-duck-dark/30 bg-background/80 hover:border-duck-teal/50'
} ${anySelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
>
{checked && <Check className="h-3 w-3" />}
</button>
);
const MaterialFileIcon = ({ name, className }: { name: string; className?: string }) => {
const svg = useMemo(() => getIcon(name).svg, [name]);
return <span className={className} dangerouslySetInnerHTML={{ __html: svg }} />;
};
export const FileItem = ({
entry,
viewMode,
selected,
anySelected,
selectedCount,
isCut,
onOpen,
onDelete,
onRename,
onChat,
onDownload,
onSelect,
onCut,
onCopy,
forceRename,
onRenamingChange,
matchingTasks,
onRunTask,
}: FileItemProps) => {
const [renaming, setRenaming] = useState(false);
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const isDir = entry.type === 'directory';
const icon = isDir ? (
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
) : (
<MaterialFileIcon name={entry.name} className="inline-flex h-5 w-5" />
);
const iconLarge = isDir ? (
<Folder className="h-10 w-10 text-duck-yellow fill-duck-yellow/30" />
) : (
<MaterialFileIcon name={entry.name} className="inline-flex h-10 w-10" />
);
useEffect(() => {
return () => {
if (clickTimer.current) clearTimeout(clickTimer.current);
};
}, []);
useEffect(() => {
if (forceRename) {
setRenaming(true);
onRenamingChange(null);
}
}, [forceRename]);
const handleCommitRename = (newName: string) => {
setRenaming(false);
onRename(entry, newName);
};
const handleClick = (ev: React.MouseEvent) => {
if (renaming) return;
// Modifier clicks select immediately (intentional multi-select)
if (ev.ctrlKey || ev.metaKey || ev.shiftKey) {
onSelect(entry, ev);
return;
}
// Delay plain click so double-click doesn't trigger selection + layout shift
const syntheticEv = { ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent;
clickTimer.current = setTimeout(() => {
clickTimer.current = null;
onSelect(entry, syntheticEv);
}, 200);
};
const handleDoubleClick = () => {
if (renaming) return;
if (clickTimer.current) {
clearTimeout(clickTimer.current);
clickTimer.current = null;
}
onOpen(entry);
};
const handleContextMenu = (ev: React.MouseEvent) => {
ev.stopPropagation();
if (!selected) {
onSelect(entry, { ...ev, ctrlKey: false, shiftKey: false, metaKey: false } as React.MouseEvent);
}
};
const menuProps: MenuItemsProps = {
entry,
multiSelected: selectedCount > 1,
onDelete,
onStartRename: () => setRenaming(true),
onChat,
onDownload,
onCut,
onCopy,
matchingTasks,
onRunTask,
};
const cutOpacity = isCut ? 'opacity-50' : '';
if (viewMode === 'list') {
return (
<ContextMenu>
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group flex items-center gap-3 px-3 py-2 hover:bg-duck-teal/5 data-[state=open]:bg-duck-teal/5 cursor-pointer border-b border-duck-dark/5 last:border-b-0 transition-colors ${
selected ? 'bg-duck-teal/10 border-duck-teal/50' : ''
} ${cutOpacity} select-none`}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
>
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
<span className="shrink-0">{icon}</span>
<div className="flex-1 min-w-0">
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
)}
</div>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
{isDir ? '--' : formatSize(entry.size)}
</span>
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-28 text-right">
{formatDate(entry.modifiedAt)}
</span>
<div className="file-item-ellipsis opacity-0 group-hover:opacity-100 data-[state=open]:opacity-100 transition-opacity shrink-0">
<EllipsisMenu {...menuProps} />
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItems {...menuProps} />
</ContextMenuContent>
</ContextMenu>
);
}
return (
<ContextMenu>
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
} ${cutOpacity} select-none`}
style={
selected
? undefined
: cardStyle({
backgroundColor: 'rgba(255, 255, 255, 0.85)',
backgroundImage: `
linear-gradient(to right, rgba(20, 83, 45, 0.06) 1px, transparent 1px),
linear-gradient(to bottom, rgba(20, 83, 45, 0.06) 1px, transparent 1px)
`,
})
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onPointerDown={(ev) => ev.stopPropagation()}
>
<div className="absolute top-1 left-1">
<Checkbox checked={selected} anySelected={anySelected} onClick={(ev) => onSelect(entry, ev)} />
</div>
<div className="file-item-ellipsis absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity">
<EllipsisMenu {...menuProps} />
</div>
{iconLarge}
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
)}
<span className="text-xs text-duck-dark/50">
{isDir ? 'Folder' : formatSize(entry.size)}
{' · '}
{formatDate(entry.modifiedAt)}
</span>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">
<ContextMenuItems {...menuProps} />
</ContextMenuContent>
</ContextMenu>
);
};