feat(files): a file that is not media opens in a viewer — an image, a PDF, text as text

- GET /api/files/{id}/preview: an allow-list on the stored extension (downloads/preview.py) picks
  image / PDF / text; served inline under `CSP: sandbox; frame-ancestors 'self'`, the type from the
  table; HTML and SVG arrive as plain text; a text is its first 1 MiB (X-Preview-Truncated), sent
  as UTF-8 whatever it was written in (BOM, UTF-8, else cp1250); a NUL means binary: 415
- the list says which preview each file gets (asset.preview); FE/BE contract on kinds and header
- the viewer (shared dialog shell): ← / → through the list's such files in its order, Save, open in
  a tab, the file's menu; one stage height for every kind; a no-preview card offers the file
- the name, the card's picture and the menu's Preview open it; an image up to 8 MB is its own
  thumbnail
This commit is contained in:
2026-10-12 15:28:31 +02:00
parent 6707550513
commit fb8fb980e5
26 changed files with 1090 additions and 48 deletions
+27 -8
View File
@@ -44,6 +44,7 @@ import { useContextMenu } from "./ui/useContextMenu";
const UploadDialog = lazy(() => import("./UploadDialog"));
const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
const FilePreview = lazy(() => import("./FilePreview"));
const VideoEditor = lazy(() => import("./VideoEditor"));
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
@@ -110,12 +111,12 @@ function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number |
function NameCell({
file,
renaming,
onPlay,
onOpen,
onRenamed,
}: {
file: LibraryFile;
renaming: boolean;
onPlay: (() => void) | null;
onOpen: (() => void) | null;
onRenamed: (name: string | null, byKey: boolean) => void;
}) {
const { t } = useTranslation();
@@ -128,11 +129,11 @@ function NameCell({
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
return (
<div className="flex min-w-0 flex-col">
{onPlay ? (
{onOpen ? (
<Pressable
selectable
data-nav-open=""
onClick={onPlay}
onClick={onOpen}
title={jobTitle(file)}
className="min-w-0 rounded-sm hover:text-accent"
>
@@ -194,10 +195,13 @@ export default function Files() {
const [editFile, setEditFile] = useState<LibraryFile | null>(null);
const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null);
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart });
// The file open in the viewer (one that is not media), by id: the list under it keeps re-reading.
const [viewing, setViewing] = useState<number | null>(null);
const actions = useFileActions({
folders,
onPlay: play,
onView: (f) => setViewing(f.id),
onEdit: setEditFile,
onRename: (f) => setRenamingId(f.id),
onEditDetails: setMetaFile,
@@ -309,8 +313,11 @@ export default function Files() {
}, []);
const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
const playOf = (f: LibraryFile) =>
(f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null;
// What a row's name or a card's picture does: plays it, or opens it in the viewer.
const openOf = (f: LibraryFile) => {
const a = f.shared ? sharedRowActions(f) : libraryRowActions(f);
return a.play ? () => play(f) : a.view ? () => setViewing(f.id) : null;
};
const columns: Column<LibraryFile>[] = [
{
key: "thumb",
@@ -338,7 +345,7 @@ export default function Files() {
key={renamingId === f.id ? "renaming" : "shown"}
file={f}
renaming={renamingId === f.id}
onPlay={playOf(f)}
onOpen={openOf(f)}
onRenamed={(name, byKey) => {
if (byKey) refocusFile.current = f.id;
setRenamingId(null);
@@ -504,7 +511,7 @@ export default function Files() {
key={f.id}
file={f}
index={i}
onPlay={playOf(f)}
onOpen={openOf(f)}
menuProps={rowMenu.triggerProps(f)}
moreProps={rowMenu.buttonProps(f)}
menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
@@ -655,6 +662,18 @@ export default function Files() {
/>
)}
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />}
{viewing !== null && (
<FilePreview
// The viewer pages the list as it reads (its sort), through the files it can open.
files={sortRows(rows, columns, sort, lang).filter(
(f) => (f.shared ? sharedRowActions(f) : libraryRowActions(f)).view,
)}
fileId={viewing}
onFileChange={setViewing}
onClose={() => setViewing(null)}
menuItems={(f) => actions.items(f, true)}
/>
)}
</Suspense>
</div>
{dragging && (