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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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 |
|
||||||
|
|||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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)];
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" };
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user