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
+19
View File
@@ -37,6 +37,14 @@ export interface LibraryFile extends DownloadJob {
source_key: string;
}
// Set by the server on a text preview cut at its size cap (`routes/files.py` TRUNCATED_HEADER).
const PREVIEW_TRUNCATED_HEADER = "X-Preview-Truncated";
export interface FilePreviewText {
text: string;
truncated: boolean;
}
// What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
export interface FilePatch {
name?: string | null;
@@ -133,4 +141,15 @@ export const filesApi = {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`;
},
// The viewer's copy of an image or a PDF (`/preview`, sandboxed): an <img> / <iframe> source, so it
// carries the account the same way.
previewUrl: (id: number): string => {
const a = getActiveAccount();
return a != null ? `/api/files/${id}/preview?account=${a}` : `/api/files/${id}/preview`;
},
// A text file's head as text, and whether the server cut it there. Quiet: the viewer says why.
previewText: async (id: number): Promise<FilePreviewText> => {
const r = await req<Response>(`/api/files/${id}/preview`, {}, { raw: true, quiet: true });
return { text: await r.text(), truncated: r.headers.get(PREVIEW_TRUNCATED_HEADER) === "1" };
},
};