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
+7 -2
View File
@@ -1,8 +1,8 @@
import type { DownloadJob } from "./api";
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and
// job_kind. A generic uploaded file (media_kind='file') is download + share only: no player,
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// job_kind. A generic uploaded file (media_kind='file') opens in the Files viewer instead of a player,
// and has no trim and no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload
// both lack (the backend 409s both). A single source for the gate the Library + Shared rows render,
// so it's unit-testable instead of buried in JSX conditionals — and the two tabs can't drift apart.
@@ -11,6 +11,8 @@ const hasDuration = (job: DownloadJob): boolean => (job.asset?.duration_s ?? 0)
export interface LibraryRowActions {
play: boolean;
/** Opens in the Files viewer: a file that is not media (its preview, or the "no preview" card). */
view: boolean;
edit: boolean;
editDetails: boolean;
redownload: boolean;
@@ -20,6 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
const media = isMediaKind(job);
return {
play: media,
view: !media && job.can_download,
edit: media && job.can_download && hasDuration(job),
editDetails: media,
redownload: !job.job_kind || job.job_kind === "download",
@@ -28,6 +31,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
export interface SharedRowActions {
play: boolean;
view: boolean;
save: boolean;
edit: boolean;
}
@@ -37,6 +41,7 @@ export function sharedRowActions(job: DownloadJob): SharedRowActions {
// The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
return {
play: media && job.can_download,
view: !media && job.can_download,
save: job.can_download,
edit: media && job.can_download && hasDuration(job),
};