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

+82 -7
View File
@@ -5,6 +5,7 @@ import type {
FileEntry,
FileIdentifyItem,
FileKindTag,
FilePreview,
FileRef,
FileRoot,
FileStat,
@@ -238,6 +239,7 @@ function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
}),
f("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }),
f("README.txt", 1_024, 700),
f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX-FraMeSToR.torrent", 186_000, 30),
f("escape-link", 0, 700, { escape: true }),
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
f(".hidden-note", 64, 700),
@@ -922,6 +924,8 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
return list(p);
case "/files/stat":
return stat(p);
case "/files/preview":
return mockFilePreview(p);
case "/files/mkdir": {
const { node } = find(b.root, b.path);
if ((node.children ?? []).some((c) => c.name === b.name))
@@ -1167,11 +1171,82 @@ function mockRecord(id: string) {
};
}
export async function mockPreview(root: string, path: string): Promise<string> {
await delay(100);
if (path.endsWith(".nfo"))
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘 2</title>\n <year>2024</year>\n</movie>`;
if (/\.(srt|ass)$/.test(path))
return "1\n00:00:01,000 --> 00:00:04,000\n 厄拉科斯…\n\n2\n00:00:05,000 --> 00:00:07,500\n 一切都从这里开始。";
return `(${root}:${path})`;
function mockFilePreview(p: Record<string, unknown>): FilePreview {
const path = String(p.path ?? "");
const { node } = find(String(p.root ?? ""), path);
const size = node.size;
const ext = /\.([A-Za-z0-9]{1,8})$/.exec(path)?.[1]?.toLowerCase() ?? "";
if (ext === "nfo")
return {
type: "text",
size,
format: "nfo",
encoding: "utf-8",
truncated: false,
text: `<?xml version="1.0" encoding="utf-8"?>\n<movie>\n <title>沙丘 2</title>\n <originaltitle>Dune: Part Two</originaltitle>\n <year>2024</year>\n <uniqueid type="tmdb" default="true">693134</uniqueid>\n <genre>科幻</genre>\n <runtime>166</runtime>\n</movie>`,
};
if (ext === "txt")
return {
type: "text",
size,
format: "txt",
encoding: "gb18030",
truncated: false,
text: ["本目录是 qBittorrent 的默认保存路径。", "做种中的文件不要改名或移动。"].join("\n"),
};
if (ext === "srt" || ext === "ass") {
const lines = [
"厄拉科斯。",
"一切都从这里开始。",
"香料必须流动。",
"我看见了未来。",
"沙虫来了!",
"保罗·厄崔迪。",
];
const cues = Array.from({ length: 24 }, (_, i) => ({
start: 62_000 + i * 3_400,
end: 64_500 + i * 3_400,
text: lines[i % lines.length] as string,
}));
return {
type: "subtitle",
size,
format: ext,
encoding: ext === "ass" ? "utf-16le" : "utf-8",
cues,
parsed: 1_342,
truncated: false,
title: ext === "ass" ? "Severance S02E01 简中" : null,
};
}
if (ext === "torrent")
return {
type: "torrent",
size,
name: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
hash: "3f786850e387550fdab836ed7e6dc881de23001b",
infohashV1: "3f786850e387550fdab836ed7e6dc881de23001b",
infohashV2: null,
private: true,
totalSize: Math.round(71.2 * GB + 96_000),
fileCount: 2,
files: [
{
path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv",
length: Math.round(71.2 * GB),
},
{
path: "Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR/chs.srt",
length: 96_000,
},
],
trackers: ["https://tracker.example.org/announce.php?passkey=…"],
comment: null,
createdBy: "mktorrent 1.1",
createdAt: ago(40),
pieceLength: 16 * MB,
};
if (IMG.has(ext))
return { type: "image", size, mime: `image/${ext === "jpg" ? "jpeg" : ext}`, width: 2000, height: 3000 };
throw new ApiRequestError("forbidden", `forbidden:不支持预览 .${ext} 文件`);
}