From 85aa917b387531353685130160aa9ec05cf5d7fb Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 11 Oct 2026 05:34:25 +0200 Subject: [PATCH] =?UTF-8?q?feat(files):=20the=20Files=20page=20=E2=80=94?= =?UTF-8?q?=20the=20library=20in=20one=20aligned=20list,=20its=20folders?= =?UTF-8?q?=20in=20a=20rail?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Files is the one library: every done file, the ones shared with me, or one folder's, picked in a side rail (sources, own folders, the storage meter) and mirrored to ?folder=. One table with every kind of value in its own column; Copy link, Share and Save in sight, every action on a right click: play, cut, rename in the row, move to an own folder or back home, keep, details, re-download, delete. The Downloads page keeps only the queue until the tray replaces it. The old /api/downloads library routes are gone: list shared, upload, the details PATCH, delete, the file and the usage now live under /api/files, and a listed file says which source it came from. The job's "expired" flag is gone with the state it described. --- backend/app/bodylimit.py | 3 +- backend/app/logs.py | 4 +- backend/app/models.py | 2 +- backend/app/routes/downloads.py | 158 +----- backend/app/routes/files.py | 126 ++++- backend/tests/test_body_limit.py | 4 +- backend/tests/test_download_job_locks.py | 3 +- backend/tests/test_download_transitions.py | 2 +- backend/tests/test_files_api.py | 76 +++ backend/tests/test_library_progress.py | 26 +- backend/tests/test_phone_safe_contract.py | 4 +- backend/tests/test_public_unlock_limit.py | 4 +- backend/tests/test_spa_entries.py | 2 +- backend/tests/test_upload.py | 67 ++- backend/tests/test_upload_conversion.py | 4 +- frontend/src/App.tsx | 25 +- .../DownloadCenter.confirm.dom.test.tsx | 37 +- .../DownloadCenter.menu.dom.test.tsx | 164 +----- frontend/src/components/DownloadCenter.tsx | 152 +----- .../src/components/Files.menu.dom.test.tsx | 397 +++++++++++++++ frontend/src/components/Files.tsx | 478 ++++++++++++++++++ frontend/src/components/FilesProvider.tsx | 55 ++ frontend/src/components/FilesRail.tsx | 187 +++++++ frontend/src/components/Header.tsx | 12 + .../src/components/LibraryPlayer.dom.test.tsx | 22 +- frontend/src/components/LibraryPlayer.tsx | 27 +- .../src/components/UploadDialog.dom.test.tsx | 72 ++- frontend/src/components/UploadDialog.tsx | 23 +- frontend/src/components/VideoEditor.tsx | 6 +- .../WriteFailureReporting.dom.test.tsx | 8 +- frontend/src/components/copyItems.tsx | 7 +- frontend/src/components/downloadModals.tsx | 22 +- .../src/components/downloadRow.dom.test.tsx | 1 - frontend/src/components/downloadRow.tsx | 60 +-- frontend/src/components/fileFolders.tsx | 35 ++ frontend/src/components/filesContext.ts | 47 ++ frontend/src/components/moduleRegistry.tsx | 11 +- frontend/src/components/navigationInit.ts | 1 + .../ui/pressableSelectable.contract.test.ts | 1 + .../src/components/useDownloadActions.tsx | 231 +-------- frontend/src/components/useFileActions.tsx | 303 +++++++++++ frontend/src/i18n/locales/en/downloads.json | 16 +- frontend/src/i18n/locales/en/errors.json | 8 +- frontend/src/i18n/locales/en/files.json | 60 +++ frontend/src/i18n/locales/en/keys.json | 1 + frontend/src/i18n/locales/en/nav.json | 1 + frontend/src/i18n/locales/hu/downloads.json | 16 +- frontend/src/i18n/locales/hu/errors.json | 8 +- frontend/src/i18n/locales/hu/files.json | 60 +++ frontend/src/i18n/locales/hu/keys.json | 1 + frontend/src/i18n/locales/hu/nav.json | 1 + frontend/src/lib/api.ts | 3 + frontend/src/lib/api/core.test.ts | 19 +- frontend/src/lib/api/downloads.ts | 65 +-- frontend/src/lib/api/facade.test.ts | 1 + frontend/src/lib/api/files.ts | 123 +++++ frontend/src/lib/downloads.ts | 9 +- frontend/src/lib/keymap.ts | 1 + frontend/src/lib/modules.ts | 7 + frontend/src/lib/pageMeta.test.ts | 1 + frontend/src/lib/panelLayout.ts | 12 +- frontend/src/lib/queryKeys.ts | 9 +- frontend/src/lib/storage.ts | 6 + frontend/src/lib/urlState.ts | 1 + frontend/src/main.tsx | 41 +- 65 files changed, 2295 insertions(+), 1044 deletions(-) create mode 100644 frontend/src/components/Files.menu.dom.test.tsx create mode 100644 frontend/src/components/Files.tsx create mode 100644 frontend/src/components/FilesProvider.tsx create mode 100644 frontend/src/components/FilesRail.tsx create mode 100644 frontend/src/components/fileFolders.tsx create mode 100644 frontend/src/components/filesContext.ts create mode 100644 frontend/src/components/useFileActions.tsx create mode 100644 frontend/src/i18n/locales/en/files.json create mode 100644 frontend/src/i18n/locales/hu/files.json create mode 100644 frontend/src/lib/api/files.ts diff --git a/backend/app/bodylimit.py b/backend/app/bodylimit.py index 134a5458..c0ecab4c 100644 --- a/backend/app/bodylimit.py +++ b/backend/app/bodylimit.py @@ -92,8 +92,7 @@ ROUTE_MAX_BODY_BYTES: dict[tuple[str, str], int] = { # So this 413 is a refusal a real user meets on a default install, not a theoretical edge, and # it has to be as readable as the route's own: the client localizes `too_large` (`errors.tooLarge`) # exactly as it does `upload_too_large`, and the upload dialog refuses an over-cap file before - # sending it at all, off the `upload_max_bytes` that `GET /api/downloads/usage` reports. - ("POST", "/api/downloads/upload"): settings.download_upload_max_bytes, + # sending it at all, off the `upload_max_bytes` that `GET /api/files/usage` reports. ("POST", "/api/files/upload"): settings.download_upload_max_bytes, ("POST", "/api/debug-sessions/{sid}/screenshots"): debugsession.SCREENSHOT_MAX_BYTES, } diff --git a/backend/app/logs.py b/backend/app/logs.py index ecafc7d2..62ff3609 100644 --- a/backend/app/logs.py +++ b/backend/app/logs.py @@ -690,7 +690,9 @@ _POLLED_ROUTES = frozenset( "/api/me/notifications/unread_count", "/api/downloads", "/api/downloads/index", - "/api/downloads/usage", + "/api/files", + "/api/files/folders", + "/api/files/usage", "/api/sync/my-status", "/api/admin/scheduler", "/api/admin/downloads", diff --git a/backend/app/models.py b/backend/app/models.py index fd863d71..e1136a22 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -39,7 +39,7 @@ def display_text(raw: str | None) -> str | None: blank -> None. One definition because there are SIX writers and they had drifted: `POST /api/downloads`, - `POST /api/downloads/edit`, `POST /api/downloads/upload`, the metadata PATCH's two fields, and + `POST /api/downloads/edit`, `POST /api/files/upload`, the library PATCH's two fields, and the Google sign-in callback. Only the PATCH truncated. Truncating is the right answer rather than refusing: the name is a label on work the user has already done — a file already transferred, an account already authenticated — and losing its tail is not worth failing the diff --git a/backend/app/routes/downloads.py b/backend/app/routes/downloads.py index 63300b2a..f50e4162 100644 --- a/backend/app/routes/downloads.py +++ b/backend/app/routes/downloads.py @@ -2,7 +2,8 @@ All routes are behind `current_user`; admin routes add `admin_user`. Live job state is polled by the frontend (the worker is a separate process); this module never downloads — it enqueues, manages job -state, serves finished files (range-aware, with the user's custom display name), and shares them. +state, and shares finished files. The library itself — listing, renaming, moving, deleting and +serving a done file, and uploading one — is `routes.files`. """ import re @@ -50,18 +51,13 @@ from app.routes._common import ( ) from app.routes.files import ( MAX_LIST, - SpooledUpload, accessible_job, assets_for, - delete_file, own_job_for_update, positions_for, quota_detail, serialize, serialize_job, - serve_file, - spool_upload, - ingest_spooled, ) from app.security import hash_password from app.userscope import is_messageable_user, messageable_clauses @@ -328,16 +324,6 @@ def enqueue_download( return serialize_job(db, job) -@router.post("/upload") -def upload_download( - upload: SpooledUpload = Depends(spool_upload), - db: Session = Depends(get_db), -) -> dict: - """The Files library's upload (`routes.files.upload_file`) at its old address, until the client - moves to the new one.""" - return serialize_job(db, ingest_spooled(db, upload)) - - class EnqueueEditIn(BaseModel): source_job_id: int = 0 edit_spec: dict @@ -433,24 +419,6 @@ def list_downloads(user: User = Depends(current_user), db: Session = Depends(get ] -@router.get("/usage") -def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: - """The caller's download quota, plus the per-file upload cap. - - The cap rides here rather than in `quota.usage` because it is not a quota value — it is a - `sysconfig` knob about one route — and this is the route layer, where composing the two is the - job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side - caps answer too late to be pleasant (the edge ceiling refuses the request outright, see - `bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the - limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the - boundary: the caps stay where they are and still refuse a client that ignores this. - """ - return { - **quota.usage(db, user.id), - "upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"), - } - - # Priority when a source has several jobs (e.g. a done one plus a fresh queued one). _INDEX_RANK: dict[str, int] = { JobStatus.DONE: 4, @@ -477,90 +445,6 @@ def my_download_index(user: User = Depends(current_user), db: Session = Depends( return out -@router.get("/shared") -def shared_with_me(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]: - jobs = ( - db.execute( - select(DownloadJob) - .join(DownloadShare, DownloadShare.job_id == DownloadJob.id) - .where(DownloadShare.to_user_id == user.id) - .order_by(DownloadShare.created_at.desc(), DownloadJob.id.desc()) - .limit(MAX_LIST) - ) - .scalars() - .all() - ) - assets = assets_for(db, jobs) - positions = positions_for(db, user, jobs) - out = [] - for j in jobs: - data = serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None) - data["shared"] = True - data["position_seconds"] = positions.get(j.id, 0) - out.append(data) - return out - - -_MAX_EXTRA_LINKS = 8 - - -def _clean_url(raw) -> str | None: - """Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a - public watch page); anything else is dropped rather than trusted.""" - if not isinstance(raw, str): - return None - url = raw.strip() - if not url: - return None - if not url.startswith(("http://", "https://")): - return None - # The third site in this module handing user input to `urlparse`, which raises on a malformed - # authority. This one already has a "not a plausible URL" answer, so the typo belongs in it. - try: - parsed = urlparse(url) - # silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL" - # answer, the caller already has one, and any line here would carry the URL itself. - except ValueError: - return None - if not parsed.netloc: - return None - return url[:2048] - - -class UpdateDownloadMetaIn(BaseModel): - # PATCH display-only overrides; presence (model_fields_set) decides what's touched. A field - # set to null/"" clears the override (falls back to the on-disk system value). - display_name: str | None = None - display_uploader: str | None = None - display_uploader_url: str | None = None - extra_links: list | None = None - - -@router.patch("/{job_id}") -def update_download_meta( - job_id: int, - body: UpdateDownloadMetaIn, - user: User = Depends(current_user), - db: Session = Depends(get_db), -) -> dict: - """Update a download's display metadata: title, channel name + link, and extra reference URLs. - All are display-only overrides — the on-disk file keeps its system-decided, id-bound name.""" - job = owned_or_404(db, DownloadJob, job_id, user, what="download") - sent = body.model_fields_set - if "display_name" in sent: - job.display_name = display_text(body.display_name) - if "display_uploader" in sent: - job.display_uploader = display_text(body.display_uploader) - if "display_uploader_url" in sent: - job.display_uploader_url = _clean_url(body.display_uploader_url) - if "extra_links" in sent: - raw = body.extra_links or [] - cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS] - job.extra_links = cleaned or None - db.commit() - return serialize_job(db, job) - - @router.post("/{job_id}/pause") def pause_download( job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) @@ -615,26 +499,6 @@ def cancel_download( return serialize_job(db, job) -@router.delete("/{job_id}") -def delete_download( - job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) -) -> dict: - """The Files library's delete (`routes.files.delete_file`, audited) at its old address, until the - client moves to the new one.""" - delete_file(job_id, user, db) - return {"ok": True, "deleted": job_id} - - -# --- file download (range-aware, custom display name) -------------------------------------- - - -@router.get("/{job_id}/file") -def download_file(job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)): - response = serve_file(db, accessible_job(db, user, job_id)) - db.commit() - return response - - # --- editor filmstrip (scrub track) -------------------------------------------------------- @@ -807,24 +671,6 @@ def unshare_download( return {"ok": True} -@router.delete("/shared/{job_id}") -def remove_shared_with_me( - job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) -) -> dict: - """Remove a download that was shared WITH this user from their 'Shared with me' list. Deletes - only the recipient's share grant — the owner's job and the physical file are untouched (this is - a per-user dismissal, not a delete).""" - row = db.execute( - select(DownloadShare).where( - DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id - ) - ).scalar_one_or_none() - if row is not None: - db.delete(row) - db.commit() - return {"ok": True, "removed": job_id} - - # --- recipients (for the internal "share with a user" picker) ------------------------------ diff --git a/backend/app/routes/files.py b/backend/app/routes/files.py index c15d9bd7..7c33a86e 100644 --- a/backend/app/routes/files.py +++ b/backend/app/routes/files.py @@ -18,7 +18,7 @@ from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path from typing import BinaryIO, Literal -from urllib.parse import unquote +from urllib.parse import unquote, urlparse from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import FileResponse @@ -95,7 +95,6 @@ def serialize(job: DownloadJob, asset: MediaAsset | None) -> dict: "source_job_id": job.source_job_id, "edit_spec": job.edit_spec, "can_download": ready and job.status == JobStatus.DONE, - "expired": job.asset_id is None and job.status == JobStatus.DONE, # The Files library: its folder, whether the user keeps it, and when it goes (NULL = never). "folder_id": job.folder_id, "kept": job.kept, @@ -468,6 +467,42 @@ def delete_folder( return {"ok": True, "deleted": folder_id, "files_moved": moved} +@router.get("/usage") +def my_usage(user: User = Depends(current_user), db: Session = Depends(get_db)) -> dict: + """The caller's storage quota, plus the per-file upload cap. + + The cap rides here rather than in `quota.usage` because it is not a quota value — it is a + `sysconfig` knob about one route — and this is the route layer, where composing the two is the + job. The upload dialog needs it to refuse an over-cap file BEFORE sending it: both server-side + caps answer too late to be pleasant (the edge ceiling refuses the request outright, see + `bodylimit.ROUTE_MAX_BODY_BYTES`), and making the user push 600 MB up the wire to be told the + limit is 500 is a bad answer even when it is a correct one. It is a convenience, not the + boundary: the caps stay where they are and still refuse a client that ignores this. + """ + return { + **quota.usage(db, user.id), + "upload_max_bytes": sysconfig.get_int(db, "download_upload_max_bytes"), + } + + +@router.delete("/shared/{job_id}") +def remove_shared_with_me( + job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db) +) -> dict: + """Remove a file that was shared WITH this user from their 'Shared with me' list. Deletes + only the recipient's share grant — the owner's job and the physical file are untouched (this is + a per-user dismissal, not a delete).""" + row = db.execute( + select(DownloadShare).where( + DownloadShare.job_id == job_id, DownloadShare.to_user_id == user.id + ) + ).scalar_one_or_none() + if row is not None: + db.delete(row) + db.commit() + return {"ok": True, "removed": job_id} + + # --- files --------------------------------------------------------------------------------- @@ -489,7 +524,8 @@ def list_files( .join(MediaAsset, MediaAsset.id == DownloadJob.asset_id) .where(DownloadJob.status == JobStatus.DONE) ) - if scope == "shared": + shared = scope == "shared" + if shared: stmt = stmt.join(DownloadShare, DownloadShare.job_id == DownloadJob.id).where( DownloadShare.to_user_id == user.id ) @@ -507,8 +543,10 @@ def list_files( func.coalesce(DownloadJob.display_uploader, MediaAsset.uploader, "").ilike(like), ) ) + # A file shared with the caller was added to THEIR list when it was shared, not when its owner + # downloaded it. key = { - "added": DownloadJob.created_at, + "added": DownloadShare.created_at if shared else DownloadJob.created_at, "name": collated(name, lang_for_user(user)), "size": MediaAsset.size_bytes, }[sort] @@ -517,20 +555,83 @@ def list_files( jobs = db.execute(stmt.order_by(first.nulls_last(), tie).limit(MAX_LIST)).scalars().all() assets = assets_for(db, jobs) positions = positions_for(db, user, jobs) - shared = scope == "shared" + shared_at = _shared_at(db, user, jobs) if shared else {} + sources = _source_keys(db, jobs) return [ { **serialize(j, assets.get(j.asset_id) if j.asset_id is not None else None), "position_seconds": positions.get(j.id, 0), "shared": shared, + "shared_at": iso(shared_at.get(j.id)), + "source_key": sources[j.id], } for j in jobs ] +def _source_keys(db: Session, jobs: Sequence[DownloadJob]) -> dict[int, str]: + """Where each file came from (its source folder's key), for the list's Source column and the + "back to its source folder" move: a file in a source folder has its folder's key; one moved into + an own folder (or someone else's, shared) is traced back by `folders.source_key_for`.""" + ids = {j.folder_id for j in jobs if j.folder_id is not None} + by_id = ( + {f.id: f for f in db.execute(select(FileFolder).where(FileFolder.id.in_(ids))).scalars()} + if ids + else {} + ) + out: dict[int, str] = {} + for j in jobs: + folder = by_id.get(j.folder_id) if j.folder_id is not None else None + if folder is not None and folder.kind == folders.FolderKind.SOURCE and folder.source_key: + out[j.id] = folder.source_key + else: + out[j.id] = folders.source_key_for(db, j) + return out + + +def _shared_at(db: Session, user: User, jobs: Sequence[DownloadJob]) -> dict[int, datetime]: + rows = db.execute( + select(DownloadShare.job_id, DownloadShare.created_at).where( + DownloadShare.to_user_id == user.id, + DownloadShare.job_id.in_([j.id for j in jobs]), + ) + ).all() + return {job_id: at for job_id, at in rows} + + +_MAX_EXTRA_LINKS = 8 + + +def _clean_url(raw) -> str | None: + """Accept only a plausible http(s) URL (these get rendered as clickable links + embedded in a + public watch page); anything else is dropped rather than trusted.""" + if not isinstance(raw, str): + return None + url = raw.strip() + if not url: + return None + if not url.startswith(("http://", "https://")): + return None + # `urlparse` raises on a malformed authority; this function already has a "not a plausible URL" + # answer, so the typo belongs in it. + try: + parsed = urlparse(url) + # silent-ok: a typo in a link someone pasted. None is this function's "not a plausible URL" + # answer, the caller already has one, and any line here would carry the URL itself. + except ValueError: + return None + if not parsed.netloc: + return None + return url[:2048] + + class UpdateFileIn(BaseModel): - # Presence (model_fields_set) decides what is touched; a null/"" name falls back to the title. + # Presence (model_fields_set) decides what is touched; a null/"" text falls back to the system + # value (the title, the channel). All display-only: the file on disk keeps its own name. name: str | None = None + uploader: str | None = None + uploader_url: str | None = None + extra_links: list | None = None folder_id: int | None = None kept: bool | None = None @@ -542,12 +643,21 @@ def update_file( user: User = Depends(current_user), db: Session = Depends(get_db), ) -> dict: - """Rename a file (its display name; the file on disk keeps its own), move it to another folder - (`folders.move`), or keep it until deleted / hand it back to its folder's retention.""" + """Rename a file and its channel, set its reference links (all display-only: the file on disk + keeps its own name), move it to another folder (`folders.move`), or keep it until deleted / + hand it back to its folder's retention.""" job = owned_or_404(db, DownloadJob, job_id, user, what="download") sent = body.model_fields_set if "name" in sent: job.display_name = display_text(body.name) + if "uploader" in sent: + job.display_uploader = display_text(body.uploader) + if "uploader_url" in sent: + job.display_uploader_url = _clean_url(body.uploader_url) + if "extra_links" in sent: + raw = body.extra_links or [] + cleaned = [u for u in (_clean_url(x) for x in raw) if u][:_MAX_EXTRA_LINKS] + job.extra_links = cleaned or None if "folder_id" in sent: if body.folder_id is None: raise refused(400, "file_folder_gone") diff --git a/backend/tests/test_body_limit.py b/backend/tests/test_body_limit.py index 72a66bc9..dc308d01 100644 --- a/backend/tests/test_body_limit.py +++ b/backend/tests/test_body_limit.py @@ -367,7 +367,7 @@ def test_every_body_reading_site_is_known_and_fits_under_its_ceiling(): assert ("POST", "/api/debug-sessions/{sid}/screenshots") in bodylimit.ROUTE_MAX_BODY_BYTES # The upload site's limit is the sysconfig knob, which is bounded BY the edge ceiling rather # than being a constant to compare against — that pairing is its own test below. - assert ("POST", "/api/downloads/upload") in bodylimit.ROUTE_MAX_BODY_BYTES + assert ("POST", "/api/files/upload") in bodylimit.ROUTE_MAX_BODY_BYTES def test_release_read_connection_ends_the_transaction_it_was_called_for(db): @@ -487,7 +487,7 @@ def test_the_upload_ceiling_and_the_admin_knob_cannot_drift_apart(): `errors.tooLarge` exists and why the dialog checks the file against `upload_max_bytes` first. """ spec = next(s for s in sysconfig.SPECS if s.key == "download_upload_max_bytes") - edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/downloads/upload")] + edge = bodylimit.ROUTE_MAX_BODY_BYTES[("POST", "/api/files/upload")] assert spec.max == edge diff --git a/backend/tests/test_download_job_locks.py b/backend/tests/test_download_job_locks.py index e702d0b3..ada7e477 100644 --- a/backend/tests/test_download_job_locks.py +++ b/backend/tests/test_download_job_locks.py @@ -18,7 +18,7 @@ from sqlalchemy import select from app.db import SessionLocal from app.downloads import service from app.models import DownloadJob, MediaAsset, User -from app.routes.downloads import cancel_download, delete_download, pause_download +from app.routes.downloads import cancel_download, pause_download from app.routes.files import delete_file, own_job_for_update _REF = "https://example.com/video" @@ -54,7 +54,6 @@ def _asset_held_by_two_jobs(db, status="error"): ("status", "transition", "route", "left"), [ ("error", service.cancel, cancel_download, "canceled"), - ("error", service.delete, delete_download, None), ("error", service.delete, delete_file, None), ("queued", service.cancel, pause_download, "canceled"), ], diff --git a/backend/tests/test_download_transitions.py b/backend/tests/test_download_transitions.py index 67aab12f..212e22d6 100644 --- a/backend/tests/test_download_transitions.py +++ b/backend/tests/test_download_transitions.py @@ -77,7 +77,7 @@ def test_resume_resets_a_failed_shared_asset_to_pending(db, as_user): def test_delete_releases_and_removes_the_job(db, as_user): u, asset, job = _seed(db, job_status="done", asset_status="ready", ref_count=1, rel_path=None) - r = as_user(u).request("DELETE", f"/api/downloads/{job.id}") + r = as_user(u).request("DELETE", f"/api/files/{job.id}") assert r.status_code == 200, r.text db.expire_all() assert db.get(DownloadJob, job.id) is None diff --git a/backend/tests/test_files_api.py b/backend/tests/test_files_api.py index dc0955f3..b0de520a 100644 --- a/backend/tests/test_files_api.py +++ b/backend/tests/test_files_api.py @@ -257,6 +257,54 @@ def test_shared_with_me_lists_what_others_shared_and_nothing_else(db, make_user, assert [(f["id"], f["shared"]) for f in got] == [(theirs.id, True)] +def test_shared_with_me_is_ordered_by_when_it_was_shared_and_can_be_dismissed( + db, make_user, as_user +): + """A friend's old file shared today is new to me: "added" is the share's date there. Dismissing + one drops only my grant; the owner's file stays.""" + alice, bob = make_user(), make_user() + old = _file(db, bob.id, created_at=NOW - timedelta(days=30)) + new = _file(db, bob.id, created_at=NOW - timedelta(days=1)) + db.add( + DownloadShare( + job_id=new.id, + from_user_id=bob.id, + to_user_id=alice.id, + created_at=NOW - timedelta(hours=2), + ) + ) + db.add( + DownloadShare( + job_id=old.id, + from_user_id=bob.id, + to_user_id=alice.id, + created_at=NOW - timedelta(hours=1), + ) + ) + db.commit() + c = as_user(alice) + got = c.get("/api/files", params={"scope": "shared"}).json() + assert [f["id"] for f in got] == [old.id, new.id] + assert got[0]["shared_at"] is not None and got[0]["shared_at"] > got[1]["shared_at"] + r = c.delete(f"/api/files/shared/{old.id}") + assert r.json() == {"ok": True, "removed": old.id} + assert [f["id"] for f in c.get("/api/files", params={"scope": "shared"}).json()] == [new.id] + assert db.get(DownloadJob, old.id) is not None + + +def test_each_listed_file_says_where_it_came_from_wherever_it_sits(db, make_user, as_user): + """The Source column: a file's own source folder, also after it moved into an own folder.""" + alice = make_user() + yt = _file(db, alice.id) + site = _file(db, alice.id, kind="url", ref="https://www.tiktok.com/@x/video/1") + moved = _file(db, alice.id) + own = _own(db, alice.id) + c = as_user(alice) + assert c.patch(f"/api/files/{moved.id}", json={"folder_id": own.id}).status_code == 200 + got = {f["id"]: f["source_key"] for f in c.get("/api/files").json()} + assert got == {yt.id: "youtube", site.id: "site:tiktok.com", moved.id: "youtube"} + + # --- one file ------------------------------------------------------------------------------ @@ -279,6 +327,34 @@ def test_a_file_is_renamed_kept_and_given_back_to_its_folders_retention(db, make ) +def test_a_files_channel_and_links_are_set_and_only_plausible_links_are_kept( + db, make_user, as_user +): + alice = make_user() + job = _file(db, alice.id) + c = as_user(alice) + got = c.patch( + f"/api/files/{job.id}", + json={ + "uploader": " Saját csatorna ", + "uploader_url": "mailto:someone@example.com", + "extra_links": ["https://a.example/x", "ftp://b.example", "", 7, "http://[::1/x"] + + [f"https://c.example/{i}" for i in range(10)], + }, + ).json() + assert got["display_uploader"] == "Saját csatorna" + assert got["display_uploader_url"] is None + assert got["extra_links"][0] == "https://a.example/x" and len(got["extra_links"]) == 8 + cleared = c.patch(f"/api/files/{job.id}", json={"uploader": "", "extra_links": []}).json() + assert (cleared["display_uploader"], cleared["extra_links"]) == (None, []) + + +def test_the_usage_carries_the_quota_and_the_upload_cap(db, make_user, as_user): + got = as_user(make_user()).get("/api/files/usage").json() + assert got["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes") + assert "footprint_bytes" in got and "max_bytes" in got + + def test_a_file_moves_to_an_own_folder_or_home_and_nowhere_else(db, make_user, as_user): alice, bob = make_user(), make_user() job = _file(db, alice.id) diff --git a/backend/tests/test_library_progress.py b/backend/tests/test_library_progress.py index 12228e39..3c033f05 100644 --- a/backend/tests/test_library_progress.py +++ b/backend/tests/test_library_progress.py @@ -9,13 +9,26 @@ cannot reach, is ever read or written. DB-backed (real Postgres). import pytest from sqlalchemy import select -from app.models import DownloadJob, DownloadShare, LibraryState +from app.models import DownloadJob, DownloadShare, LibraryState, MediaAsset from app.routes._common import ProgressIn, kept_position def _done_job(db, user) -> DownloadJob: + asset = MediaAsset( + source_kind="url", + source_ref="https://example.com/a.mp4", + format_sig=f"sig-{user.id}", + status="ready", + rel_path=f"a-{user.id}.mp4", + ref_count=1, + media_kind="video", + container="mp4", + ) + db.add(asset) + db.flush() job = DownloadJob( user_id=user.id, + asset_id=asset.id, source_kind="url", source_ref="https://example.com/a.mp4", status="done", @@ -59,7 +72,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user): "position_seconds": 754, } - [item] = c.get("/api/downloads").json() + [item] = c.get("/api/files").json() assert item["position_seconds"] == 754 # A second checkpoint moves it rather than adding a row. assert _checkpoint(c, job.id, 900) == 900 @@ -69,7 +82,7 @@ def test_a_checkpoint_is_listed_back_on_the_item(db, make_user, as_user): def test_an_item_never_played_lists_zero(db, make_user, as_user): me = make_user() _done_job(db, me) - [item] = as_user(me).get("/api/downloads").json() + [item] = as_user(me).get("/api/files").json() assert item["position_seconds"] == 0 @@ -110,8 +123,11 @@ def test_a_recipient_keeps_their_own_place_in_a_shared_item(db, make_user, as_us assert _checkpoint(as_user(owner), job.id, 100) == 100 assert _checkpoint(as_user(friend), job.id, 2000) == 2000 - assert as_user(owner).get("/api/downloads").json()[0]["position_seconds"] == 100 - assert as_user(friend).get("/api/downloads/shared").json()[0]["position_seconds"] == 2000 + assert as_user(owner).get("/api/files").json()[0]["position_seconds"] == 100 + assert ( + as_user(friend).get("/api/files", params={"scope": "shared"}).json()[0]["position_seconds"] + == 2000 + ) def test_an_item_the_caller_cannot_play_is_a_404_and_writes_nothing(db, make_user, as_user): diff --git a/backend/tests/test_phone_safe_contract.py b/backend/tests/test_phone_safe_contract.py index c40220fe..1a0f659c 100644 --- a/backend/tests/test_phone_safe_contract.py +++ b/backend/tests/test_phone_safe_contract.py @@ -141,9 +141,7 @@ def test_what_a_link_serves_passes_the_rule( subprocess.run(["ffmpeg", "-y", "-loglevel", "error", *args, str(src)], check=True) owner = as_user(make_user()) - r = owner.post( - "/api/downloads/upload", content=src.read_bytes(), headers=upload_headers(filename) - ) + r = owner.post("/api/files/upload", content=src.read_bytes(), headers=upload_headers(filename)) assert r.status_code == 200, r.text job_id = r.json()["id"] assert r.json()["status"] == "queued" # every one of these fails the rule as it came diff --git a/backend/tests/test_public_unlock_limit.py b/backend/tests/test_public_unlock_limit.py index f5603cbc..ab14fe61 100644 --- a/backend/tests/test_public_unlock_limit.py +++ b/backend/tests/test_public_unlock_limit.py @@ -25,9 +25,7 @@ def unlock_url(as_user, make_user, tmp_path, monkeypatch, upload_headers): data = {"streams": [video, audio], "format": {"format_name": "mov,mp4", "duration": "5"}} monkeypatch.setattr(probemod, "probe_json", lambda _p: data) owner = as_user(make_user()) - job = owner.post( - "/api/downloads/upload", content=b"fake-video", headers=upload_headers("clip.mp4") - ) + job = owner.post("/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4")) assert job.status_code == 200, job.text link = owner.post(f"/api/downloads/{job.json()['id']}/links", json={"password": _PW}) assert link.status_code == 200, link.text diff --git a/backend/tests/test_spa_entries.py b/backend/tests/test_spa_entries.py index 95837020..22e46475 100644 --- a/backend/tests/test_spa_entries.py +++ b/backend/tests/test_spa_entries.py @@ -34,7 +34,7 @@ def link_token(as_user, make_user, tmp_path, monkeypatch, upload_headers): monkeypatch.setattr(probemod, "probe_json", lambda _p: data) owner = as_user(make_user()) job = owner.post( - "/api/downloads/upload", + "/api/files/upload", content=b"fake-video", headers=upload_headers("clip.mp4", display_name="Holiday clip"), ) diff --git a/backend/tests/test_upload.py b/backend/tests/test_upload.py index d563033d..889192d0 100644 --- a/backend/tests/test_upload.py +++ b/backend/tests/test_upload.py @@ -212,7 +212,7 @@ def test_an_anonymous_upload_is_refused_before_a_byte_of_it_is_read(client): multipart the old route would have spooled, not the raw body the new one expects.""" body, pulled = _counting_multipart() r = client.post( - "/api/downloads/upload", + "/api/files/upload", content=body, headers={"content-type": f"multipart/form-data; boundary={_BOUNDARY}"}, ) @@ -227,7 +227,7 @@ def test_an_authenticated_upload_still_has_its_body_read(as_user, make_user, _ro body, pulled = _counting_body() c = as_user(make_user()) r = c.post( - "/api/downloads/upload", + "/api/files/upload", content=body, headers={"content-type": "application/octet-stream", "x-upload-filename": "clip.bin"}, ) @@ -281,7 +281,7 @@ def test_upload_route_generic_forces_download( _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) r = c.post( - "/api/downloads/upload", + "/api/files/upload", content=b"", headers=upload_headers("report.html"), ) @@ -290,7 +290,7 @@ def test_upload_route_generic_forces_download( assert job["asset"]["media_kind"] == "file" assert job["can_download"] is True # The served file must be a neutral force-download attachment — never inline text/html. - fr = c.get(f"/api/downloads/{job['id']}/file") + fr = c.get(f"/api/files/{job['id']}/file") assert fr.status_code == 200 assert fr.headers["content-type"].startswith("application/octet-stream") assert "attachment" in fr.headers["content-disposition"].lower() @@ -307,7 +307,7 @@ def test_upload_route_media_html_extension_not_served_as_html( _probe(monkeypatch, ("h264", "aac", 320, 240, 5)) # ffprobe says it's video… c = as_user(make_user()) r = c.post( - "/api/downloads/upload", + "/api/files/upload", content=b"", headers=upload_headers("evil.html"), ) @@ -324,7 +324,7 @@ def test_upload_route_media_html_extension_not_served_as_html( db.commit() # Authenticated endpoint: never text/html. - fr = c.get(f"/api/downloads/{job['id']}/file") + fr = c.get(f"/api/files/{job['id']}/file") assert fr.status_code == 200 assert fr.headers["content-type"].startswith("application/octet-stream") assert "attachment" in fr.headers["content-disposition"].lower() @@ -348,7 +348,7 @@ def test_public_watch_file_forces_download_for_generic_file( _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) job = c.post( - "/api/downloads/upload", + "/api/files/upload", content=b"", headers=upload_headers("notes.html"), ).json() @@ -369,7 +369,7 @@ def test_public_watch_file_serves_media_inline_with_real_type( _probe(monkeypatch, ("h264", "aac", 640, 360, 12)) c = as_user(make_user()) job = c.post( - "/api/downloads/upload", + "/api/files/upload", content=b"fake-mp4-bytes", headers=upload_headers("clip.mp4"), ).json() @@ -389,7 +389,7 @@ def test_upload_route_enforces_per_file_cap( db.commit() c = as_user(make_user()) r = c.post( - "/api/downloads/upload", + "/api/files/upload", content=b"way over the cap", headers=upload_headers("big.bin"), ) @@ -412,14 +412,10 @@ def test_the_cap_admits_a_body_of_EXACTLY_the_cap_and_refuses_one_byte_more( db.commit() c = as_user(make_user()) - at_the_cap = c.post( - "/api/downloads/upload", content=payload, headers=upload_headers("exact.bin") - ) + at_the_cap = c.post("/api/files/upload", content=payload, headers=upload_headers("exact.bin")) assert at_the_cap.status_code == 200, at_the_cap.text - over = c.post( - "/api/downloads/upload", content=payload + b"x", headers=upload_headers("over.bin") - ) + over = c.post("/api/files/upload", content=payload + b"x", headers=upload_headers("over.bin")) assert over.status_code == 422 assert over.json()["detail"] == {"code": "upload_too_large", "limit": len(payload)} @@ -437,9 +433,7 @@ def test_a_non_ascii_filename_survives_the_header_round_trip( """ _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) - r = c.post( - "/api/downloads/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin") - ) + r = c.post("/api/files/upload", content=b"data", headers=upload_headers("nyaralás ősszel.bin")) assert r.status_code == 200, r.text asset = db.execute(select(MediaAsset).order_by(MediaAsset.id.desc())).scalars().first() assert asset is not None @@ -467,9 +461,7 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one( c = as_user(make_user()) # 1. the upload route — the name arrives in a request header. - up = c.post( - "/api/downloads/upload", content=b"video", headers=upload_headers("a.mp4", long_name) - ) + up = c.post("/api/files/upload", content=b"video", headers=upload_headers("a.mp4", long_name)) assert up.status_code == 200, up.text assert up.json()["display_name"] == bound @@ -494,10 +486,10 @@ def test_a_long_display_name_is_truncated_on_EVERY_route_that_takes_one( assert edit.status_code == 200, edit.text assert edit.json()["display_name"] == bound - # 4 + 5. the metadata PATCH's two fields. + # 4 + 5. the library PATCH's two fields. patched = c.patch( - f"/api/downloads/{up.json()['id']}", - json={"display_name": long_name, "display_uploader": long_name}, + f"/api/files/{up.json()['id']}", + json={"name": long_name, "uploader": long_name}, ) assert patched.status_code == 200, patched.text assert patched.json()["display_name"] == bound @@ -610,12 +602,11 @@ DISPLAY_WRITE_SITES = { ("routes/downloads.py", "enqueue_download"): {"display_text(body.display_name)"}, ("routes/downloads.py", "enqueue_edit"): {"display_text(body.display_name)"}, ("routes/downloads.py", "enqueue_clip"): {"display_text(body.display_name)", "display_name"}, - ("routes/downloads.py", "update_download_meta"): { - "display_text(body.display_name)", - "display_text(body.display_uploader)", - }, ("routes/files.py", "ingest_spooled"): {"upload.display_name"}, - ("routes/files.py", "update_file"): {"display_text(body.name)"}, + ("routes/files.py", "update_file"): { + "display_text(body.name)", + "display_text(body.uploader)", + }, ("routes/files.py", "spool_upload"): { "display_text(_header_text(request, UPLOAD_DISPLAY_NAME_HEADER))" }, @@ -666,7 +657,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded( c = as_user(make_user()) ok = c.post( - "/api/downloads/upload", + "/api/files/upload", content=b"data", headers=upload_headers("clip.bin", "Nyári vakáció"), ) @@ -675,7 +666,7 @@ def test_the_display_name_header_reaches_the_job_and_is_bounded( long_name = "n" * 300 bounded = c.post( - "/api/downloads/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name) + "/api/files/upload", content=b"data2", headers=upload_headers("clip2.bin", long_name) ) assert bounded.status_code == 200, bounded.text assert bounded.json()["display_name"] == "n" * DISPLAY_TEXT_MAX @@ -718,7 +709,7 @@ def test_the_upload_holds_no_db_transaction_while_the_body_streams( monkeypatch.setattr(files_routes, "release_read_connection", spy_release) monkeypatch.setattr(files_routes, "capped_stream_to", spy_stream) c = as_user(make_user()) - r = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("held.bin")) + r = c.post("/api/files/upload", content=b"data", headers=upload_headers("held.bin")) assert r.status_code == 200, r.text assert seen["before"] is True, ( "nothing had opened a transaction before the release, so this test proves nothing — the " @@ -740,14 +731,14 @@ def test_the_usage_route_reports_the_per_file_upload_cap(as_user, make_user, db) from app import sysconfig c = as_user(make_user()) - r = c.get("/api/downloads/usage") + r = c.get("/api/files/usage") assert r.status_code == 200, r.text assert r.json()["upload_max_bytes"] == sysconfig.get_int(db, "download_upload_max_bytes") # And it is the KNOB, not a constant: a lowered override must show through. sysconfig.set_value(db, "download_upload_max_bytes", 4096) db.commit() - assert c.get("/api/downloads/usage").json()["upload_max_bytes"] == 4096 + assert c.get("/api/files/usage").json()["upload_max_bytes"] == 4096 def test_a_refused_upload_leaves_nothing_in_the_staging_directory( @@ -765,7 +756,7 @@ def test_a_refused_upload_leaves_nothing_in_the_staging_directory( staging = Path(settings.files_root) / ".staging" / "uploads" c = as_user(make_user()) - r = c.post("/api/downloads/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin")) + r = c.post("/api/files/upload", content=b"x" * 4096, headers=upload_headers("leaky.bin")) assert r.status_code == 422 assert staging.exists(), "the dependency did not get as far as creating the staging dir" assert list(staging.iterdir()) == [], "a refused upload left its partial file behind" @@ -787,7 +778,7 @@ def test_upload_cap_cannot_be_disabled_with_zero(db): def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, upload_headers): c = as_user(make_user()) - r = c.post("/api/downloads/upload", content=b"", headers=upload_headers("empty.bin")) + r = c.post("/api/files/upload", content=b"", headers=upload_headers("empty.bin")) assert r.status_code == 400 assert r.json()["detail"]["code"] == "upload_empty" @@ -795,7 +786,7 @@ def test_upload_route_rejects_empty_file(as_user, make_user, _root, monkeypatch, def test_redownload_rejects_an_upload(as_user, make_user, _root, monkeypatch, upload_headers): _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) - job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json() + job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json() r = c.post(f"/api/downloads/{job['id']}/redownload") assert r.status_code == 409 @@ -805,7 +796,7 @@ def test_edit_rejects_a_generic_upload_source( ): _probe(monkeypatch, (None, None, None, None, None)) c = as_user(make_user()) - job = c.post("/api/downloads/upload", content=b"data", headers=upload_headers("a.bin")).json() + job = c.post("/api/files/upload", content=b"data", headers=upload_headers("a.bin")).json() r = c.post( "/api/downloads/edit", json={"source_job_id": job["id"], "edit_spec": {"trim": {"start_s": 0, "end_s": 1}}}, diff --git a/backend/tests/test_upload_conversion.py b/backend/tests/test_upload_conversion.py index b4d84521..969c33c0 100644 --- a/backend/tests/test_upload_conversion.py +++ b/backend/tests/test_upload_conversion.py @@ -549,7 +549,7 @@ def test_a_link_says_preparing_only_while_its_video_is_being_made( _probe(monkeypatch, vcodec="h264") c = as_user(make_user()) job = c.post( - "/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4") + "/api/files/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4") ).json() link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json() assert c.get(f"/api/public/watch/{link['token']}").status_code == 200 @@ -570,7 +570,7 @@ def test_a_link_to_a_suspended_owners_waiting_rebuild_says_it_is_gone( owner = make_user() c = as_user(owner) job = c.post( - "/api/downloads/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4") + "/api/files/upload", content=b"h264 bytes", headers=upload_headers("clip.mp4") ).json() link = c.post(f"/api/downloads/{job['id']}/links", json={"allow_download": False}).json() db.get(MediaAsset, job["asset"]["id"]).status = "pending" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5c59153e..c3ac441e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -87,6 +87,7 @@ export default function App() { feed: seedLayout("feed", prefs), plex: seedLayout("plex", prefs), playlists: seedLayout("playlists", prefs), + files: seedLayout("files", prefs), }; }); const [aboutOpen, setAboutOpen] = useState(false); @@ -119,6 +120,17 @@ export default function App() { localKey: LS.playlistsCollapsed, parse: Boolean, }); + // The Files rail is the library's folder list — how you move around it, not a filter you set once — + // so it starts pinned open (the owner's mockup), and has its own flag. + const [filesCollapsed, setFilesCollapsed] = usePref("filesCollapsed", false, { + localKey: LS.filesCollapsed, + parse: Boolean, + }); + const railCollapse = { + filter: [filterCollapsed, setFilterCollapsed], + playlists: [playlistsCollapsed, setPlaylistsCollapsed], + files: [filesCollapsed, setFilesCollapsed], + } as const; // (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and // NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.) @@ -378,18 +390,17 @@ export default function App() { /> {/* The active page's side rail (feed filters / plex filters / playlists list), if it has one. A floating collapsible column beside the nav rail; hidden while a channel page overlays the - content. feed + plex share the filter-rail collapse flag, playlists has its own. */} + content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */} {railDef && ( setPanelLayout(railDef.panelId, l)} - collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed} - onToggleCollapse={() => - railDef.collapse === "playlists" - ? setPlaylistsCollapsed(!playlistsCollapsed) - : setFilterCollapsed(!filterCollapsed) - } + collapsed={railCollapse[railDef.collapse][0]} + onToggleCollapse={() => { + const [collapsed, setCollapsed] = railCollapse[railDef.collapse]; + setCollapsed(!collapsed); + }} /> )} diff --git a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx index f3ca2fc5..d216f11f 100644 --- a/frontend/src/components/DownloadCenter.confirm.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.confirm.dom.test.tsx @@ -1,8 +1,9 @@ // @vitest-environment jsdom // // The Download Center's confirm dialogs name their ACTION on the confirm button: the shared -// `confirmThen` once labelled it with the dialog's question — "Re-download this file?" on the button, -// and the cancel dialog's own "Cancel" beside "Cancel". i18n is key-passthrough, so labels read as keys. +// `confirmThen` once labelled it with the dialog's question — the cancel dialog's own "Cancel" beside +// "Cancel". A finished file's confirms are the Files page's (Files.menu.dom.test.tsx). i18n is +// key-passthrough, so labels read as keys. import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -73,7 +74,6 @@ const job = (id: number, status: DownloadJob["status"]): DownloadJob => ({ display_uploader_url: null, extra_links: [], can_download: status === "done", - expired: false, folder_id: null, kept: false, expires_at: null, @@ -105,7 +105,7 @@ async function pick(row: string, label: string) { describe("DownloadCenter confirm dialogs", () => { it("label the confirm button with the action, not the dialog's question", async () => { - mocks.jobs = [job(1, "running"), job(2, "done")]; + mocks.jobs = [job(1, "running")]; render( @@ -115,18 +115,9 @@ describe("DownloadCenter confirm dialogs", () => { // The destructive and rare row buttons live in the row's "…". await pick("Clip 1", "downloads.actions.cancel"); await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); - // The tab's label shares its button with a count badge. - fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ })); - await screen.findByText("Clip 2"); - await pick("Clip 2", "downloads.actions.redownload"); - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); - await pick("Clip 2", "downloads.actions.delete"); - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3)); expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([ ["downloads.confirm.cancelTitle", "downloads.confirm.cancelAction"], - ["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"], - ["downloads.confirm.deleteTitle", "downloads.actions.delete"], ]); }); @@ -163,24 +154,4 @@ describe("DownloadCenter confirm dialogs", () => { "downloads.confirm.cancelEditAction", ]); }); - - it.each([ - ["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"], - ["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"], - ["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"], - ] as const)("deleting a finished %s says what happens to its file", async (kind, title, body) => { - // Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone. - mocks.jobs = [{ ...job(5, "done"), job_kind: kind }]; - render( - - - , - ); - fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ })); - await screen.findByText("Clip 5"); - await pick("Clip 5", "downloads.actions.delete"); - await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); - const [opts] = mocks.confirm.mock.calls[0]!; - expect([opts.title, opts.message]).toEqual([title, body]); - }); }); diff --git a/frontend/src/components/DownloadCenter.menu.dom.test.tsx b/frontend/src/components/DownloadCenter.menu.dom.test.tsx index 59b762d8..a02fe0d5 100644 --- a/frontend/src/components/DownloadCenter.menu.dom.test.tsx +++ b/frontend/src/components/DownloadCenter.menu.dom.test.tsx @@ -1,10 +1,9 @@ // @vitest-environment jsdom // -// The Download Center: a download row keeps its primary action (play; pause / resume / -// retry in the queue) and the save link in sight, and everything else — the editors, sharing, -// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it -// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own -// menu. i18n is key-passthrough, so labels read as keys. +// The Download Center's queue: a row keeps its primary action (pause / resume / retry) in sight, and +// the copies and cancel are its ONE menu, opened by the row's "…" (it shows on hover or focus) or a +// right-click on the row. A finished download's row is the Files page's (Files.menu.dom.test.tsx). +// i18n is key-passthrough, so labels read as keys. 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"; @@ -68,7 +67,6 @@ const job = ( display_uploader_url: null, extra_links: [], can_download: status === "done", - expired: false, job_kind: "download", asset: { media_kind: "video", duration_s: 60 }, ...extra, @@ -209,160 +207,6 @@ describe("DownloadCenter · the queue row", () => { }); }); -describe("DownloadCenter · the library row", () => { - it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => { - mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })]; - mount("done"); - await screen.findByText("Clip 2"); - expect(rowButtons("Clip 2")).toEqual([ - "downloads.actions.play", - "downloads.actions.share", - "downloads.actions.download", - "downloads.rowMenu", - ]); - await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); - expect(menuRows()).toEqual([ - "downloads.actions.play", - "downloads.actions.share", - "downloads.actions.download", - "downloads.actions.edit", - "downloads.actions.editDetails", - "downloads.actions.redownload", - "downloads.actions.shareManage", - "common.copy.title", - "common.copy.link", - "downloads.actions.delete", - ]); - }); - - it("a started item continues — its bar shows how far — and its menu can start it over", async () => { - mocks.jobs = [job(2, "done", { position_seconds: 30 })]; - mount("done"); - await screen.findByText("Clip 2"); - expect(rowButtons("Clip 2")[0]).toBe("card.continueTitle"); - // The media cards' bar along the picture's bottom edge: 30 s of the 60 s item. - expect(rowOf("Clip 2").querySelector(".bg-accent")!.style.width).toBe("50%"); - await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); - expect(menuRows().slice(0, 2)).toEqual(["card.continue", "card.restartTitle"]); - - // Starting over opens the player at 0:00, not at the kept point. - fireEvent.click( - screen - .getAllByRole("menuitem", { hidden: true }) - .find((b) => b.textContent === "card.restartTitle")!, - ); - const video = await waitFor(() => { - const v = document.querySelector("video"); - expect(v).not.toBeNull(); - return v!; - }); - let t = 0; - Object.defineProperties(video, { - currentTime: { get: () => t, set: (v: number) => void (t = v), configurable: true }, - duration: { get: () => 60, configurable: true }, - readyState: { get: () => 1, configurable: true }, - }); - video.dispatchEvent(new Event("loadedmetadata")); - expect(video.currentTime).toBe(0); - }); - - it("an item never played offers Play only, with no bar", async () => { - mocks.jobs = [job(2, "done", { position_seconds: 0 })]; - mount("done"); - await screen.findByText("Clip 2"); - expect(rowOf("Clip 2").querySelector(".bg-accent")).toBeNull(); - await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); - expect(menuRows()).not.toContain("card.restartTitle"); - }); - - it("copies the share link from the row's own Share button", async () => { - mocks.jobs = [job(2, "done")]; - vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never); - const create = vi - .spyOn(api, "createDownloadLink") - .mockResolvedValue({ id: 1, url: "/s/tok" } as never); - mount("done"); - await screen.findByText("Clip 2"); - fireEvent.click( - rowOf("Clip 2").querySelector('button[aria-label="downloads.actions.share"]')!, - ); - await waitFor(() => expect(create).toHaveBeenCalledWith(2, {})); - expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0); - }); - - it("saves the file through the same link as the row's save button", async () => { - mocks.jobs = [job(2, "done")]; - const followed: string[] = []; - vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( - this: HTMLAnchorElement, - ) { - followed.push(this.href); - }); - mount("done"); - await screen.findByText("Clip 2"); - await openMenu(() => fireEvent.click(moreOf("Clip 2"))); - fireEvent.click( - screen - .getAllByRole("menuitem", { hidden: true }) - .find((b) => b.textContent === "downloads.actions.download")!, - ); - const button = rowOf("Clip 2").querySelector( - 'a[aria-label="downloads.actions.download"]', - )!; - expect(followed).toEqual([button.href]); - }); - - it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => { - mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)]; - mount("done"); - await screen.findByText("Clip 3"); - await openMenu(() => fireEvent.click(moreOf("Clip 3"))); - expect(menuRows()).toEqual([ - "downloads.actions.share", - "downloads.actions.download", - "downloads.actions.shareManage", - "common.copy.title", - "downloads.actions.delete", - ]); - }); - - it("gives a link inside the row the browser's own menu", async () => { - mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })]; - mount("done"); - const link = await screen.findByText("youtu.be/x"); - const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true }); - act(() => void link.dispatchEvent(ev)); - expect(ev.defaultPrevented).toBe(false); - expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0); - }); -}); - -describe("DownloadCenter · the shared row", () => { - it("holds play, save, edit, the copies and Remove from my list", async () => { - mocks.jobs = []; - vi.spyOn(api, "downloadsShared").mockResolvedValue([ - job(4, "done", { can_download: true }), - ] as never); - const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never); - mount("shared"); - await screen.findByText("Clip 4"); - await openMenu(() => fireEvent.click(moreOf("Clip 4"))); - expect(menuRows()).toEqual([ - "downloads.actions.play", - "downloads.actions.download", - "downloads.actions.edit", - "common.copy.title", - "downloads.actions.removeShared", - ]); - fireEvent.click( - screen - .getAllByRole("menuitem", { hidden: true }) - .find((b) => b.textContent === "downloads.actions.removeShared")!, - ); - await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); - }); -}); - describe("menuGroups", () => { const row = (id: string) => ({ id, label: id, onSelect: () => {} }); it("rules between non-empty groups only", () => { diff --git a/frontend/src/components/DownloadCenter.tsx b/frontend/src/components/DownloadCenter.tsx index a6f0bea4..79777477 100644 --- a/frontend/src/components/DownloadCenter.tsx +++ b/frontend/src/components/DownloadCenter.tsx @@ -1,47 +1,29 @@ import { lazy, Suspense, useState } from "react"; import { qk } from "../lib/queryKeys"; import { useTranslation } from "react-i18next"; -import { useQuery } from "@tanstack/react-query"; -import { - ClipboardPaste, - Download, - Pause, - Play, - PlayCircle, - Plus, - RefreshCw, - Settings2, - Share2, - UploadCloud, -} from "lucide-react"; +import { ClipboardPaste, Pause, Play, Plus, RefreshCw, Settings2, UploadCloud } from "lucide-react"; import Tabs from "./ui/Tabs"; import { useTabPanel } from "./ui/tabPanel"; import { PageToolbar } from "./ui/PageShell"; import { LoadingState, StateMessage } from "./ui/QueryState"; -// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until -// used (the video editor in particular is heavy — filmstrip + scrubber). +// Lazy: these dialogs open on demand, so they stay out of the Downloads page chunk until used. const DownloadDialog = lazy(() => import("./DownloadDialog")); const UploadDialog = lazy(() => import("./UploadDialog")); const ProfileEditor = lazy(() => import("./ProfileEditor")); -const VideoEditor = lazy(() => import("./VideoEditor")); -const ShareDialog = lazy(() => import("./ShareDialog")); -const LibraryPlayer = lazy(() => import("./LibraryPlayer")); import { useLiveQuery } from "../lib/useLiveQuery"; import { useMe } from "../lib/useMe"; import { api, type DownloadJob } from "../lib/api"; -import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; import { Button } from "./ui/Button"; import { IconButton } from "./ui/IconButton"; -import { DownloadRow, IconBtn, UsageBar } from "./downloadRow"; -import { MetaEditModal, ErrorModal } from "./downloadModals"; +import { DownloadRow, IconBtn } from "./downloadRow"; +import { ErrorModal } from "./downloadModals"; import { AdminSystem } from "./downloadAdmin"; import NavGrid from "./ui/NavGrid"; import { Input } from "./ui/Input"; -import { IconLink } from "./ui/LinkButton"; import { useContextMenu } from "./ui/useContextMenu"; import { ContextMenu } from "./ui/ContextMenu"; import { MoreButton } from "./ui/MoreButton"; -import { useDownloadActions, type DownloadTab } from "./useDownloadActions"; +import { useDownloadActions } from "./useDownloadActions"; const jobKey = (job: DownloadJob) => job.id; @@ -55,6 +37,9 @@ async function clipboardText(): Promise { } } +// The download queue: what is downloading, waiting or failed, the link box and the upload, the +// download profiles, and (admin) the instance's downloads. A finished download is a library file and +// lives in Files. export default function DownloadCenter() { const me = useMe(); const { t } = useTranslation(); @@ -66,12 +51,6 @@ export default function DownloadCenter() { const [addSource, setAddSource] = useState(null); const [upload, setUpload] = useState(false); const [manage, setManage] = useState(false); - const [metaJob, setMetaJob] = useState(null); - const [shareJob, setShareJob] = useState(null); - // What the player opens, and whether over the kept point ("Play from the beginning"). - const [playing, setPlaying] = useState<{ job: DownloadJob; fromStart: boolean } | null>(null); - const setPlayJob = (job: DownloadJob, fromStart = false) => setPlaying({ job, fromStart }); - const [editJob, setEditJob] = useState(null); const [errorJob, setErrorJob] = useState(null); // Poll fast (2s) while work is actually in flight; back off to 15s once the queue is settled @@ -81,29 +60,15 @@ export default function DownloadCenter() { intervalMs: (data) => data?.some((j) => j.status === "queued" || j.status === "running") ? 2000 : 15000, }); - const sharedQ = useQuery({ - queryKey: qk.downloadsShared(), - queryFn: api.downloadsShared, - enabled: tab === "shared", - }); const jobs = jobsQ.data ?? []; const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status)); - const library = jobs.filter((j) => j.status === "done"); // --- The rows' ONE menu ------------------------------------------------------------- // Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an - // open menu, and its "…" must still close it. The tab is the one on screen. + // open menu, and its "…" must still close it. const rowMenu = useContextMenu({ keyOf: jobKey }); - const actions = useDownloadActions({ - onPlay: setPlayJob, - onEdit: setEditJob, - onEditDetails: setMetaJob, - onManageShares: setShareJob, - }); - const menuTarget = rowMenu.target; - const listTab: DownloadTab | null = - tab === "queue" || tab === "done" || tab === "shared" ? tab : null; - const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : []; + const actions = useDownloadActions(); + const menuItems = rowMenu.target && tab === "queue" ? actions.items(rowMenu.target) : []; // The row's menu triggers and its "…". const rowMenuProps = (job: DownloadJob) => { const mine = rowMenu.open && rowMenu.target?.id === job.id; @@ -133,8 +98,6 @@ export default function DownloadCenter() { const tabs = [ { id: "queue", label: t("downloads.tabs.queue"), badge: queue.length }, - { id: "done", label: t("downloads.tabs.done"), badge: library.length }, - { id: "shared", label: t("downloads.tabs.shared") }, ...(me.role === "admin" ? [{ id: "system", label: t("downloads.tabs.system") }] : []), ]; // Guard against the active tab going away under us (e.g. admin on "system" gets demoted mid-session) @@ -144,12 +107,6 @@ export default function DownloadCenter() { // account), so a render-phase write is allowed, and it converges — "queue" is always in the list. if (!tabs.some((tb) => tb.id === tab)) setTab("queue"); - const saveBtn = (job: DownloadJob) => ( - - - - ); - return ( <> {/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the @@ -268,83 +225,6 @@ export default function DownloadCenter() { )} - {tab === "done" && ( - <> - - - {library.map((job, i) => ( - - {libraryRowActions(job).play && ( - setPlayJob(job)} - title={ - job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play") - } - > - - - )} - {/* Sharing the link is the library's main errand: in sight, ahead of save. */} - actions.share(job)} title={t("downloads.actions.share")}> - - - {saveBtn(job)} - - ))} - {library.length === 0 && - (jobsQ.isError && !jobsQ.data ? ( - jobsQ.refetch()}> - {t("common.loadError")} - - ) : jobsQ.isLoading ? ( - - ) : ( -
- {t("downloads.empty.done")} -
- ))} -
- - )} - - {tab === "shared" && ( - - {(sharedQ.data ?? []).map((job, i) => { - // Same media_kind + can_download gate as the library, via the shared helper. - const a = sharedRowActions(job); - return ( - - {a.play && ( - setPlayJob(job)} - title={ - job.position_seconds ? t("card.continueTitle") : t("downloads.actions.play") - } - > - - - )} - {a.save && saveBtn(job)} - - ); - })} - {(sharedQ.data ?? []).length === 0 && - (sharedQ.isError && !sharedQ.data ? ( - sharedQ.refetch()}> - {t("common.loadError")} - - ) : sharedQ.isLoading ? ( - - ) : ( -
- {t("downloads.empty.shared")} -
- ))} -
- )} - {tab === "system" && me.role === "admin" && } @@ -361,16 +241,6 @@ export default function DownloadCenter() { )} {upload && setUpload(false)} />} {manage && setManage(false)} />} - {metaJob && setMetaJob(null)} />} - {shareJob && setShareJob(null)} />} - {playing && ( - setPlaying(null)} - /> - )} - {editJob && setEditJob(null)} />} {errorJob && ( ({ + files: [] as unknown[], + folders: [] as unknown[], + confirm: vi.fn(async (_o?: unknown) => true), + keys: [] as unknown[], +})); + +vi.mock("react-i18next", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useTranslation: () => ({ + i18n: { language: "en" }, + t: (key: string, o?: Record) => + o && "folder" in o ? `${key}:${String(o.folder)}` : key, + }), + }; +}); +vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm })); +vi.mock("../lib/useLiveQuery", () => ({ + useLiveQuery: (key: readonly unknown[]) => { + mocks.keys.push(key); + return { + data: + key[0] === "files" ? mocks.files : key[0] === "file-folders" ? mocks.folders : undefined, + isError: false, + isLoading: false, + refetch: vi.fn(), + }; + }, +})); +vi.mock("./ui/PageShell", async (importOriginal) => ({ + ...(await importOriginal()), + PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +import Files from "./Files"; + +const file = (id: number, extra: Partial = {}): LibraryFile => + ({ + id, + status: "done", + progress: 100, + created_at: "2026-10-10T10:00:00+00:00", + source_kind: "youtube", + source_ref: `v${id}`, + source_url: null, + poster_url: null, + display_name: `Clip ${id}`, + display_uploader: null, + extra_links: [], + can_download: true, + job_kind: "download", + folder_id: 1, + kept: false, + expires_at: null, + shared: false, + source_key: "youtube", + asset: { media_kind: "video", duration_s: 60, size_bytes: 2048 }, + ...extra, + }) as unknown as LibraryFile; + +const folder = (id: number, extra: Partial): FileFolder => ({ + id, + kind: "source", + source_key: null, + name: null, + retention_days: 30, + count: 1, + size_bytes: 0, + created_at: null, + ...extra, +}); +const YT = folder(1, { source_key: "youtube" }); +const OWN = folder(2, { kind: "own", name: "Cimboráknak", retention_days: null }); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + mocks.confirm.mockClear(); + mocks.keys = []; +}); + +function mount(view: FilesView = ALL_FILES, setView = vi.fn(), search = "") { + render( + + + + + + + , + ); + return setView; +} + +const rowOf = (text: string) => screen.getByText(text).closest("[data-nav-index]")!; +const moreOf = (text: string) => + rowOf(text).querySelector('button[aria-label="files.rowMenu"]')!; +// A folder in the Move submenu is a choice of one (menuitemradio), the rest plain rows. +const menuEls = () => [ + ...screen.getAllByRole("menuitem", { hidden: true }), + ...screen.queryAllByRole("menuitemradio", { hidden: true }), +]; +const menuRows = () => menuEls().map((b) => b.textContent ?? ""); +const menuItem = (label: string) => menuEls().find((b) => b.textContent === label)!; +async function openMenu(open: () => void) { + act(open); + await waitFor(() => + expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0), + ); +} +const rowButtons = (text: string) => + [...rowOf(text).querySelectorAll("button[aria-label], a[aria-label]")].map((b) => + b.getAttribute("aria-label"), + ); + +describe("Files · my file's row", () => { + it("shows Copy link, Share, Save and the '…'; the menu holds every action", async () => { + mocks.files = [file(2, { source_url: "https://youtu.be/x" })]; + mocks.folders = [YT, OWN]; + mount(); + expect(rowButtons("Clip 2")).toEqual([ + "files.actions.copyLink", + "files.actions.share", + "downloads.actions.download", + "files.rowMenu", + ]); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + expect(menuRows()).toEqual([ + "downloads.actions.play", + "files.actions.copyLink", + "files.actions.share", + "downloads.actions.download", + "downloads.actions.edit", + "files.actions.rename", + "files.actions.move", + "files.actions.keep", + "files.actions.details", + "downloads.actions.redownload", + "common.copy.title", + "files.actions.copySourceLink", + "downloads.actions.delete", + ]); + }); + + it("keeps the slot of an action a plain file lacks, so the icons line up", () => { + mocks.files = [file(3, { job_kind: "upload", asset: { media_kind: "file" } } as never)]; + mocks.folders = [YT]; + mount(); + expect(rowButtons("Clip 3")).toEqual([ + "files.actions.share", + "downloads.actions.download", + "files.rowMenu", + ]); + // The empty slot holds the Copy link's place, the same width as an icon. + const actions = rowOf("Clip 3").querySelector( + 'button[aria-label="files.actions.share"]', + )!.parentElement!; + expect(actions.firstElementChild!.getAttribute("aria-hidden")).toBe("true"); + }); + + it("copies the link from the row's own icon", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never); + const create = vi + .spyOn(api, "createDownloadLink") + .mockResolvedValue({ id: 1, url: "/s/tok" } as never); + mount(); + fireEvent.click( + rowOf("Clip 2").querySelector('button[aria-label="files.actions.copyLink"]')!, + ); + await waitFor(() => expect(create).toHaveBeenCalledWith(2, {})); + }); + + it("saves through the file's own address", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + const followed: string[] = []; + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function ( + this: HTMLAnchorElement, + ) { + followed.push(new URL(this.href).pathname); + }); + mount(); + await openMenu(() => fireEvent.click(moreOf("Clip 2"))); + fireEvent.click(menuItem("downloads.actions.download")); + expect(followed).toEqual(["/api/files/2/file"]); + const icon = rowOf("Clip 2").querySelector( + 'a[aria-label="downloads.actions.download"]', + )!; + expect(new URL(icon.href).pathname).toBe("/api/files/2/file"); + }); + + it("renames in the row: Enter saves, an emptied name gives the title back", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("files.actions.rename")); + const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); + fireEvent.change(field, { target: { value: " Új név " } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => expect(update).toHaveBeenCalledWith(2, { name: "Új név" })); + expect(screen.queryByRole("textbox", { name: "files.actions.rename" })).toBeNull(); + + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("files.actions.rename")); + const again = await screen.findByRole("textbox", { name: "files.actions.rename" }); + fireEvent.change(again, { target: { value: "" } }); + fireEvent.keyDown(again, { key: "Enter" }); + await waitFor(() => expect(update).toHaveBeenLastCalledWith(2, { name: null })); + }); + + it("Escape leaves the name as it was", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("files.actions.rename")); + const field = await screen.findByRole("textbox", { name: "files.actions.rename" }); + fireEvent.change(field, { target: { value: "Más" } }); + fireEvent.keyDown(field, { key: "Escape" }); + expect(screen.queryByRole("textbox")).toBeNull(); + expect(update).not.toHaveBeenCalled(); + }); + + it("moves to an own folder, and from there back to its source folder only", async () => { + mocks.files = [file(2, { folder_id: 2 })]; + // Another source folder first, so "its own" is chosen by its key, not by its place in the list. + mocks.folders = [folder(3, { source_key: "site:tiktok.com" }), YT, OWN]; + const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" }); + await waitFor(() => expect(menuRows()).toContain("Cimboráknak")); + // The folder it is in, ticked; home is its own source folder, never another site's. + expect(menuRows()).toContain("files.actions.moveHome:files.source.youtube"); + expect(menuRows().some((r) => r.includes("tiktok"))).toBe(false); + expect(menuItem("Cimboráknak").getAttribute("aria-checked")).toBe("true"); + fireEvent.click(menuItem("files.actions.moveHome:files.source.youtube")); + await waitFor(() => expect(update).toHaveBeenCalledWith(2, { folder_id: 1 })); + }); + + it("offers no move home while it is home, and says so when there is nowhere to go", async () => { + mocks.files = [file(2, { folder_id: 1 })]; + mocks.folders = [YT]; + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.keyDown(menuItem("files.actions.move"), { key: "ArrowRight" }); + await waitFor(() => expect(menuRows()).toContain("files.actions.noOwnFolders")); + expect(menuItem("files.actions.noOwnFolders").hasAttribute("disabled")).toBe(true); + }); + + it("keeps a file, and a kept one is handed back to its folder", async () => { + mocks.files = [file(2), file(3, { kept: true })]; + mocks.folders = [YT]; + const update = vi.spyOn(api, "updateFile").mockResolvedValue({} as never); + mount(); + expect(rowOf("Clip 3").textContent).toContain("files.retention.kept"); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("files.actions.keep")); + await waitFor(() => expect(update).toHaveBeenCalledWith(2, { kept: true })); + fireEvent.keyDown(document.activeElement!, { key: "Escape" }); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 3"))); + fireEvent.click(menuItem("files.actions.unkeep")); + await waitFor(() => expect(update).toHaveBeenLastCalledWith(3, { kept: false })); + }); + + it("deletes only through the confirm", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + const del = vi.spyOn(api, "deleteFile").mockResolvedValue({} as never); + mocks.confirm.mockResolvedValueOnce(false); + mount(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("downloads.actions.delete")); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + expect(del).not.toHaveBeenCalled(); + await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2"))); + fireEvent.click(menuItem("downloads.actions.delete")); + await waitFor(() => expect(del).toHaveBeenCalledWith(2)); + }); +}); + +describe("Files · the confirms", () => { + it("label the confirm button with the action, not the dialog's question", async () => { + mocks.files = [file(2)]; + mocks.folders = [YT]; + mocks.confirm.mockResolvedValue(false); + mount(); + await openMenu(() => fireEvent.click(moreOf("Clip 2"))); + fireEvent.click(menuItem("downloads.actions.redownload")); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + await openMenu(() => fireEvent.click(moreOf("Clip 2"))); + fireEvent.click(menuItem("downloads.actions.delete")); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2)); + expect( + mocks.confirm.mock.calls.map(([o]) => { + const opts = o as { title: string; confirmLabel?: string }; + return [opts.title, opts.confirmLabel]; + }), + ).toEqual([ + ["downloads.confirm.redownloadTitle", "downloads.confirm.redownloadAction"], + ["downloads.confirm.deleteTitle", "downloads.actions.delete"], + ]); + mocks.confirm.mockResolvedValue(true); + }); + + it.each([ + ["upload", "downloads.confirm.deleteUploadTitle", "downloads.confirm.deleteUploadBody"], + ["edit", "downloads.confirm.deleteEditTitle", "downloads.confirm.deleteEditBody"], + ["download", "downloads.confirm.deleteTitle", "downloads.confirm.deleteBody"], + ] as const)("deleting a %s says what happens to its file", async (kind, title, body) => { + // Only a download's file may stay cached for other users; an upload's or a clip's is theirs alone. + mocks.files = [file(5, { job_kind: kind })]; + mocks.folders = [YT]; + mocks.confirm.mockResolvedValueOnce(false); + mount(); + await openMenu(() => fireEvent.click(moreOf("Clip 5"))); + fireEvent.click(menuItem("downloads.actions.delete")); + await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1)); + const opts = mocks.confirm.mock.calls[0]![0] as { title: string; message?: string }; + expect([opts.title, opts.message]).toEqual([title, body]); + }); +}); + +describe("Files · a shared file's row", () => { + it("plays, saves, cuts, copies and drops it from my list — nothing of the owner's", async () => { + mocks.files = [file(4, { shared: true, shared_at: "2026-10-11T08:00:00+00:00" })]; + mocks.folders = [YT]; + const remove = vi.spyOn(api, "removeSharedFile").mockResolvedValue({} as never); + mount({ kind: "shared" }); + expect(rowButtons("Clip 4")).toEqual(["downloads.actions.download", "files.rowMenu"]); + await openMenu(() => fireEvent.click(moreOf("Clip 4"))); + expect(menuRows()).toEqual([ + "downloads.actions.play", + "downloads.actions.download", + "downloads.actions.edit", + "common.copy.title", + "downloads.actions.removeShared", + ]); + fireEvent.click(menuItem("downloads.actions.removeShared")); + await waitFor(() => expect(remove).toHaveBeenCalledWith(4)); + // The shared list is its own read, and nothing can be uploaded into it. + expect(mocks.keys).toContainEqual(["files", { shared: true }]); + expect(screen.queryByText("files.actions.upload")).toBeNull(); + }); +}); + +describe("Files · the view", () => { + it("reads one folder's files and names the folder", () => { + mocks.files = [file(2, { folder_id: 2 })]; + mocks.folders = [YT, OWN]; + mount({ kind: "folder", id: 2 }); + expect(mocks.keys).toContainEqual(["files", { folderId: 2 }]); + expect(screen.getByRole("heading", { name: "Cimboráknak" })).toBeTruthy(); + }); + + it("opens the whole library when the folder it was given is gone", async () => { + mocks.files = []; + mocks.folders = [YT]; + const setView = mount({ kind: "folder", id: 99 }); + expect(mocks.keys).toContainEqual(["files", {}]); + expect(mocks.keys).not.toContainEqual(["files", { folderId: 99 }]); + await waitFor(() => expect(setView).toHaveBeenCalledWith(ALL_FILES)); + }); + + it("searches the rows it holds by name and channel", () => { + mocks.files = [ + file(2, { display_name: "Szméagol szompolyog" }), + file(3, { display_name: "Más", display_uploader: "Szméagol rajongó" }), + file(4, { display_name: "Harmadik" }), + ]; + mocks.folders = [YT]; + mount(ALL_FILES, vi.fn(), "szméagol"); + expect(screen.queryByText("Szméagol szompolyog")).not.toBeNull(); + expect(screen.queryByText("Más")).not.toBeNull(); + expect(screen.queryByText("Harmadik")).toBeNull(); + }); +}); diff --git a/frontend/src/components/Files.tsx b/frontend/src/components/Files.tsx new file mode 100644 index 00000000..b946a737 --- /dev/null +++ b/frontend/src/components/Files.tsx @@ -0,0 +1,478 @@ +import { lazy, Suspense, useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + Clock, + Download, + Link, + Pin, + Scissors, + Share2, + Smartphone, + UploadCloud, +} from "lucide-react"; +import { api, type FileFolder, type FileListParams, type LibraryFile } from "../lib/api"; +import { libraryRowActions, sharedRowActions } from "../lib/downloadActions"; +import { formatBytes, formatDateTime, relativeTime } from "../lib/format"; +import { qk } from "../lib/queryKeys"; +import { LS } from "../lib/storage"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import { JobThumb } from "./downloadRow"; +import { MetaEditModal } from "./downloadModals"; +import { jobTitle } from "./downloadTitle"; +import { folderLabel, sourceIcon, sourceLabel } from "./fileFolders"; +import { ALL_FILES, useFiles, useFilesActions, type FilesView } from "./filesContext"; +import { useFileActions } from "./useFileActions"; +import { Badge } from "./ui/Badge"; +import { Button } from "./ui/Button"; +import { ContextMenu } from "./ui/ContextMenu"; +import DataTable, { type Column } from "./ui/DataTable"; +import { IconButton } from "./ui/IconButton"; +import { Input } from "./ui/Input"; +import { IconLink } from "./ui/LinkButton"; +import { MoreButton } from "./ui/MoreButton"; +import { PageToolbar } from "./ui/PageShell"; +import { Pressable } from "./ui/Pressable"; +import { LoadingState, StateMessage } from "./ui/QueryState"; +import { useContextMenu } from "./ui/useContextMenu"; +// Lazy: these open on demand, so they stay out of the Files chunk until used (the video editor in +// particular is heavy — filmstrip + scrubber). +const UploadDialog = lazy(() => import("./UploadDialog")); +const ShareDialog = lazy(() => import("./ShareDialog")); +const LibraryPlayer = lazy(() => import("./LibraryPlayer")); +const VideoEditor = lazy(() => import("./VideoEditor")); + +// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail +// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of +// value in its own column, an action that does not apply leaving its slot empty, the same menu on a +// right click and on the "…" (DESIGN.md "Files page", "Library list columns"). + +const DAY_MS = 24 * 60 * 60 * 1000; +// A file this close to leaving says so in the warning colour. +const SOON_DAYS = 7; +const fileKey = (f: LibraryFile) => f.id; + +/** The list a view reads. The search is applied here, on the rows (the list is the whole library, + * capped server-side): a request per keystroke would buy nothing the rows do not already hold. */ +function listParams(view: FilesView): FileListParams { + if (view.kind === "shared") return { shared: true }; + if (view.kind === "folder") return { folderId: view.id }; + return {}; +} + +function matches(file: LibraryFile, q: string, lang: string): boolean { + if (!q) return true; + const hay = `${jobTitle(file)} ${file.display_uploader ?? file.asset?.uploader ?? ""}`; + return hay.toLocaleLowerCase(lang).includes(q.toLocaleLowerCase(lang)); +} + +/** Where an upload from this view lands: the folder itself when it may take one (an own folder, the + * Uploads folder — `folders.upload_target`), else the Uploads folder. */ +function uploadFolder(view: FilesView, folders: readonly FileFolder[]): number | null { + if (view.kind !== "folder") return null; + const f = folders.find((x) => x.id === view.id); + return f && (f.kind === "own" || f.source_key === "upload") ? f.id : null; +} + +function daysLeft(iso: string): number { + return Math.max(0, Math.ceil((new Date(iso).getTime() - Date.now()) / DAY_MS)); +} + +function RetentionCell({ file }: { file: LibraryFile }) { + const { t, i18n } = useTranslation(); + if (file.kept) + return ( + + + {t("files.retention.kept")} + + ); + if (!file.expires_at) + return ( + + — + + ); + const days = daysLeft(file.expires_at); + const when = formatDateTime(file.expires_at, i18n.language); + return days <= SOON_DAYS ? ( + + + {t("files.retention.days", { count: days })} + + ) : ( + + {t("files.retention.days", { count: days })} + + ); +} + +function NameCell({ + file, + renaming, + onPlay, + onRenamed, +}: { + file: LibraryFile; + renaming: boolean; + onPlay: (() => void) | null; + onRenamed: (name: string | null) => void; +}) { + const { t } = useTranslation(); + const [draft, setDraft] = useState(() => jobTitle(file)); + const channel = file.display_uploader || file.asset?.uploader || null; + const clip = file.job_kind === "edit" || file.job_kind === "clip"; + if (renaming) + return ( + e.currentTarget.select()} + onChange={(e) => setDraft(e.target.value)} + onBlur={() => onRenamed(draft)} + onKeyDown={(e) => { + // preventDefault: a real Enter's own activation would otherwise press what focus lands on. + if (e.key === "Enter") { + e.preventDefault(); + onRenamed(draft); + } + // Unmounting the input fires no blur in React, so Escape really does cancel. + if (e.key === "Escape") { + e.preventDefault(); + onRenamed(null); + } + }} + /> + ); + const title = {jobTitle(file)}; + return ( +
+ {onPlay ? ( + + {title} + + ) : ( + {title} + )} + + {channel ?? "—"} + {clip && ( + + + {t("downloads.clipBadge")} + + )} + +
+ ); +} + +export default function Files() { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + const { search, view } = useFiles(); + const { setView } = useFilesActions(); + + const foldersQ = useLiveQuery(qk.fileFolders(), api.fileFolders, { intervalMs: 15000 }); + const folders = foldersQ.data ?? []; + // A remembered or bookmarked folder that is gone (deleted, or another account's id) opens the whole + // library instead of an empty page that names nothing; the provider is told, so the rail agrees. + const gone = view.kind === "folder" && !!foldersQ.data && !folders.some((f) => f.id === view.id); + const shown = gone ? ALL_FILES : view; + useEffect(() => { + if (gone) setView(ALL_FILES); + }, [gone, setView]); + + const params = listParams(shown); + // Live at rest: a download finishing elsewhere (the queue, a clip, another tab) lands here on its own. + const filesQ = useLiveQuery(qk.files(params), () => api.files(params), { intervalMs: 15000 }); + const all = filesQ.data ?? []; + const q = search.trim(); + const rows = all.filter((f) => matches(f, q, lang)); + + const [renamingId, setRenamingId] = useState(null); + const [upload, setUpload] = useState(false); + const [metaFile, setMetaFile] = useState(null); + const [shareFile, setShareFile] = useState(null); + const [editFile, setEditFile] = useState(null); + const [playing, setPlaying] = useState<{ file: LibraryFile; fromStart: boolean } | null>(null); + const play = (file: LibraryFile, fromStart = false) => setPlaying({ file, fromStart }); + + const actions = useFileActions({ + folders, + onPlay: play, + onEdit: setEditFile, + onRename: (f) => setRenamingId(f.id), + onEditDetails: setMetaFile, + onManageShares: setShareFile, + }); + // Keyed by id: the list re-reads every 15 s, replacing the file under an open menu, and its "…" + // must still close it. + const rowMenu = useContextMenu({ keyOf: fileKey }); + const menuItems = rowMenu.target ? actions.items(rowMenu.target) : []; + + const open = shown.kind === "folder" ? folders.find((f) => f.id === shown.id) : undefined; + // A folder view names its folder once the folder list is in (blank until then, never a guess). + const title = + shown.kind === "shared" + ? t("files.rail.shared") + : shown.kind === "folder" + ? open + ? folderLabel(open, t) + : "" + : t("files.rail.all"); + const canUpload = shown.kind !== "shared"; + + const iconSlot = ; + const columns: Column[] = [ + { + key: "thumb", + header: "", + width: "5.5rem", + hideInCard: true, + // A narrow table (a pinned rail beside it) gives its fixed columns' room to the name: with the + // picture and the date in, the name had none left. + narrowHidden: true, + render: (f) => ( + + ), + }, + { + key: "name", + header: t("files.columns.name"), + cardPrimary: true, + sortable: true, + sortValue: (f) => jobTitle(f), + render: (f) => { + const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); + return ( + play(f) : null} + onRenamed={(name) => { + setRenamingId(null); + if (name !== null) actions.rename(f, name); + }} + /> + ); + }, + }, + { + key: "source", + header: t("files.columns.source"), + width: "8rem", + tabletHidden: true, + render: (f) => { + const Icon = sourceIcon(f.source_key); + return ( + + + {sourceLabel(f.source_key, t)} + + ); + }, + }, + { + key: "phone", + header: t("files.columns.phone"), + width: "5.5rem", + narrowHidden: true, + // Every library video and sound is phone-safe by construction (backend downloads/playable.py); + // a plain file is not a thing a phone plays. + render: (f) => + f.asset?.media_kind === "file" ? ( + — + ) : ( + + + ✓ + + ), + }, + { + key: "retention", + header: t("files.columns.retention"), + width: "7.5rem", + narrowHidden: true, + render: (f) => , + }, + { + key: "size", + header: t("files.columns.size"), + width: "5.5rem", + align: "right", + nowrap: true, + sortable: true, + sortValue: (f) => f.asset?.size_bytes ?? 0, + render: (f) => ( + + {formatBytes(f.asset?.size_bytes, lang)} + + ), + }, + { + key: "added", + header: t("files.columns.added"), + width: "6.5rem", + narrowHidden: true, + align: "right", + nowrap: true, + sortable: true, + sortValue: (f) => f.shared_at ?? f.created_at ?? "", + render: (f) => { + const at = f.shared_at ?? f.created_at; + return ( + + {relativeTime(at, lang, t)} + + ); + }, + }, + { + key: "actions", + header: "", + width: "10rem", + align: "right", + cardLabel: false, + render: (f) => { + const a = f.shared ? sharedRowActions(f) : libraryRowActions(f); + const mine = rowMenu.open && rowMenu.target?.id === f.id; + return ( + + {!f.shared && a.play ? ( + actions.share(f)} + > + + + ) : ( + iconSlot + )} + {!f.shared ? ( + setShareFile(f)} + > + + + ) : ( + iconSlot + )} + {f.can_download ? ( + + + + ) : ( + iconSlot + )} + + + ); + }, + }, + ]; + + const body = + filesQ.isError && !filesQ.data ? ( + filesQ.refetch()}> + {t("common.loadError")} + + ) : filesQ.isLoading ? ( + + ) : ( + String(f.id)} + persistKey={LS.filesTable} + pageSize={50} + keyboardNav + fixedLayout + rowProps={(f) => rowMenu.triggerProps(f)} + emptyText={ + q + ? t("files.empty.search", { q }) + : t( + shown.kind === "shared" + ? "files.empty.shared" + : shown.kind === "folder" + ? "files.empty.folder" + : "files.empty.all", + ) + } + /> + ); + + return ( + <> + +
+

{title}

+ {filesQ.data && ( + + {t("files.count", { count: rows.length })} + + )} + {canUpload && ( + + )} +
+
+ +
+ {body} + + + {upload && ( + setUpload(false)} + /> + )} + {metaFile && setMetaFile(null)} />} + {shareFile && setShareFile(null)} />} + {playing && ( + setPlaying(null)} + /> + )} + {editFile && setEditFile(null)} />} + +
+ + ); +} diff --git a/frontend/src/components/FilesProvider.tsx b/frontend/src/components/FilesProvider.tsx new file mode 100644 index 00000000..3abed005 --- /dev/null +++ b/frontend/src/components/FilesProvider.tsx @@ -0,0 +1,55 @@ +import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { LS, getAccountRaw, setAccountRaw } from "../lib/storage"; +import { useUrlMirror } from "../lib/useUrlMirror"; +import { useNavigation } from "./navigationContext"; +import { + ALL_FILES, + FilesActionsContext, + FilesStateContext, + parseView, + viewParam, + type FilesActions, + type FilesState, + type FilesView, +} from "./filesContext"; + +// Owns the Files module's shared state — the header search term and the view the rail picked (every +// file, shared with me, or a folder) — so the header's search box, the FilesRail and the Files page +// read it via hooks. Shaped like PlaylistsProvider: split State + Actions contexts, composed in +// main.tsx around . +// +// The search is ephemeral (resets on reload, like the other list filters); the view is remembered per +// account and mirrored to `?folder=` on the Files page, so a reload or a bookmark opens the same folder. +// A `?folder=` in the address wins over the remembered one. A folder that no longer exists is the +// page's to fall back from (it knows the folder list). + +function initialView(): FilesView { + return ( + parseView(new URLSearchParams(window.location.search).get("folder")) ?? + parseView(getAccountRaw(LS.filesView)) ?? + ALL_FILES + ); +} + +export function FilesProvider({ children }: { children: ReactNode }) { + const [search, setSearchState] = useState(""); + const [view, setViewState] = useState(initialView); + + const setSearch = useCallback((q: string) => setSearchState(q), []); + const setView = useCallback((next: FilesView) => { + setViewState(next); + setAccountRaw(LS.filesView, viewParam(next)); + }, []); + + const { page } = useNavigation(); + useUrlMirror({ folder: page === "files" ? viewParam(view) : null }); + + const state = useMemo(() => ({ search, view }), [search, view]); + const actions = useMemo(() => ({ setSearch, setView }), [setSearch, setView]); + + return ( + + {children} + + ); +} diff --git a/frontend/src/components/FilesRail.tsx b/frontend/src/components/FilesRail.tsx new file mode 100644 index 00000000..c709c182 --- /dev/null +++ b/frontend/src/components/FilesRail.tsx @@ -0,0 +1,187 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import clsx from "clsx"; +import { FolderOpen, HardDrive, Users } from "lucide-react"; +import { api, type FileFolder } from "../lib/api"; +import { compareText } from "../lib/collate"; +import { formatBytes } from "../lib/format"; +import { type PanelLayout } from "../lib/panelLayout"; +import { qk } from "../lib/queryKeys"; +import { useLiveQuery } from "../lib/useLiveQuery"; +import FilterRail from "./FilterRail"; +import { folderIcon, folderLabel } from "./fileFolders"; +import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext"; +import { Pressable } from "./ui/Pressable"; +import { type PanelItem } from "./ui/PanelGroups"; + +// The Files module's left rail: the library's places — every file, the ones shared with me, the +// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted +// to App level like the other rails (the SidePanel's collapse and customise come with it); the view it +// picks lives in FilesProvider, which the Files page reads. + +function Entry({ + icon: Icon, + label, + count, + view, +}: { + icon: React.ComponentType<{ className?: string }>; + label: string; + count?: number; + view: FilesView; +}) { + const { view: current } = useFiles(); + const { setView } = useFilesActions(); + const active = sameView(view, current); + return ( + setView(view)} + className={clsx( + "flex w-full min-h-8 items-center gap-2 rounded-lg px-2 text-sm", + active ? "bg-accent/12 font-medium text-accent" : "text-fg", + )} + > + + {label} + {count !== undefined && {count}} + + ); +} + +function StorageMeter() { + const { t, i18n } = useTranslation(); + const lang = i18n.language; + // Live while something is in flight (a download's bytes land when the worker finishes it), at rest + // otherwise — the old Download Center's meter, moved to where the files are. + const usage = useLiveQuery(qk.storageUsage(), api.filesUsage, { + intervalMs: (u) => (u?.running_jobs ? 2000 : 15000), + }); + const u = usage.data; + if (!u) return null; + const pct = + u.unlimited || !u.max_bytes ? 0 : Math.min(100, (u.footprint_bytes / u.max_bytes) * 100); + return ( +
+ + {t("files.usage.title")} + + {formatBytes(u.footprint_bytes, lang)} + {u.unlimited ? "" : ` / ${formatBytes(u.max_bytes, lang)}`} + + + {!u.unlimited && ( + + 90 ? "bg-danger" : "bg-accent")} + style={{ width: `${pct}%` }} + /> + + )} +
+ ); +} + +export default function FilesRail({ + layout, + setLayout, + collapsed, + onToggleCollapse, +}: { + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const { t, i18n } = useTranslation(); + const { view } = useFiles(); + const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); + const folders = foldersQ.data ?? []; + const total = folders.reduce((n, f) => n + f.count, 0); + // A source folder its files have all left stays on the server; here it shows only while it holds + // something, or while it is the one open (so the page never points at a folder the rail hides). + const shown = (f: FileFolder) => f.count > 0 || sameView(view, { kind: "folder", id: f.id }); + const byName = (a: FileFolder, b: FileFolder) => + compareText(folderLabel(a, t), folderLabel(b, t), i18n.language); + const sources = folders.filter((f) => f.kind === "source" && shown(f)).sort(byName); + const own = folders.filter((f) => f.kind === "own").sort(byName); + + const failed = foldersQ.isError && !foldersQ.data && ( + foldersQ.refetch()} + className="px-2 py-1 text-xs text-danger hover:underline" + > + {t("common.loadError")} — {t("common.retry")} + + ); + const entry = (f: FileFolder) => ( + + ); + + const items: PanelItem[] = [ + { + id: "library", + title: t("files.rail.library"), + body: ( +
+ + +
+ ), + }, + { + id: "sources", + title: t("files.rail.sources"), + body: ( +
+ {failed} + {sources.map(entry)} + {foldersQ.data && sources.length === 0 && ( + {t("files.rail.noSources")} + )} +
+ ), + }, + { + id: "own", + title: t("files.rail.own"), + body: ( +
+ {failed} + {own.map(entry)} + {foldersQ.data && own.length === 0 && ( + {t("files.rail.noOwn")} + )} +
+ ), + }, + ]; + + return ( + } + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + layout={layout} + setLayout={setLayout} + items={items} + > + + + ); +} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 14b49611..d9f0c154 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { YouTubeIcon } from "../lib/brandIcons"; import type { Me } from "../lib/api"; import SearchBar from "./SearchBar"; +import { useFiles, useFilesActions } from "./filesContext"; import PlexSearchBox from "./PlexSearchBox"; import PlexScopeToggle from "./PlexScopeToggle"; import PlexViewToggle from "./PlexViewToggle"; @@ -37,6 +38,8 @@ const Header = forwardRef< const { search: playlistsSearch } = usePlaylists(); const { setSearch: setPlaylistsSearch } = usePlaylistsActions(); const { search: channelsSearch } = useChannels(); + const { search: filesSearch } = useFiles(); + const { setSearch: setFilesSearch } = useFilesActions(); const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions(); // Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube). const blur = () => (document.activeElement as HTMLElement | null)?.blur?.(); @@ -77,6 +80,15 @@ const Header = forwardRef< onGo={blur} /> ); + } else if (page === "files") { + search = ( + + ); } else if (page === "playlists") { search = ( { ); const video = document.body.querySelector("video"); expect(video).not.toBeNull(); - expect(video!.getAttribute("src")).toBe("/api/downloads/7/file"); + expect(video!.getAttribute("src")).toBe("/api/files/7/file"); // No