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
@@ -1,17 +1,8 @@
|
||||
import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react";
|
||||
import { LoaderCircle, ScanSearch } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileRoot,
|
||||
fileDownloadUrl,
|
||||
filesErrorText,
|
||||
filesMockEnabled,
|
||||
useFileStat,
|
||||
useProbe,
|
||||
useTextPreview,
|
||||
} from "~/api/files.ts";
|
||||
import { type FileEntry, type FileRoot, filesErrorText, useFileStat, useProbe } from "~/api/files.ts";
|
||||
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
@@ -19,14 +10,13 @@ import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { Timeline } from "~/components/console/timeline.tsx";
|
||||
import { SLOT_STATUS } from "~/components/library/labels.ts";
|
||||
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { downloadLabel } from "~/lib/labels.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { EntryStatus } from "./file-list.tsx";
|
||||
import { FilePreviewSection } from "./file-preview.tsx";
|
||||
import { IdentifyPanel } from "./identify-panel.tsx";
|
||||
import { baseName } from "./model.ts";
|
||||
|
||||
const PREVIEW_MAX = 20 * 1024 * 1024;
|
||||
import { baseName, previewKindOf } from "./model.ts";
|
||||
|
||||
const ACTION_LABEL: Record<string, string> = {
|
||||
copy: "复制",
|
||||
@@ -78,7 +68,7 @@ function RecordsSection({ entityId }: { entityId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
|
||||
/** 选中单项的详情抽屉:文件信息、作品/收录版本/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与预览 */
|
||||
export function FileDetail({
|
||||
root,
|
||||
entry,
|
||||
@@ -99,11 +89,7 @@ export function FileDetail({
|
||||
const stat = useFileStat(root.id, entry.path);
|
||||
const probe = useProbe();
|
||||
const e = stat.data?.entry ?? entry;
|
||||
const previewable =
|
||||
e.type === "file" &&
|
||||
e.size <= PREVIEW_MAX &&
|
||||
(e.fileKind === "nfo" || e.fileKind === "subtitle" || e.fileKind === "image");
|
||||
const text = useTextPreview(root.id, e.path, previewable && e.fileKind !== "image");
|
||||
const pk = previewKindOf(e);
|
||||
const media = probe.data?.items[0]?.summary ?? e.media;
|
||||
|
||||
return (
|
||||
@@ -113,6 +99,7 @@ export function FileDetail({
|
||||
title={e.name}
|
||||
description={<EntryStatus e={e} />}
|
||||
footer={actions}
|
||||
width={pk && pk !== "image" ? "lg" : "md"}
|
||||
>
|
||||
<DetailSection title="文件">
|
||||
<KvList
|
||||
@@ -292,51 +279,7 @@ export function FileDetail({
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{previewable ? (
|
||||
<DetailSection
|
||||
title="预览"
|
||||
actions={
|
||||
filesMockEnabled() ? null : (
|
||||
<a
|
||||
className={buttonVariants({ size: "xs", variant: "outline" })}
|
||||
href={fileDownloadUrl(root.id, e.path)}
|
||||
download={e.name}
|
||||
>
|
||||
<Download />
|
||||
下载
|
||||
</a>
|
||||
)
|
||||
}
|
||||
>
|
||||
{e.fileKind === "image" ? (
|
||||
filesMockEnabled() ? (
|
||||
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
|
||||
) : (
|
||||
<a href={fileDownloadUrl(root.id, e.path, true)} target="_blank" rel="noreferrer" className="block">
|
||||
<img
|
||||
src={fileDownloadUrl(root.id, e.path, true)}
|
||||
alt={e.name}
|
||||
className="max-h-80 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>
|
||||
)
|
||||
) : text.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : text.error ? (
|
||||
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
|
||||
) : (
|
||||
<>
|
||||
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
|
||||
{text.data?.text}
|
||||
</pre>
|
||||
{text.data?.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 256 KB</p> : null}
|
||||
</>
|
||||
)}
|
||||
</DetailSection>
|
||||
) : null}
|
||||
<FilePreviewSection root={root} entry={e} />
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user