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
+107
View File
@@ -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
+2 -1
View File
@@ -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: # 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 # 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 # 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 # 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 # 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 # suspenders — modern browsers use the CSP directive and ignore the header). A full Content-Security-Policy
+54 -2
View File
@@ -21,7 +21,7 @@ from typing import BinaryIO, Literal
from urllib.parse import unquote, urlparse from urllib.parse import unquote, urlparse
from fastapi import APIRouter, Depends, HTTPException, Query, Request 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 pydantic import BaseModel, Field
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
@@ -33,7 +33,7 @@ from app.auth import current_user
from app.collation import collated from app.collation import collated
from app.config import settings from app.config import settings
from app.db import get_db, release_read_connection 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 import upload as uploadmod
from app.downloads.status import JobStatus from app.downloads.status import JobStatus
from app.mailtext import lang_for_user from app.mailtext import lang_for_user
@@ -117,6 +117,8 @@ def serialize(job: DownloadJob, asset: MediaAsset | None) -> dict:
"media_kind": asset.media_kind, "media_kind": asset.media_kind,
"uploader_url": asset.uploader_url, "uploader_url": asset.uploader_url,
"thumbnail_url": asset.thumbnail_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 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") @router.get("/{job_id}/sha256")
def get_sha256( def get_sha256(
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
+174
View File
@@ -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"<svg onload=x><script>y</script></svg>"
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
+1
View File
@@ -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 | 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 | 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-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 |
+12 -8
View File
@@ -18,15 +18,16 @@ import {
import { MoreButton } from "./ui/MoreButton"; import { MoreButton } from "./ui/MoreButton";
import { Pressable } from "./ui/Pressable"; import { Pressable } from "./ui/Pressable";
// A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it, the // A library file in the Files grid: the one media card (ui/MediaCard) — its picture plays it (a file
// title under it, then where it came from and what needs saying about it (kept, leaving soon). The // 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": // 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). // the grid is the list's other paint, a toggle remembered per folder).
export function FileCard({ export function FileCard({
file, file,
index, index,
onPlay, onOpen,
menuProps, menuProps,
moreProps, moreProps,
menuOpen, menuOpen,
@@ -35,8 +36,8 @@ export function FileCard({
file: LibraryFile; file: LibraryFile;
/** Its place on the page, for the keyboard walk. */ /** Its place on the page, for the keyboard walk. */
index: number; index: number;
/** Null: nothing to play (a plain file, a shared one its owner keeps from me). */ /** Plays it, or opens it in the viewer. Null: a shared file its owner keeps from me. */
onPlay: (() => void) | null; onOpen: (() => void) | null;
menuProps: Record<string, unknown>; menuProps: Record<string, unknown>;
moreProps: Record<string, unknown>; moreProps: Record<string, unknown>;
menuOpen: boolean; menuOpen: boolean;
@@ -47,6 +48,7 @@ export function FileCard({
const title = jobTitle(file); const title = jobTitle(file);
const channel = file.display_uploader || file.asset?.uploader || null; const channel = file.display_uploader || file.asset?.uploader || null;
const clip = file.job_kind === "edit" || file.job_kind === "clip"; const clip = file.job_kind === "edit" || file.job_kind === "clip";
const media = file.asset?.media_kind !== "file";
const thumb = <JobThumb job={file} box="h-full w-full" />; const thumb = <JobThumb job={file} box="h-full w-full" />;
return ( return (
<MediaCard <MediaCard
@@ -55,17 +57,19 @@ export function FileCard({
className={clsx(arrived && "ring-2 ring-accent/60")} className={clsx(arrived && "ring-2 ring-accent/60")}
picture={ picture={
<div className={mediaPictureBox("wide")}> <div className={mediaPictureBox("wide")}>
{onPlay ? ( {onOpen ? (
<Pressable <Pressable
onClick={onPlay} onClick={onOpen}
aria-label={t("downloads.actions.play")} aria-label={t(media ? "downloads.actions.play" : "files.actions.view")}
data-nav-open="" data-nav-open=""
className={MEDIA_PICTURE} className={MEDIA_PICTURE}
> >
{thumb} {thumb}
{media && (
<div className={MEDIA_HOVER_LAYER}> <div className={MEDIA_HOVER_LAYER}>
<MediaPlayGlyph small /> <MediaPlayGlyph small />
</div> </div>
)}
</Pressable> </Pressable>
) : ( ) : (
<div className={clsx(MEDIA_PICTURE, "cursor-default")}>{thumb}</div> <div className={clsx(MEDIA_PICTURE, "cursor-default")}>{thumb}</div>
@@ -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<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({
i18n: { language: "en" },
t: (key: string, o?: Record<string, unknown>) =>
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<string, unknown> = {}) =>
({
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) => (
<QueryClientProvider client={new QueryClient()}>
<FilePreview
files={list}
fileId={id}
onFileChange={onFileChange}
onClose={onClose}
menuItems={() => []}
/>
</QueryClientProvider>
);
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: "<h1 id=x>hi</h1>\n<script>boom()</script>",
truncated: false,
});
mount([file(3, "text")], 3);
await screen.findByText("<h1 id=x>hi</h1>");
expect(screen.getByText("<script>boom()</script>")).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());
});
});
+287
View File
@@ -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<LibraryFile>({ 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 (
<Modal
initialFocus="close"
title={<span className="block truncate">{title}</span>}
onClose={onClose}
maxWidth="max-w-6xl"
headerActions={
<>
<IconButton
variant="ghost"
label={t("files.preview.prev")}
disabled={at <= 0}
onClick={() => step(-1)}
>
<ChevronLeft />
</IconButton>
<span className="text-xs tabular-nums text-muted">
{t("files.preview.position", { n: at + 1, total: files.length })}
</span>
<IconButton
variant="ghost"
label={t("files.preview.next")}
disabled={at >= files.length - 1}
onClick={() => step(1)}
>
<ChevronRight />
</IconButton>
{openable ? (
<IconLink
href={api.previewUrl(file.id)}
target="_blank"
rel="noopener"
label={t("files.preview.openInTab")}
>
<ExternalLink />
</IconLink>
) : (
// Its place kept, so the icons after it do not shift from one file to the next.
<span className="size-7 shrink-0" aria-hidden />
)}
<IconLink href={api.fileUrl(file.id)} label={t("downloads.actions.download")}>
<Download />
</IconLink>
<MoreButton
label={t("files.rowMenu")}
expanded={menu.open && menu.target?.id === file.id}
menuProps={menu.buttonProps(file)}
/>
<ContextMenu
menu={menu}
ariaLabel={t("files.rowMenu")}
items={menu.target ? menuItems(menu.target) : []}
/>
</>
}
>
{/* Keyed by file: a step starts the next one's own load, never a frame of the last one. */}
<Stage key={file.id} file={file} kind={kind} title={title} />
</Modal>
);
}
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 ? (
<StateMessage tone="error" className={`${STAGE} justify-center`}>
{t("common.loadError")}
</StateMessage>
) : (
<div className={`${STAGE} flex items-center justify-center`}>
<img
src={api.previewUrl(file.id)}
alt={title}
onError={() => setFailed(true)}
className="max-h-full max-w-full rounded-md object-contain"
/>
</div>
);
if (kind === "pdf")
return (
<iframe
src={api.previewUrl(file.id)}
title={title}
className={`${STAGE} rounded-md border border-border bg-white`}
/>
);
if (kind === "text") return <TextStage file={file} />;
return <NoPreview file={file} />;
}
function TextStage({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
const [wrap, setWrap] = useState(true);
const q = useTextPreview(file.id, true);
if (q.isError)
return refused(q.error) ? (
<NoPreview file={file} />
) : (
<StateMessage
tone="error"
onRetry={() => void q.refetch()}
className={`${STAGE} justify-center`}
>
{t("common.loadError")}
</StateMessage>
);
if (!q.data) return <LoadingState className={`${STAGE} justify-center`} />;
// A file's last line ends in a newline; that is no line of its own.
const lines = q.data.text.replace(/\r?\n$/, "").split(/\r?\n/);
return (
<div className={`${STAGE} flex flex-col gap-2`}>
<div className="flex items-center gap-2 text-xs text-muted">
<span className="min-w-0 flex-1 truncate">
{q.data.truncated
? t("files.preview.truncated", {
size: formatBytes(file.asset?.size_bytes, i18n.language),
})
: t("files.preview.asText")}
</span>
<IconButton
variant="ghost"
label={t("files.preview.wrap")}
pressed={wrap}
onClick={() => setWrap(!wrap)}
>
<WrapText />
</IconButton>
</div>
<div
// Scrolls by keyboard too: a focusable region with a name.
tabIndex={0}
role="region"
aria-label={jobTitle(file)}
className="min-h-0 flex-1 overflow-auto rounded-md border border-border bg-bg font-mono text-xs leading-relaxed"
>
<ol className="m-0 list-none p-0">
{lines.map((line, i) => (
<li key={i} className="flex">
<span
aria-hidden
className="sticky left-0 w-12 shrink-0 select-none bg-bg pr-3 text-right text-muted"
>
{i + 1}
</span>
<span
className={wrap ? "min-w-0 whitespace-pre-wrap wrap-anywhere" : "whitespace-pre"}
>
{line || " "}
</span>
</li>
))}
</ol>
</div>
</div>
);
}
function NoPreview({ file }: { file: LibraryFile }) {
const { t, i18n } = useTranslation();
const size = file.asset?.size_bytes;
return (
<div className={`${STAGE} flex flex-col items-center justify-center gap-3 text-sm text-muted`}>
<FileQuestion className="size-10" aria-hidden />
<span className="text-center">
{jobTitle(file)}
{file.asset?.container ? ` · ${file.asset.container.toUpperCase()}` : ""}
{size ? ` · ${formatBytes(size, i18n.language)}` : ""}
</span>
<span>{t("files.preview.none")}</span>
<LinkButton href={api.fileUrl(file.id)} icon={<Download className="size-4" />}>
{t("downloads.actions.download")}
</LinkButton>
</div>
);
}
@@ -402,6 +402,62 @@ describe("Files · a shared file's row", () => {
}); });
}); });
describe("Files · the viewer", () => {
const doc = (id: number, preview: string | null = "pdf") =>
file(id, {
job_kind: "upload",
display_name: `Doc ${id}`,
asset: { media_kind: "file", preview, container: "pdf", size_bytes: 10 },
} as never);
const viewer = () => screen.findByRole("dialog");
it("opens a file that is not media from its name, in the sandboxed preview", async () => {
mocks.files = [file(1), doc(2)];
mocks.folders = [YT];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(2));
});
it("puts Preview first in such a file's menu, and it opens the viewer", async () => {
mocks.files = [doc(2, null)];
mocks.folders = [YT];
mount();
await openMenu(() => fireEvent.contextMenu(rowOf("Doc 2")));
expect(menuRows()[0]).toBe("files.actions.view");
fireEvent.click(menuItem("files.actions.view"));
expect(await viewer()).toBeTruthy();
expect(screen.getByText("files.preview.none")).toBeTruthy();
});
it("steps through the list's files that are not media, in the list's order", async () => {
mocks.files = [doc(2), file(1), doc(3)];
mocks.folders = [YT];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
fireEvent.keyDown(window, { key: "ArrowRight" });
await waitFor(() =>
expect(dialog.querySelector("iframe")!.getAttribute("src")).toBe(api.previewUrl(3)),
);
});
it("leaves Preview and Rename out of the viewer's own menu", async () => {
mocks.files = [doc(2)];
mocks.folders = [YT, OWN];
mount();
fireEvent.click(screen.getByText("Doc 2"));
const dialog = await viewer();
await openMenu(() =>
fireEvent.click(dialog.querySelector<HTMLElement>('button[aria-label="files.rowMenu"]')!),
);
expect(menuRows()).not.toContain("files.actions.view");
expect(menuRows()).not.toContain("files.actions.rename");
expect(menuRows()).toContain("files.actions.move");
});
});
describe("Files · the view", () => { describe("Files · the view", () => {
it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => { it("starts the list at the scroller's top, so its sticky header does not jump on the first scroll", () => {
mocks.files = [file(2)]; mocks.files = [file(2)];
+27 -8
View File
@@ -44,6 +44,7 @@ import { useContextMenu } from "./ui/useContextMenu";
const UploadDialog = lazy(() => import("./UploadDialog")); const UploadDialog = lazy(() => import("./UploadDialog"));
const ShareDialog = lazy(() => import("./ShareDialog")); const ShareDialog = lazy(() => import("./ShareDialog"));
const LibraryPlayer = lazy(() => import("./LibraryPlayer")); const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
const FilePreview = lazy(() => import("./FilePreview"));
const VideoEditor = lazy(() => import("./VideoEditor")); const VideoEditor = lazy(() => import("./VideoEditor"));
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail // 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({ function NameCell({
file, file,
renaming, renaming,
onPlay, onOpen,
onRenamed, onRenamed,
}: { }: {
file: LibraryFile; file: LibraryFile;
renaming: boolean; renaming: boolean;
onPlay: (() => void) | null; onOpen: (() => void) | null;
onRenamed: (name: string | null, byKey: boolean) => void; onRenamed: (name: string | null, byKey: boolean) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -128,11 +129,11 @@ function NameCell({
const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>; const title = <span className="block truncate text-sm font-medium">{jobTitle(file)}</span>;
return ( return (
<div className="flex min-w-0 flex-col"> <div className="flex min-w-0 flex-col">
{onPlay ? ( {onOpen ? (
<Pressable <Pressable
selectable selectable
data-nav-open="" data-nav-open=""
onClick={onPlay} onClick={onOpen}
title={jobTitle(file)} title={jobTitle(file)}
className="min-w-0 rounded-sm hover:text-accent" 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 [editFile, setEditFile] = useState<LibraryFile | null>(null);
const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null); const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null);
const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart }); 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({ const actions = useFileActions({
folders, folders,
onPlay: play, onPlay: play,
onView: (f) => setViewing(f.id),
onEdit: setEditFile, onEdit: setEditFile,
onRename: (f) => setRenamingId(f.id), onRename: (f) => setRenamingId(f.id),
onEditDetails: setMetaFile, onEditDetails: setMetaFile,
@@ -309,8 +313,11 @@ export default function Files() {
}, []); }, []);
const iconSlot = <span className="size-9 shrink-0" aria-hidden />; const iconSlot = <span className="size-9 shrink-0" aria-hidden />;
const playOf = (f: LibraryFile) => // What a row's name or a card's picture does: plays it, or opens it in the viewer.
(f.shared ? sharedRowActions(f) : libraryRowActions(f)).play ? () => play(f) : null; 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>[] = [ const columns: Column<LibraryFile>[] = [
{ {
key: "thumb", key: "thumb",
@@ -338,7 +345,7 @@ export default function Files() {
key={renamingId === f.id ? "renaming" : "shown"} key={renamingId === f.id ? "renaming" : "shown"}
file={f} file={f}
renaming={renamingId === f.id} renaming={renamingId === f.id}
onPlay={playOf(f)} onOpen={openOf(f)}
onRenamed={(name, byKey) => { onRenamed={(name, byKey) => {
if (byKey) refocusFile.current = f.id; if (byKey) refocusFile.current = f.id;
setRenamingId(null); setRenamingId(null);
@@ -504,7 +511,7 @@ export default function Files() {
key={f.id} key={f.id}
file={f} file={f}
index={i} index={i}
onPlay={playOf(f)} onOpen={openOf(f)}
menuProps={rowMenu.triggerProps(f)} menuProps={rowMenu.triggerProps(f)}
moreProps={rowMenu.buttonProps(f)} moreProps={rowMenu.buttonProps(f)}
menuOpen={rowMenu.open && rowMenu.target?.id === f.id} menuOpen={rowMenu.open && rowMenu.target?.id === f.id}
@@ -655,6 +662,18 @@ export default function Files() {
/> />
)} )}
{editFile && <VideoEditor job={editFile} onClose={() => setEditFile(null)} />} {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> </Suspense>
</div> </div>
{dragging && ( {dragging && (
@@ -105,3 +105,33 @@ describe("JobThumb · no picture", () => {
expect(container.querySelector("svg.lucide-film")).not.toBeNull(); expect(container.querySelector("svg.lucide-film")).not.toBeNull();
}); });
}); });
// A file that is not media: an image is its own picture (its sandboxed preview), unless it is too big
// for a thumbnail or fails; anything else draws the file glyph.
describe("JobThumb · a file", () => {
const image = (over: Partial<NonNullable<DownloadJob["asset"]>> = {}) =>
withAsset("png", { media_kind: "file", preview: "image", size_bytes: 200_000, ...over });
it("shows an image through its preview", () => {
const { container } = render(<JobThumb job={image()} />);
expect(new URL(container.querySelector("img")!.src).pathname).toBe("/api/files/1/preview");
});
it("keeps the glyph for an image too big to be a thumbnail, and for other files", () => {
const { container, unmount } = render(<JobThumb job={image({ size_bytes: 50_000_000 })} />);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
unmount();
const { container: pdf } = render(
<JobThumb job={withAsset("pdf", { media_kind: "file", preview: "pdf" })} />,
);
expect(pdf.querySelector("img")).toBeNull();
});
it("falls back to the glyph when the image fails", () => {
const { container } = render(<JobThumb job={image()} />);
fireEvent.error(container.querySelector("img")!);
expect(container.querySelector("img")).toBeNull();
expect(container.querySelector("svg.lucide-file")).not.toBeNull();
});
});
+28 -1
View File
@@ -58,6 +58,18 @@ const THUMB_BOX =
/** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes /** A job's picture: its poster, else the source's thumbnail, else a glyph of what it is. `box` sizes
* it (the Files list draws a smaller one). */ * it (the Files list draws a smaller one). */
// An image larger than this keeps the file glyph: a row's thumbnail is the whole file, and a page of
// camera originals would pull hundreds of megabytes for pictures a few rem wide.
const IMAGE_THUMB_MAX_BYTES = 8 * 1024 * 1024;
/** An image file's own picture for its thumbnail, or null (not an image, not readable, too big). */
function imageThumbOf(job: DownloadJob): string | null {
const a = job.asset;
if (a?.preview !== "image" || !job.can_download) return null;
if ((a.size_bytes ?? Infinity) > IMAGE_THUMB_MAX_BYTES) return null;
return api.previewUrl(job.id);
}
export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) { export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: string }) {
// Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so // Prefer OUR self-hosted poster: it's always present on disk, never expires, and is same-origin, so
// a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields // a source-CDN signature expiry (Facebook's fbcdn URLs expire within days) or an ad-blocker/Shields
@@ -83,14 +95,29 @@ export function JobThumb({ job, box = THUMB_BOX }: { job: DownloadJob; box?: str
// way, so the row falls to the glyph below instead of the browser's broken-image mark. // way, so the row falls to the glyph below instead of the browser's broken-image mark.
const [failedRemote, setFailedRemote] = useState<string | null>(null); const [failedRemote, setFailedRemote] = useState<string | null>(null);
if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null); if (failedRemote !== null && failedRemote !== remote) setFailedRemote(null);
// An image file's own picture failed (gone since, or not an image after all): the glyph instead.
const [failedPicture, setFailedPicture] = useState<string | null>(null);
const candidate = failedPrimary && failedPrimary === primary ? remote : primary; const candidate = failedPrimary && failedPrimary === primary ? remote : primary;
const src = candidate && candidate === failedRemote ? null : candidate; const src = candidate && candidate === failedRemote ? null : candidate;
// A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/ // A generic uploaded file (media_kind='file') has no video frame to poster — it's a document/
// archive, not playable media. Show a neutral file glyph instead of an (always-empty) thumbnail. // archive, not playable media. An image is its own picture (its sandboxed preview); the rest show
// a neutral file glyph instead of an (always-empty) thumbnail.
if (job.asset?.media_kind === "file") { if (job.asset?.media_kind === "file") {
const picture = imageThumbOf(job);
return ( return (
<div className={`${box} flex items-center justify-center text-muted`}> <div className={`${box} flex items-center justify-center text-muted`}>
{picture && picture !== failedPicture ? (
<img
src={picture}
alt=""
loading="lazy"
decoding="async"
className="h-full w-full object-cover"
onError={() => setFailedPicture(picture)}
/>
) : (
<File className="w-8 h-8" aria-hidden="true" /> <File className="w-8 h-8" aria-hidden="true" />
)}
</div> </div>
); );
} }
+27 -6
View File
@@ -3,6 +3,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
Download, Download,
Eye,
FolderInput, FolderInput,
Info, Info,
Link, Link,
@@ -54,6 +55,7 @@ const patchFail = (p: Patch) =>
export function useFileActions({ export function useFileActions({
folders, folders,
onPlay, onPlay,
onView,
onEdit, onEdit,
onRename, onRename,
onEditDetails, onEditDetails,
@@ -62,6 +64,8 @@ export function useFileActions({
/** The caller's folders — the Move submenu's places. */ /** The caller's folders — the Move submenu's places. */
folders: readonly FileFolder[]; folders: readonly FileFolder[];
onPlay: (file: LibraryFile, fromStart?: boolean) => void; onPlay: (file: LibraryFile, fromStart?: boolean) => void;
/** Open a file that is not media in the Files viewer. */
onView: (file: LibraryFile) => void;
onEdit: (file: LibraryFile) => void; onEdit: (file: LibraryFile) => void;
onRename: (file: LibraryFile) => void; onRename: (file: LibraryFile) => void;
onEditDetails: (file: LibraryFile) => void; onEditDetails: (file: LibraryFile) => void;
@@ -126,6 +130,12 @@ export function useFileActions({
: []), : []),
]; ];
}; };
const view = (file: LibraryFile): ActionMenuItem => ({
id: "view",
label: t("files.actions.view"),
icon: icon(Eye),
onSelect: () => onView(file),
});
const save = (file: LibraryFile): ActionMenuItem => ({ const save = (file: LibraryFile): ActionMenuItem => ({
id: "save", id: "save",
label: t("downloads.actions.download"), label: t("downloads.actions.download"),
@@ -184,12 +194,13 @@ export function useFileActions({
return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items }; return { id: "move", label: t("files.actions.move"), icon: icon(FolderInput), items };
}; };
function mineItems(file: LibraryFile): ActionMenuItem[] { function mineItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
const a = libraryRowActions(file); const a = libraryRowActions(file);
return menuGroups( return menuGroups(
// The row's icons first, in the row's order. // The row's icons first, in the row's order.
[ [
...(a.play ? play(file) : []), ...(a.play ? play(file) : []),
...(a.view && !inViewer ? [view(file)] : []),
...(a.play ? [copyLink(file)] : []), ...(a.play ? [copyLink(file)] : []),
{ {
id: "share", id: "share",
@@ -201,12 +212,17 @@ export function useFileActions({
], ],
[ [
...(a.edit ? [edit(file)] : []), ...(a.edit ? [edit(file)] : []),
// Renamed in place, in its row: not from the viewer that covers the row.
...(inViewer
? []
: [
{ {
id: "rename", id: "rename",
label: t("files.actions.rename"), label: t("files.actions.rename"),
icon: icon(Pencil), icon: icon(Pencil),
onSelect: () => onRename(file), onSelect: () => onRename(file),
}, },
]),
moveTo(file), moveTo(file),
{ {
id: "keep", id: "keep",
@@ -261,10 +277,14 @@ export function useFileActions({
); );
} }
function sharedItems(file: LibraryFile): ActionMenuItem[] { function sharedItems(file: LibraryFile, inViewer: boolean): ActionMenuItem[] {
const a = sharedRowActions(file); const a = sharedRowActions(file);
return menuGroups( return menuGroups(
[...(a.play ? play(file) : []), ...(a.save ? [save(file)] : [])], [
...(a.play ? play(file) : []),
...(a.view && !inViewer ? [view(file)] : []),
...(a.save ? [save(file)] : []),
],
a.edit ? [edit(file)] : [], a.edit ? [edit(file)] : [],
copies(file), copies(file),
[ [
@@ -285,9 +305,10 @@ export function useFileActions({
); );
} }
/** The file's menu rows. */ /** The file's menu rows; `inViewer`: the viewer's "…", which leaves out what the viewer is or
const items = (file: LibraryFile): ActionMenuItem[] => * what needs the row it covers. */
file.shared ? sharedItems(file) : mineItems(file); const items = (file: LibraryFile, inViewer = false): ActionMenuItem[] =>
file.shared ? sharedItems(file, inViewer) : mineItems(file, inViewer);
/** The row's Copy-link icon: the menu's row, one click. */ /** The row's Copy-link icon: the menu's row, one click. */
const share = (file: LibraryFile) => void quickShare(file); const share = (file: LibraryFile) => void quickShare(file);
+2 -1
View File
@@ -237,6 +237,7 @@
"file_folder_name_taken": "You already have a folder with that name", "file_folder_name_taken": "You already have a folder with that name",
"file_folder_not_own": "Only a folder you made can be renamed or deleted", "file_folder_not_own": "Only a folder you made can be renamed or deleted",
"file_folder_gone": "That folder no longer exists", "file_folder_gone": "That folder no longer exists",
"file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from" "file_folder_not_its_source": "A file can only go into one of your own folders or back to the folder it came from",
"file_no_preview": "There is no preview for this file"
} }
} }
+12 -1
View File
@@ -55,7 +55,8 @@
"keep": "Keep", "keep": "Keep",
"unkeep": "Stop keeping", "unkeep": "Stop keeping",
"details": "Details…", "details": "Details…",
"upload": "Upload" "upload": "Upload",
"view": "Preview"
}, },
"folder": { "folder": {
"menu": "Folder actions", "menu": "Folder actions",
@@ -91,5 +92,15 @@
"label": "A link to download", "label": "A link to download",
"placeholder": "Paste a link and I'll download it (YouTube, TikTok, Facebook…)", "placeholder": "Paste a link and I'll download it (YouTube, TikTok, Facebook…)",
"download": "Download" "download": "Download"
},
"preview": {
"prev": "Previous file",
"next": "Next file",
"position": "{{n}} / {{total}}",
"openInTab": "Open in a new tab",
"none": "There is no preview for this file",
"asText": "Shown as plain text",
"truncated": "Showing the start of this {{size}} file",
"wrap": "Wrap long lines"
} }
} }
+1
View File
@@ -10,6 +10,7 @@
"fineSeek": "Fine seek", "fineSeek": "Fine seek",
"episode": "Previous / next episode", "episode": "Previous / next episode",
"video": "Previous / next video", "video": "Previous / next video",
"file": "Previous / next file",
"volume": "Volume up / down", "volume": "Volume up / down",
"mute": "Mute", "mute": "Mute",
"audio": "Cycle audio track", "audio": "Cycle audio track",
+2 -1
View File
@@ -237,6 +237,7 @@
"file_folder_name_taken": "Már van ilyen nevű mappád", "file_folder_name_taken": "Már van ilyen nevű mappád",
"file_folder_not_own": "Csak a saját mappádat nevezheted át vagy törölheted", "file_folder_not_own": "Csak a saját mappádat nevezheted át vagy törölheted",
"file_folder_gone": "Ez a mappa már nem létezik", "file_folder_gone": "Ez a mappa már nem létezik",
"file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet" "file_folder_not_its_source": "Egy fájl csak a saját mappáidba vagy vissza az eredeti mappájába kerülhet",
"file_no_preview": "Ehhez a fájlhoz nincs előnézet"
} }
} }
+12 -1
View File
@@ -55,7 +55,8 @@
"keep": "Megtartom", "keep": "Megtartom",
"unkeep": "Megtartás visszavonása", "unkeep": "Megtartás visszavonása",
"details": "Részletek…", "details": "Részletek…",
"upload": "Feltöltés" "upload": "Feltöltés",
"view": "Előnézet"
}, },
"folder": { "folder": {
"menu": "Mappa műveletei", "menu": "Mappa műveletei",
@@ -91,5 +92,15 @@
"label": "Letöltendő link", "label": "Letöltendő link",
"placeholder": "Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)", "placeholder": "Illessz be egy linket, és letöltöm (YouTube, TikTok, Facebook…)",
"download": "Letöltés" "download": "Letöltés"
},
"preview": {
"prev": "Előző fájl",
"next": "Következő fájl",
"position": "{{n}} / {{total}}",
"openInTab": "Megnyitás új lapon",
"none": "Ehhez a fájlhoz nincs előnézet",
"asText": "Egyszerű szövegként",
"truncated": "A {{size}} méretű fájl eleje látszik",
"wrap": "Hosszú sorok tördelése"
} }
} }
+1
View File
@@ -9,6 +9,7 @@
"seek5": "Tekerés 5 másodperc", "seek5": "Tekerés 5 másodperc",
"fineSeek": "Finom léptetés", "fineSeek": "Finom léptetés",
"episode": "Előző / következő rész", "episode": "Előző / következő rész",
"file": "Előző / következő fájl",
"video": "Előző / következő videó", "video": "Előző / következő videó",
"volume": "Hangerő fel / le", "volume": "Hangerő fel / le",
"mute": "Némítás", "mute": "Némítás",
@@ -36,3 +36,33 @@ describe("display-name bound", () => {
for (const m of declared) expect(m[1]).toBe("DISPLAY_TEXT_MAX"); for (const m of declared) expect(m[1]).toBe("DISPLAY_TEXT_MAX");
}); });
}); });
// The viewer reads what the server decides (backend downloads/preview.py): the preview kinds it may
// be told and the header that says a text was cut. Each side names them in its own language, so the
// gate reads both: a kind the server adds that the viewer does not know would fall to "no preview".
describe("file preview", () => {
it("knows the kinds the server hands out", () => {
const backend = src("../../../../backend/app/downloads/preview.py").match(
/^PreviewKind = Literal\[(.*)\]$/m,
);
const frontend = src("./downloads.ts").match(/^export type FilePreviewKind = (.*);$/m);
expect(backend, "downloads/preview.py no longer defines PreviewKind").toBeTruthy();
expect(frontend, "lib/api/downloads.ts no longer exports FilePreviewKind").toBeTruthy();
const kinds = (s: string, sep: string) =>
s
.split(sep)
.map((k) => k.trim().replace(/"/g, ""))
.sort();
expect(kinds(frontend![1]!, "|")).toEqual(kinds(backend![1]!, ","));
});
it("reads the header the server cuts a text with", () => {
const backend = src("../../../../backend/app/routes/files.py").match(
/^TRUNCATED_HEADER = "(.*)"$/m,
);
const frontend = src("./files.ts").match(/^const PREVIEW_TRUNCATED_HEADER = "(.*)";$/m);
expect(backend, "routes/files.py no longer defines TRUNCATED_HEADER").toBeTruthy();
expect(frontend, "lib/api/files.ts no longer defines PREVIEW_TRUNCATED_HEADER").toBeTruthy();
expect(frontend![1]).toBe(backend![1]);
});
});
+5
View File
@@ -39,9 +39,14 @@ interface DownloadAsset {
// (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null // (video/audio) gets play/edit; a generic uploaded file is download+share only, no player. Null
// while still in flight (set at finalize). See backend downloads/mediakind.py. // while still in flight (set at finalize). See backend downloads/mediakind.py.
media_kind: "video" | "audio" | "file" | null; media_kind: "video" | "audio" | "file" | null;
// What the library's viewer shows of a generic file (backend downloads/preview.py): an image, a PDF
// in the browser's own viewer, plain text; null = no preview (media plays instead).
preview?: FilePreviewKind | null;
thumbnail_url: string | null; thumbnail_url: string | null;
} }
export type FilePreviewKind = "image" | "pdf" | "text";
export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled"; export type DownloadStatus = "queued" | "running" | "paused" | "done" | "error" | "canceled";
// "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload" // "download" (default), "edit" (a per-user trim/crop derivative cut from another job), "upload"
// (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight // (a locally-uploaded file) or "clip" (a marked range of a YouTube video or a Plex item, cut straight
+19
View File
@@ -37,6 +37,14 @@ export interface LibraryFile extends DownloadJob {
source_key: string; 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. // What a PATCH may change. Presence decides: a null / "" text falls back to the system value.
export interface FilePatch { export interface FilePatch {
name?: string | null; name?: string | null;
@@ -133,4 +141,15 @@ export const filesApi = {
const a = getActiveAccount(); const a = getActiveAccount();
return a != null ? `/api/files/${id}/file?account=${a}` : `/api/files/${id}/file`; 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" };
},
}; };
+19 -6
View File
@@ -1,5 +1,5 @@
// The Library/Shared row-action gate. A generic uploaded file (media_kind='file') is // The Library/Shared row-action gate. A generic uploaded file (media_kind='file') opens in the viewer
// download+share only (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded. // instead of a player (no Play/Edit/Details); an edit clip and an upload can't be re-downloaded.
// These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload, // These pin the gate so a regression that shows Play/Edit on a file, or re-download on an upload,
// is caught (the affordance itself, not just the backend guard behind it). // is caught (the affordance itself, not just the backend guard behind it).
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
@@ -31,14 +31,16 @@ describe("libraryRowActions", () => {
it("a media (video) item gets play, edit, details, redownload", () => { it("a media (video) item gets play, edit, details, redownload", () => {
expect(libraryRowActions(job({}, {}))).toEqual({ expect(libraryRowActions(job({}, {}))).toEqual({
play: true, play: true,
view: false,
edit: true, edit: true,
editDetails: true, editDetails: true,
redownload: true, redownload: true,
}); });
}); });
it("a generic file gets NO play/edit/details (download+share only)", () => { it("a generic file opens in the viewer and gets NO play/edit/details", () => {
const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null })); const a = libraryRowActions(job({}, { media_kind: "file", duration_s: null }));
expect(a.view).toBe(true);
expect(a.play).toBe(false); expect(a.play).toBe(false);
expect(a.edit).toBe(false); expect(a.edit).toBe(false);
expect(a.editDetails).toBe(false); expect(a.editDetails).toBe(false);
@@ -62,19 +64,30 @@ describe("libraryRowActions", () => {
describe("sharedRowActions", () => { describe("sharedRowActions", () => {
it("a downloadable media item gets play, save, edit", () => { it("a downloadable media item gets play, save, edit", () => {
expect(sharedRowActions(job({}, {}))).toEqual({ play: true, save: true, edit: true }); expect(sharedRowActions(job({}, {}))).toEqual({
play: true,
view: false,
save: true,
edit: true,
});
}); });
it("nothing is offered when the owner disabled download", () => { it("nothing is offered when the owner disabled download", () => {
expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({ expect(sharedRowActions(job({ can_download: false }, {}))).toEqual({
play: false, play: false,
view: false,
save: false, save: false,
edit: false, edit: false,
}); });
}); });
it("a shared generic file gets only save (no player/trim)", () => { it("a shared generic file opens in the viewer and saves (no player/trim)", () => {
const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null })); const a = sharedRowActions(job({}, { media_kind: "file", duration_s: null }));
expect(a).toEqual({ play: false, save: true, edit: false }); expect(a).toEqual({ play: false, view: true, save: true, edit: false });
});
it("a shared generic file its owner keeps from me does not open either", () => {
const a = sharedRowActions(job({ can_download: false }, { media_kind: "file" }));
expect(a.view).toBe(false);
}); });
}); });
+7 -2
View File
@@ -1,8 +1,8 @@
import type { DownloadJob } from "./api"; import type { DownloadJob } from "./api";
// Which row actions apply to a completed library / shared item — gated on the stored media_kind and // 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, // job_kind. A generic uploaded file (media_kind='file') opens in the Files viewer instead of a player,
// no trim, no details edit. Re-download needs a re-fetchable source, which an edit clip and an upload // 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, // 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. // 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 { export interface LibraryRowActions {
play: boolean; play: boolean;
/** Opens in the Files viewer: a file that is not media (its preview, or the "no preview" card). */
view: boolean;
edit: boolean; edit: boolean;
editDetails: boolean; editDetails: boolean;
redownload: boolean; redownload: boolean;
@@ -20,6 +22,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
const media = isMediaKind(job); const media = isMediaKind(job);
return { return {
play: media, play: media,
view: !media && job.can_download,
edit: media && job.can_download && hasDuration(job), edit: media && job.can_download && hasDuration(job),
editDetails: media, editDetails: media,
redownload: !job.job_kind || job.job_kind === "download", redownload: !job.job_kind || job.job_kind === "download",
@@ -28,6 +31,7 @@ export function libraryRowActions(job: DownloadJob): LibraryRowActions {
export interface SharedRowActions { export interface SharedRowActions {
play: boolean; play: boolean;
view: boolean;
save: boolean; save: boolean;
edit: 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). // The owner's `can_download` gates save/play/edit on a shared item (it reflects their link ACL).
return { return {
play: media && job.can_download, play: media && job.can_download,
view: !media && job.can_download,
save: job.can_download, save: job.can_download,
edit: media && job.can_download && hasDuration(job), edit: media && job.can_download && hasDuration(job),
}; };
+5
View File
@@ -37,6 +37,7 @@ export type KeyScope =
| "ytPlayer" | "ytPlayer"
| "plexPlayer" | "plexPlayer"
| "mediaPlayer" | "mediaPlayer"
| "fileViewer"
| "plexBrowse"; | "plexBrowse";
const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([ const PAGE_LEVEL: ReadonlySet<KeyScope> = new Set<KeyScope>([
@@ -234,6 +235,10 @@ export const SHORTCUTS = [
}, },
{ id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" }, { id: "mute", scope: "mediaPlayer", keys: ["m"], labelKey: "keys.mute" },
// --- the Files viewer: through the list's files that are not media ---------------------------
{ id: "prevFile", scope: "fileViewer", keys: ["ArrowLeft"], labelKey: "keys.file" },
{ id: "nextFile", scope: "fileViewer", keys: ["ArrowRight"], labelKey: "keys.file" },
// --- the Plex browser (drill-down) ---------------------------------------------------------- // --- the Plex browser (drill-down) ----------------------------------------------------------
{ id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" }, { id: "back", scope: "plexBrowse", keys: ["Backspace"], labelKey: "keys.browseBack" },
] as const satisfies readonly Shortcut[]; ] as const satisfies readonly Shortcut[];
+1
View File
@@ -68,6 +68,7 @@ export const qk = {
// Every listing's key starts with "files", so one invalidation reaches each folder, search and sort. // Every listing's key starts with "files", so one invalidation reaches each folder, search and sort.
files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)), files: (p?: FileListParams) => (p ? (["files", p] as const) : (["files"] as const)),
fileFolders: () => ["file-folders"] as const, fileFolders: () => ["file-folders"] as const,
filePreview: (id: number) => ["file-preview", id] as const,
storageUsage: () => ["storage-usage"] as const, storageUsage: () => ["storage-usage"] as const,
// --- downloads --- // --- downloads ---
downloads: () => ["downloads"] as const, downloads: () => ["downloads"] as const,