diff --git a/backend/app/downloads/preview.py b/backend/app/downloads/preview.py new file mode 100644 index 00000000..9a8cb593 --- /dev/null +++ b/backend/app/downloads/preview.py @@ -0,0 +1,107 @@ +"""What a library file previews as — the single home of the preview allow-list. + +A generic file (`media_kind == "file"`; video and audio have their players) previews by its stored +`container`, the upload's cleaned extension, and ONLY through this table: an image is shown as an +image, a PDF in the browser's own viewer, and a text format as plain text. Anything else has no +preview. The served Content-Type comes from here too, never from a guess on the name, and HTML / SVG +sit in the TEXT list on purpose: rendered on this origin, an uploaded page would run with the +reader's session. The route adds `CSP: sandbox` on top, so even a mislabelled file (a "png" that is +really a page) gets no script and no origin. + +The serializer hands the kind to the client (`asset.preview`), so the library and the server can +never disagree on which files open a preview. +""" + +import codecs +from typing import Literal + +from app.downloads import mediakind + +PreviewKind = Literal["image", "pdf", "text"] + +IMAGE_TYPES: dict[str, str] = { + "png": "image/png", + "jpg": "image/jpeg", + "jpeg": "image/jpeg", + "gif": "image/gif", + "webp": "image/webp", + "avif": "image/avif", + "bmp": "image/bmp", +} + +PDF_TYPE = "application/pdf" + +# Read as text: notes, data, subtitles, config and source. HTML and SVG belong here (see above). +TEXT_EXTS: frozenset[str] = frozenset( + { + "txt", "text", "md", "markdown", "log", "csv", "tsv", "json", "jsonl", "ndjson", "xml", + "yaml", "yml", "toml", "ini", "conf", "cfg", "properties", "env", "srt", "vtt", "ass", "ssa", + "sub", "nfo", "cue", "m3u", "m3u8", "html", "htm", "xhtml", "svg", "css", "scss", "less", + "js", "mjs", "cjs", "ts", "tsx", "jsx", "py", "rb", "php", "pl", "lua", "go", "rs", "java", + "kt", "kts", "swift", "c", "h", "cc", "cpp", "hpp", "cs", "sh", "bash", "zsh", "fish", "ps1", + "bat", "cmd", "sql", "r", "dart", "diff", "patch", "tex", "rst", "adoc", "gradle", "dockerfile", + } +) # fmt: skip + +TEXT_TYPE = "text/plain; charset=utf-8" + +# A text preview is the file's head: enough to read, never a whole log loaded into a tab. +TEXT_PREVIEW_BYTES = 1 << 20 +# A NUL byte in the first this-many bytes means the "text" is not text. +BINARY_SNIFF_BYTES = 8192 + + +def kind(media_kind: str | None, container: str | None) -> PreviewKind | None: + """The preview a library file gets, or None (a media file plays instead; others download).""" + if media_kind != mediakind.FILE: + return None + ext = (container or "").lower() + if ext in IMAGE_TYPES: + return "image" + if ext == "pdf": + return "pdf" + if ext in TEXT_EXTS: + return "text" + return None + + +def media_type(preview: PreviewKind, container: str | None) -> str: + """The Content-Type a preview is served with: from this table, never guessed from the name.""" + if preview == "image": + return IMAGE_TYPES[(container or "").lower()] + if preview == "pdf": + return PDF_TYPE + return TEXT_TYPE + + +# A text that is not UTF-8 is read as Central European Windows: what an old Hungarian subtitle or +# note is written in (its ő and ű are not in Latin-1). +FALLBACK_ENCODING = "cp1250" +_BOMS = ( + (codecs.BOM_UTF8, "utf-8-sig"), + (codecs.BOM_UTF16_LE, "utf-16"), + (codecs.BOM_UTF16_BE, "utf-16"), +) + + +def looks_binary(head: bytes) -> bool: + """Whether the start of a "text" file is binary after all (a NUL in it).""" + return b"\x00" in head[:BINARY_SNIFF_BYTES] + + +def text_utf8(head: bytes) -> bytes | None: + """A text file's head as UTF-8, the one charset the preview is served in; None when it is not text. + A byte-order mark names its encoding (UTF-16 is full of NULs, so it is read before the binary + check); else UTF-8 is kept as it is — a character cut at the size cap included, the reader shows + that one as a replacement mark — and anything else is read as `FALLBACK_ENCODING`.""" + for bom, encoding in _BOMS: + if head.startswith(bom): + return head.decode(encoding, errors="replace").encode() + if looks_binary(head): + return None + try: + head.decode("utf-8") + except UnicodeDecodeError as e: # silent-ok: not UTF-8 is an answer — read it as the fallback + if not (e.reason == "unexpected end of data" and e.end == len(head)): + return head.decode(FALLBACK_ENCODING, errors="replace").encode() + return head diff --git a/backend/app/main.py b/backend/app/main.py index 33bbf9ca..0da3d6c2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -268,7 +268,8 @@ async def static_cache_headers(request, call_next): # Baseline security response headers (SEC-1). A minimal, safe set that needs no per-route tuning: # nosniff stops MIME-confusion, the Referrer-Policy keeps the (private) app URL from leaking to # external sites the user follows, `frame-ancestors 'none'` blocks click-jacking by refusing to be -# iframed anywhere (we never embed our own pages; we embed OTHERS, which this does not affect), and +# iframed anywhere (we embed OTHERS, which this does not affect; the one own answer the app frames, a +# library file's preview, sets its own `frame-ancestors 'self'` — these are defaults, `setdefault`), and # Permissions-Policy turns off powerful features the app never uses. `X-Frame-Options: DENY` duplicates # the frame-ancestors intent for legacy engines that never implemented CSP frame-ancestors (belt and # suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index 0e3d8d1e..45ee6e20 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -21,7 +21,7 @@ from typing import BinaryIO, Literal from urllib.parse import unquote, urlparse from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi.responses import FileResponse +from fastapi.responses import FileResponse, Response from pydantic import BaseModel, Field from sqlalchemy import func, or_, select from sqlalchemy.exc import IntegrityError @@ -33,7 +33,7 @@ from app.auth import current_user from app.collation import collated from app.config import settings from app.db import get_db, release_read_connection -from app.downloads import folders, quota, service, storage +from app.downloads import folders, preview, quota, service, storage from app.downloads import upload as uploadmod from app.downloads.status import JobStatus from app.mailtext import lang_for_user @@ -117,6 +117,8 @@ def serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "media_kind": asset.media_kind, "uploader_url": asset.uploader_url, "thumbnail_url": asset.thumbnail_url, + # "image" | "pdf" | "text" | null — what `/preview` shows (`downloads/preview.py`). + "preview": preview.kind(asset.media_kind, asset.container), }, } @@ -784,6 +786,56 @@ def get_file(job_id: int, user: User = Depends(current_user), db: Session = Depe return response +# A preview's own headers. `sandbox` leaves the answer no script and an opaque origin, whatever the +# file turns out to hold; `frame-ancestors 'self'` lets the app's viewer frame a PDF (every other +# answer keeps the app-wide 'none', main.py), with X-Frame-Options saying the same for old engines. +PREVIEW_HEADERS = { + "Content-Security-Policy": "sandbox; frame-ancestors 'self'", + "X-Frame-Options": "SAMEORIGIN", +} +# Set on a text preview cut at `preview.TEXT_PREVIEW_BYTES`, so the viewer can say it shows the start. +TRUNCATED_HEADER = "X-Preview-Truncated" + + +@router.get("/{job_id}/preview") +def preview_file( + job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> Response: + """The file shown in the library's viewer: an image or a PDF inline, a text format as plain text + (its first `TEXT_PREVIEW_BYTES`). The type comes from `downloads/preview.py`'s allow-list, so + an HTML or SVG upload arrives as text, never as a page. Anything else: 415 `file_no_preview`.""" + job = accessible_job(db, user, job_id) + asset, path = _ready_file(db, job) + kind = preview.kind(asset.media_kind, asset.container) + if kind is None: + raise refused(415, "file_no_preview") + headers = dict(PREVIEW_HEADERS) + if kind == "text": + with path.open("rb") as f: + head = f.read(preview.TEXT_PREVIEW_BYTES + 1) + if len(head) > preview.TEXT_PREVIEW_BYTES: + head = head[: preview.TEXT_PREVIEW_BYTES] + headers[TRUNCATED_HEADER] = "1" + text = preview.text_utf8(head) + if text is None: + raise refused(415, "file_no_preview") + asset.last_access_at = datetime.now(timezone.utc) + db.commit() + return Response(text, media_type=preview.media_type(kind, asset.container), headers=headers) + filename = storage.download_filename( + job.display_name or asset.title or asset.source_ref, asset.container, path + ) + asset.last_access_at = datetime.now(timezone.utc) + db.commit() + return FileResponse( + path, + filename=filename, + media_type=preview.media_type(kind, asset.container), + content_disposition_type="inline", + headers=headers, + ) + + @router.get("/{job_id}/sha256") def get_sha256( job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) diff --git a/backend/tests/test_files_preview.py b/backend/tests/test_files_preview.py new file mode 100644 index 00000000..904ede98 --- /dev/null +++ b/backend/tests/test_files_preview.py @@ -0,0 +1,174 @@ +"""A library file's preview (`GET /api/files/{id}/preview`, `downloads/preview.py`), DB-lane. + +What it may show is an allow-list on the stored extension: an image or a PDF inline, a text format +(HTML and SVG among them) as plain text, its head only. Every answer is sandboxed and may be framed +by the app alone; anything else, a binary "text" included, has no preview. +""" + +import pytest +from sqlalchemy import select + +from app.config import settings +from app.downloads import folders, preview, service +from app.downloads.status import JobStatus +from app.models import DownloadJob, DownloadShare, MediaAsset +from app.routes.files import TRUNCATED_HEADER + + +@pytest.fixture +def _root(tmp_path, monkeypatch): + monkeypatch.setattr(settings, "files_root", str(tmp_path)) + return tmp_path + + +def _upload(db, root, user_id, ext, body, *, media_kind="file", title="notes"): + """A done uploaded file named `title.ext` holding `body`.""" + n = len(db.execute(select(MediaAsset.id)).all()) + rel = f"up{n}.{ext}" if ext else f"up{n}" + (root / rel).write_bytes(body) + asset = MediaAsset( + source_kind="upload", + source_ref=f"up{n}", + format_sig=f"sig-up{n}", + status="ready", + rel_path=rel, + size_bytes=len(body), + ref_count=0, + title=title, + media_kind=media_kind, + container=ext, + ) + db.add(asset) + db.flush() + job = DownloadJob( + user_id=user_id, + asset_id=asset.id, + source_kind="upload", + source_ref=f"up{n}", + profile_snapshot={}, + status=JobStatus.DONE, + ) + db.add(job) + db.flush() + folders.place(db, job) + service.incr_ref_count(db, asset.id) + db.commit() + return job + + +def _assert_sandboxed(r): + assert r.headers["content-security-policy"] == "sandbox; frame-ancestors 'self'" + assert r.headers["x-frame-options"] == "SAMEORIGIN" + assert r.headers["x-content-type-options"] == "nosniff" + + +@pytest.mark.parametrize( + "ext,ctype", + [ + ("png", "image/png"), + ("JPG", "image/jpeg"), + ("webp", "image/webp"), + ("pdf", "application/pdf"), + ], +) +def test_an_image_or_a_pdf_is_shown_inline_with_its_listed_type_in_a_sandbox( + db, make_user, as_user, _root, ext, ctype +): + alice = make_user() + job = _upload(db, _root, alice.id, ext, b"bytes of it") + r = as_user(alice).get(f"/api/files/{job.id}/preview") + assert r.status_code == 200 and r.content == b"bytes of it" + assert r.headers["content-type"] == ctype + assert r.headers["content-disposition"].startswith("inline;") + assert f'filename="notes.{ext}"' in r.headers["content-disposition"] + _assert_sandboxed(r) + + +@pytest.mark.parametrize("ext", ["html", "svg", "txt", "py"]) +def test_html_svg_and_code_arrive_as_plain_text_never_as_a_page(db, make_user, as_user, _root, ext): + alice = make_user() + body = b"" + job = _upload(db, _root, alice.id, ext, body) + r = as_user(alice).get(f"/api/files/{job.id}/preview") + assert r.status_code == 200 and r.content == body + assert r.headers["content-type"] == "text/plain; charset=utf-8" + assert TRUNCATED_HEADER.lower() not in r.headers + _assert_sandboxed(r) + + +def test_a_long_text_is_cut_to_its_head_and_says_so(db, make_user, as_user, _root): + alice = make_user() + body = b"a" * preview.TEXT_PREVIEW_BYTES + b"tail" + job = _upload(db, _root, alice.id, "log", body) + r = as_user(alice).get(f"/api/files/{job.id}/preview") + assert r.status_code == 200 and len(r.content) == preview.TEXT_PREVIEW_BYTES + assert r.headers[TRUNCATED_HEADER] == "1" + exact = _upload(db, _root, alice.id, "log", b"a" * preview.TEXT_PREVIEW_BYTES) + whole = as_user(alice).get(f"/api/files/{exact.id}/preview") + assert len(whole.content) == preview.TEXT_PREVIEW_BYTES + assert TRUNCATED_HEADER.lower() not in whole.headers + + +@pytest.mark.parametrize( + "ext,body,media_kind", + [ + ("zip", b"PK\x03\x04", "file"), # not on the list + ("docx", b"PK\x03\x04", "file"), + (None, b"plain", "file"), # no extension + ("txt", b"text\x00with a nul", "file"), # a "text" that is binary + ("mp4", b"video", "video"), # media plays in the player instead + ("png", b"png", "video"), # an extension alone never makes media a preview + ], +) +def test_anything_else_has_no_preview(db, make_user, as_user, _root, ext, body, media_kind): + alice = make_user() + job = _upload(db, _root, alice.id, ext, body, media_kind=media_kind) + r = as_user(alice).get(f"/api/files/{job.id}/preview") + assert r.status_code == 415 + assert r.json()["detail"] == {"code": "refused", "reason": "file_no_preview"} + + +@pytest.mark.parametrize( + "body", + [ + "Árvíztűrő tükörfúrógép".encode("cp1250"), # an old Hungarian subtitle + "Árvíztűrő tükörfúrógép".encode("utf-8-sig"), + "Árvíztűrő tükörfúrógép".encode("utf-16"), # a BOM, then NULs: text all the same + "Árvíztűrő tükörfúrógép".encode("utf-8"), + ], +) +def test_a_text_arrives_as_utf8_whatever_it_was_written_in(db, make_user, as_user, _root, body): + alice = make_user() + job = _upload(db, _root, alice.id, "srt", body) + r = as_user(alice).get(f"/api/files/{job.id}/preview") + assert r.status_code == 200 + assert r.content.decode("utf-8") == "Árvíztűrő tükörfúrógép" + + +def test_a_character_cut_at_the_cap_keeps_the_text_utf8(db, make_user, as_user, _root): + alice = make_user() + body = b"a" * (preview.TEXT_PREVIEW_BYTES - 1) + "ő".encode() # its 2nd byte past the cap + job = _upload(db, _root, alice.id, "txt", body) + r = as_user(alice).get(f"/api/files/{job.id}/preview") + assert r.content == body[: preview.TEXT_PREVIEW_BYTES] # not read as cp1250 + + +def test_the_list_says_which_preview_each_file_gets(db, make_user, as_user, _root): + alice = make_user() + for ext in ["png", "pdf", "html", "zip"]: + _upload(db, _root, alice.id, ext, b"x", title=ext) + _upload(db, _root, alice.id, "mp4", b"x", media_kind="video", title="mp4") + got = { + f["asset"]["container"]: f["asset"]["preview"] + for f in as_user(alice).get("/api/files").json() + } + assert got == {"png": "image", "pdf": "pdf", "html": "text", "zip": None, "mp4": None} + + +def test_a_preview_is_the_holders_or_a_friends_it_was_shared_with(db, make_user, as_user, _root): + alice, friend = make_user(), make_user() + job = _upload(db, _root, alice.id, "png", b"img") + assert as_user(make_user()).get(f"/api/files/{job.id}/preview").status_code == 404 + db.add(DownloadShare(job_id=job.id, from_user_id=alice.id, to_user_id=friend.id)) + db.commit() + assert as_user(friend).get(f"/api/files/{job.id}/preview").status_code == 200 diff --git a/docs/DESIGN.md b/docs/DESIGN.md index c53d7873..37de8148 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -779,3 +779,4 @@ of a full-width panel and nothing on the right. | 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look | | 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing | | 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu | +| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin | diff --git a/frontend/src/components/FileCard.tsx b/frontend/src/components/FileCard.tsx index 230e8688..aaceae90 100644 --- a/frontend/src/components/FileCard.tsx +++ b/frontend/src/components/FileCard.tsx @@ -18,15 +18,16 @@ import { import { MoreButton } from "./ui/MoreButton"; import { Pressable } from "./ui/Pressable"; -// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it, the -// title under it, then where it came from and what needs saying about it (kept, leaving soon). The +// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it (a file +// that is not media: opens it in the viewer), the title under it, then where it came from and what +// needs saying about it (kept, leaving soon). The // right click, the long press and the "…" open the same menu as its table row (DESIGN.md "Files page": // the grid is the list's other paint, a toggle remembered per folder). export function FileCard({ file, index, - onPlay, + onOpen, menuProps, moreProps, menuOpen, @@ -35,8 +36,8 @@ export function FileCard({ file: LibraryFile; /** Its place on the page, for the keyboard walk. */ index: number; - /** Null: nothing to play (a plain file, a shared one its owner keeps from me). */ - onPlay: (() => void) | null; + /** Plays it, or opens it in the viewer. Null: a shared file its owner keeps from me. */ + onOpen: (() => void) | null; menuProps: Record; moreProps: Record; menuOpen: boolean; @@ -47,6 +48,7 @@ export function FileCard({ const title = jobTitle(file); const channel = file.display_uploader || file.asset?.uploader || null; const clip = file.job_kind === "edit" || file.job_kind === "clip"; + const media = file.asset?.media_kind !== "file"; const thumb = ; return ( - {onPlay ? ( + {onOpen ? ( {thumb} -
- -
+ {media && ( +
+ +
+ )}
) : (
{thumb}
diff --git a/frontend/src/components/FilePreview.dom.test.tsx b/frontend/src/components/FilePreview.dom.test.tsx new file mode 100644 index 00000000..7d41591f --- /dev/null +++ b/frontend/src/components/FilePreview.dom.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +// +// The Files viewer: what each kind of file shows (an image, a PDF in a frame, text as text — HTML too, +// never a page — or the "no preview" card), ← / → through the files it was given in their order, +// and closing when the file it shows leaves the list. i18n is key-passthrough. +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { api, HttpError, type LibraryFile } from "../lib/api"; + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: Record) => + o && "n" in o ? `${key}:${String(o.n)}/${String(o.total)}` : key, + }), + }; +}); + +import FilePreview from "./FilePreview"; + +const file = (id: number, preview: string | null, extra: Record = {}) => + ({ + id, + status: "done", + display_name: `File ${id}`, + can_download: true, + job_kind: "upload", + shared: false, + source_key: "upload", + asset: { media_kind: "file", preview, container: "x", size_bytes: 4096, ...extra }, + }) as unknown as LibraryFile; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +function mount(files: LibraryFile[], fileId: number, onClose = vi.fn()) { + const onFileChange = vi.fn(); + const view = (list: LibraryFile[], id: number) => ( + + []} + /> + + ); + const r = render(view(files, fileId)); + return { + onFileChange, + onClose, + rerender: (l: LibraryFile[], id: number) => r.rerender(view(l, id)), + }; +} + +describe("FilePreview · what it shows", () => { + it("an image as an image, from the sandboxed preview", () => { + mount([file(1, "image")], 1); + const img = screen.getByRole("img", { name: "File 1" }); + expect(img.getAttribute("src")).toBe(api.previewUrl(1)); + }); + + it("a PDF in a frame of the preview, the browser's own viewer", () => { + mount([file(2, "pdf")], 2); + const frame = document.querySelector("iframe")!; + expect(frame.getAttribute("src")).toBe(api.previewUrl(2)); + expect(frame.title).toBe("File 2"); + }); + + it("a page's source as text, line by line — never as a page", async () => { + vi.spyOn(api, "previewText").mockResolvedValue({ + text: "

hi

\n", + truncated: false, + }); + mount([file(3, "text")], 3); + await screen.findByText("

hi

"); + expect(screen.getByText("")).toBeTruthy(); + expect(document.getElementById("x")).toBeNull(); + expect(document.querySelector("script")).toBeNull(); + expect(screen.getByText("files.preview.asText")).toBeTruthy(); + }); + + it("numbers a file's lines, the newline that ends it making no line of its own", async () => { + vi.spyOn(api, "previewText").mockResolvedValue({ text: "one\r\ntwo\n", truncated: false }); + mount([file(8, "text")], 8); + await screen.findByText("one"); + expect([...document.querySelectorAll("li")].map((li) => li.textContent)).toEqual([ + "1one", + "2two", + ]); + }); + + it("says when it shows only the start of a long text", async () => { + vi.spyOn(api, "previewText").mockResolvedValue({ text: "a", truncated: true }); + mount([file(4, "text")], 4); + expect(await screen.findByText("files.preview.truncated")).toBeTruthy(); + }); + + it("a text the server refuses (binary after all) gets the no-preview card, not an error", async () => { + vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(415, "x", {})); + mount([file(5, "text")], 5); + expect(await screen.findByText("files.preview.none")).toBeTruthy(); + expect(screen.queryByRole("alert")).toBeNull(); + // A new tab would only show the refusal. + expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull(); + }); + + it("a text that fails to load says so, with a retry", async () => { + vi.spyOn(api, "previewText").mockRejectedValue(new HttpError(500, "x", {})); + mount([file(6, "text")], 6); + expect(await screen.findByRole("alert", {}, { timeout: 3000 })).toBeTruthy(); + expect(screen.getByRole("button", { name: "common.retry" })).toBeTruthy(); + }); + + it("anything else: the card that offers the file itself", () => { + mount([file(7, null)], 7); + expect(screen.getByText("files.preview.none")).toBeTruthy(); + const save = screen.getAllByRole("link", { name: "downloads.actions.download" }); + expect(save.every((a) => a.getAttribute("href") === api.fileUrl(7))).toBe(true); + // Nothing to open in a tab: the preview would refuse it. + expect(screen.queryByRole("link", { name: "files.preview.openInTab" })).toBeNull(); + }); +}); + +describe("FilePreview · stepping", () => { + const list = [file(1, "image"), file(2, "pdf"), file(3, null)]; + + it("← / → go to the files beside it, in the order given", () => { + const { onFileChange } = mount(list, 2); + expect(screen.getByText("files.preview.position:2/3")).toBeTruthy(); + fireEvent.keyDown(window, { key: "ArrowRight" }); + expect(onFileChange).toHaveBeenLastCalledWith(3); + fireEvent.keyDown(window, { key: "ArrowLeft" }); + expect(onFileChange).toHaveBeenLastCalledWith(1); + }); + + it("stops at either end", () => { + const { onFileChange } = mount(list, 1); + fireEvent.keyDown(window, { key: "ArrowLeft" }); + expect(onFileChange).not.toHaveBeenCalled(); + expect( + screen.getByRole("button", { name: "files.preview.prev" }).hasAttribute("disabled"), + ).toBe(true); + }); + + it("closes when the file it shows leaves the list", async () => { + const { onClose, rerender } = mount(list, 2); + expect(onClose).not.toHaveBeenCalled(); + act(() => rerender([list[0]!, list[2]!], 2)); + await waitFor(() => expect(onClose).toHaveBeenCalled()); + }); +}); diff --git a/frontend/src/components/FilePreview.tsx b/frontend/src/components/FilePreview.tsx new file mode 100644 index 00000000..6c37e261 --- /dev/null +++ b/frontend/src/components/FilePreview.tsx @@ -0,0 +1,287 @@ +import { useEffect, useEffectEvent, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { + ChevronLeft, + ChevronRight, + Download, + ExternalLink, + FileQuestion, + WrapText, +} from "lucide-react"; +import { api, HttpError, type FilePreviewKind, type LibraryFile } from "../lib/api"; +import { formatBytes } from "../lib/format"; +import { dispatch, useKeyScope } from "../lib/keymap"; +import { qk } from "../lib/queryKeys"; +import { jobTitle } from "./downloadTitle"; +import { type ActionMenuItem } from "./ui/ActionMenu"; +import { ContextMenu } from "./ui/ContextMenu"; +import { IconButton } from "./ui/IconButton"; +import { IconLink, LinkButton } from "./ui/LinkButton"; +import Modal from "./ui/Modal"; +import { MoreButton } from "./ui/MoreButton"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import { useContextMenu } from "./ui/useContextMenu"; + +// The Files viewer (DESIGN.md "Files preview"): the library's files that are not media, one at a time, +// in the shared dialog shell. What a file shows is the server's call (`asset.preview`, +// backend downloads/preview.py): an image as an image, a PDF in the browser's own viewer, a text +// format as plain text — HTML and SVG too, never rendered: on this origin a page would run with the +// reader's session. The rest gets a card that offers the file itself. ← / → step through `files`, the +// list's own order, so the viewer pages the way the list reads. +// +// Every kind fills the same stage, so a step never resizes the dialog: its ‹ › stay under the pointer. +const STAGE = "h-[min(75dvh,56rem)] w-full"; + +/** A text file's head (one read, shared by the viewer's header and its stage). */ +function useTextPreview(id: number, enabled: boolean) { + return useQuery({ + queryKey: qk.filePreview(id), + queryFn: () => api.previewText(id), + enabled, + // A refusal (the "text" turned out binary) is an answer, not a blip to retry. + retry: (n, e) => !(e instanceof HttpError && e.status < 500) && n < 1, + }); +} +/** The server's "no preview" for a file it listed as text: it was binary after all. */ +const refused = (e: unknown) => e instanceof HttpError && e.status === 415; + +export default function FilePreview({ + files, + fileId, + onFileChange, + onClose, + menuItems, +}: { + /** The files the viewer steps through, in the list's order. */ + files: readonly LibraryFile[]; + fileId: number; + onFileChange: (id: number) => void; + onClose: () => void; + /** The file's row menu, behind the viewer's "…". */ + menuItems: (file: LibraryFile) => ActionMenuItem[]; +}) { + const { t } = useTranslation(); + const at = files.findIndex((f) => f.id === fileId); + const file = at >= 0 ? files[at] : undefined; + // Gone from the list under the viewer (deleted from its menu, or by another tab): nothing to show. + useEffect(() => { + if (!file) onClose(); + }, [file, onClose]); + + const step = (by: number) => { + const next = files[at + by]; + if (next) onFileChange(next.id); + }; + useKeyScope("fileViewer"); + const onKey = useEffectEvent((e: KeyboardEvent) => { + dispatch(e, "fileViewer", { + prevFile: (ev) => { + ev.preventDefault(); + step(-1); + }, + nextFile: (ev) => { + ev.preventDefault(); + step(1); + }, + }); + }); + useEffect(() => { + const handler = (e: KeyboardEvent) => onKey(e); + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, []); + + const menu = useContextMenu({ keyOf: (f) => String(f.id) }); + const text = useTextPreview(fileId, file?.asset?.preview === "text"); + if (!file) return null; + const kind = file.asset?.preview ?? null; + // A new tab would only show the refusal. + const openable = kind !== null && !refused(text.error); + const title = jobTitle(file); + + return ( + {title}} + onClose={onClose} + maxWidth="max-w-6xl" + headerActions={ + <> + step(-1)} + > + + + + {t("files.preview.position", { n: at + 1, total: files.length })} + + = files.length - 1} + onClick={() => step(1)} + > + + + {openable ? ( + + + + ) : ( + // Its place kept, so the icons after it do not shift from one file to the next. + + )} + + + + + + + } + > + {/* Keyed by file: a step starts the next one's own load, never a frame of the last one. */} + + + ); +} + +function Stage({ + file, + kind, + title, +}: { + file: LibraryFile; + kind: FilePreviewKind | null; + title: string; +}) { + const { t } = useTranslation(); + const [failed, setFailed] = useState(false); + if (kind === "image") + return failed ? ( + + {t("common.loadError")} + + ) : ( +
+ {title} setFailed(true)} + className="max-h-full max-w-full rounded-md object-contain" + /> +
+ ); + if (kind === "pdf") + return ( +