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
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user