Files FIles Files
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
import { useRef, useCallback, useMemo, useState, useEffect } from 'react';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import type { DirEntry, TaskSummary } from 'apps/FileBrowser';
|
||||
import { getFileType } from 'apps/FileViewer';
|
||||
import { FileItem } from './FileItem';
|
||||
|
||||
type ClipboardState = { paths: string[]; mode: 'copy' | 'cut' } | null;
|
||||
|
||||
type SortField = 'name' | 'size' | 'type' | 'date';
|
||||
type SortDirection = 'asc' | 'desc';
|
||||
|
||||
type FileGridProps = {
|
||||
entries: DirEntry[];
|
||||
viewMode: 'grid' | 'list';
|
||||
@@ -21,6 +26,11 @@ type FileGridProps = {
|
||||
onCopy: () => void;
|
||||
renamingName: string | null;
|
||||
onRenamingChange: (name: string | null) => void;
|
||||
onReadAloud: (entry: DirEntry) => void;
|
||||
onOcr: (entry: DirEntry) => void;
|
||||
onTranscribe: (entry: DirEntry) => void;
|
||||
onExtractAudio: (entry: DirEntry) => void;
|
||||
onExtract: (entry: DirEntry) => void;
|
||||
getMatchingTasks: (fileName: string, entryType: 'file' | 'directory') => TaskSummary[];
|
||||
onRunTask: (task: TaskSummary, entry: DirEntry) => void;
|
||||
onCreateWorkspace: (entry: DirEntry) => void;
|
||||
@@ -30,6 +40,7 @@ type FileGridProps = {
|
||||
const LIST_ROW_HEIGHT = 42;
|
||||
const GRID_ROW_HEIGHT = 130;
|
||||
const GRID_GAP = 12;
|
||||
const HEADER_HEIGHT = 36;
|
||||
|
||||
const useColumnCount = (scrollRef: React.RefObject<HTMLDivElement | null>) => {
|
||||
const [cols, setCols] = useState(4);
|
||||
@@ -71,21 +82,55 @@ export const FileGrid = ({
|
||||
onCopy,
|
||||
renamingName,
|
||||
onRenamingChange,
|
||||
onReadAloud,
|
||||
onOcr,
|
||||
onTranscribe,
|
||||
onExtractAudio,
|
||||
onExtract,
|
||||
getMatchingTasks,
|
||||
onRunTask,
|
||||
onCreateWorkspace,
|
||||
scrollRef,
|
||||
}: FileGridProps) => {
|
||||
const lastClickedIdx = useRef<number>(-1);
|
||||
const [sortField, setSortField] = useState<SortField>('name');
|
||||
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
|
||||
|
||||
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 sorted = useMemo(() => {
|
||||
const compare = (a: DirEntry, b: DirEntry): number => {
|
||||
if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
|
||||
let result: number;
|
||||
switch (sortField) {
|
||||
case 'name':
|
||||
result = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'size':
|
||||
result = a.size - b.size;
|
||||
break;
|
||||
case 'type': {
|
||||
const typeA = a.type === 'directory' ? 'directory' : getFileType(a.name);
|
||||
const typeB = b.type === 'directory' ? 'directory' : getFileType(b.name);
|
||||
result = typeA.localeCompare(typeB);
|
||||
if (result === 0) result = a.name.localeCompare(b.name);
|
||||
break;
|
||||
}
|
||||
case 'date':
|
||||
result = a.modifiedAt - b.modifiedAt;
|
||||
break;
|
||||
}
|
||||
return sortDirection === 'asc' ? result : -result;
|
||||
};
|
||||
return [...entries].sort(compare);
|
||||
}, [entries, sortField, sortDirection]);
|
||||
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDirection((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDirection('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(entry: DirEntry, ev: React.MouseEvent) => {
|
||||
@@ -128,6 +173,7 @@ export const FileGrid = ({
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => (viewMode === 'list' ? LIST_ROW_HEIGHT : GRID_ROW_HEIGHT + GRID_GAP),
|
||||
overscan: 10,
|
||||
scrollMargin: HEADER_HEIGHT,
|
||||
});
|
||||
|
||||
if (entries.length === 0) {
|
||||
@@ -153,44 +199,117 @@ export const FileGrid = ({
|
||||
onCopy={onCopy}
|
||||
forceRename={renamingName === entry.name}
|
||||
onRenamingChange={onRenamingChange}
|
||||
onReadAloud={onReadAloud}
|
||||
onOcr={onOcr}
|
||||
onTranscribe={onTranscribe}
|
||||
onExtractAudio={onExtractAudio}
|
||||
onExtract={onExtract}
|
||||
matchingTasks={getMatchingTasks(entry.name, entry.type)}
|
||||
onRunTask={onRunTask}
|
||||
onCreateWorkspace={onCreateWorkspace}
|
||||
/>
|
||||
);
|
||||
|
||||
const sortIcon = (field: SortField) =>
|
||||
sortField === field ? (
|
||||
sortDirection === 'asc' ? (
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
)
|
||||
) : null;
|
||||
|
||||
const sortBtnClass = (field: SortField) =>
|
||||
`flex items-center gap-1 text-xs font-medium cursor-pointer transition-colors ${
|
||||
sortField === field ? 'text-duck-teal' : 'text-duck-dark/50 hover:text-duck-dark/80'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div className="file-grid relative w-full" style={{ height: virtualizer.getTotalSize() }}>
|
||||
{virtualizer.getVirtualItems().map((virtualRow) => {
|
||||
if (viewMode === 'list') {
|
||||
const entry = sorted[virtualRow.index]!;
|
||||
<>
|
||||
{viewMode === 'list' ? (
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-3 px-3"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className="shrink-0 w-5" />
|
||||
<span className="shrink-0 w-5" />
|
||||
<button className={`flex-1 min-w-0 ${sortBtnClass('name')}`} onClick={() => toggleSort('name')}>
|
||||
Name {sortIcon('name')}
|
||||
</button>
|
||||
<button
|
||||
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('type')}`}
|
||||
onClick={() => toggleSort('type')}
|
||||
>
|
||||
Type {sortIcon('type')}
|
||||
</button>
|
||||
<button
|
||||
className={`hidden md:flex shrink-0 w-20 justify-end ${sortBtnClass('size')}`}
|
||||
onClick={() => toggleSort('size')}
|
||||
>
|
||||
Size {sortIcon('size')}
|
||||
</button>
|
||||
<button
|
||||
className={`hidden md:flex shrink-0 w-28 justify-end ${sortBtnClass('date')}`}
|
||||
onClick={() => toggleSort('date')}
|
||||
>
|
||||
Date {sortIcon('date')}
|
||||
</button>
|
||||
<span className="shrink-0 w-8" />
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm border-b border-duck-dark/10 flex items-center gap-2 px-3"
|
||||
style={{ height: HEADER_HEIGHT }}
|
||||
>
|
||||
<span className="text-xs text-duck-dark/40">Sort:</span>
|
||||
{(['name', 'size', 'type', 'date'] as SortField[]).map((field) => (
|
||||
<button
|
||||
key={field}
|
||||
onClick={() => toggleSort(field)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-md text-xs cursor-pointer transition-colors ${
|
||||
sortField === field
|
||||
? 'bg-duck-teal/10 text-duck-teal font-medium'
|
||||
: 'text-duck-dark/50 hover:bg-duck-dark/5'
|
||||
}`}
|
||||
>
|
||||
{field.charAt(0).toUpperCase() + field.slice(1)}
|
||||
{sortIcon(field)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<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"
|
||||
style={{ top: virtualRow.start, height: virtualRow.size }}
|
||||
className="absolute left-0 w-full grid gap-3"
|
||||
style={{
|
||||
top: virtualRow.start,
|
||||
height: virtualRow.size - GRID_GAP,
|
||||
gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`,
|
||||
}}
|
||||
>
|
||||
{renderItem(entry)}
|
||||
{rowEntries.map(renderItem)}
|
||||
</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>
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user