feat(files): wide file manager layout and basic previews (FM-2)
- Files page uses a wide layout (route handle layout:"wide"): no max width, full-height directory tree docked next to the sidebar, resizable (200-520px, remembered, double-click resets) and collapsible; mobile keeps the tree in a drawer. - GET /files/preview (admin console session only, same path guards as download): text/NFO with encoding detection (BOM/UTF-16/UTF-8/GB18030/Big5), subtitles first 200 cues (SRT/VTT/ASS/SSA, PGS timings), .torrent file list/trackers (passkey masked)/infohash, image dimensions. Reads at most 1 MiB of text; .torrent capped at 4 MiB. - Download also allows text files, .torrent and avif/gif. - Research notes in docs/design/file-preview-research.md. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
3add4bab41
commit
289e8b297a
20 files changed
+1294
-159
No files matched your search
@@ -52,7 +52,6 @@ import {
|
||||
useTrash,
|
||||
} from "~/api/files.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||||
import {
|
||||
@@ -88,7 +87,7 @@ import { ShortcutsDialog } from "~/components/files/shortcuts-dialog.tsx";
|
||||
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
import { type OrganizeSource, OrganizeWizard } from "~/components/organize-wizard/organize-wizard.tsx";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
@@ -113,7 +112,13 @@ type Dialog =
|
||||
| { kind: "wizard"; source: OrganizeSource }
|
||||
| { kind: "shortcuts" };
|
||||
|
||||
/** 宽布局:内容区不受全站最大宽度限制,目录树贴着侧边栏(layout.tsx 读 handle.layout) */
|
||||
export const handle = { layout: "wide" };
|
||||
|
||||
const TREE_W_KEY = "umt-files-tree-w";
|
||||
const TREE_W_DEFAULT = 280;
|
||||
const TREE_W_MIN = 200;
|
||||
const TREE_W_MAX = 520;
|
||||
const TREE_OPEN_KEY = "umt-files-tree-open";
|
||||
const LAST_LOC_KEY = "umt-files-last";
|
||||
const FILES_CHANGED = "files.changed";
|
||||
@@ -150,13 +155,13 @@ function writeStore(key: string, v: string) {
|
||||
}
|
||||
|
||||
function useTreeWidth() {
|
||||
const [w, setW] = useState(260);
|
||||
const [w, setW] = useState(TREE_W_DEFAULT);
|
||||
useEffect(() => {
|
||||
const v = Number(readStore(TREE_W_KEY));
|
||||
if (v >= 200 && v <= 440) setW(v);
|
||||
if (v >= TREE_W_MIN && v <= TREE_W_MAX) setW(v);
|
||||
}, []);
|
||||
const save = (v: number) => {
|
||||
const c = Math.max(200, Math.min(440, Math.round(v)));
|
||||
const c = Math.max(TREE_W_MIN, Math.min(TREE_W_MAX, Math.round(v)));
|
||||
setW(c);
|
||||
writeStore(TREE_W_KEY, String(c));
|
||||
};
|
||||
@@ -876,45 +881,47 @@ export default function FilesPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="文件管理"
|
||||
actions={
|
||||
mobile ? undefined : (
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<Trash2 />
|
||||
回收站{trashCount && trashCount !== "0" ? ` ${trashCount}` : ""}
|
||||
</Link>
|
||||
</>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{/* 桌面左栏:登记目录 + 目录树(可折叠、可拖宽) */}
|
||||
{/* 顶栏已显示页面名,这里只给读屏留标题,把高度让给列表 */}
|
||||
<h1 className="sr-only">文件管理</h1>
|
||||
<div className={cn("flex h-[calc(100dvh-3.5rem)] min-w-0", mobile && "flex-col p-3")}>
|
||||
{/* 桌面左栏:贴着侧边栏的整高目录树(可折叠、可拖宽,宽度记在本机) */}
|
||||
{!mobile ? (
|
||||
<aside
|
||||
className="pg-chrome relative shrink-0 self-start overflow-hidden rounded-2xl"
|
||||
className="pg-chrome pg-chrome-side relative flex shrink-0 flex-col border-r"
|
||||
style={{ width: treeOpen ? treeW : 44 }}
|
||||
aria-label="登记目录与目录树"
|
||||
>
|
||||
{treeOpen ? (
|
||||
<>
|
||||
<div className="max-h-[calc(100dvh-11rem)] overflow-y-auto p-2.5">{tree}</div>
|
||||
{/* 左侧留出全站侧栏折叠钮的位置 */}
|
||||
<div className="flex h-11 shrink-0 items-center border-b border-[var(--shell-border)] pr-2 pl-6">
|
||||
<span className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
|
||||
登记目录
|
||||
</span>
|
||||
{filesMockEnabled() ? (
|
||||
<StatusBadge tone="warning" className="ml-auto">
|
||||
演示数据
|
||||
</StatusBadge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">{tree}</div>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整目录树宽度"
|
||||
title="拖动调整宽度,双击恢复默认"
|
||||
aria-valuenow={treeW}
|
||||
aria-valuemin={200}
|
||||
aria-valuemax={440}
|
||||
aria-valuemin={TREE_W_MIN}
|
||||
aria-valuemax={TREE_W_MAX}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||
}}
|
||||
onDoubleClick={() => setTreeW(TREE_W_DEFAULT)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startW = treeW;
|
||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||
@@ -925,25 +932,28 @@ export default function FilesPage() {
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", upH);
|
||||
}}
|
||||
className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
className="absolute inset-y-0 -right-1 z-10 w-2 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<TreeRail
|
||||
roots={rootList}
|
||||
current={rootId}
|
||||
onSelect={go}
|
||||
onExpand={() => setTreeOpenStored(true)}
|
||||
trashCount={trashCount}
|
||||
/>
|
||||
<>
|
||||
<div aria-hidden className="h-11 shrink-0 border-b border-[var(--shell-border)]" />
|
||||
<TreeRail
|
||||
roots={rootList}
|
||||
current={rootId}
|
||||
onSelect={go}
|
||||
onExpand={() => setTreeOpenStored(true)}
|
||||
trashCount={trashCount}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section
|
||||
className={cn(
|
||||
"pg-slab relative flex min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
|
||||
mobile ? "h-[calc(100dvh-9.5rem)] min-h-[24rem]" : "h-[calc(100dvh-11rem)] min-h-[28rem]",
|
||||
"pg-slab relative flex min-h-[24rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl",
|
||||
!mobile && "m-3",
|
||||
)}
|
||||
aria-label="目录内容"
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user