123 lines
11 KiB
TypeScript
123 lines
11 KiB
TypeScript
import { useRef, useState, type FormEvent } from 'react';
|
|
import type { FolderInfo } from '@/core-bridge/types';
|
|
import { useWorkspace } from '@/store/workspace';
|
|
import { Button } from '@/ui/Button';
|
|
import { Icon } from '@/ui/Icon';
|
|
import { ExplorerFileRow } from './ExplorerFileRow';
|
|
import { ExplorerFolderRow } from './ExplorerFolderRow';
|
|
import { ExplorerHover } from './ExplorerHover';
|
|
import { ExplorerMoveDialog } from './ExplorerMoveDialog';
|
|
import { useExplorerInteractions } from './useExplorerInteractions';
|
|
import { ContextMenu } from '@/ui/ContextMenu';
|
|
import styles from './FileExplorer.module.css';
|
|
import { formatSize } from '@/lib/format';
|
|
|
|
interface Props {
|
|
scope: string;
|
|
threadId?: string;
|
|
threadName: string;
|
|
fileBytes: number;
|
|
maxFileBytes?: number;
|
|
folders: FolderInfo[];
|
|
folderId?: string;
|
|
ids: string[];
|
|
selectedId?: string;
|
|
checked: ReadonlySet<string>;
|
|
enabled: boolean;
|
|
structureEnabled: boolean;
|
|
onFolder: (id?: string) => void;
|
|
onSelect: (id?: string) => void;
|
|
onOpen: (id: string) => void;
|
|
onToggle: (id: string) => void;
|
|
onSelection: (ids: ReadonlySet<string>) => void;
|
|
onFiles: (files: File[], folderId?: string) => void;
|
|
onCreateFolder: (name: string) => Promise<void>;
|
|
onRenameFolder: (id: string, name: string) => Promise<void>;
|
|
onDownloadItems: (ids: string[]) => Promise<void>;
|
|
onDeleteItems: (ids: string[]) => Promise<void>;
|
|
onMoveItems: (ids: string[], folderId?: string) => Promise<void>;
|
|
}
|
|
|
|
export function FileExplorer(props: Props) {
|
|
const input = useRef<HTMLInputElement>(null);
|
|
const [sort, setSort] = useState<'name' | 'size' | 'date'>('name');
|
|
const [edit, setEdit] = useState<string>();
|
|
const [name, setName] = useState('');
|
|
const [busy, setBusy] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [selectedFolder, setSelectedFolder] = useState<string>();
|
|
const byId = useWorkspace.getState().byId;
|
|
const ids = props.ids.slice().sort((a, b) => {
|
|
const first = byId[a]; const second = byId[b];
|
|
if (first?.kind !== 'file' || second?.kind !== 'file') return 0;
|
|
return sort === 'size' ? second.size - first.size : sort === 'date' ? second.ts - first.ts : first.name.localeCompare(second.name, 'zh-CN');
|
|
});
|
|
const path: FolderInfo[] = [];
|
|
const seen = new Set<string>();
|
|
let current = props.folders.find((folder) => folder.id === props.folderId);
|
|
while (current && !seen.has(current.id)) {
|
|
seen.add(current.id); path.unshift(current); current = props.folders.find((folder) => folder.id === current?.parentId);
|
|
}
|
|
const children = props.folders.filter((folder) => folder.parentId === props.folderId).sort((a, b) => a.name.localeCompare(b.name, 'zh-CN'));
|
|
const maxFileBytes = props.maxFileBytes;
|
|
const usedRatio = maxFileBytes && maxFileBytes > 0 ? Math.min(1, props.fileBytes / maxFileBytes) : 0;
|
|
function beginFolder(id?: string) { const folder = props.folders.find((row) => row.id === id); setEdit(id ?? 'create'); setName(folder?.name ?? ''); setError(''); }
|
|
const interaction = useExplorerInteractions({ ...props, ids, onDownload: props.onDownloadItems, onDelete: props.onDeleteItems, onMove: props.onMoveItems,
|
|
onRenameFolder: (id) => beginFolder(id), onCreateFolder: () => beginFolder(), onUpload: () => input.current?.click(), onFolder: navigateFolder });
|
|
function navigateFolder(id?: string) { interaction.dismiss(); setSelectedFolder(undefined); props.onFolder(id); }
|
|
async function submit(event: FormEvent) {
|
|
event.preventDefault(); if (!edit || busy || !name.trim()) return;
|
|
setBusy(true); setError('');
|
|
try { if (edit === 'create') await props.onCreateFolder(name); else await props.onRenameFolder(edit, name); setEdit(undefined); }
|
|
catch (cause) { setError(cause instanceof Error ? cause.message : '目录操作失败'); }
|
|
finally { setBusy(false); }
|
|
}
|
|
return <section className={styles.explorer} aria-label="频道文件管理器" tabIndex={0} onKeyDown={interaction.keyDown}>
|
|
<header className={styles.heading}><strong>频道文件</strong><span>{props.threadName}</span></header>
|
|
<div className={styles.toolbar}>
|
|
<input type="file" multiple hidden ref={input} onChange={(event) => { if (event.target.files?.length) props.onFiles([...event.target.files], props.folderId); event.target.value = ''; }} />
|
|
<Button icon="share" disabled={!props.enabled} onClick={() => input.current?.click()}>上传文件</Button>
|
|
<Button variant="ghost" icon="folder" disabled={!props.structureEnabled || busy} onClick={() => beginFolder()}>新建文件夹</Button>
|
|
<Button variant="ghost" icon="eye" disabled={!props.selectedId} onClick={() => { if (props.selectedId) props.onOpen(props.selectedId); }}>预览</Button>
|
|
</div>
|
|
<nav className={styles.breadcrumb} aria-label="文件夹路径"><Button variant="ghost" icon="chevronLeft" label="返回上级文件夹" disabled={!props.folderId} onClick={() => navigateFolder(path[path.length - 1]?.parentId)} />
|
|
<button type="button" data-drop-folder="root" className={interaction.dropFolder === '__root' ? styles.dropTarget : ''} {...interaction.dropHandlers()} onClick={() => navigateFolder()}><Icon name="folder" size={16} />根目录</button>
|
|
{path.map((folder) => <span key={folder.id}><Icon name="chevronRight" size={12} /><button type="button" className={interaction.dropFolder === folder.id ? styles.dropTarget : ''} {...interaction.dropHandlers(folder.id)} onClick={() => navigateFolder(folder.id)}>{folder.name}</button></span>)}
|
|
</nav>
|
|
{edit ? <form className={styles.edit} onSubmit={(event) => { void submit(event); }}><input autoFocus maxLength={80} aria-label="文件夹名称" value={name} onChange={(event) => setName(event.target.value)} />
|
|
<Button type="submit" disabled={busy || !name.trim()}>{edit === 'create' ? '创建' : '保存'}</Button><Button variant="ghost" onClick={() => setEdit(undefined)}>取消</Button></form> : null}
|
|
<div className={`${styles.scroll} ${interaction.dropFolder === (props.folderId ?? '__root') ? styles.dropTarget : ''}`} {...interaction.dropHandlers(props.folderId)} onContextMenu={interaction.blankMenu}
|
|
onClick={(event) => { if (!(event.target as HTMLElement).closest('tr,button,input')) { props.onSelection(new Set()); props.onSelect(undefined); setSelectedFolder(undefined); event.currentTarget.focus(); } }} tabIndex={-1}>
|
|
<table className={styles.table}><thead><tr><th aria-label="选择" /><th><button type="button" onClick={() => setSort('name')}>名称{sort === 'name' ? ' ↑' : ''}</button></th>
|
|
<th><button type="button" onClick={() => setSort('size')}>大小{sort === 'size' ? ' ↓' : ''}</button></th><th className={styles.date}><button type="button" onClick={() => setSort('date')}>时间{sort === 'date' ? ' ↓' : ''}</button></th><th aria-label="操作" /></tr></thead>
|
|
<tbody>{children.map((folder) => <ExplorerFolderRow key={folder.id} folder={folder} selected={folder.id === selectedFolder} enabled={props.structureEnabled}
|
|
dropping={interaction.dropFolder === folder.id} drop={interaction.dropHandlers(folder.id)} onOpen={() => navigateFolder(folder.id)}
|
|
onSelect={() => { interaction.dismiss(); setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); }} onRename={() => beginFolder(folder.id)}
|
|
onMenu={(x, y) => { setSelectedFolder(folder.id); props.onSelection(new Set()); props.onSelect(undefined); interaction.showFolderMenu(folder.id, folder.name, x, y); }} />)}
|
|
{ids.map((id) => <ExplorerFileRow key={id} id={id} selected={id === props.selectedId} checked={props.checked.has(id)} enabled={props.enabled}
|
|
onSelect={(id, event) => { setSelectedFolder(undefined); interaction.select(id, event); }} onOpen={props.onOpen} onToggle={props.onToggle} onMenu={(id, x, y) => { setSelectedFolder(undefined); interaction.showFileMenu(id, x, y); }}
|
|
onHover={interaction.hoverFile} onLeave={interaction.leave} onDrag={interaction.drag} onDragEnd={interaction.dismiss} />)}</tbody>
|
|
</table>{ids.length === 0 && children.length === 0 ? <div className={styles.empty}><Icon name="folder" size={28} /><p>此文件夹还没有文件</p><span>上传文件或将文件拖到窗口中。</span></div> : null}</div>
|
|
{props.checked.size > 0 ? <div className={styles.batch}><span>已选 {props.checked.size} 项</span><Button variant="ghost" icon="download" onClick={() => { void props.onDownloadItems([...props.checked]); }}>下载所选</Button>
|
|
{props.enabled ? <select aria-label="移动所选文件到文件夹" value="__choose" onChange={(event) => {
|
|
const target = event.target.value; if (target !== '__choose') void props.onMoveItems([...props.checked], target === '__root' ? undefined : target).catch((cause: unknown) => setError(cause instanceof Error ? cause.message : '移动失败'));
|
|
}}><option value="__choose">移动到…</option><option value="__root">根目录</option>{props.folders.map((folder) => <option value={folder.id} key={folder.id}>{folder.name}</option>)}</select> : null}
|
|
</div> : null}
|
|
{error || interaction.error ? <p className={styles.error} role="alert">{error || interaction.error}</p> : null}
|
|
<footer className={styles.status}>{children.length} 个文件夹 · {ids.length} 个文件<span>双击打开 · 右键操作 · 拖动移动</span></footer>
|
|
<div className={styles.quota} role="group" aria-label="当前频道文件空间">
|
|
<div className={styles.quotaLabel}><span>频道文件空间</span><span>{formatSize(props.fileBytes)} / {maxFileBytes ? formatSize(maxFileBytes) : '读取中'}</span></div>
|
|
<div className={styles.quotaTrack} role="progressbar" aria-label="文件空间使用量" aria-valuemin={0}
|
|
aria-valuemax={maxFileBytes ?? 0} aria-valuenow={Math.min(props.fileBytes, maxFileBytes ?? 0)}>
|
|
<span className={props.fileBytes >= (maxFileBytes ?? Number.POSITIVE_INFINITY) ? styles.quotaFull : undefined}
|
|
style={{ width: `${usedRatio * 100}%` }} />
|
|
</div>
|
|
{maxFileBytes ? <span className={styles.quotaRemaining}>剩余 {formatSize(Math.max(0, maxFileBytes - props.fileBytes))}</span> : null}
|
|
</div>
|
|
{interaction.menu ? <ContextMenu point={interaction.menu} actions={interaction.actions} onClose={interaction.dismiss} /> : null}
|
|
{interaction.hover && ids.includes(interaction.hover.id) && !interaction.menu ? <ExplorerHover key={interaction.hover.id} {...interaction.hover} /> : null}
|
|
{interaction.moveIds ? <ExplorerMoveDialog folders={props.folders} count={interaction.moveIds.length} currentFolderId={props.folderId} onClose={() => interaction.setMoveIds(undefined)}
|
|
onMove={(folderId) => props.onMoveItems(interaction.moveIds!, folderId)} /> : null}
|
|
</section>;
|
|
}
|