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

+36
View File
@@ -519,3 +519,39 @@ export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyComple
items: r.items.map((it) => by.get(identifyKey(it)) ?? it),
};
}
// ── 预览(FM-2)──
export type PreviewKind = "text" | "subtitle" | "torrent" | "image";
const PREVIEW_EXT: Record<string, PreviewKind> = {};
for (const e of "nfo txt log md markdown json xml yaml yml ini conf cfg csv tsv cue sfv md5 sha1 sha256 url".split(" "))
PREVIEW_EXT[e] = "text";
for (const e of "srt vtt ass ssa sup".split(" ")) PREVIEW_EXT[e] = "subtitle";
for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) PREVIEW_EXT[e] = "image";
PREVIEW_EXT.torrent = "torrent";
/** 与服务端 preview-parse.ts 同一张扩展名表;目录、链接、无扩展名返回 null */
export function previewKindOf(e: Pick<FileEntry, "type" | "ext">): PreviewKind | null {
if (e.type !== "file" || !e.ext) return null;
return PREVIEW_EXT[e.ext.toLowerCase()] ?? null;
}
/** 毫秒 → 00:01:02.345 */
export function cueTime(ms: number): string {
const h = Math.floor(ms / 3_600_000);
const m = Math.floor((ms % 3_600_000) / 60_000);
const s = Math.floor((ms % 60_000) / 1000);
const f = ms % 1000;
const p = (n: number, w = 2) => String(n).padStart(w, "0");
return `${p(h)}:${p(m)}:${p(s)}.${p(f, 3)}`;
}
/** JSON 文本美化;解析失败或已截断时原样返回 */
export function prettyJson(text: string, truncated: boolean): string {
if (truncated) return text;
try {
return JSON.stringify(JSON.parse(text), null, 2);
} catch {
return text;
}
}