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:
rootandClaude Opus 5.5 committed 2026-09-29 21:44:13 +08:00
1 parent 3add4bab41
commit 289e8b297a
20 files changed
+1294 -159

No files matched your search

@@ -0,0 +1,236 @@
import type { FilePreview } from "@umt/contracts";
import { Download, ExternalLink } from "lucide-react";
import {
type FileEntry,
type FileRoot,
fileDownloadUrl,
filesErrorText,
filesMockEnabled,
useFilePreview,
} from "~/api/files.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { buttonVariants } from "~/components/ui/button.tsx";
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { cueTime, prettyJson, previewKindOf } from "./model.ts";
/** 图片原图经 /files/download 内联,上限与下载一致 */
const IMAGE_INLINE_MAX = 20 * 1024 * 1024;
const ENCODING_LABEL: Record<string, string> = {
"utf-8": "UTF-8",
"utf-16le": "UTF-16 LE",
"utf-16be": "UTF-16 BE",
gb18030: "GBK / GB18030",
big5: "Big5",
"windows-1252": "Latin-1",
};
const encLabel = (e: string | null) => (e ? (ENCODING_LABEL[e] ?? e) : null);
const preBox =
"max-h-[28rem] overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap break-words";
function Meta({ items }: { items: (string | null | false | undefined)[] }) {
return <p className="mb-2 text-meta text-muted-foreground">{items.filter(Boolean).join(" · ")}</p>;
}
function TextView({ p }: { p: Extract<FilePreview, { type: "text" }> }) {
const body = p.format === "json" ? prettyJson(p.text, p.truncated) : p.text;
return (
<>
{body.trim() ? (
<pre className={preBox}>{body}</pre>
) : (
<p className="text-caption text-muted-foreground">空文件。</p>
)}
{p.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 1 MB</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "格式", value: p.format.toUpperCase() },
{ label: "编码", value: encLabel(p.encoding) },
{ label: "大小", value: formatBytes(p.size, 1) },
]}
/>
</>
);
}
function SubtitleView({ p }: { p: Extract<FilePreview, { type: "subtitle" }> }) {
const graphic = p.format === "sup";
const count =
p.parsed > p.cues.length
? `共 ${p.parsed}${p.truncated ? "+" : ""} 条,显示前 ${p.cues.length} 条`
: `${p.cues.length} 条`;
return (
<>
<Meta items={[p.title, count]} />
{graphic ? <p className="mb-2 text-caption text-muted-foreground">PGS 图形字幕没有文字,只列时间轴。</p> : null}
{p.cues.length ? (
<ol className="max-h-[28rem] divide-y divide-border overflow-auto rounded-xl border border-border">
{p.cues.map((c, i) => (
<li key={i} className="flex gap-3 px-3 py-1.5 text-caption">
<span className="shrink-0 font-mono text-meta text-muted-foreground tabular-nums">
{cueTime(c.start)}
{graphic ? ` → ${cueTime(c.end)}` : ""}
</span>
{graphic ? null : <span className="min-w-0 whitespace-pre-wrap break-words">{c.text}</span>}
</li>
))}
</ol>
) : (
<p className="text-caption text-muted-foreground">没有读到字幕条目。</p>
)}
{p.truncated ? <p className="mt-1 text-meta text-muted-foreground">只读了文件前 1 MB</p> : null}
<TechInfo
className="mt-2"
items={[
{ label: "格式", value: p.format.toUpperCase() },
{ label: "编码", value: encLabel(p.encoding) },
{ label: "大小", value: formatBytes(p.size, 1) },
]}
/>
</>
);
}
function TorrentView({ p }: { p: Extract<FilePreview, { type: "torrent" }> }) {
const prefix = `${p.name}/`;
return (
<div className="space-y-4">
<KvList
items={[
{ label: "名称", value: p.name, wide: true },
{ label: "总大小", value: `${formatBytes(p.totalSize, 2)} · ${p.fileCount} 个文件` },
{ label: "私有种子", value: p.private ? <StatusBadge tone="warning">私有</StatusBadge> : "否" },
...(p.comment ? [{ label: "注释", value: p.comment, wide: true }] : []),
]}
/>
<div>
<p className="mb-1 text-meta text-muted-foreground">
文件 {p.fileCount}
{p.fileCount > p.files.length ? `,显示前 ${p.files.length} 个` : ""}
</p>
<ul className="max-h-72 divide-y divide-border overflow-auto rounded-xl border border-border">
{p.files.map((f) => (
<li key={f.path} className="flex items-baseline gap-3 px-3 py-1.5 text-caption">
<span className="min-w-0 flex-1 font-mono text-meta break-all">
{f.path.startsWith(prefix) ? f.path.slice(prefix.length) : f.path}
</span>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">{formatBytes(f.length, 1)}</span>
</li>
))}
</ul>
</div>
<TechInfo
items={[
{ label: "infohash", value: p.infohashV1 ?? p.hash, wide: true },
p.infohashV2 ? { label: "infohash v2", value: p.infohashV2, wide: true } : null,
{ label: "分块", value: p.pieceLength ? formatBytes(p.pieceLength, 0) : null },
{ label: "创建时间", value: p.createdAt ? formatDateTime(p.createdAt) : null },
{ label: "创建工具", value: p.createdBy },
{ label: ".torrent 大小", value: formatBytes(p.size, 1) },
]}
>
<div>
<p className="mb-1 text-meta text-muted-foreground">Tracker {p.trackers.length}(passkey 已隐藏)</p>
{p.trackers.length ? (
<ul className="space-y-0.5 font-mono text-meta break-all">
{p.trackers.map((t) => (
<li key={t}>{t}</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有 tracker,只能走 DHT。</p>
)}
</div>
</TechInfo>
</div>
);
}
function ImageView({
root,
entry,
p,
}: {
root: FileRoot;
entry: FileEntry;
p: Extract<FilePreview, { type: "image" }>;
}) {
const dims = p.width && p.height ? `${p.width} × ${p.height}` : null;
const meta = <Meta items={[dims, formatBytes(p.size, 1)]} />;
if (filesMockEnabled())
return (
<>
{meta}
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
</>
);
if (p.size > IMAGE_INLINE_MAX)
return (
<>
{meta}
<p className="text-caption text-muted-foreground">图片超过 20 MB,不在这里显示。</p>
</>
);
const src = fileDownloadUrl(root.id, entry.path, true);
return (
<>
{meta}
<a href={src} target="_blank" rel="noreferrer" className="block">
<img
src={src}
alt={entry.name}
loading="lazy"
decoding="async"
className="max-h-[28rem] rounded-xl border border-border object-contain"
/>
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口看原图
</span>
</a>
</>
);
}
/** 详情抽屉的「预览」节:文本/NFO、字幕、.torrent、图片;其它类型不渲染 */
export function FilePreviewSection({ root, entry }: { root: FileRoot; entry: FileEntry }) {
const kind = previewKindOf(entry);
const q = useFilePreview(root.id, entry.path, !!kind && !entry.escape);
if (!kind || entry.escape) return null;
return (
<DetailSection
title="预览"
actions={
filesMockEnabled() || entry.size > IMAGE_INLINE_MAX ? null : (
<a
className={buttonVariants({ size: "xs", variant: "outline" })}
href={fileDownloadUrl(root.id, entry.path)}
download={entry.name}
>
<Download />
下载
</a>
)
}
>
{q.isPending ? (
<LoadingState rows={3} />
) : q.error ? (
<p className="text-caption text-destructive">{filesErrorText(q.error, "预览失败")}</p>
) : q.data.type === "text" ? (
<TextView p={q.data} />
) : q.data.type === "subtitle" ? (
<SubtitleView p={q.data} />
) : q.data.type === "torrent" ? (
<TorrentView p={q.data} />
) : (
<ImageView root={root} entry={entry} p={q.data} />
)}
</DetailSection>
);
}