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

191 lines
5.6 KiB
TypeScript

import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
import { useVirtualizer } from '@tanstack/react-virtual';
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
import { FileItem } from './FileItem';
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
type FileGridProps = {
entries: DirEntry[];
viewMode: 'grid' | 'list';
currentPath: string;
selected: Set<string>;
clipboard: ClipboardState;
onOpen: (entry: DirEntry) => void;
onDelete: (entry: DirEntry) => void;
onRename: (entry: DirEntry, newName: string) => void;
onChat: (entry: DirEntry) => void;
onSelect: (names: Set<string>) => void;
onCut: () => void;
onCopy: () => void;
renamingName: string | null;
onRenamingChange: (name: string | null) => void;
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
scrollRef: React.RefObject<HTMLDivElement | null>;
};
const LIST_ROW_HEIGHT = 42;
const GRID_ROW_HEIGHT = 130;
const GRID_GAP = 12;
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
const [cols, setCols] = useState(4);
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
const measure = () => {
const width = el.clientWidth - 32; // subtract p-4 (16px each side)
// Breakpoints matching grid-cols-2 sm:3 md:4 lg:5 xl:6
if (width >= 1280) setCols(6);
else if (width >= 1024) setCols(5);
else if (width >= 768) setCols(4);
else if (width >= 640) setCols(3);
else setCols(2);
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, [scrollRef]);
return cols;
};
export const FileGrid = ({
entries,
viewMode,
currentPath,
selected,
clipboard,
onOpen,
onDelete,
onRename,
onChat,
onSelect,
onCut,
onCopy,
renamingName,
onRenamingChange,
getMatchingTasks,
onRunTask,
scrollRef,
}: FileGridProps) => {
const lastClickedIdx = useRef<number>(-1);
const sorted = useMemo(
() =>
[...entries].sort((a, b) => {
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
return a.name.localeCompare(b.name);
}),
[entries],
);
const handleSelect = useCallback(
(entry: DirEntry, ev: React.MouseEvent) => {
const idx = sorted.findIndex((e) => e.name === entry.name);
if (ev.shiftKey && lastClickedIdx.current >= 0) {
const start = Math.min(lastClickedIdx.current, idx);
const end = Math.max(lastClickedIdx.current, idx);
const next = new Set(selected);
for (let i = start; i <= end; i++) {
next.add(sorted[i]!.name);
}
onSelect(next);
} else if (ev.ctrlKey || ev.metaKey) {
const next = new Set(selected);
if (next.has(entry.name)) {
next.delete(entry.name);
} else {
next.add(entry.name);
}
onSelect(next);
lastClickedIdx.current = idx;
} else {
onSelect(new Set([entry.name]));
lastClickedIdx.current = idx;
}
},
[sorted, selected, onSelect],
);
const cols = useColumnCount(scrollRef);
const cutPaths = clipboard?.mode === 'cut' ? new Set(clipboard.paths) : new Set<string>();
const anySelected = selected.size > 0;
const entryPath = (name: string) => (currentPath === '/' ? `/${name}` : `${currentPath}/${name}`);
const rowCount = viewMode === 'list' ? sorted.length : Math.ceil(sorted.length / cols);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => scrollRef.current,
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
overscan: 10,
});
if (entries.length === 0) {
return <div className="flex items-center justify-center py-20 text-duck-dark/40 text-sm">This folder is empty</div>;
}
const renderItem = (entry: DirEntry) => (
<FileItem
key={entry.name}
entry={entry}
viewMode={viewMode}
selected={selected.has(entry.name)}
anySelected={anySelected}
selectedCount={selected.size}
isCut={cutPaths.has(entryPath(entry.name))}
onOpen={onOpen}
onDelete={onDelete}
onRename={onRename}
onChat={onChat}
onSelect={handleSelect}
onCut={onCut}
onCopy={onCopy}
forceRename={renamingName === entry.name}
onRenamingChange={onRenamingChange}
matchingTasks={getMatchingTasks(entry.name, entry.type)}
onRunTask={onRunTask}
/>
);
return (
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((virtualRow) => {
if (viewMode === 'list') {
const entry = sorted[virtualRow.index]!;
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full"
style={{ top: virtualRow.start, height: virtualRow.size }}
>
{renderItem(entry)}
</div>
);
}
const startIdx = virtualRow.index * cols;
const rowEntries = sorted.slice(startIdx, startIdx + cols);
return (
<div
key={virtualRow.key}
className="absolute left-0 w-full grid gap-3"
style={{
top: virtualRow.start,
height: virtualRow.size - GRID_GAP,
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
}}
>
{rowEntries.map(renderItem)}
</div>
);
})}
</div>
);
};