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

@@ -4,6 +4,7 @@ import {
applyIdentifyCompleted,
clickSelect,
collapseCrumbs,
cueTime,
deleteBlockText,
emptySelection,
entryStatusView,
@@ -18,6 +19,8 @@ import {
opApplies,
opDisabledReason,
parseEpisodes,
prettyJson,
previewKindOf,
pruneSelection,
selectAll,
shiftEpisodes,
@@ -329,3 +332,20 @@ describe("识别远端回填(S-7)", () => {
expect(f.items[0]?.warnings).toEqual(["远端查询失败,只有本地结果:超时"]);
});
});
describe("预览(FM-2)", () => {
it("按扩展名分预览类型,目录与未知扩展名不预览", () => {
expect(previewKindOf({ type: "file", ext: "NFO" })).toBe("text");
expect(previewKindOf({ type: "file", ext: "ass" })).toBe("subtitle");
expect(previewKindOf({ type: "file", ext: "torrent" })).toBe("torrent");
expect(previewKindOf({ type: "file", ext: "avif" })).toBe("image");
expect(previewKindOf({ type: "file", ext: "mkv" })).toBeNull();
expect(previewKindOf({ type: "dir", ext: null })).toBeNull();
});
it("时间轴与 JSON 美化", () => {
expect(cueTime(3_723_045)).toBe("01:02:03.045");
expect(prettyJson('{"a":1}', false)).toBe('{\n "a": 1\n}');
expect(prettyJson('{"a":', false)).toBe('{"a":');
expect(prettyJson('{"a":1}', true)).toBe('{"a":1}');
});
});