release: 0.117.0 (improvement/e57-files-and-small-ideas)
This commit is contained in:
@@ -7,6 +7,19 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 0.117.0 — 2026-11-06
|
||||
|
||||
### New
|
||||
|
||||
- A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.
|
||||
- The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.
|
||||
- The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.
|
||||
- A Plex auto-skip waits 3 seconds into the marker before its countdown starts.
|
||||
|
||||
## 0.116.3 — 2026-11-04
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -74,7 +74,7 @@ only along a declared edge and writes them only through that unit's service; cor
|
||||
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
||||
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
||||
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
||||
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||
boot; a share link (`/watch`, `/file`, `/folder`) boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
||||
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
|
||||
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
|
||||
|
||||
@@ -201,6 +201,16 @@ def _ratio(v: object) -> float | None:
|
||||
return n / d if n > 0 and d > 0 else None
|
||||
|
||||
|
||||
def quarter_turned(probe: dict) -> bool | None:
|
||||
"""Whether the first video stream of an ffprobe `-show_streams` JSON is stored turned a quarter
|
||||
(ffmpeg stands it upright before any filter, so its filters see width and height swapped). None
|
||||
when there is no video stream."""
|
||||
for s in probe.get("streams") or []:
|
||||
if s.get("codec_type") == "video":
|
||||
return _quarter_turned(s)
|
||||
return None
|
||||
|
||||
|
||||
def _quarter_turned(stream: dict) -> bool:
|
||||
rotation: object = (stream.get("tags") or {}).get("rotate")
|
||||
for sd in stream.get("side_data_list") or []:
|
||||
|
||||
@@ -11,7 +11,9 @@ with the worker branching on the asset's `source_kind` to run ffmpeg instead of
|
||||
(the `-ss` seeks first), so a short clip is cheap even from a long source
|
||||
* crop (± trim) → always re-encode (a filter can't be stream-copied), frame-accurate
|
||||
|
||||
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h}, accurate?: bool}`. `accurate` is the
|
||||
`edit_spec = {trim?: {start_s, end_s}, crop?: {x, y, w, h, frame_w?, frame_h?}, accurate?: bool}`. A
|
||||
crop with `frame_w`/`frame_h` counts the picture as it is SHOWN, measured on a frame of that size
|
||||
(`crop_filter`); one without them is in the file's stored pixels (its older form). `accurate` is the
|
||||
per-edit user choice for a trim-only cut (ignored when a crop forces a re-encode anyway). `split`
|
||||
is a frontend concept: the
|
||||
UI fans a split out into N trim jobs, so there is one output file per job here.
|
||||
@@ -60,6 +62,46 @@ class EditAborted(Exception):
|
||||
_MAX_SEGMENTS = 100
|
||||
|
||||
|
||||
def _crop_frame(crop: dict) -> dict | None:
|
||||
"""The picture size a crop was measured on, or None when it has none it could be scaled by (a
|
||||
spec from before it was sent, which `crop_filter` cuts in the file's own pixels)."""
|
||||
try:
|
||||
w, h = int(crop["frame_w"]), int(crop["frame_h"])
|
||||
# silent-ok: no frame is a crop's older form, and it is still the crop that was asked for. An
|
||||
# infinite number (JSON `1e400`) is OverflowError, and it is no more a frame than "x" is.
|
||||
except (KeyError, TypeError, ValueError, OverflowError):
|
||||
return None
|
||||
return {"frame_w": w, "frame_h": h} if w > 0 and h > 0 else None
|
||||
|
||||
|
||||
def crop_filter(crop: dict) -> str:
|
||||
"""The ffmpeg `crop` of an edit. Its numbers count the picture as the browser reports it, which
|
||||
is its SHOWN size: a video with wide pixels (320 stored, 640 shown) would be cut off to one side
|
||||
in ffmpeg's stored ones, so they are scaled to `iw` / `ih` by the size they were measured on. A
|
||||
rotated file needs nothing more: both the browser and ffmpeg's filters see it upright."""
|
||||
frame_w, frame_h = crop.get("frame_w"), crop.get("frame_h")
|
||||
if not frame_w or not frame_h:
|
||||
return f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}"
|
||||
return (
|
||||
f"crop=iw*{crop['w']}/{frame_w}:ih*{crop['h']}/{frame_h}"
|
||||
f":iw*{crop['x']}/{frame_w}:ih*{crop['y']}/{frame_h}"
|
||||
)
|
||||
|
||||
|
||||
def crop_stored_size(crop: dict, src_w: int | None, src_h: int | None) -> tuple[int, int] | None:
|
||||
"""The size `crop_filter` cuts out of a `src_w` x `src_h` frame, evened as `EVEN_FRAME` evens it —
|
||||
the clip's size when ffprobe cannot tell. The frame is the one ffmpeg's filters see: a rotated
|
||||
file's stood upright, its stored size turned (the caller's to turn). A crop counted in the shown
|
||||
picture is a fraction of that frame, not its own pixel count; None when the size is unknown."""
|
||||
if crop.get("frame_w") and crop.get("frame_h"):
|
||||
if not src_w or not src_h:
|
||||
return None
|
||||
w, h = src_w * crop["w"] // crop["frame_w"], src_h * crop["h"] // crop["frame_h"]
|
||||
else:
|
||||
w, h = crop["w"], crop["h"]
|
||||
return w // 2 * 2, h // 2 * 2
|
||||
|
||||
|
||||
def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
"""Canonicalize a raw edit spec: clamp/round trim, coerce crop to ints, drop empties.
|
||||
|
||||
@@ -72,7 +114,7 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
if crop:
|
||||
try:
|
||||
c = {k: int(round(float(crop[k]))) for k in ("x", "y", "w", "h")}
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
except (KeyError, TypeError, ValueError, OverflowError) as exc:
|
||||
# The clip still renders — uncropped. The user asked for a crop, got a whole frame,
|
||||
# and nothing anywhere called it a failure; this line is the only trace there is.
|
||||
log.warning(
|
||||
@@ -81,7 +123,8 @@ def normalize_edit_spec(spec: dict | None) -> dict:
|
||||
)
|
||||
c = None
|
||||
if c and c["w"] > 0 and c["h"] > 0 and c["x"] >= 0 and c["y"] >= 0:
|
||||
out["crop"] = c
|
||||
frame = _crop_frame(crop)
|
||||
out["crop"] = {**c, **frame} if frame else c
|
||||
|
||||
# Multi-segment JOIN (cut-list → one concatenated file). Takes precedence over `trim`.
|
||||
segs = spec.get("segments")
|
||||
@@ -268,7 +311,7 @@ def build_edit_cmd(src: Path, dest: Path, spec: dict, out_ext: str) -> list[str]
|
||||
if reencode:
|
||||
# Input -ss + re-encode is frame-accurate (decodes from the prior keyframe, discards up to
|
||||
# the exact cut). A crop adds the filter; an accurate trim only evens the frame.
|
||||
cropf = f"crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}," if crop else ""
|
||||
cropf = f"{crop_filter(crop)}," if crop else ""
|
||||
cmd += ["-vf", cropf + EVEN_FRAME, *X264_ARGS, *AAC_ARGS]
|
||||
else:
|
||||
cmd += ["-c", "copy", "-avoid_negative_ts", "make_zero"]
|
||||
@@ -313,7 +356,7 @@ def build_concat_plan(src: Path, dest: Path, spec: dict, out_ext: str, staging:
|
||||
]
|
||||
|
||||
if needs_reencode(spec):
|
||||
cropf = f",crop={crop['w']}:{crop['h']}:{crop['x']}:{crop['y']}" if crop else ""
|
||||
cropf = f",{crop_filter(crop)}" if crop else ""
|
||||
parts, labels = [], []
|
||||
for i, s in enumerate(segs):
|
||||
st, en = s["start_s"], s["end_s"]
|
||||
|
||||
@@ -127,14 +127,35 @@ def check_grant(token: str, grant: str | None, password_version: int) -> bool:
|
||||
# --- serialization -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def owner_view(link: DownloadLink, base_url: str = "", token: str | None = None) -> dict:
|
||||
def opens(db: Session, link: DownloadLink) -> str:
|
||||
"""What a link opens, in the words its address and its preview use: "folder", "video" or
|
||||
"file". A file still being made reads as the video it mostly is."""
|
||||
from app.downloads import mediakind
|
||||
|
||||
if link.folder_id is not None:
|
||||
return "folder"
|
||||
job = db.get(DownloadJob, link.job_id)
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
kind = asset.media_kind if asset else None
|
||||
return "file" if kind and kind != mediakind.VIDEO else "video"
|
||||
|
||||
|
||||
def share_path(db: Session, link: DownloadLink, token: str) -> str:
|
||||
"""The address a link is given out on: its path says what it opens (`opens`). Only a word for the
|
||||
reader, the token is the whole key: every prefix of `main.SHARE_PREFIXES` opens every link, so
|
||||
the links already sent on /watch/ work for good."""
|
||||
prefix = {"folder": "folder", "file": "file", "video": "watch"}[opens(db, link)]
|
||||
return f"/{prefix}/{token}"
|
||||
|
||||
|
||||
def owner_view(db: Session, link: DownloadLink, token: str | None = None) -> dict:
|
||||
"""What the file owner sees when managing their links. `token` is the one just made (it is in
|
||||
hand even where no copy is kept); else it is read back, and `url` is None when it cannot be."""
|
||||
token = token or recover(link)
|
||||
return {
|
||||
"id": link.id,
|
||||
"token": token,
|
||||
"url": f"{base_url}/watch/{token}" if token else None,
|
||||
"url": share_path(db, link, token) if token else None,
|
||||
"allow_download": link.allow_download,
|
||||
"has_password": link.password_hash is not None,
|
||||
"expires_at": link.expires_at.isoformat() if link.expires_at else None,
|
||||
|
||||
@@ -234,7 +234,9 @@ class DownloadJob(Base, TimestampMixin, UpdatedAtMixin):
|
||||
source_asset_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("media_assets.id", ondelete="SET NULL")
|
||||
)
|
||||
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h}} in pixels. For clip
|
||||
# For edit jobs: the trim/crop recipe {trim?:{start_s,end_s}, crop?:{x,y,w,h,frame_w?,frame_h?}}, its
|
||||
# crop in the SHOWN picture's pixels when it carries the frame it was measured on (`edit.crop_filter`),
|
||||
# else in the file's stored ones. For clip
|
||||
# jobs: the clip spec {source, ref, start_s, end_s, audio_track?, crop?:{x,y,w,h,aspect}}, its crop
|
||||
# in fractions of the box it was drawn in (`downloads.clip.normalize`).
|
||||
edit_spec: Mapped[dict | None] = mapped_column(JSON)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Server-rendered Open Graph tags for the public /watch/{token} page.
|
||||
"""Server-rendered Open Graph tags for the public page of a share link (/watch/, /file/, /folder/).
|
||||
|
||||
The watch page is a client-side SPA, so a social crawler (facebookexternalhit, Twitterbot, …) that
|
||||
fetches /watch/{token} would otherwise only see the generic index.html — a blank/greyed link card.
|
||||
@@ -76,16 +76,21 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
link = linksmod.find(db, token)
|
||||
if link is None or linksmod.is_expired(link):
|
||||
return None
|
||||
url = f"{settings.app_base}/watch/{token}"
|
||||
folder = link.folder_id is not None
|
||||
url = f"{settings.app_base}{linksmod.share_path(db, link, token)}"
|
||||
# What the card calls it, as its address does.
|
||||
kind = linksmod.opens(db, link)
|
||||
folder, video = kind == "folder", kind == "video"
|
||||
if link.password_hash:
|
||||
# Password-gated: don't leak the title/thumbnail in the unfurl.
|
||||
return "\n ".join(
|
||||
[
|
||||
"<title>Siftlode</title>",
|
||||
_tag("og:title", "Password-protected " + ("folder" if folder else "video")),
|
||||
_tag(
|
||||
"og:title",
|
||||
"Password-protected " + ("folder" if folder else "video" if video else "file"),
|
||||
),
|
||||
_tag("og:description", "Shared via Siftlode"),
|
||||
_tag("og:type", "website" if folder else "video.other"),
|
||||
_tag("og:type", "video.other" if video else "website"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
]
|
||||
@@ -96,7 +101,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
asset = db.get(MediaAsset, job.asset_id) if job and job.asset_id else None
|
||||
if job is None or asset is None or asset.status != "ready":
|
||||
return None
|
||||
title = (job.display_name or asset.title or "Video").strip()
|
||||
title = (job.display_name or asset.title or ("Video" if video else "File")).strip()
|
||||
channel = (job.display_uploader or asset.uploader or "").strip()
|
||||
# Pick the link-preview image. Facebook's scraper REJECTS our own poster ("Corrupted Image" in the
|
||||
# Sharing Debugger): the poster is an ffmpeg-produced JPEG whose non-standard marker order FB's
|
||||
@@ -118,7 +123,7 @@ def _watch_tags(token: str, db: Session) -> str | None:
|
||||
f"<title>{html.escape(title)}</title>",
|
||||
_tag("og:title", title),
|
||||
_tag("og:description", channel or "Shared via Siftlode"),
|
||||
_tag("og:type", "video.other"),
|
||||
_tag("og:type", "video.other" if video else "website"),
|
||||
_tag("og:url", url),
|
||||
_tag("og:site_name", "Siftlode"),
|
||||
_tag("twitter:title", title, attr="name"),
|
||||
|
||||
@@ -837,6 +837,18 @@ def _fail_edit(
|
||||
)
|
||||
|
||||
|
||||
def _filter_frame(
|
||||
path: Path, width: int | None, height: int | None
|
||||
) -> tuple[int | None, int | None]:
|
||||
"""A file's frame as ffmpeg's filters see it: the stored size, turned when the stream is stored
|
||||
turned a quarter (ffmpeg stands it upright first). Unknown when the file cannot be read."""
|
||||
data = probe.probe_json(path)
|
||||
turned = clipmod.quarter_turned(data) if data else None
|
||||
if turned is None:
|
||||
return None, None
|
||||
return (height, width) if turned else (width, height)
|
||||
|
||||
|
||||
def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
"""Produce a per-user trim/crop clip from the source asset's downloaded file via ffmpeg."""
|
||||
with SessionLocal() as db:
|
||||
@@ -905,6 +917,12 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
size = final.stat().st_size if final.exists() else None
|
||||
# The clip's real frame: a re-encode evens an odd crop or source (`editmod.EVEN_FRAME`).
|
||||
_, _, out_w, out_h, _ = probe.probe_media(final)
|
||||
# Unsized, a crop's own size stands in, cut from the frame the filters saw.
|
||||
cut = (
|
||||
editmod.crop_stored_size(edit_spec["crop"], *_filter_frame(src_path, src_w, src_h))
|
||||
if crop and not (out_w and out_h)
|
||||
else None
|
||||
)
|
||||
|
||||
with SessionLocal() as db:
|
||||
asset = db.get(MediaAsset, asset_id)
|
||||
@@ -916,11 +934,10 @@ def _process_edit(job_id: int, asset_id: int) -> None:
|
||||
asset.duration_s = total_s or asset.duration_s
|
||||
if out_w and out_h:
|
||||
asset.width, asset.height = out_w, out_h
|
||||
elif crop:
|
||||
asset.width = edit_spec["crop"]["w"]
|
||||
asset.height = edit_spec["crop"]["h"]
|
||||
elif cut:
|
||||
asset.width, asset.height = cut
|
||||
else:
|
||||
asset.width, asset.height = src_w, src_h
|
||||
asset.width, asset.height = (None, None) if crop else (src_w, src_h)
|
||||
asset.error = None
|
||||
asset.last_access_at = datetime.now(timezone.utc)
|
||||
job = db.get(DownloadJob, job_id)
|
||||
|
||||
@@ -16,7 +16,8 @@ That is a per-job ceiling, not an aggregate ledger: concurrent jobs of one user
|
||||
remainder (in-flight bytes are not `ready`, so `footprint` cannot see them), so a wave of
|
||||
max_concurrent downloads can still land over the cap — the deliberate trade, since a download's size
|
||||
is unknown until it finishes. CLIP jobs consult it before their fetch or encode and again with
|
||||
their output's real size before it is placed. Edit jobs do not consult it at all (tracked as SEC-19).
|
||||
their output's real size before it is placed. Edit jobs consult it with their output's real size
|
||||
before it is placed (`test_edit_cap.py`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
+10
-4
@@ -368,6 +368,10 @@ INDEX_HTML = STATIC_DIR / "index.html"
|
||||
# A public share link's own page: the player alone, without the app's bundle (frontend/watch.html).
|
||||
WATCH_HTML = STATIC_DIR / "watch.html"
|
||||
INDEX_HEADERS = {"Cache-Control": "no-cache"}
|
||||
# The paths a share link opens on, by what it opens (`links.share_path`). Any of them opens any link.
|
||||
# A new one also goes in frontend/public/robots.txt, the Vite dev entry and WatchPage's token parsing
|
||||
# (watchEntry.contract.test.ts).
|
||||
SHARE_PREFIXES = ("watch/", "file/", "folder/")
|
||||
app.mount(
|
||||
"/assets",
|
||||
StaticFiles(directory=STATIC_DIR / "assets", check_dir=False),
|
||||
@@ -381,8 +385,10 @@ async def index() -> FileResponse:
|
||||
|
||||
|
||||
@app.get("/watch/{token}")
|
||||
async def watch_page(token: str):
|
||||
"""Serve the public player page for a share link, with per-video Open Graph tags injected so the
|
||||
@app.get("/file/{token}")
|
||||
@app.get("/folder/{token}")
|
||||
async def share_page(token: str):
|
||||
"""Serve the public page of a share link, with per-link Open Graph tags injected so the
|
||||
link unfurls richly (title/channel/thumbnail) in Messenger/social crawlers. A real browser
|
||||
ignores the extra tags and boots WatchPage; invalid/expired/password links fall back to the
|
||||
generic card."""
|
||||
@@ -409,8 +415,8 @@ async def spa_fallback(full_path: str) -> FileResponse:
|
||||
# Real SPA-root assets (welcome images, favicon, robots.txt) rarely change and have
|
||||
# stable names, so a moderate cache is safe and satisfies the cache-lifetime audit.
|
||||
return FileResponse(candidate, headers={"Cache-Control": "public, max-age=604800"})
|
||||
# A /watch/ path the route above does not match (a trailing segment, no token) is still the
|
||||
# A share path the route above does not match (a trailing segment, no token) is still the
|
||||
# player's: WatchPage reads the token off the path and says when a link is gone.
|
||||
if full_path.startswith("watch/"):
|
||||
if full_path.startswith(SHARE_PREFIXES):
|
||||
return FileResponse(WATCH_HTML, headers=INDEX_HEADERS)
|
||||
return FileResponse(INDEX_HTML, headers=INDEX_HEADERS)
|
||||
|
||||
@@ -41,11 +41,25 @@ def video(db: Session, video_id: str) -> Video | None:
|
||||
# --- tags ------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int]]:
|
||||
"""The tags this user sees (the shared system ones and their own), each with how many channel
|
||||
links it has, by category then name."""
|
||||
def tags_with_counts(db: Session, user: User) -> list[tuple[Tag, int, int]]:
|
||||
"""The tags this user sees (the shared system ones and their own), by category then name, each
|
||||
with how many channel links it has and how many of the user's own links sit on a channel they
|
||||
subscribe to and have not hidden — the rows the Channels page's tag filter shows (a hidden
|
||||
channel has its own tab). A link outlives an unsubscribe and a hide, so the two differ."""
|
||||
subscribed = select(Subscription.channel_id).where(
|
||||
owned_by(Subscription.user_id, user), Subscription.hidden.is_(False)
|
||||
)
|
||||
on_subscribed = dict(
|
||||
db.execute(
|
||||
select(ChannelTag.tag_id, func.count(ChannelTag.id))
|
||||
.where(owned_by(ChannelTag.user_id, user), ChannelTag.channel_id.in_(subscribed))
|
||||
.group_by(ChannelTag.tag_id)
|
||||
)
|
||||
.tuples()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
(tag, count)
|
||||
(tag, count, on_subscribed.get(tag.id, 0))
|
||||
for tag, count in db.execute(
|
||||
select(Tag, func.count(ChannelTag.id))
|
||||
.outerjoin(ChannelTag, ChannelTag.tag_id == Tag.id)
|
||||
|
||||
@@ -611,7 +611,7 @@ def list_links(
|
||||
job_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
own_job(db, user, job_id)
|
||||
return [linksmod.owner_view(link) for link in queries.links_to(db, job_id=job_id)]
|
||||
return [linksmod.owner_view(db, link) for link in queries.links_to(db, job_id=job_id)]
|
||||
|
||||
|
||||
class CreateLinkIn(BaseModel):
|
||||
@@ -653,7 +653,7 @@ def create_link(
|
||||
raise refused(409, "share_unfinished")
|
||||
link, token = _new_link(db, user, body, job_id=job_id)
|
||||
db.commit()
|
||||
return linksmod.owner_view(link, token=token)
|
||||
return linksmod.owner_view(db, link, token=token)
|
||||
|
||||
|
||||
def _shareable_job(db: Session, user: User, job_id: int) -> "DownloadJob":
|
||||
@@ -684,7 +684,7 @@ def list_folder_links(
|
||||
folder_id: int, user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> list[dict]:
|
||||
own_folder(db, user, folder_id)
|
||||
return [linksmod.owner_view(link) for link in queries.links_to(db, folder_id=folder_id)]
|
||||
return [linksmod.owner_view(db, link) for link in queries.links_to(db, folder_id=folder_id)]
|
||||
|
||||
|
||||
@router.post("/folders/{folder_id}/links")
|
||||
@@ -697,7 +697,7 @@ def create_folder_link(
|
||||
_shareable_folder(db, user, folder_id)
|
||||
link, token = _new_link(db, user, body, folder_id=folder_id)
|
||||
db.commit()
|
||||
return linksmod.owner_view(link, token=token)
|
||||
return linksmod.owner_view(db, link, token=token)
|
||||
|
||||
|
||||
class UpdateLinkIn(BaseModel):
|
||||
@@ -724,7 +724,7 @@ def update_link(
|
||||
sent=frozenset(body.model_fields_set),
|
||||
)
|
||||
db.commit()
|
||||
return linksmod.owner_view(link)
|
||||
return linksmod.owner_view(db, link)
|
||||
|
||||
|
||||
@router.delete("/links/{link_id}")
|
||||
|
||||
@@ -432,6 +432,7 @@ def item_detail(
|
||||
"summary": (it.summary_hu or it.summary) if hu else it.summary,
|
||||
"year": it.year,
|
||||
"duration_seconds": it.duration_s,
|
||||
"size_bytes": it.size_bytes,
|
||||
"playable": it.playable,
|
||||
# Playback-class detail: HW/SW transcode split + the codec/bit-depth the info-page chip shows.
|
||||
"transcode_hw": it.transcode_hw,
|
||||
|
||||
@@ -176,8 +176,15 @@ def watch_meta(token: str, db: Session = Depends(get_db)) -> dict:
|
||||
returns only `needs_password` until the viewer unlocks it (no title/thumbnail leak)."""
|
||||
link = _link_or_404(db, token)
|
||||
if link.password_hash:
|
||||
# Which kind it opens, for the password prompt's title — never what is in it.
|
||||
return {"needs_password": True, "kind": "folder" if link.folder_id is not None else "file"}
|
||||
# Which kind it opens, for the password prompt's words — never what is in it. `video` tells
|
||||
# a video (watched) from any other file (opened); the address is only a hint, every
|
||||
# prefix opens every link.
|
||||
opens = linksmod.opens(db, link)
|
||||
return {
|
||||
"needs_password": True,
|
||||
"kind": "folder" if opens == "folder" else "file",
|
||||
"video": opens == "video",
|
||||
}
|
||||
meta = _opened(db, link, token)
|
||||
db.commit()
|
||||
return meta
|
||||
|
||||
@@ -48,8 +48,9 @@ def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db))
|
||||
"category": tag.category,
|
||||
"system": tag.user_id is None,
|
||||
"channel_count": count,
|
||||
"subscribed_count": subscribed,
|
||||
}
|
||||
for tag, count in queries.tags_with_counts(db, user)
|
||||
for tag, count, subscribed in queries.tags_with_counts(db, user)
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"""The editor's crop lands on the part of the picture the frame was drawn on, also when a video's
|
||||
pixels are not square. The browser reports such a video by its shown size (320 stored, 640 shown),
|
||||
and ffmpeg's `crop` counts stored pixels: measured on a real clip, a frame over the middle half kept
|
||||
half the frame's red surroundings. So a crop carries the size it was measured on.
|
||||
|
||||
Real ffmpeg: the pixels of the output are the oracle.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from app.config import settings
|
||||
from app.downloads import edit, pipeline, service, storage
|
||||
from app.downloads import upload as uploadmod
|
||||
from app.downloads.models import DownloadJob, MediaAsset
|
||||
|
||||
pytestmark = pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not available")
|
||||
|
||||
|
||||
def _source(path: Path, size: str, box: str, sar: int, rotate: int = 0) -> None:
|
||||
"""Red, with a green box: a crop of the box holds no red. `rotate` tags the file to be shown
|
||||
turned by that many degrees counter-clockwise, as a phone records a portrait video."""
|
||||
tag = ["-display_rotation:v:0", str(rotate)] if rotate else []
|
||||
subprocess.run(
|
||||
[
|
||||
"ffmpeg", "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", f"color=c=red:s={size}:d=2:r=10",
|
||||
"-f", "lavfi", "-i", "sine=duration=2",
|
||||
"-vf", f"drawbox={box}:c=lime:t=fill,setsar={sar}",
|
||||
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-c:a", "aac", str(path),
|
||||
],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
if tag:
|
||||
tagged = path.with_name("tagged-" + path.name)
|
||||
subprocess.run(
|
||||
["ffmpeg", "-y", "-loglevel", "error", *tag, "-i", str(path), "-c", "copy", str(tagged)],
|
||||
check=True,
|
||||
) # fmt: skip
|
||||
tagged.replace(path)
|
||||
|
||||
|
||||
def _red_and_green(path: Path) -> tuple[int, int]:
|
||||
raw = subprocess.run(
|
||||
["ffmpeg", "-loglevel", "error", "-i", str(path), "-frames:v", "1",
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24", "-"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
).stdout # fmt: skip
|
||||
red = green = 0
|
||||
for i in range(0, len(raw) - 2, 3):
|
||||
if raw[i] > 180 and raw[i + 1] < 80:
|
||||
red += 1
|
||||
elif raw[i + 1] > 180 and raw[i] < 80:
|
||||
green += 1
|
||||
return red, green
|
||||
|
||||
|
||||
def _cut_asset(db, make_user, src: Path, spec: dict, then=lambda: None) -> MediaAsset:
|
||||
user = make_user()
|
||||
source = uploadmod.ingest_upload(
|
||||
db, user.id, filename="clip.mp4", src_path=src, size=src.stat().st_size
|
||||
)
|
||||
assert source.status == "done"
|
||||
then()
|
||||
job = service.enqueue_edit(db, user.id, source, spec)
|
||||
asset = db.get(MediaAsset, job.asset_id)
|
||||
job.status, asset.status = "running", "downloading" # the worker's claim
|
||||
db.commit()
|
||||
|
||||
pipeline._process_edit(job.id, asset.id)
|
||||
|
||||
db.expire_all()
|
||||
job, asset = db.get(DownloadJob, job.id), db.get(MediaAsset, job.asset_id)
|
||||
assert (job.status, job.error, asset.status) == ("done", None, "ready")
|
||||
return asset
|
||||
|
||||
|
||||
def _cut(db, make_user, tmp_path, src: Path, crop: dict, **spec) -> Path:
|
||||
asset = _cut_asset(db, make_user, src, {"crop": crop, **spec})
|
||||
return storage.abs_path(str(tmp_path), asset.rel_path)
|
||||
|
||||
|
||||
def test_a_crop_of_a_video_with_wide_pixels_keeps_what_the_frame_showed(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
|
||||
|
||||
# What the editor sends for the middle half of this video: the browser shows it at 640 x 240.
|
||||
final = _cut(
|
||||
db, make_user, tmp_path, src,
|
||||
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
|
||||
) # fmt: skip
|
||||
|
||||
red, green = _red_and_green(final)
|
||||
assert (red, green) == (0, 160 * 120)
|
||||
|
||||
|
||||
def test_a_crop_of_square_pixels_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "480x640", "x=120:y=160:w=240:h=320", sar=1)
|
||||
|
||||
final = _cut(
|
||||
db, make_user, tmp_path, src,
|
||||
{"x": 120, "y": 160, "w": 240, "h": 320, "frame_w": 480, "frame_h": 640},
|
||||
) # fmt: skip
|
||||
|
||||
assert _red_and_green(final) == (0, 240 * 320)
|
||||
|
||||
|
||||
def test_a_crop_stored_before_it_carried_its_frame_is_cut_as_it_always_was():
|
||||
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50}})
|
||||
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
|
||||
cmd = edit.build_edit_cmd(Path("in.mp4"), Path("out.mp4"), spec, "mp4")
|
||||
assert cmd[cmd.index("-vf") + 1].startswith("crop=100:50:4:6,")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame", [{"frame_w": 0, "frame_h": 240}, {"frame_w": "x", "frame_h": 2}])
|
||||
def test_a_frame_that_cannot_be_read_is_not_divided_by(frame):
|
||||
spec = edit.normalize_edit_spec({"crop": {"x": 4, "y": 6, "w": 100, "h": 50, **frame}})
|
||||
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
|
||||
|
||||
|
||||
def test_a_crop_of_a_join_keeps_what_the_frame_showed(db, make_user, tmp_path, monkeypatch):
|
||||
"""A cut-list joins through `filter_complex`, a crop path of its own."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
|
||||
|
||||
final = _cut(
|
||||
db, make_user, tmp_path, src,
|
||||
{"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240},
|
||||
segments=[{"start_s": 0, "end_s": 0.5}, {"start_s": 1, "end_s": 1.5}], accurate=True,
|
||||
) # fmt: skip
|
||||
|
||||
assert _red_and_green(final) == (0, 160 * 120)
|
||||
|
||||
|
||||
def test_a_crop_of_a_rotated_video_is_cut_where_it_was_drawn(db, make_user, tmp_path, monkeypatch):
|
||||
"""The browser shows a rotated file upright, so the frame was drawn on the upright picture."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
# Stored 320 x 240, green on its left half; shown turned a quarter, 240 x 320.
|
||||
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
|
||||
|
||||
# Turned counter-clockwise, the stored left half is the shown bottom half.
|
||||
final = _cut(
|
||||
db, make_user, tmp_path, src,
|
||||
{"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320},
|
||||
) # fmt: skip
|
||||
|
||||
assert _red_and_green(final) == (0, 240 * 160)
|
||||
|
||||
|
||||
def test_a_clip_ffprobe_cannot_size_gets_the_size_its_crop_cut(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "320x240", "x=80:y=60:w=160:h=120", sar=2)
|
||||
|
||||
def probe_fails():
|
||||
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
|
||||
|
||||
asset = _cut_asset(
|
||||
db, make_user, src,
|
||||
{"crop": {"x": 160, "y": 60, "w": 320, "h": 120, "frame_w": 640, "frame_h": 240}},
|
||||
then=probe_fails,
|
||||
) # fmt: skip
|
||||
|
||||
# Half the shown width of a 320-wide file: 160 stored, not the 320 the frame counted.
|
||||
assert (asset.width, asset.height) == (160, 120)
|
||||
|
||||
|
||||
def test_a_rotated_clip_ffprobe_cannot_size_gets_the_upright_size_its_crop_cut(
|
||||
db, make_user, tmp_path, monkeypatch
|
||||
):
|
||||
"""The frame was drawn on the upright picture, which is the stored one turned: so is its cut."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path))
|
||||
src = tmp_path / "in.mp4"
|
||||
_source(src, "320x240", "x=0:y=0:w=160:h=240", sar=1, rotate=90)
|
||||
|
||||
def probe_fails():
|
||||
monkeypatch.setattr(pipeline.probe, "probe_media", lambda _p: (None,) * 5)
|
||||
|
||||
asset = _cut_asset(
|
||||
db, make_user, src,
|
||||
{"crop": {"x": 0, "y": 160, "w": 240, "h": 160, "frame_w": 240, "frame_h": 320}},
|
||||
then=probe_fails,
|
||||
) # fmt: skip
|
||||
|
||||
# What ffmpeg cut (the rotated test above): 240 x 160, not the stored 320 x 120.
|
||||
assert (asset.width, asset.height) == (240, 160)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("key", ["frame_w", "x"])
|
||||
def test_an_infinite_number_in_a_crop_is_dropped_not_a_crash(key):
|
||||
crop = {"x": 4, "y": 6, "w": 100, "h": 50, "frame_w": 640, "frame_h": 240}
|
||||
spec = edit.normalize_edit_spec({"crop": {**crop, key: float("inf")}})
|
||||
if key == "frame_w":
|
||||
assert spec["crop"] == {"x": 4, "y": 6, "w": 100, "h": 50}
|
||||
else:
|
||||
assert "crop" not in spec
|
||||
@@ -150,6 +150,7 @@ def test_a_password_folder_link_shows_nothing_until_unlocked(shelf, client):
|
||||
assert client.get(f"/api/public/watch/{token}").json() == {
|
||||
"needs_password": True,
|
||||
"kind": "folder",
|
||||
"video": False,
|
||||
}
|
||||
bare = f"/api/public/watch/{token}/files/{shelf['inside'][0]}/file"
|
||||
assert client.get(bare).status_code == 403
|
||||
@@ -312,7 +313,7 @@ def test_a_password_file_link_says_it_opens_a_file(shelf, client):
|
||||
.json()
|
||||
)
|
||||
meta = client.get(f"/api/public/watch/{made['token']}").json()
|
||||
assert meta == {"needs_password": True, "kind": "file"}
|
||||
assert meta == {"needs_password": True, "kind": "file", "video": True}
|
||||
|
||||
|
||||
def test_a_file_whose_bytes_are_gone_is_not_offered(shelf, client, db):
|
||||
|
||||
@@ -219,6 +219,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
|
||||
library_id=lib.id,
|
||||
kind="movie",
|
||||
title="The Godfather",
|
||||
size_bytes=15_000_000_000,
|
||||
genres=["Crime", "Drama"],
|
||||
directors=["Francis Ford Coppola"],
|
||||
studio="Paramount",
|
||||
@@ -238,6 +239,7 @@ def test_item_detail_movie_serves_from_row_despite_live_outage(db, monkeypatch,
|
||||
resp = _get(db, user, monkeypatch, override_dep, "/api/plex/item/m1")
|
||||
assert resp.status_code == 200, resp.text
|
||||
body = resp.json()
|
||||
assert body["size_bytes"] == 15_000_000_000
|
||||
assert body["imdb_rating"] == 8.7
|
||||
assert body["imdb_id"] == "tt0068646"
|
||||
assert body["imdb_url"] == "https://www.imdb.com/title/tt0068646/"
|
||||
|
||||
@@ -14,7 +14,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from app.discover import pins
|
||||
from app.catalog.models import Channel, Video
|
||||
from app.catalog.models import Channel, Subscription, Video
|
||||
from app.reader import queries, service
|
||||
from app.reader.models import ChannelTag, ComfortPin, Playlist, PlaylistItem, Tag, VideoState
|
||||
|
||||
@@ -60,12 +60,44 @@ def test_a_system_tag_is_nobodys_to_edit(db, make_user):
|
||||
service.create_tag(db, other, "Theirs", "other", None) # another user's tag stays theirs
|
||||
db.commit()
|
||||
assert queries.own_tag(db, user, system.id) is None
|
||||
assert [t.id for t, _ in queries.tags_with_counts(db, user)] == [system.id]
|
||||
assert [t.id for t, *_ in queries.tags_with_counts(db, user)] == [system.id]
|
||||
|
||||
|
||||
# --- tags ------------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _music_on_four(db, make_user):
|
||||
"""A tag on four channels: two followed, one followed but hidden, one no longer followed."""
|
||||
user, other = make_user(), make_user()
|
||||
tag = service.create_tag(db, user, "Music", "other", None)
|
||||
for cid in ("UCa", "UCb", "UChid", "UCgone"):
|
||||
db.add(Channel(id=cid, title=cid))
|
||||
db.flush()
|
||||
for cid in ("UCa", "UCb"):
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid))
|
||||
db.add(Subscription(user_id=user.id, channel_id="UChid", hidden=True))
|
||||
db.add(Subscription(user_id=other.id, channel_id="UCgone"))
|
||||
for cid in ("UCa", "UCb", "UChid", "UCgone"):
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=user.id, source="user"))
|
||||
db.commit()
|
||||
return user, tag
|
||||
|
||||
|
||||
def test_a_tag_counts_apart_the_links_on_channels_the_user_still_follows(db, make_user):
|
||||
"""An unsubscribe and a hide keep the channel's tag links; the Channels page's filter lists only
|
||||
the followed channels not hidden, so its count must not hold the others."""
|
||||
user, tag = _music_on_four(db, make_user)
|
||||
[(_, count, subscribed)] = [r for r in queries.tags_with_counts(db, user) if r[0].id == tag.id]
|
||||
assert (count, subscribed) == (4, 2)
|
||||
|
||||
|
||||
def test_the_tag_list_sends_each_count_under_its_own_name(db, make_user, as_user):
|
||||
"""Both counts are ints, so a swap would type-check: the answer itself pins which is which."""
|
||||
user, tag = _music_on_four(db, make_user)
|
||||
[row] = [t for t in as_user(user).get("/api/tags").json() if t["id"] == tag.id]
|
||||
assert (row["channel_count"], row["subscribed_count"]) == (4, 2)
|
||||
|
||||
|
||||
def test_a_tag_is_made_once_per_name_and_an_unknown_category_is_other(db, make_user):
|
||||
user = make_user()
|
||||
tag = service.create_tag(db, user, "Later", "nonsense", "#fff")
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
"""A share link's address says what it opens: /watch/ a video, /file/ anything else, /folder/ a folder.
|
||||
|
||||
Only a word for the reader: the token is the key, so every path opens every link, and the links
|
||||
already sent out on /watch/ keep working for good. DB-lane.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
from app import main, security
|
||||
from app.config import settings
|
||||
from app.downloads import og
|
||||
from app.downloads import probe as probemod
|
||||
|
||||
_PAGE = """<!doctype html><html><head>
|
||||
<!--OG:START--><meta property="og:title" content="Siftlode" /><!--OG:END-->
|
||||
</head><body><script src="/assets/{entry}.js"></script></body></html>"""
|
||||
|
||||
_VIDEO = {
|
||||
"streams": [
|
||||
{"codec_type": "video", "codec_name": "h264", "pix_fmt": "yuv420p"},
|
||||
{"codec_type": "audio", "codec_name": "aac"},
|
||||
],
|
||||
"format": {"format_name": "mov,mp4", "duration": "5"},
|
||||
}
|
||||
_AUDIO = {
|
||||
"streams": [{"codec_type": "audio", "codec_name": "aac"}],
|
||||
"format": {"format_name": "mov,mp4", "duration": "5"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pages(tmp_path, monkeypatch):
|
||||
index, watch = tmp_path / "index.html", tmp_path / "watch.html"
|
||||
index.write_text(_PAGE.format(entry="app"), encoding="utf-8")
|
||||
watch.write_text(_PAGE.format(entry="watch"), encoding="utf-8")
|
||||
monkeypatch.setattr(main, "INDEX_HTML", index)
|
||||
monkeypatch.setattr(main, "WATCH_HTML", watch)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key(monkeypatch):
|
||||
"""A link's address is read back from its encrypted token."""
|
||||
monkeypatch.setattr(security, "_fernet", Fernet(Fernet.generate_key()))
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def shared(as_user, make_user, tmp_path, monkeypatch, upload_headers):
|
||||
"""Uploads a file ffprobe reads as `data` (None: it cannot read it) and shares it."""
|
||||
monkeypatch.setattr(settings, "files_root", str(tmp_path / "media"))
|
||||
owner = as_user(make_user())
|
||||
|
||||
def share(name: str, data: dict | None, **link: object) -> dict:
|
||||
monkeypatch.setattr(probemod, "probe_json", lambda _p: data)
|
||||
job = owner.post(
|
||||
"/api/files/upload",
|
||||
content=b"some bytes",
|
||||
headers=upload_headers(name, display_name=name),
|
||||
)
|
||||
assert job.status_code == 200, job.text
|
||||
made = owner.post(f"/api/downloads/{job.json()['id']}/links", json=link)
|
||||
assert made.status_code == 200, made.text
|
||||
return made.json()
|
||||
|
||||
share.owner = owner # type: ignore[attr-defined]
|
||||
return share
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "data", "prefix"),
|
||||
[
|
||||
("clip.mp4", _VIDEO, "/watch/"),
|
||||
("song.m4a", _AUDIO, "/file/"),
|
||||
("backup.tar.gz", None, "/file/"),
|
||||
],
|
||||
)
|
||||
def test_a_link_is_given_out_on_the_path_of_what_it_opens(shared, name, data, prefix):
|
||||
made = shared(name, data)
|
||||
assert made["url"] == f"{prefix}{made['token']}"
|
||||
|
||||
|
||||
def test_a_folder_link_is_given_out_on_folder(shared, key):
|
||||
owner = shared.owner
|
||||
folder = owner.post("/api/files/folders", json={"name": "Friends"}).json()
|
||||
made = owner.post(f"/api/downloads/folders/{folder['id']}/links", json={}).json()
|
||||
assert made["url"] == f"/folder/{made['token']}"
|
||||
listed = owner.get(f"/api/downloads/folders/{folder['id']}/links").json()
|
||||
assert [entry["url"] for entry in listed] == [made["url"]]
|
||||
|
||||
|
||||
def test_the_list_gives_the_same_address_as_the_making(shared, key):
|
||||
made = shared("backup.tar.gz", None)
|
||||
owner = shared.owner
|
||||
files = owner.get("/api/files").json()
|
||||
job_id = next(f["id"] for f in files if f["display_name"] == "backup.tar.gz")
|
||||
listed = owner.get(f"/api/downloads/{job_id}/links").json()
|
||||
assert [entry["url"] for entry in listed] == [made["url"]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["watch", "file", "folder"])
|
||||
@pytest.mark.parametrize(
|
||||
("name", "data"), [("clip.mp4", _VIDEO), ("backup.tar.gz", None)], ids=["video", "file"]
|
||||
)
|
||||
def test_every_path_opens_every_link(pages, shared, client, path, name, data):
|
||||
"""The links already sent on /watch/ open whatever they open, for good."""
|
||||
token = shared(name, data)["token"]
|
||||
r = client.get(f"/{path}/{token}")
|
||||
assert r.status_code == 200
|
||||
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
|
||||
assert f'content="{name}"' in r.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", ["/file/no-such-token", "/folder/no-such-token", "/file/a/b"])
|
||||
def test_a_new_path_without_a_live_link_still_gets_the_player(pages, client, path):
|
||||
r = client.get(path)
|
||||
assert r.status_code == 200
|
||||
assert "/assets/watch.js" in r.text and "/assets/app.js" not in r.text
|
||||
|
||||
|
||||
def test_a_card_names_a_file_as_a_file(shared, db):
|
||||
made = shared("backup.tar.gz", None)
|
||||
tags = og._watch_tags(made["token"], db)
|
||||
assert f'{made["url"]}"' in tags
|
||||
assert "video.other" not in tags
|
||||
|
||||
locked = shared("notes.tar.gz", None, password="pw")
|
||||
hidden = og._watch_tags(locked["token"], db)
|
||||
assert "Password-protected file" in hidden and "notes" not in hidden
|
||||
assert "video.other" not in hidden
|
||||
|
||||
|
||||
def test_a_card_of_a_video_stays_a_video(shared, db):
|
||||
made = shared("clip.mp4", _VIDEO)
|
||||
tags = og._watch_tags(made["token"], db)
|
||||
assert f"/watch/{made['token']}" in tags and "video.other" in tags
|
||||
locked = shared("other.mp4", _VIDEO, password="pw")
|
||||
assert "Password-protected video" in og._watch_tags(locked["token"], db)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "data", "video"),
|
||||
[("clip.mp4", _VIDEO, True), ("song.m4a", _AUDIO, False), ("backup.tar.gz", None, False)],
|
||||
)
|
||||
def test_a_password_link_says_whether_it_opens_a_video(shared, client, name, data, video):
|
||||
"""The lock screen asks to watch a video and to open anything else, whichever path it is on."""
|
||||
token = shared(name, data, password="pw")["token"]
|
||||
meta = client.get(f"/api/public/watch/{token}").json()
|
||||
assert meta == {"needs_password": True, "kind": "file", "video": video}
|
||||
+154
-146
@@ -301,7 +301,8 @@ Also in force:
|
||||
`fade-below` utility (`fade-below-panel` / `fade-above-panel` on a panel); the page band and a
|
||||
scrolling panel under its header fade by the scroller's own edge mask (`useScrollFade`), because what
|
||||
they sit on is the backdrop, not a colour. The chip rows fold their overflow into "+N" rather than
|
||||
scroll, so no row has clipped ends; the carousels fade theirs. The dialog edges come with C4's pinned
|
||||
scroll, so no row has clipped ends, and a filter row puts its picked chips first so the fold never
|
||||
holds the only sign that a filter is on; the carousels fade theirs. The dialog edges come with C4's pinned
|
||||
footer (S3f).
|
||||
- **Viewport height:** `dvh`, never `vh` / `h-screen`: `100vh` is the viewport with a mobile browser's
|
||||
toolbar retracted, so an `h-screen` shell runs its last rows under the toolbar (contract-tested).
|
||||
@@ -717,148 +718,155 @@ of a full-width panel and nothing on the right.
|
||||
|
||||
## Decision log
|
||||
|
||||
| Date | Decision | Chosen | Over | Why |
|
||||
| ---------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
||||
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
||||
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
||||
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
||||
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
||||
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
||||
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
||||
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
||||
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
||||
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
||||
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
||||
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
||||
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
||||
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
||||
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
||||
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
||||
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
||||
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
||||
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
||||
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
||||
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
||||
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||||
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||||
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||||
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||||
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||||
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||||
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||||
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||||
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||||
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
||||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||||
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||||
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||||
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||||
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||||
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||||
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||||
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||||
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||||
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||||
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||||
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||||
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||||
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||||
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||||
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||||
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||||
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||||
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
||||
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
|
||||
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
|
||||
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
||||
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
||||
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
||||
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
|
||||
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
|
||||
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
||||
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
||||
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
||||
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
|
||||
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
|
||||
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
|
||||
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
|
||||
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
|
||||
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
|
||||
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
||||
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
||||
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
||||
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
|
||||
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
|
||||
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
|
||||
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
|
||||
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
|
||||
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
|
||||
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
|
||||
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
|
||||
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
|
||||
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
|
||||
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
|
||||
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
||||
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
||||
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
|
||||
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
|
||||
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
|
||||
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
|
||||
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
|
||||
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
|
||||
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
|
||||
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
|
||||
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
|
||||
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
|
||||
| Date | Decision | Chosen | Over | Why |
|
||||
| ---------- | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 2026-09-24 | Floating button | Back to top: a round 48 glass FAB; "round" = floats over something (media or the page) | A Material 3 rounded square; a labelled pill; the old 64 circle | The common web convention for back to top; round tells it apart from every toolbar button; 64 was oversized |
|
||||
| 2026-09-24 | Device contract | Tablet portrait + landscape and up; phones out | A 1024px floor with a "too small" message | Portrait tablet use is real, and the app already renders at 820 portrait |
|
||||
| 2026-09-24 | Card actions on touch | Fewer visible, the rest in a "…" menu | 44px buttons that no longer fit the card | Six 44px targets need more width than a card has |
|
||||
| 2026-09-24 | Action placement | One menu with several triggers, never gesture-only, a desktop background menu that mirrors the toolbar | Visible buttons for everything | Fewer non-critical buttons on screen, without hiding any function |
|
||||
| 2026-09-24 | Text selection | Text stays selectable; a click that ends a selection does nothing | Click-to-play on titles | The user must be able to copy any text the app shows |
|
||||
| 2026-09-24 | A1 Button shape | r8 + a rung per context; glass is a Button variant | r12; a pill | Nested corners (r8 in r12 in r16); the pill already means "a filter or a choice" |
|
||||
| 2026-09-24 | A2 Icon buttons | Two sizes, r8; a round button only on media | One size; all round | A lone toolbar icon at the row size looks lost; round rows read as tags |
|
||||
| 2026-09-24 | A3 Touch size | 44 for every control primitive | 36 + gaps; Button only | One rule (HIG 44) instead of two sizes nobody remembers |
|
||||
| 2026-09-24 | A4 Input | Bordered, a 55% fg-into-bg edge (≥ 3:1) | Filled; today's look as a primitive | The only option that clears WCAG 1.4.11 without a heavy fill on glass |
|
||||
| 2026-09-24 | A5 Dropdown trigger | Pill in a toolbar, r8 in a form | Pill everywhere; r8 everywhere | Matches the neighbours in each context |
|
||||
| 2026-09-24 | A6 Checkbox, switch, slider | Native, accent-tinted, the row is the target | Drawn primitives | Free keyboard / screen-reader / forced-colours support; only 3 sites |
|
||||
| 2026-09-24 | B1 Density | Compact 32 / 28 / 24 by explicit height | Standard 38 / 32 / 26 | The owner's read of the lab; touch is covered separately (A3), and one toolbar row lines up at 28 |
|
||||
| 2026-09-24 | B2 Scheme set | Midnight, Forest→cyan, Slate→fuchsia, Matrix→lime | Today's six | The accent collided with danger (YouTube), warn (Slate) and success (Forest, Matrix); Midnight and Starship were one scheme twice; 4 of 6 light accents failed 4.5:1 |
|
||||
| 2026-09-24 | B3 State layers | Text colour at 10% / 16% over anything | Material's 8 / 12%; today's opacity + bg-card | Visible in every scheme and mode (≥ 1.2:1); today's row hover was 1.00:1 on a card |
|
||||
| 2026-09-24 | B4 Page chrome | One sticky band + fade; filters fold when short | A floating glass bar; everything scrolls | Tabs and filters always in reach, in one place; a keyboard-up tablet keeps its content |
|
||||
| 2026-09-24 | B5 Surfaces | One class + radius per role | Today's per-site mix | Nested corners and no per-site alpha drift |
|
||||
| 2026-09-24 | B6 /watch | The app's surfaces, native video controls | Flat and minimal | The page strangers see should look like the product; native controls work on any device |
|
||||
| 2026-09-24 | B7 Nav rail | Soft tint + edge bar, optional labels (a user setting), slim | Solid accent (recommended); a Material rail | The owner's combination: calmer than a solid fill, labels for who wants them, no width cost |
|
||||
| 2026-09-24 | B8 Loading | Skeleton by shape, spinner in a control | A spinner everywhere | Nothing jumps when data lands; a long wait doesn't look stuck |
|
||||
| 2026-09-24 | B9 Fade | Toolbars / headers, panel tabs, dialog edges, chip rows | Thumbnails; buttons | The north-star depth where content scrolls under chrome; not on content or small controls |
|
||||
| 2026-09-24 | C1 Action classification | The table (lab C1): state + primary at rest, everyday toggles on hover, "…" on touch, destructive into the menu | Visible buttons per site, as today | One action list per object, nothing gesture-only, fewer buttons without losing a function |
|
||||
| 2026-09-24 | C2 Side panels | A tap opens an overlay drawer; pin is a separate button ≥ 1024 | A tap pins and the content reflows | Below 768 the filters were unreachable; on touch a tap pinned and pushed the grid |
|
||||
| 2026-09-24 | C3 Players on touch | Tap = show the controls; 44px seek hit area; 44 buttons; the YT description behind ⓘ | Today (tap also pauses; 7.5px seek bar) | Measured live: you couldn't reach the seek bar without pausing, and the hover-only description never opened on a tap |
|
||||
| 2026-09-24 | C4 Dialogs | Focus by purpose; a pinned footer | Focus on Close; the footer scrolls | A form starts in its first field, and Save stays on screen with the keyboard up |
|
||||
| 2026-09-24 | C5 Plex player controls | The owner's lean set: no Stop / Restart / ±10 s buttons, Back by position, Next only with a next item, Resume + From the start on the card, touch double-tap ±10 s | Every control in one row (it ran off-screen below 1024); Back by a press counter; ±10 s keyboard-only | The row fits a portrait tablet; the decision to restart is made on the card; a tablet keeps the 10 s skip |
|
||||
| 2026-09-24 | Glass tuner | Retired; the schemes are the only colour choice | Keep it with ranges clamped to the rules | Free tuning is the drift the constitution exists to stop (owner) |
|
||||
| 2026-09-24 | Matrix toned down | Moss #86a05a at Midnight's lightness, near-neutral greys; every white-on-accent part → `--accent-fg` | The B2 lime #a3e635 | The owner found the lime neon and tiring; it was the loudest accent (L 0.85, C 0.21), and fixed white parts sat on it at 1.5:1 |
|
||||
| 2026-09-24 | Field primitives | `Input` / `Textarea` / `InnerInput` + `fieldClass`, `SelectTrigger`, the native controls; the lint rule "no raw `<input>`" | `inputCls` and ~40 hand-styled boxes | Six edges, four radii and two focus looks for one control; the ≥ 3:1 edge held nowhere |
|
||||
| 2026-09-25 | Surface roles | `SURFACE` roles in `ui/surfaces.ts`; the lint rule "no bare glass class"; menus p-1 with r8 rows | Three tiers paired ad hoc with five radii and three menu paddings | ~140 sites drifted; nested corners only hold if the radius comes with the role |
|
||||
| 2026-09-25 | Static labels | Flat tonal `Badge`, no edge; a filtering tag is an edged chip | A glass pill with an edge, like the chips | Edged = pressable at a glance; a static tag that looks like a chip gets clicked and does nothing |
|
||||
| 2026-09-25 | Header search | A 40 glass pill with the field edge (`lg`); what sits inside it is round | 44, r16, a glass box with a faint edge | The one rounded box among pills, and its edge under 3:1; the hero search is the YouTube/Gmail pill |
|
||||
| 2026-09-25 | Switch row (F12) | The whole settings row toggles (`SwitchRow`; a hand-laid row points a label at the switch) | Only the 36×24 switch toggles | The iOS / Material settings row; the text was a dead click beside the control it names |
|
||||
| 2026-09-25 | Edges and hovers (F14) | The ≥ 3:1 edge on the field family (fields, the form trigger); labelled pills keep the faint edge and the accent-edge hover | The ≥ 3:1 edge and a state-layer hover on every control | 1.4.11 asks an edge only where nothing else identifies the control; a heavy edge on every chip and tab weighs the whole UI down |
|
||||
| 2026-09-25 | Rating and live (F16) | Own tokens: `--rating` (theme-scoped gold), `--live`/`--live-fg`, `--rating-media`, `--media-chrome`; palette classes lint-refused | Map them to `--warn` / `--danger` | A gold star and a red LIVE are what the eye expects; as warn/danger they would read as a status. The old amber score was 2:1 on light |
|
||||
| 2026-09-25 | Fjord trial (B2 exception) | A fifth scheme, temporary: Midnight's neutrals, Playnite's steel accent (#337599 lifted to #5aa1cb) and steel rules; exit = fold into Midnight or remove | Retune Midnight straight away; borrow only the qualities | The owner wants to live with the calmer palette app-wide before it replaces the default; it breaks "own hue family" on purpose, and only until that call |
|
||||
| 2026-09-25 | Whole-card row (owner) | A card row's B3 layer is the card's `::before` under its content; the open button's hit layer spans the card, the actions sit above it | The name button's own hover, ending at the first badge or button | A row that half-answers the pointer reads as broken; a badge click did nothing |
|
||||
| 2026-09-25 | Badge tones (F29) | `Badge tone` neutral / accent / warn at 6%; light `--warn` #b45309 → #a14a07; edged label spans lint-refused | Keep edged status pills (a private Badge in Users, Settings "Granted", the suggestions count) | Edged = pressable at a glance; amber-700 was 4.58:1 as text and fell under 4.5 on its own tint |
|
||||
| 2026-09-26 | Page band built (B4) | Controls only in the band (intros scroll); the fold shows the same row in place; one `h1` per page (`PageHeading` steps the hidden one aside); `SectionLabel` headings; Skip to content | A popover of the folded filters; the page title kept visible | A folded row is still the same controls, nothing duplicated; the rail and the tab title already name the page |
|
||||
| 2026-09-26 | Fade built (B9) | `fade-below` on sticky toolbars and table headers; the scroller mask where the band sits on the backdrop | A painted band over the backdrop | The backdrop is the colour under the band, so fading the content into it is the same edge |
|
||||
| 2026-09-26 | Viewport units | `dvh` everywhere (the Plex player stage with S3f) | `vh` / `h-screen` | `100vh` hides the last rows under a tablet browser's toolbar |
|
||||
| 2026-09-26 | Video actions built (C1) | One list per video (menu, right-click, long-press, "…"); the card keeps watched / save / add to playlist on its picture on hover; download, details, copy, hide, reset in the menu | The six-button row under the card and the hover ⓘ | The menu is the one door every device has; the card loses a line of height and the touch card its row of 28px buttons |
|
||||
| 2026-09-26 | Plex actions built (C1) | One list per Plex title (play/open, watched, add to playlist, media info, copy title, pin); the poster keeps watched / add / "…" on its picture on hover; seasons get open / watched / add / copy | The hover ⓘ, the pin-only menu, an episode's add button at rest, a collection's "Remove" X on the card | The same door as a video's; the collection grid goes to two columns on a phone so the touch "…" leaves the title its line |
|
||||
| 2026-09-26 | Playlist actions built (C1) | A playlist row's "…" (play, pin, copy title / link, move, order, remove), Delete + an undo toast; one list per playlist (rename, pin, YouTube open / push / revert, delete) from its rail row and its header's "…" | The row's X column and its confirm; the header's pencil, YouTube link, push, revert and trash buttons | Remove is reversible, so it needs no confirm; the header keeps Play all; a narrow table drops "#" and Channel so the title keeps a width |
|
||||
| 2026-09-26 | Channel + Plex list actions (C1) | A channel row's "…" (open, pin, YouTube, copy, full history, hide + undo, unsubscribe); a Plex playlist's title / show / season "…", Delete + undo; its header's "…" (rename, delete); an action toast lasts ≥ 8 s | The channel row's full-history, hide and unsubscribe buttons; the Plex list's red X on every row, show and season; the header's pencil and trash | Hiding and removing are reversible, so they need no confirm and the undo needs time; unsubscribe and deleting a playlist keep theirs |
|
||||
| 2026-09-26 | Download / notification / Scheduler actions (C1) | A download row's "…" (play, share, save, edit, details, re-download, manage sharing, copies, cancel / delete / remove); a library row's Share stays in sight, ahead of save; a notification row's "…" and hover dismiss; the inbox band's and the Scheduler strip's "…" | The download row's five rare buttons and its cancel / delete / remove; the source link's copy button; an activity notice's Find / Undo links; the band's Clear everything; the strip's Purge discovery | The destructive rows keep their confirm, and Clear everything gains one; a row link keeps the browser's own menu; Find in feed drops the tag / search / age filters that could hide its video |
|
||||
| 2026-09-26 | Channel menu: Tags + Priority (C1) | Tags A–Z as in-place checks; Priority as fixed steps 10 · 5 · 3 · 2 · 1 · 0, the current one ticked; a pick re-sorts at once and the page + focus follow the row; one drawn `MenuCheck` for every checkable row that cannot be an `<input>` | Relative steps (+1 / +5); straight to the top; the stepper only; seven hand-drawn check marks (three box sizes, a tinted box, a × for "on", a trailing tick) | Bubbling a channel up is one pick; fixed levels keep the scale the reader already uses; the list moving where the reader can see it is the feedback |
|
||||
| 2026-09-26 | Reorder without a drag (WCAG 2.5.7) | Row menus: Top / Up / Down / Bottom (YouTube + Plex playlists); saved views: four buttons with the place beside the selected view (owner: A); side panels in edit mode: an Up / Down pair per group (owner: A), the name wrapping there | A "…" on every saved-view row; a "…" on every panel group; drag only | A visible, one-click path where the list is edited; the edge moves shown off so the buttons never shift |
|
||||
| 2026-09-26 | Titles in click surfaces | The surface stays a button, flagged `selectable`: its text selects, and the click that ends a selection does nothing; every plain `Pressable` pinned with its reason | Rebuilding each card and row so the title sits outside the button; exceptions for master-detail lists | Chrome selects inside a button once it is `user-select: text` (measured), so one flag on the primitive keeps the keyboard, focus and list walking every surface already has |
|
||||
| 2026-09-26 | Right-click on a selection | The browser's own menu opens on selected words; beside them the object's menu | A "Copy selection" row in the app menu | The reader selected the words to do something with them; the native menu copies, searches and translates, and changes nothing else |
|
||||
| 2026-09-26 | Keyboard ring on a flush row | Inside the row (a table row by its tag, a log line, a show header in its clipping card, with the card's corners) | The ring outside, as on a card | Outside it ran under the table head and past the table, and a clipping card cut it away entirely (invisible focus) |
|
||||
| 2026-09-26 | Fade at rest | The sticky header's fade appears with the first 1rem of scroll (a scroll-driven animation; no fade where unsupported) | A fade that is always drawn | At rest it dimmed the first row and hid its ring's top edge; nothing is under the header yet |
|
||||
| 2026-09-26 | Narrow playlist table | "#" and Channel hide by the table's own width (under 42rem), not the window's | Hiding below the `sm` window width | Beside the playlist rail a desktop window left the table 432 px and the title 0 px |
|
||||
| 2026-09-26 | Background menu built | Five list pages (feed, Plex grid, channels, playlist, notifications); the toolbar's sort / view / filters and the band's actions; no destructive row | Every page with a toolbar; the "…" menus copied whole, delete included | The tab-only pages have nothing a menu would add; a blank-area right-click must not be one click from delete |
|
||||
| 2026-09-26 | Media card anatomy (C) | One frame; the meta line pinned to its bottom; the length a badge on the picture; the Plex card on the same primitive as the video card | A: the year as an overline over the title; B: the year inline after it; a fixed two-line title slot | A one-line title left a hole the eye reads as a missing line; C puts nothing under a variable-height title |
|
||||
| 2026-09-26 | One Play | `MediaPlayGlyph` (the media button variant) and only where the click plays | The Plex card's hand-painted accent circle; a playable dot on every card | One Play for every card; a dot that only a colour and a tooltip explain says nothing |
|
||||
| 2026-09-26 | Filtering values on the info page | A value that filters (year, certificate, studio, director) is an edged chip; a fact is flat text | Everything flat, the clickable ones only hover-tinted | The S3d rule: edged = pressable, so the reader sees which values do something |
|
||||
| 2026-09-26 | Shelf density | On any strip: one title line over one meta line | The grid's two lines and full meta on a strip | The tallest card set the strip's height and left holes in the cards with less to say |
|
||||
| 2026-09-26 | Compact list row (B) | The title leads; a faint "…" at the end; the toggles float over the date on hover | A leading 128px action column | That column sat empty at rest, a hole at the start of every row |
|
||||
| 2026-09-26 | Media-info layout (B) | A main column (collections first, crew, cast) beside a facts sidebar; an empty column is not drawn | Every section full width, stacked | The collection is the most used; the keywords' column left a void beside a short details box |
|
||||
| 2026-09-26 | Mixed Home shelf (A) | Plex titles as wide art beside videos | Posters, with the videos not stretched | Stretching a video card to a poster's height left an ~80px gap |
|
||||
| 2026-09-27 | Home hero | A card (hover row + Play on the picture, the whole menu, a touch "…"; owner: A); three quarters wide and centred on a wide window; one fact line; the summary at the measure | The hero's pin row alone; full width; an 18rem picture column | The one answer stands in focus, and it offers what the same card offers anywhere |
|
||||
| 2026-09-27 | Home shelf tiles | A channel / playlist tile: Play, ↗ open, "…" with Open; a show acts on its next episode, ⓘ opens it; the shelf slot 232px | The tile's pin row alone; 200px | A tile without its object's actions read as broken; at 200 the Play touched the hover row |
|
||||
| 2026-09-27 | Synopsis preview (C) | A ~0.9 s mouse rest opens a passive panel beside the card | The synopsis as the picture's `title` tooltip; an ⓘ to hover; none | One long tooltip line fired anywhere on the art and clashed with the cut title's own tooltip |
|
||||
| 2026-09-27 | Reading measure | Prose capped at ~65 characters a line (`measure`) | The container's full width | At 110–130 characters a line the eye loses the next line's start |
|
||||
| 2026-09-24 | Chip family | One `Chip` (filter / token / tag / suggest / ghost) + `DismissibleChip`; nothing under 24; the lint rule "no raw `<button>` outside ui/" | Five chip geometries in five files | The applied-filter row, the tag cells, the year picker and the saved-view editor each drew their own chip; a one-letter tag was 21px wide |
|
||||
| 2026-09-24 | Button families | `Button` / `IconButton` / `Pressable` / chips / `DragHandle`; `lg` 40 for a hero CTA; pills and a 48 Play on media (from C5) | Every button hand-painted | 268 hand-rolled buttons in 79 files drifted into as many looks; the ladder only holds if every button stands on it |
|
||||
| 2026-09-27 | C4 as built | `initialFocus` required on every `Modal`; the commit row in `ModalFooter`, portalled under the body from any depth; the fades driven by the body's scroll timeline; `interactive-widget=resizes-content` | An optional prop defaulting to the ✕; a `footer` prop (the issue composer's buttons live three components down); always-on fades | A default is how 21 dialogs came to open on the ✕; lifting the composer's state to reach a prop is the larger change; a resting fade dimmed the first line |
|
||||
| 2026-09-27 | C3 + C5 as built | A tap toggles the bar through `pointerType` from `pointerdown`; a double-tap takes back its first tap's toggle; the card's pair is the video card's `MediaResumeButtons` (Continue / Restart), a sibling over the picture; the YT description is an ⓘ click popover | A delayed single tap (wait out the double-tap window); new "Resume / From the start" words for Plex; the pair inside the picture's button | A delayed tap feels dead; one media card says one pair of words; a button inside a button is invalid and the hover row already sits beside the picture |
|
||||
| 2026-09-27 | C2 as built | The side panel's drawer is `ui/Modal` `drawer-start`; drawer = a coarse pointer or below 768; the pin offered in its header ≥ 1024 | A drawer of its own inside `SidePanel` | Esc, Back, the scrim, the trap and the scroll lock are the dialog's; a second implementation is how two shells drift |
|
||||
| 2026-09-30 | Composition and the reader (E30 S6) | A chapter and a Conformance step: the space is used, one focal point, worst real data, where am I, a click answers at once, zoom and text size first-class; the person page as two columns (facts and bio left, "Known for" right) | Rules for pieces only; the bio in a full-width panel; a narrower panel | The person page passed every piece rule with half its panel empty; the owner reads zoomed in, so zoom is a requirement, not a nicety |
|
||||
| 2026-09-30 | Hungarian words (E30 S5) | The D3 list: Feed, Shorts, Admin, Token, Port stay; stream, Prio, Job, Audit, Demo, Auto, Info, Frontend/Backend, Backfill, transcode get the Hungarian word; one name per thing (kérelem, behúzás, one "Test connection") | Keep every loanword; translate Feed/Shorts too | The owner chose the list (D3); the final sweep found the same feature under two names in three places |
|
||||
| 2026-09-29 | Numbers and order in the UI language (E30 S3) | Every number through `lib/format.ts` in the UI language (decimal mark, grouping, compact, units); A→Z by the shown name in the UI language's alphabet, client and server alike; the language comes off `useTranslation`, never the singleton | `toFixed`, hand-built "1.2K", `localeCompare` in the browser's locale, byte-order server sorts | A Hungarian reader saw "8.3", "1.2K", "Életrajzi" after "Z"; the singleton read also left values in the old language after a switch (React Compiler) |
|
||||
| 2026-09-27 | Narrow rows by container | The download row, the playlist table and the Channels tag row stack by a container query; a fact line never wraps | Breakpoints on the window | The iPad zoomed in, split screen and a column beside a rail are narrow while the window is not; the zoomed download row broke "18.9 / MB" apart |
|
||||
| 2026-09-27 | Phones deferred | A phone-only layout (the hamburger nav, a folded feed band) waits for its own epic; E28 stays desktop + tablet | Fix the phone views inside E28 | Principle 2; the first phone attempts made the feed worse, and the epic's review grows with every sprint |
|
||||
| 2026-09-27 | Conformance check (S3g) | A four-step check before a UI change is done: primitive at a named rung, the chapter re-read, the rendered page measured by kind and look, every state seen | A reviewer's eye per sprint | The S1 probe found 69 button looks; after S3 every control kind is drawn by its primitive, and the check keeps a new look from arriving unnoticed |
|
||||
| 2026-09-27 | Segmented rung | `SegmentedToggle` defaults to the toolbar rung (28, `text-xs`); 24 only for a panel's captioned match toggle | The small default | Two toolbars had inherited 10px type from the default beside 28px buttons |
|
||||
| 2026-09-27 | Copy rule (S3g) | One name per feature (the rail may shorten it); four verbs for four meanings; sentence case with the app's places capitalised; "…" when a command asks first; one-sentence messages without a stop; curly quotes; dates relative in lists with the exact minute on hover, three absolute forms; units from Intl, short | Word-for-word names everywhere; the verbs as they were; all lower case; no "…"; always a stop; always absolute dates; long or compact units | The copy survey found 7 EN and 6 HU double names, 22 Delete against 27 Remove, five date shapes and three spellings of a minute; the owner picked every recommended option |
|
||||
| 2026-09-27 | One glyph, one meaning (S4) | Play only for playback or resuming a paused transfer; Retry = `RefreshCw`; reset / revert = `RotateCcw` | Retry drawn with Play | The first UI-review run (Downloads) found a failed row whose Retry showed ▷ in the row and its menu |
|
||||
| 2026-09-27 | Fjord kept | Fjord stays a fifth scheme beside Midnight; the trial wording goes | Fold it into Midnight; remove it | The owner lived with it and likes it; the vivid Midnight stays for those who want it. B2's own-hue rule still binds any new scheme |
|
||||
| 2026-09-27 | Tablet columns | A table hides `tabletHidden` columns below 64rem of its own width; Channels drops Prio, About, Length | Hide About only; give the name a bigger share | At 1024 the channel name had 60 px; the owner chose to drop all three there |
|
||||
| 2026-09-27 | Glyph pager | First / previous / next / last as glyph buttons round the page box, names as tooltips | Words beside the arrows (hidden only on a phone) | The owner's call: the words widened the row; four arrows are the shared convention |
|
||||
| 2026-09-27 | Match switch on the panel | `ui/MatchToggle` above the picker from two picks, everywhere; one wording (Any / All) | The picker's popover footer (Feed, Plex collection) | A closed picker hid an active "all"; six pickers had drawn it two ways |
|
||||
| 2026-09-27 | Missing picture, bare link | A glyph of the kind for no picture; a download with no name is titled by its short link, once | An empty box; the URL as title, channel and link | The owner's picks 4A and 3A: an empty box read as loading, the URL showed three times |
|
||||
| 2026-09-27 | Copy on a selection | Right-click on selected words keeps the browser's menu; no app Copy row | An app menu with Copy first | A real browser's menu has copy, search and translate; only the desktop pane's is thin |
|
||||
| 2026-10-01 | Zoom target 125–133% | Screens are held at 125% and 133% browser zoom (1536 × 729, 1444 × 685 on a 1080p window) and the largest text size | 200% browser zoom | The owner: 200% was a guess; 125–133% is what they read Siftlode and most sites at, so 200% would be an upper bound nobody uses |
|
||||
| 2026-10-01 | E31 Nav rail at short heights | One "Admin" rail item opening a panel of the admin pages | A denser rail when short; a visible "+4" hint | The rail fits at 133–150% without a hidden scroll; the admin pages are rare and admin-only |
|
||||
| 2026-10-01 | E31 Configuration sections | A vertical, grouped section list, as Settings has | Group tabs + sub-tabs; one long page with a contents list | Sixteen tabs wrapped to three rows at 133%; one pattern for both settings pages |
|
||||
| 2026-10-01 | E31 Statistics overview | Number tiles, and the per-operation counts as horizontal bars | Three narrow cards; a 30-day chart | The label sits by its value; what spends the quota is seen at a glance |
|
||||
| 2026-10-01 | E31 Channel page header | The full banner, folding to one row once the grid scrolls | Always compact; no banner | The header is fixed and took 40% of a 685px window; the channel keeps its face at first sight |
|
||||
| 2026-10-01 | E31 Channel page watch state | A fresh channel page shows the unwatched videos; a picked state is remembered | Show all, forgotten on reload | The owner: open a channel to see what is new; a choice made there should stick |
|
||||
| 2026-10-01 | E31 Audit log runs | A job's successful runs fold into one row that opens, whatever ran between; only that job's failure (never folded, danger colour) breaks it | A filter defaulting to admin actions; both | Nothing hidden, the noise collapsed, the failure seen |
|
||||
| 2026-10-01 | E31 Users row | Role and state as badges, the actions in the row's one menu | An inline role toggle; icon-only buttons | Action placement: the row shows state, rare and destructive actions live in the menu |
|
||||
| 2026-10-01 | E31 YouTube player | Video beside an info panel at 1280px and wider (the video as large as the height allows); stacked below | Today's stack with a one-line bar; full-window like Plex | The owner's eyesight: about 1460px of video on 1080p instead of 1008, the actions still visible |
|
||||
| 2026-10-01 | E31 Plex info and show pages | A second column for the facts, keywords and crew, as the person page; a collection's hero sized to its content | Full-width text; a larger poster and fanart | The right ~40% was empty; one composition for every media page |
|
||||
| 2026-10-01 | E31 Nav rail rows are links | Each page row is a real link: a middle-click or Ctrl-click opens the page in a new tab | A button | The owner: open a page in a tab of its own, as on any site |
|
||||
| 2026-10-02 | E31 Plex detail second column (refined) | The facts over the key crew beside the text; the keywords, chips that filter like the genres, under the text; the show page gets all three too | All three in the column, as the mockup drew it | At 20 keywords and 12 crew the column ran to twice the text's height (849 vs ~400 px at 133%): a hole under the text instead of beside it |
|
||||
| 2026-10-02 | E31 Plex detail poster and crew | The movie and show poster keeps its 2:3 and grows a rung in a wide hero (w-56 at a 64rem panel); the crew shows its first 6 names and "Show N more" (a list of 8 or fewer shows whole) | Poster stretched to the row; a "More" on the keywords | The row is as tall as the crew column, so the poster drew ~175×590 and cropped; the crew, not the keywords, set the hero's height |
|
||||
| 2026-10-04 | E32 `field` focus skips a mode switch | A form opens on its first field; a control above it that is not a field (the composer's Edit / Preview) is passed over by marking the field `data-initial-focus` | The first text field everywhere | That moved the focus in the download dialog off its profile choice and in the upload dialog off its drop zone; the mark changes only the form that needs it |
|
||||
| 2026-10-04 | E35 Lint for z-levels and edged labels | Both rules stay; the two eslint selectors (`Z_RAW`, `EDGED_LABEL`) that refused them go | Keep the selectors | 0 hits since the E28 sweep that fixed the sites: a check that never fires costs a probe test and a snapshot each, and the UI review holds both rules |
|
||||
| 2026-10-06 | `player` focus | A dialog that is a player opens focused on its panel (the YouTube player's rule, now `Modal`'s too); on the media element itself, Space and the arrows stay the element's own | `close`, or the media element | On the ✕ Space closed the player; on the element the browser's own controls acted on the same key as the player's (one Space played and paused, measured in Chrome) |
|
||||
| 2026-10-08 | Captions on touch | A `Tooltip` opens for a mouse and a key's focus only, never for a finger | A caption on tap; a long-press caption | On an iPad the tap that showed the caption was taken as a hover and lost its click (every rail button took two taps), and no finger sends the mouseleave that hides it; the rail's labels setting names the buttons on touch |
|
||||
| 2026-10-08 | Header search on touch | No key cap on a touch screen; the pill grows to 52 px so a ring shows round the 44 px Go; Go drops to its icon by the column's width | Keep the key cap; a 44 px Go overhanging a 40 px pill | On a tablet the 44 px key cap and Go sat on the pill's edge and cut the placeholder; an overhanging Go breaks the nested radii |
|
||||
| 2026-10-08 | Plex subtitles and the control bar | The player draws the cues itself; while the bar shows they rise above it (200 ms), then settle back | The browser's own cues, moved by `line` | On an iPad the bar covered the text; a native cue cannot be animated or measured, and our layer behaves the same on every engine |
|
||||
| 2026-10-08 | Hover on a button over a picture | The `media` variant's hover is a 2px accent ring over a darker body (Plex + YT cards, rail arrows; option B) | An accent fill (reads as "selected"); a white wash (weak on a bright poster) | The 60 → 75% black step was invisible, and over a card a rail arrow could not be told from the card's own Play under it |
|
||||
| 2026-10-08 | Plex "Add to collection" | A movie or show card's menu row opens a panel beside the card: its library's manual collections tick in place, a name + Enter creates one with the title in it (admin; option A) | An Add submenu with a "New…" dialog; the per-title editor modal from the menu | A collection took a trip to the Collections page, a name, a title search, then a second search per title; the panel is the video card's "Add to playlist" |
|
||||
| 2026-10-08 | YouTube modal chrome | No prev/next strips beside the card; Close an icon (the panel's top right; stacked, the title row's end); the description clamped to three lines + More; the panel shuts to a 2.75rem strip of close, expand, prev/next (option D1) | Keep the strips; a "Close" text button; the full description in a scroller; a panel that hides outright | The strips cost the video 7rem of width for a second copy of the panel's buttons; a long description pushed the channel and the actions out of sight; a hidden panel left no way to step the queue without the keyboard |
|
||||
| 2026-10-08 | The wheel over the YouTube modal | Over the video: volume. Over a panel with something to scroll: scroll only, never volume, not even at its end. Over a panel with nothing to scroll: volume (owner) | Volume everywhere; a scroll that hands over to the volume at its end | Reading a long description turned the volume down once it hit the end |
|
||||
| 2026-10-09 | A Plex show card plays | Its picture plays the show's next episode everywhere (an episode with a resume point → Continue + Restart, else the first unwatched, else S1·E1), resolved on the click; the preview names it once up; title and ⓘ open the show (option A + hover ask) | The picture opens the show page (as before); every card carrying `next` | A show card on Discover had no Play while Home's had one; carrying `next` costs a grid page up to 180 queries for the few cards anyone plays |
|
||||
| 2026-10-10 | Clip mode in the players (E38) | Scissors in the control row + `C`, `[` `]`; the panel in the player with the watched video as preview; overview + zoomed strip on `ui/RangeTrack` (ends are sliders); a toast + the Ready notification after Cut | A ⋯ menu entry; permanent In / Out buttons; a big dialog with its own preview (a second Plex transcode / YouTube iframe) | Owner's picks A, P1, a; a film is hours and a clip seconds, so only a zoomed strip gives a hand the precision. The Downloads editor's cuts moved onto the same track (keyboard-steppable now) |
|
||||
| 2026-10-10 | Crop in clip mode (E38) | A Crop toggle in the clip panel: a frame over the playing picture (`ui/CropFrame`, the editor's crop too), Free / 16:9 / 9:16 / 1:1, Whole picture; maximised YouTube keeps the panel in the bar's place; the wide side panel shuts while clipping | A frame always on the picture in clip mode (K2); a free shape only | Owner's K1 + a: a crop is rare, so it never covers the picture unasked; a phone or square clip needs a held shape. The iframe hides its picture's size: drawn on the 16:9 box, the cut fits it to the real picture |
|
||||
| 2026-10-10 | Marks on the player chrome | `--media-mark` (amber) and `--media-danger` (light red), the same in both themes; `media-marks` points the focus ring (`--focus`) and the chosen mark (`--chosen`: a track's tick, a tab's underline; the label stays white) at the amber on the player and every chrome surface; `media-scope` (a form) also the errors at the red | The theme's accent ring, chosen text, warn and danger | On the chrome (#171717) a light theme's accent ring (Slate 2.84:1), warn (2.99:1) and danger (2.77:1) all fall under 3:1; the accent as chosen text under 4.5:1 |
|
||||
| 2026-10-11 | Files page (one library) | List rows by default, the media grid a toggle remembered per folder; a sidebar of automatic source folders, own folders, Shared with me and the storage meter; the paste-a-link box in the toolbar | A media grid by default | The library holds videos AND plain files: rows read both, show more per screen and scan by size and date |
|
||||
| 2026-10-11 | Library list columns | Every kind of value in its own column (name, source, phone-safe, retention, size, added, actions), one grid with a subgrid per row so they line up; an action that does not apply keeps its slot empty; a right-click menu on every row (the ⋯ opens the same menu) | Badges flowing after the title; icons that shift when one is missing | Owner: values of one kind must fall in one vertical line; a row's every action reachable by right click |
|
||||
| 2026-10-11 | Running downloads | An app-wide tray, bottom right, shown only while something runs or failed (reason + retry), foldable to a pill | A rail icon with a count and a popover | Downloading became an action you start anywhere; a running one must be visible without going to look |
|
||||
| 2026-10-11 | Download done | The notification says whether it plays on a phone and carries Link másolása + Megnyitás (the folder, the item focused); never an automatic jump | A plain "ready" notice; auto-navigation to the item | The daily path is paste → done → copy the link; a jump would pull the user out of what they were doing |
|
||||
| 2026-10-11 | Library row quick icons (owner A) | On a wide list only the ⋯ holds a place; Copy link, Share and Save come up over the date while the row is hovered or holds the keyboard; a card (phone) keeps them in its own line; Keeps narrower | Three icons always in sight (the name kept ~150px at 1200); dropping Added below 64rem | Owner: the name is what a row is read by; every action stays in the ⋯ and the right-click menu |
|
||||
| 2026-10-12 | Files preview (owner ★) | A file that is not media opens from its name in the dialog shell, wide: an image, a PDF in the browser's own viewer, text as numbered plain text (HTML and SVG too) with a wrap toggle, else a no-preview card with Save; ← / → page the list's such files in its order; an image under 8 MB is its own thumbnail | A side panel beside the list; a new browser tab; pdf.js; syntax highlighting; a click that saves a file without a preview | Owner: one way to open anything, paged like the list; the browser's PDF viewer is free and isolated; a page is never rendered on our origin |
|
||||
| 2026-10-12 | A running download's step (owner ★) | The tray row names the step beside its bar ("Downloading audio · 12%", "Merging" with a pulse); a paused-only tray shows a pause mark, never the done check | A bare bar and % | A download fetches video and audio as two streams: unnamed, the second fill reads as a restart; the tray is the only place a download is followed |
|
||||
| 2026-10-14 | API tokens (owner A/A/A) | Settings → API tokens: a row per token in three lines (name + prefix, its folders and rights, last use and expiry), revoked from its ⋯; a new token asks the kind first (Hub exchange or Custom: rights as chips per folder), expiry 1 year by default; the token shows once in the same dialog, with Copy and a curl line | A table; one form with a preset link; the token in a banner above the list | A table truncates the rights at this width; the common token never shows a matrix to get wrong; closing the dialog is plainly when the token is gone |
|
||||
| 2026-10-15 | Folder links (owner A/A/A) | An own folder's menu has Copy link and Share with link… as a file row does (the dialog is the link half of the file's); the link opens a list of the folder's files as it is now, Play opening the watch player above it with the playing row lit, a document only downloading; the rail marks a folder with a live link beside its count | Share with link… alone; cards; a download-only list; no mark | Mostly videos from a phone, read on a phone: a list gives a long name its line and the next one is a tap away; the folder link is the one place where moving a file in publishes it, so where it is must show |
|
||||
| 2026-10-17 | Copy-only changes (owner ★) | Step 4 of the Conformance check alone (not 1–3 or 5): every state the changed text renders in, seen in both languages | The whole five-step check; no screenshot at all (DOM test + key parity) | Steps 1–3 and 5 judge controls, patterns and composition, which a text change does not touch; the one risk left is a longer Hungarian string wrapping or clipping, and only a screenshot shows that |
|
||||
| 2026-10-17 | Text on glass (owner ★) | Light `--muted` 15% toward `--fg` in every scheme (≥ 6:1 on the bare page); the item count, the badges and the current Files row on opaque tints mixed into the surface | Only the two failing components; the item count's number in `--fg` | Pixel-measured over every scheme × mode × backdrop, the count fell to 3.8:1 (dark too: thumbnails scroll under the toolbar's glass) and Channels' tabs to 4.1:1; an `--fg` count outshouts the filters beside it |
|
||||
| 2026-10-18 | Scheduler run history (owner D4, E1, E2 ★) | A row's last three runs as dots, newest first (interrupted: a hollow warn ring, set apart from skipped and failed), and a history button that opens the job's runs on the Activity page; a user's ad-hoc runs in their own block under the jobs, with no interval and no run button | An inline expandable history per row; a dark-grey interrupted dot; ad-hoc runs only on the Activity page | Three dots answer "did it run, did it work" at a glance; the whole history needs filters, which the Activity page has |
|
||||
| 2026-10-18 | Activity page (owner D1, D2, G1–G3 ★) | One page, "Napló" / "Activity": four tabs (Runs, Admin actions, Server log, Quota) under one filter bar (text, a period of 1 h · 24 h · 7 d · 30 d · 90 d defaulting to 7 days, an account), the view in the URL; runs and admin actions as dense rows that open in place as a log line does, filtered and paged on the server; a run's or a request's log lines one pivot away, as a history entry | One merged timeline; a page per source; a default period per tab; card rows; the audit log's client-side table | Each source keeps its own table and retention, read in one place; one bar keeps what you narrowed across tabs; about 1.8k runs a day need a log's density and a server-side filter |
|
||||
| 2026-10-24 | Related videos (owner U1–U3 ★, D1 A, D3) | On demand: a tab beside the description (wide), a drawer (stacked); the narrow thumbnailed row, its menu alone, a followed check; plays in place and the list stays the opened video's; an on-topic / Hungarian-trending switch kept on the account | Always shown; the end-of-video grid alone; a dialog; a big card or a text row; a list that follows the playing video | The modal is full on a small screen; a picture is what a choice among strangers rests on; a list that stays put is tried one by one, and spends no more of the day's cap |
|
||||
| 2026-10-25 | Related: one played; off (owner D1 a, D2 a) | Played from the list, it is the player's item: its title, controls and progress, marked watched at its end, the queue left where it was; a daily limit of 0 takes the tab and the button away | A linked video's in-place play (no controls, nothing kept); a tab that only shows a refusal | What is watched should count as watched, wherever it was started from; a control that can only be refused is not offered |
|
||||
| 2026-10-25 | Player panel glyphs (owner 1A, 2A, 3) | In the side panel the queue row is glyph-only on one line, and Mark watched is a glyph toggle among the actions (✓ / ✓✓); the docked bar keeps its labels; the related switch at the default size, "Trending" | Labelled buttons wrapping onto three lines; glyphs on the docked bar too; Mark watched on its own line | The panel is narrow and the list controls are the same few every time; each glyph keeps its full name and shortcut in its tooltip; the docked bar has the width for words |
|
||||
| 2026-10-25 | Auto-advance glyphs (owner E A, H a) | Auto-advance wears list glyphs (`ListEnd` next and off, `ListStart` previous, `Shuffle` random), in the panel and the docked bar; the auto-advance and loop cycles are `lit`, not `pressed` | The skip arrows for the mode; an X'd list for off; `aria-pressed` on a cycle | Beside Previous / Next the skip arrows read as a second Next; a cycle of three or four modes is no two-state switch, and its name already says which |
|
||||
| 2026-10-25 | Restart and Reset glyphs (owner L A) | A menu's Play from the beginning wears `RotateCcw`, as the card's own Restart does; Reset (clear progress and status) wears `Eraser`; Redownload wears `RefreshCw`, the retry arrow | The skip arrow ⏮ on the menu's Restart; `RotateCcw` on both Restart and Reset; `TimerReset` for Restart | ⏮ is Previous beside the player; one action wore two glyphs, and two actions in one menu would have worn one |
|
||||
| 2026-10-28 | Notification order (owner A-D1 a, A-D2 a, ★ a) | What asks for the reader first, from either source ("Needs you · n"), then one stream newest first; read rows older than a week fold under "Earlier (n)", closed, its label in the section-heading style with a chevron | One stream with no band; this browser's activity above the server's (the two fixed sections); a server-side retention that deletes old rows | An admin's access requests sat under up to a hundred server rows; week-old read notices crowded what is new; folding keeps the rows (nothing is deleted) and an unread row never folds, whatever its age |
|
||||
| 2026-11-01 | Docked bar glyphs (owner D8 a) | The maximised view's docked bar is glyph-only too (tooltips carry the names and keys): transport, auto-advance, loop, Mark watched (✓ / ✓✓); its controls scroll on a narrow window while Fullscreen and Exit stay pinned at its right end | Labels from `xl` up | On a tablet the labelled bar ran past the right edge and took Exit with it; the side panel had already lost its labels |
|
||||
| 2026-11-01 | Toolbar group glyphs (owner D9 a, D10 a, D11 a) | The feed's, channel page's and Plex Browse's filter pills name their group by a glyph (`Eye` watch state, `Shapes` content types, `ArrowDownUp` sort), the word in the tooltip and the accessible name; Sort + View end the toolbar's row, the chip row shows only with a chip; a channel page has no count pill | The value alone; Sort + View on a row of their own | "Watch state:" and "Sort:" cost a tablet row its room; the value says enough once the glyph names the group; the channel header already counts the videos |
|
||||
| 2026-11-01 | Channel header on a tablet (owner E2) | Below `xl` the action row drops its two words: "Prio" (the stepper's tooltip names it) and "Subscribed" (a ✓ glyph; its name in the aria-label, Unsubscribe in the tooltip); the tags keep the row's rest, folding into "+N" | The tag lane on a full line of its own below ~12rem; the tags under the channel name | On an iPad (820 px, 44 px targets) the lane got 29 px, a sliver; now 130 px, and every tag shows at 1180 |
|
||||
| 2026-11-01 | Clear watch progress (owner E a) | A video menu's Reset reads "Clear watch progress" in English (HU "Megtekintés visszaállítása" stays); "Reset view" is only a page's view reset (sort, filters, grouping) | "Reset view" for both; the playlist page's view reset renamed instead | On a playlist page the two stood one right-click apart under one name, and one of them erases the video's progress with no undo |
|
||||
| 2026-11-01 | Clear watch progress clears only that (owner A) | The row shows only on a video with a resume point and clears that alone: a hidden video stays hidden, a watched one watched. Un-hiding and un-watching have rows of their own | The old Reset, which also un-hid and un-watched | A hidden video stays hidden: what the row's name says is all it does |
|
||||
| 2026-11-01 | Selecting a list's rows (owner D2 a) | An always visible box first in the row and one over the column for the rows shown; Shift+click a range, `x` the row under the keyboard, Esc clears; the selected row wears `accent/12` with its tick. Rows a filter hides stay selected, an action covers those shown; Watch later offers no selecting. | A "Select" button that shows the boxes on demand | Found at once and works on touch, with no mode to leave; costs about 2 rem a row |
|
||||
| 2026-11-01 | Selection band (owner D1 a, D3 a, D4 a) | The toolbar's second row while rows are selected, no floating bar: move (the last target as one click) and remove, no copy; both undo back to the places, a video the target held before stays there; a single row's move undoes too. | A floating bar at the bottom; copying to a list too; no undo on a move | It covers no row, and the player and the notices live at the bottom; twenty videos sent to the wrong list need a way back to their places |
|
||||
| 2026-11-01 | Grip on a phone (owner A) | Below a 24rem table a playlist row has no grip: the title keeps its width, and the row menu keeps the moves. | The grip and its drag kept, the title at 44 px | The title gets its width back, and the rail is shut on a phone, so a drag had nowhere to go |
|
||||
| 2026-11-01 | Selection band on a tablet (owner A) | Below `xl` the band is glyphs on one row, the names in tooltip and aria-label, as on the channel header; the replaced row stays in the same grid cell, invisible, so the first tick moves no row under the second tap. | The buttons in words, wrapping to a second row | The first tick pushed the list from under the second tap |
|
||||
| 2026-11-01 | Playlist heading on a narrow window (owner A) | The list's name had no width left: below `xl` Unsynced is its dot (the word read out, the hint in the tooltip); on a phone the cover is gone and Play all is its glyph, named in the tooltip. | Every word kept, the list's name at no width | A heading without its name says nothing |
|
||||
| 2026-11-02 | Dragging onto a list (owner D5 a, D6 a) | The grip carries a row, or every selected row with a selected one, onto a list in the rail; the toast undoes. Many dropped inside the list stay; a coarse pointer's drawer never peeks, so its rail is a target only when pinned. | Only the grabbed row going along; the grip off under a view | One row moving out of three lit ones misleads, and a filter is how the rows to move are found |
|
||||
| 2026-11-02 | Delete with every tick hidden (owner a) | It removes nothing and a warning says the filter hides them | Taking the row under the keyboard | While the band shows a selection, Delete means that selection: a row no one ticked must not go |
|
||||
| 2026-11-02 | Undo of a video gone further (owner a) | A video the move put in the target comes back only if the target still holds it, and a warning says how many | Everything back, whatever it did since | A move skips a video that left the source; the same rule back keeps a video out of lists the reader never put it in |
|
||||
| 2026-11-04 | Tag chip counts (owner D5a) | A Channels tag chip shows how many followed channels it holds: a muted number after the name, no badge | The number in brackets after the name | The owner's pick; the number is a detail of the name, so it takes the name's own text, muted, rather than a `CountBadge` |
|
||||
| 2026-11-05 | Picked tag chips lead the row (owner c) | On the Channels tag row the picked chips come first, in the tags' order, then the rest | Keep the order and fold the unpicked first; mark the "+N" when it holds a pick | The row folds from its end, so a picked tag late in the list sat only inside "+N" with nothing on the row saying a filter was on |
|
||||
| 2026-11-05 | A toggled tag chip keeps the focus (owner a) | A chip that moves when toggled takes the keyboard focus with it: to its new place, or to "+N" when it went into the closed fold | Keep a picked chip in the "+N" popover until it closes | The row always shows the filters as they are; the focus, not the order, was what got lost |
|
||||
| 2026-11-06 | Tag chip count after a dot (owner a) | The count keeps the chip's own colour, set off from the name by a "·" | The number dimmed (it fell under the 4.5:1 text floor, resting and pressed); the number in a tinted inner badge | Contrast holds in every scheme without a new token, and the dot still reads the number as a detail of the name |
|
||||
| 2026-11-06 | A pick in the open fold closes it (owner a) | Toggling a chip inside the "+N" popover closes the popover; the focus goes with the chip | Keep the popover open and focus the chip now in the picked one's place | The focus never sits outside a popover left open, and a picked chip leads the row anyway |
|
||||
| 2026-11-06 | An expired share link offers no Copy (owner a) | An expired row in the share dialog has a warn badge "expired <date>" and its address struck through; Open and Revoke stay, Copy goes | The warn badge alone, Copy kept | An expired link opens nothing, so it is no link to hand out; Open still lets the owner check it |
|
||||
| 2026-11-06 | The shared page's unlock button is a noun in Hungarian (owner a) | "Megtekintés" for a video, "Megnyitás" for a file or folder | Both verbs, "Megnézem" / "Megnyitom" | The app's Hungarian buttons are nouns; one button should not change grammatical form by link kind |
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
# Siftlode is mostly a private, login-gated app; only the public landing and legal
|
||||
# pages are worth indexing. Allow crawling but keep bots out of the API surface.
|
||||
# Share links (/watch/) are unguessable capability URLs: kept out of search indexes,
|
||||
# Share links (/watch/, /file/, /folder/) are unguessable capability URLs: kept out of search indexes,
|
||||
# but link-preview crawlers are allowed below so shared links unfurl in chat apps.
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /api/
|
||||
Disallow: /auth/
|
||||
Disallow: /watch/
|
||||
Disallow: /file/
|
||||
Disallow: /folder/
|
||||
|
||||
# Link-preview / unfurl bots — allowed to fetch /watch/ so shared links render a rich
|
||||
# Link-preview / unfurl bots — allowed to fetch share links so shared links render a rich
|
||||
# card (title/channel/thumbnail). They don't build a search index, so this doesn't
|
||||
# expose share links to search engines.
|
||||
User-agent: facebookexternalhit
|
||||
|
||||
@@ -9,7 +9,13 @@ import { act, render, screen, fireEvent, cleanup } from "@testing-library/react"
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (k: string, o?: { action?: string; title?: string }) =>
|
||||
o?.title ? `${o.action}: ${o.title}` : k,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { Carousel } from "./Carousel";
|
||||
@@ -54,7 +60,10 @@ describe("Carousel", () => {
|
||||
);
|
||||
expect(screen.getByText("card-a")).toBeTruthy();
|
||||
expect(screen.getByRole("heading", { name: "Recently added" })).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.seeAll" }));
|
||||
// Named with its rail, the visible words first: a page of rails has a See all on each.
|
||||
const seeAll = screen.getByRole("button", { name: "common.seeAll: Recently added" });
|
||||
expect(seeAll.textContent).toBe("common.seeAll");
|
||||
fireEvent.click(seeAll);
|
||||
expect(onSeeAll).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
|
||||
@@ -109,7 +109,21 @@ export function Carousel({
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{onSeeAll && (
|
||||
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={onSeeAll}
|
||||
// Named with its rail: a page of rails has a See all on each, and a screen reader's
|
||||
// list of buttons (or a voice command) must tell them apart. The visible words lead.
|
||||
aria-label={
|
||||
label
|
||||
? t("common.seeAllOf", {
|
||||
action: seeAllLabel ?? t("common.seeAll"),
|
||||
title: label,
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
className="ml-auto gap-0.5 text-xs"
|
||||
>
|
||||
{seeAllLabel ?? t("common.seeAll")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
[
|
||||
{
|
||||
"version": "0.117.0",
|
||||
"date": "2026-11-06",
|
||||
"new": [
|
||||
"A share link's address now says what it opens: /file/ for a file, /folder/ for a folder, /watch/ for a video. Links you already sent keep working.",
|
||||
"The upload dialog shows which folder the file goes to, and you can pick another one or make a new folder there. Opening Share copies a link at once.",
|
||||
"The Channels tag chips show how many followed channels each holds. Continue watching in Plex has a See all, and a Plex item's Info shows the file size."
|
||||
],
|
||||
"fixed": [
|
||||
"An expired share link is marked as expired and is never copied. A crop of a video with wide pixels keeps exactly what its frame showed.",
|
||||
"A Plex auto-skip waits 3 seconds into the marker before its countdown starts."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.116.3",
|
||||
"date": "2026-11-04",
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
[
|
||||
{
|
||||
"version": "0.117.0",
|
||||
"date": "2026-11-06",
|
||||
"new": [
|
||||
"A megosztási link címe most megmondja, mit nyit meg: /file/ egy fájlt, /folder/ egy mappát, /watch/ egy videót. A már elküldött linkek továbbra is működnek.",
|
||||
"A feltöltési ablak mutatja, melyik mappába kerül a fájl, és ott választhatsz másikat vagy új mappát hozhatsz létre. A Megosztás megnyitáskor rögtön kimásol egy linket.",
|
||||
"A Csatornák címkéi mutatják, hány követett csatorna tartozik hozzájuk. A Plex Folytatás sávjának is van Összes gombja, és az elem adatlapja mutatja a fájl méretét."
|
||||
],
|
||||
"fixed": [
|
||||
"A lejárt megosztási link lejártként látszik, és sosem kerül a vágólapra. A széles képpontú videó vágása pontosan azt tartja meg, amit a keret mutatott.",
|
||||
"A Plex automatikus átugrása a marker után 3 másodpercet vár, mielőtt a visszaszámlálás indul."
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.116.3",
|
||||
"date": "2026-11-04",
|
||||
|
||||
@@ -45,6 +45,7 @@ const tag = (id: number, name: string): Tag => ({
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
subscribed_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A tag's row in the manager says how many channels it is on, beside a hover list of them, over the
|
||||
// Channels page whose chip names the same tag with a number: all three count the same channels —
|
||||
// the followed ones the Subscriptions tab shows, hidden ones left out. Every link (the number a
|
||||
// delete removes) is only the delete's confirm.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, o?: { count?: number }) => (o?.count != null ? `${key}:${o.count}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
import TagManager from "./TagManager";
|
||||
import { api, type ManagedChannel, type Tag } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const tag = {
|
||||
id: 5,
|
||||
name: "Music",
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 4,
|
||||
subscribed_count: 2,
|
||||
} as Tag;
|
||||
const ch = (id: string, hidden: boolean) =>
|
||||
({ id, title: id, hidden, tag_ids: [5] }) as unknown as ManagedChannel;
|
||||
|
||||
describe("TagManager · a tag's count", () => {
|
||||
it("counts the followed channels the Subscriptions tab shows, not every link", async () => {
|
||||
vi.spyOn(api, "tags").mockResolvedValue([tag]);
|
||||
vi.spyOn(api, "channels").mockResolvedValue([
|
||||
ch("UCa", false),
|
||||
ch("UCb", false),
|
||||
ch("UChid", true),
|
||||
]);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<TagManager onClose={() => {}} onFocusChannel={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("tagManager.channels:2")).toBeTruthy();
|
||||
expect(screen.queryByText("tagManager.channels:4")).toBeNull();
|
||||
fireEvent.mouseEnter(screen.getByText("tagManager.channels:2"));
|
||||
expect(await screen.findByText("UCa")).toBeTruthy();
|
||||
expect(screen.queryByText("UChid")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function TagRow({
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{t("tagManager.channels", { count: tag.channel_count })}
|
||||
{t("tagManager.channels", { count: tag.subscribed_count })}
|
||||
</span>
|
||||
<IconButton variant="danger" onClick={onDelete} label={t("tagManager.delete")}>
|
||||
<Trash2 />
|
||||
@@ -119,7 +119,7 @@ export default function TagManager({
|
||||
const userTags = (tagsQuery.data ?? []).filter((tg) => !tg.system);
|
||||
const channelsForTag = (tagId: number) =>
|
||||
(channelsQuery.data ?? [])
|
||||
.filter((c) => c.tag_ids.includes(tagId))
|
||||
.filter((c) => !c.hidden && c.tag_ids.includes(tagId))
|
||||
.map((c) => ({ id: c.id, title: c.title ?? c.id }));
|
||||
|
||||
const invalidate = () => {
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"loadError": "Couldn't load this — check your connection and try again",
|
||||
"deletedTag": "Deleted tag",
|
||||
"seeAll": "See all",
|
||||
"seeAllOf": "{{action}}: {{title}}",
|
||||
"scrollLeft": "Scroll left",
|
||||
"scrollRight": "Scroll right",
|
||||
"search": "Search",
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
"uploadProgress": "Upload progress",
|
||||
"added": "File uploaded",
|
||||
"addedConverting": "File uploaded — converting it so it plays on any phone",
|
||||
"targetLabel": "Upload to",
|
||||
"makingFolder": "Making “{{name}}”…",
|
||||
"targetFailed": "The folders could not be loaded",
|
||||
"failed": "Couldn't upload the file"
|
||||
},
|
||||
"status": {
|
||||
@@ -90,7 +93,7 @@
|
||||
"pickPlaceholder": "Search a user…",
|
||||
"noUsers": "No other users yet.",
|
||||
"linkSection": "Share a link",
|
||||
"linkHint": "Anyone with the link can watch — no account needed.",
|
||||
"linkHint": "Anyone with the link can open it — no account needed.",
|
||||
"linkCopied": "Share link copied to clipboard",
|
||||
"openNewTab": "Open in new tab",
|
||||
"linkFailed": "Couldn’t create the share link",
|
||||
@@ -111,6 +114,7 @@
|
||||
"revokeConfirm": "This link will stop working immediately.",
|
||||
"views_one": "{{count}} view",
|
||||
"views_other": "{{count}} views",
|
||||
"expiredOn": "expired {{date}}",
|
||||
"expiresOn": "expires {{date}}",
|
||||
"hasPassword": "password",
|
||||
"downloadable": "downloadable",
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"loadError": "Nem sikerült betölteni — ellenőrizd a kapcsolatot, és próbáld újra",
|
||||
"deletedTag": "Törölt címke",
|
||||
"seeAll": "Összes",
|
||||
"seeAllOf": "{{action}}: {{title}}",
|
||||
"scrollLeft": "Görgetés balra",
|
||||
"scrollRight": "Görgetés jobbra",
|
||||
"search": "Keresés",
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
"uploadProgress": "Feltöltés folyamata",
|
||||
"added": "Fájl feltöltve",
|
||||
"addedConverting": "Fájl feltöltve — átalakítjuk, hogy minden telefonon lejátszható legyen",
|
||||
"targetLabel": "Feltöltés ide",
|
||||
"makingFolder": "„{{name}}” létrehozása…",
|
||||
"targetFailed": "A mappákat nem sikerült betölteni",
|
||||
"failed": "Nem sikerült feltölteni a fájlt"
|
||||
},
|
||||
"status": {
|
||||
@@ -90,7 +93,7 @@
|
||||
"pickPlaceholder": "Keress egy felhasználót…",
|
||||
"noUsers": "Nincs más felhasználó.",
|
||||
"linkSection": "Megosztás linkkel",
|
||||
"linkHint": "A link birtokában bárki megnézheti — fiók nélkül is.",
|
||||
"linkHint": "A link birtokában bárki megnyithatja — fiók nélkül is.",
|
||||
"linkCopied": "Megosztási link a vágólapra másolva",
|
||||
"openNewTab": "Megnyitás új lapon",
|
||||
"linkFailed": "Nem sikerült a megosztási link létrehozása",
|
||||
@@ -111,6 +114,7 @@
|
||||
"revokeConfirm": "A link azonnal használhatatlanná válik.",
|
||||
"views_one": "{{count}} megtekintés",
|
||||
"views_other": "{{count}} megtekintés",
|
||||
"expiredOn": "lejárt: {{date}}",
|
||||
"expiresOn": "lejár: {{date}}",
|
||||
"hasPassword": "jelszó",
|
||||
"downloadable": "letölthető",
|
||||
|
||||
@@ -70,7 +70,9 @@ export interface ClipRequest {
|
||||
export interface EditSpec {
|
||||
trim?: { start_s: number; end_s?: number };
|
||||
segments?: { start_s: number; end_s: number }[];
|
||||
crop?: { x: number; y: number; w: number; h: number };
|
||||
// In the picture's size as the browser reports it, which `frame_w` x `frame_h` says: the server
|
||||
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
|
||||
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
|
||||
accurate?: boolean;
|
||||
}
|
||||
|
||||
@@ -143,7 +145,7 @@ export interface ShareLink {
|
||||
// Null when the server cannot show this link's address again (no encrypted copy its key reads): the
|
||||
// link still opens for whoever has it; to share it anew, make a new link.
|
||||
token: string | null;
|
||||
url: string | null; // path like "/watch/<token>" — prepend window.location.origin to share
|
||||
url: string | null; // path like "/watch/<token>" or "/file/<token>" — prepend window.location.origin to share
|
||||
allow_download: boolean;
|
||||
has_password: boolean;
|
||||
expires_at: string | null;
|
||||
|
||||
@@ -154,6 +154,7 @@ export interface PlexItemDetail {
|
||||
summary?: string | null;
|
||||
year?: number | null;
|
||||
duration_seconds: number | null;
|
||||
size_bytes?: number | null; // the file's, null while unknown
|
||||
playable: string; // direct | remux | transcode
|
||||
transcode_hw?: boolean | null; // transcode items only: true = HW-capable, false = SW-only, null = n/a
|
||||
codec_video?: string | null; // e.g. "hevc" / "h264" (info-chip subtitle)
|
||||
|
||||
@@ -7,7 +7,12 @@ export interface Tag {
|
||||
color: string | null;
|
||||
category: string;
|
||||
system: boolean;
|
||||
/** Every channel link it has, a channel the user no longer follows included (an unsubscribe keeps
|
||||
* the link) — what deleting the tag removes. */
|
||||
channel_count: number;
|
||||
/** The user's own links on channels they follow and have not hidden: the rows the Channels page's
|
||||
* filter shows (a hidden channel has its own tab). */
|
||||
subscribed_count: number;
|
||||
}
|
||||
|
||||
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern.
|
||||
|
||||
@@ -18,6 +18,7 @@ import { api, HttpError } from "./api";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { useChannelTagActions } from "./useChannelTagActions";
|
||||
import { useChannelPriority } from "./useChannelPriority";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
@@ -44,6 +45,23 @@ describe("useChannelTagActions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// A tag counts the followed channels it is on: a toggle that lands changes that number.
|
||||
it("refreshes the tags once a toggle lands", async () => {
|
||||
vi.spyOn(api, "attachChannelTag").mockResolvedValue({ ok: true } as never);
|
||||
const qc = new QueryClient();
|
||||
const spy = vi.spyOn(qc, "invalidateQueries");
|
||||
const { result } = renderHook(
|
||||
() => useChannelTagActions({ optimisticToggle: () => {}, onError: () => {} }),
|
||||
{
|
||||
wrapper: ({ children }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
),
|
||||
},
|
||||
);
|
||||
act(() => result.current.toggleTag("UC1", 7, false));
|
||||
await waitFor(() => expect(spy.mock.calls.map(([f]) => f?.queryKey)).toContainEqual(qk.tags()));
|
||||
});
|
||||
|
||||
it("names a refused create-and-attach", async () => {
|
||||
vi.spyOn(api, "createTag").mockRejectedValue(new HttpError(429, "slow"));
|
||||
const { result } = renderHook(
|
||||
|
||||
@@ -94,6 +94,22 @@ describe("quickShareLink", () => {
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// An expired link stays listed for its owner, but its recipient would land on "link gone".
|
||||
it("passes over an expired link, and mints one when every link has expired", async () => {
|
||||
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
|
||||
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
|
||||
downloadLinks.mockResolvedValue([
|
||||
{ id: 5, url: "/watch/gone", expires_at: lastWeek },
|
||||
{ id: 3, url: "/watch/live", expires_at: nextWeek },
|
||||
]);
|
||||
expect(await quickShareLink(qc(), { kind: "file", id: 1 })).toBe(`${origin}/watch/live`);
|
||||
expect(createDownloadLink).not.toHaveBeenCalled();
|
||||
|
||||
downloadLinks.mockResolvedValue([{ id: 5, url: "/watch/gone", expires_at: lastWeek }]);
|
||||
createDownloadLink.mockResolvedValue({ id: 9, url: "/watch/fresh" });
|
||||
expect(await quickShareLink(qc(), { kind: "file", id: 2 })).toBe(`${origin}/watch/fresh`);
|
||||
});
|
||||
|
||||
it("mints a new link when none it has can be shown, keeping the old ones listed", async () => {
|
||||
const hidden = { id: 4, url: null };
|
||||
downloadLinks.mockResolvedValue([hidden]);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
import { api, type NewShareLink } from "./api";
|
||||
import { api, type NewShareLink, type ShareLink } from "./api";
|
||||
import { copyText } from "./clipboard";
|
||||
import { msg, notify } from "./notifications";
|
||||
import { notifyActionError } from "./actionErrors";
|
||||
@@ -38,6 +38,16 @@ export function invalidateLinks(qc: QueryClient, target: LinkTarget) {
|
||||
if (target.kind === "folder") void qc.invalidateQueries({ queryKey: qk.fileFolders() });
|
||||
}
|
||||
|
||||
/** Has a link stopped opening? The list still holds an expired link (its owner may want to see or
|
||||
* revoke it), but it is no link to hand out. */
|
||||
export const linkExpired = (link: ShareLink, now = Date.now()) =>
|
||||
link.expires_at !== null && Date.parse(link.expires_at) <= now;
|
||||
|
||||
/** The link to hand out of a list (newest first): the newest that still opens and whose address can
|
||||
* be shown — an expired one sends the recipient to "link gone", one without an address is no use
|
||||
* to copy. None: make one. */
|
||||
export const liveLink = (links: ShareLink[]) => links.find((l) => l.url && !linkExpired(l));
|
||||
|
||||
// One-click share: the row's Share button turns "share this" into a single action.
|
||||
// Ensure the file or folder has a public link (minting one on demand — omitting `allow_download` so
|
||||
// the backend default makes it downloadable), copy it to the clipboard, and toast with an "open in
|
||||
@@ -51,8 +61,7 @@ export async function quickShareLink(qc: QueryClient, target: LinkTarget): Promi
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
});
|
||||
// The newest link whose address can still be shown; one that cannot is no use to copy.
|
||||
path = existing.find((l) => l.url)?.url;
|
||||
path = liveLink(existing)?.url;
|
||||
if (!path) {
|
||||
const made = await makeLink(target, {}); // omit allow_download → backend default (true)
|
||||
qc.setQueryData(linksKey(target), [made, ...existing]);
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// An auto-skip's countdown starts below zero: the wait before it, while the Skip button already
|
||||
// shows. The bar it draws must read empty through the wait, not a negative width.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { skipCountdown } from "./plexPlayerHooks";
|
||||
|
||||
describe("skipCountdown", () => {
|
||||
it("stays empty through the wait, runs over the delay, and stops at full", () => {
|
||||
expect(skipCountdown(-3, 5)).toBe(0);
|
||||
expect(skipCountdown(-0.1, 5)).toBe(0);
|
||||
expect(skipCountdown(2.5, 5)).toBe(0.5);
|
||||
expect(skipCountdown(7, 5)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -434,6 +434,19 @@ export function usePlayerVolume(opts: {
|
||||
return { setVol, nudgeVolume, toggleMute, onWheel };
|
||||
}
|
||||
|
||||
// How long a marker plays before an auto-skip's countdown starts (seconds), at most: on a marker
|
||||
// too short for both, the wait gives way, so the countdown still ends inside it (`useAutoSkip`).
|
||||
const AUTO_SKIP_WAIT_S = 3;
|
||||
// What the countdown keeps in hand before the marker's end: it ticks on wall time, the playhead on
|
||||
// the media's, and the marker stops counting a second before its end.
|
||||
const AUTO_SKIP_SLACK_S = 0.5;
|
||||
|
||||
/** How far an auto-skip's countdown has run (0→1) after `elapsed` playing seconds of it. Through
|
||||
* the wait `elapsed` is below zero, and the bar it draws stays empty: a negative width is invalid
|
||||
* CSS, which the browser drops and keeps whatever it drew before. */
|
||||
export const skipCountdown = (elapsed: number, delay: number) =>
|
||||
Math.max(0, Math.min(1, elapsed / delay));
|
||||
|
||||
// Auto-skip intro/credits. Entering a marker whose auto-skip is on runs a countdown (skipProgress
|
||||
// 0→1 over autoSkipDelay s, shown on the Skip button); on completion it skips — to the marker end, or
|
||||
// for the credits that end the item to the next item (binge). Which credits those are, and which the
|
||||
@@ -481,6 +494,7 @@ export function useAutoSkip(opts: {
|
||||
(m) => abs >= m.start_s && abs < m.end_s - 1,
|
||||
);
|
||||
const activeMarkerRef = useLatest(activeMarker);
|
||||
const absRef = useLatest(abs);
|
||||
// A boolean, not the list, goes into the countdown's deps: a refetch hands back an equal-but-new
|
||||
// list and must not restart a countdown under way (see below); doSkip reads the list from a ref.
|
||||
const activeAuto = activeMarker != null && markerSkip(activeMarker, markers ?? []).auto;
|
||||
@@ -538,14 +552,19 @@ export function useAutoSkip(opts: {
|
||||
const enabled = (m.type === "intro" ? autoSkipIntro : autoSkipCredits) && activeAuto;
|
||||
if (!enabled || suspended || cancelledMarkerRef.current === key) return setSkipProgress(null);
|
||||
if (autoSkipDelay <= 0) return doSkip(m); // immediate
|
||||
let elapsed = 0;
|
||||
// Below zero is the wait: time to see where the marker has landed and press the button, which
|
||||
// shows from the start, before anything counts down to leaving. A delay of 0 asked for none.
|
||||
// It is cut to what the marker has left over the countdown (a short intro, or one resumed near
|
||||
// its end): a wait the countdown cannot finish after is an auto-skip that never fires.
|
||||
const left = m.end_s - 1 - absRef.current - autoSkipDelay - AUTO_SKIP_SLACK_S;
|
||||
let elapsed = -Math.max(0, Math.min(AUTO_SKIP_WAIT_S, left));
|
||||
setSkipProgress(0);
|
||||
const iv = window.setInterval(() => {
|
||||
// Freeze the countdown while the video is paused — otherwise it would fire and auto-seek a
|
||||
// deliberately-paused video. It resumes from where it stopped when playback resumes.
|
||||
if (videoRef.current?.paused) return;
|
||||
elapsed += 0.1;
|
||||
const p = Math.min(1, elapsed / autoSkipDelay);
|
||||
const p = skipCountdown(elapsed, autoSkipDelay);
|
||||
setSkipProgress(p);
|
||||
if (p >= 1) {
|
||||
window.clearInterval(iv);
|
||||
@@ -568,6 +587,7 @@ export function useAutoSkip(opts: {
|
||||
doSkip,
|
||||
videoRef,
|
||||
activeMarkerRef,
|
||||
absRef,
|
||||
activeAuto,
|
||||
]);
|
||||
|
||||
|
||||
@@ -21,9 +21,23 @@ describe("a subscription change", () => {
|
||||
qk.feedSummary(),
|
||||
qk.discoveredChannels(),
|
||||
qk.watchedChannels(),
|
||||
qk.tags(),
|
||||
]);
|
||||
});
|
||||
|
||||
// A tag counts the followed channels it is on, not hidden: a hide changes the number as an
|
||||
// unsubscribe does, and both places that hide a channel must refresh it.
|
||||
it("a hide refreshes the tags wherever it is made", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
.map((f) => f.split("\\").join("/"))
|
||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab/"))
|
||||
.map((f) => [f, readFileSync(new URL(f, root), "utf8")] as const)
|
||||
.filter(([, src]) => /api\.updateChannel\([^)]*hidden/.test(src));
|
||||
expect(sites.length).toBeGreaterThanOrEqual(2);
|
||||
for (const [f, src] of sites) expect(src, f).toContain("queryKey: qk.tags()");
|
||||
});
|
||||
|
||||
it("is refreshed through that one function wherever it is made", () => {
|
||||
const root = new URL("../", import.meta.url);
|
||||
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
|
||||
@@ -3,8 +3,8 @@ import type { QueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "./queryKeys";
|
||||
|
||||
/** Everything a subscribe or an unsubscribe changes: the channel itself, the manager's list and its
|
||||
* counts, the feed and its facets (the channel's videos come or go), and the two discovery lists (a
|
||||
* subscribed channel leaves them). ONE set for every site that makes the change: each used to keep
|
||||
* counts, the feed and its facets (the channel's videos come or go), the two discovery lists (a
|
||||
* subscribed channel leaves them), and the tags (a tag counts the followed channels it is on). ONE set for every site that makes the change: each used to keep
|
||||
* its own list, and each list missed something. */
|
||||
export function invalidateSubscription(qc: QueryClient, channelId: string): void {
|
||||
for (const queryKey of [
|
||||
@@ -15,6 +15,7 @@ export function invalidateSubscription(qc: QueryClient, channelId: string): void
|
||||
qk.feedSummary(),
|
||||
qk.discoveredChannels(),
|
||||
qk.watchedChannels(),
|
||||
qk.tags(),
|
||||
])
|
||||
void qc.invalidateQueries({ queryKey });
|
||||
}
|
||||
|
||||
@@ -28,7 +28,11 @@ export function useChannelTagActions(opts: {
|
||||
? api.detachChannelTag(channelId, tagId)
|
||||
: api.attachChannelTag(channelId, tagId);
|
||||
call
|
||||
.then(() => opts.onSuccess?.(channelId))
|
||||
.then(() => {
|
||||
// A tag counts the followed channels it is on: the toggle changed that number.
|
||||
void qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
opts.onSuccess?.(channelId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
notifyActionError(err, msg("channels.tags.updateFailed"));
|
||||
opts.onError(channelId);
|
||||
|
||||
@@ -25,6 +25,7 @@ const tag = (id: number, name: string, system: boolean): Tag => ({
|
||||
color: null,
|
||||
category: "topic",
|
||||
channel_count: 1,
|
||||
subscribed_count: 1,
|
||||
});
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
// The fit hook is mocked so `visible` is drivable: in jsdom every box measures 0, so the real
|
||||
// measurement can only ever produce one answer.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { render, screen, fireEvent, cleanup, act } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -37,7 +38,7 @@ const chips = [chip("alpha"), chip("beta"), chip("gamma")];
|
||||
// The surface mounts with `visibility: hidden` until floating-ui has positioned it, which never
|
||||
// happens in jsdom (every box measures 0) — so it is out of the accessibility tree and only a
|
||||
// `hidden: true` query can see it. Presence is the whole question here, not visibility.
|
||||
const menu = () => screen.queryByRole("menu", { hidden: true });
|
||||
const menu = () => screen.queryByRole("dialog", { hidden: true });
|
||||
const moreBtn = () => screen.queryByRole("button", { name: /channels.tags.moreCount/ });
|
||||
|
||||
afterEach(() => {
|
||||
@@ -100,3 +101,114 @@ describe("TagFilterChips — the overflow menu", () => {
|
||||
expect(menu()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TagFilterChips counts", () => {
|
||||
it("draws a chip's count after its name, and none when it has none", () => {
|
||||
fit.visible = 2;
|
||||
render(
|
||||
<TagFilterChips
|
||||
chips={[{ ...chip("alpha"), count: 42 }, { ...chip("beta"), count: 0 }, chip("gamma")]}
|
||||
/>,
|
||||
);
|
||||
const name = (label: string) =>
|
||||
screen.getAllByRole("button").find((b) => b.textContent?.startsWith(label));
|
||||
expect(name("alpha")?.textContent).toBe("alpha · 42");
|
||||
expect(name("beta")?.textContent).toBe("beta · 0");
|
||||
// In the chip's own colour: a dimmed count falls under the text contrast floor.
|
||||
expect(name("alpha")?.querySelector('[class*="opacity"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("draws nothing after the name of a chip that has no count", () => {
|
||||
fit.visible = 3;
|
||||
render(<TagFilterChips chips={[{ ...chip("alpha"), count: 42 }, chip("gamma")]} />);
|
||||
const gamma = screen.getAllByRole("button").find((b) => b.textContent?.startsWith("gamma"));
|
||||
expect(gamma?.textContent).toBe("gamma");
|
||||
expect(gamma?.querySelector("span")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// The channel manager puts the picked chips first, so a toggle moves its chip: out of the "+N" fold,
|
||||
// into it, or out of the list. The keyboard's focus goes with the chip, not to <body>.
|
||||
describe("TagFilterChips focus on a toggle that moves the chip", () => {
|
||||
function Row({ start }: { start: string[] }) {
|
||||
const [picked, setPicked] = useState(start);
|
||||
const all = ["alpha", "beta", "gamma"].map((k) => ({
|
||||
...chip(k),
|
||||
active: picked.includes(k),
|
||||
onToggle: () => setPicked((p) => (p.includes(k) ? p.filter((x) => x !== k) : [...p, k])),
|
||||
}));
|
||||
return (
|
||||
<TagFilterChips chips={[...all.filter((c) => c.active), ...all.filter((c) => !c.active)]} />
|
||||
);
|
||||
}
|
||||
const press = (el: HTMLElement) => {
|
||||
el.focus();
|
||||
fireEvent.click(el);
|
||||
};
|
||||
const named = (label: string) =>
|
||||
screen.getAllByRole("button", { hidden: true }).find((b) => b.textContent === label)!;
|
||||
|
||||
// Along the row needs no test of its own: a keyed move within one parent keeps its node, and
|
||||
// React gives the focus back to it after the commit. Only a remount (row ↔ fold) or a chip that
|
||||
// leaves the list loses it.
|
||||
|
||||
it("out of the fold: picked in the popover, it is focused on the row", async () => {
|
||||
fit.visible = 1;
|
||||
render(<Row start={[]} />);
|
||||
fireEvent.click(moreBtn()!);
|
||||
const inFold = named("beta");
|
||||
press(inFold);
|
||||
// The closing popover hands the focus back to "+N" a microtask later: past that too.
|
||||
await act(async () => {});
|
||||
const onRow = named("beta");
|
||||
expect(onRow).not.toBe(inFold);
|
||||
expect(document.activeElement).toBe(onRow);
|
||||
// …and the fold is shut: the focus is not left outside a popover still open.
|
||||
expect(menu()).toBeNull();
|
||||
expect(moreBtn()).toHaveProperty("ariaExpanded", "false");
|
||||
});
|
||||
|
||||
// A stored pick of a tag that no longer exists is an active chip that an un-pick removes for good.
|
||||
function WithGhost() {
|
||||
const [ghost, setGhost] = useState(true);
|
||||
const rest = ["alpha", "beta"].map(chip);
|
||||
return (
|
||||
<TagFilterChips
|
||||
chips={
|
||||
ghost
|
||||
? [{ ...chip("ghost"), active: true, onToggle: () => setGhost(false) }, ...rest]
|
||||
: rest
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
it.each([
|
||||
["with room for all", 3],
|
||||
["with a fold", 1],
|
||||
])("a chip that leaves the list hands the focus to the one now in its place (%s)", (_, n) => {
|
||||
fit.visible = n;
|
||||
render(<WithGhost />);
|
||||
press(named("ghost"));
|
||||
expect(document.activeElement).toBe(named("alpha"));
|
||||
});
|
||||
|
||||
it("into a closed fold: the focus goes to the +N that now holds it", () => {
|
||||
fit.visible = 1;
|
||||
render(<Row start={["gamma"]} />);
|
||||
press(named("gamma"));
|
||||
expect(named("alpha").closest('[role="dialog"]')).toBeNull(); // alpha leads the row now
|
||||
expect(document.activeElement).toBe(moreBtn());
|
||||
});
|
||||
|
||||
it("a mouse press that leaves the focus elsewhere takes nothing", () => {
|
||||
fit.visible = 3;
|
||||
render(<Row start={[]} />);
|
||||
const outside = document.body.appendChild(document.createElement("input"));
|
||||
outside.focus();
|
||||
fireEvent.click(named("gamma"));
|
||||
act(() => {});
|
||||
expect(document.activeElement).toBe(outside);
|
||||
outside.remove();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||
@@ -19,6 +19,8 @@ import { ChipLabelPending } from "../../components/ui/Skeleton";
|
||||
export interface FilterChip {
|
||||
key: string;
|
||||
label: string;
|
||||
/** How many it holds, drawn muted after the name. */
|
||||
count?: number;
|
||||
/** The name is still on its way: the chip draws a placeholder, not `label` (a raw id). */
|
||||
pending?: boolean;
|
||||
active: boolean;
|
||||
@@ -28,12 +30,40 @@ export interface FilterChip {
|
||||
// A chip's text — the visible chip and the hidden measuring mirror both draw it, so the mirror
|
||||
// measures what shows (ActiveFilterChips' ChipText, for this row's chips).
|
||||
function ChipText({ chip }: { chip: FilterChip }) {
|
||||
return chip.pending ? <ChipLabelPending value={chip.label} /> : <>{chip.label}</>;
|
||||
if (chip.pending) return <ChipLabelPending value={chip.label} />;
|
||||
return (
|
||||
<>
|
||||
{chip.label}
|
||||
{/* The count in the chip's own colour, set off by a dot rather than dimmed: a dimmed count
|
||||
falls under the text contrast floor, on a resting chip and on a pressed one. */}
|
||||
{chip.count != null && (
|
||||
<>
|
||||
{" "}
|
||||
<span aria-hidden="true">·</span> <span className="tabular-nums">{chip.count}</span>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TagChip({ chip }: { chip: FilterChip }) {
|
||||
function TagChip({
|
||||
chip,
|
||||
onToggled,
|
||||
}: {
|
||||
chip: FilterChip;
|
||||
onToggled: (key: string, from: HTMLElement) => void;
|
||||
}) {
|
||||
return (
|
||||
<Chip onClick={chip.onToggle} pressed={chip.active}>
|
||||
<Chip
|
||||
data-chip-key={chip.key}
|
||||
onClick={(e) => {
|
||||
// Before the toggle: a pressed chip may move (the caller's order can put picks first), and
|
||||
// the focus must go where it went.
|
||||
if (e.currentTarget === document.activeElement) onToggled(chip.key, e.currentTarget);
|
||||
chip.onToggle();
|
||||
}}
|
||||
pressed={chip.active}
|
||||
>
|
||||
<ChipText chip={chip} />
|
||||
</Chip>
|
||||
);
|
||||
@@ -53,7 +83,7 @@ export default function TagFilterChips({
|
||||
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
|
||||
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
|
||||
const key = chips
|
||||
.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
|
||||
.map((c) => `${c.key}:${c.label}:${c.count ?? ""}:${c.active ? 1 : 0}${c.pending ? "…" : ""}`)
|
||||
.join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
@@ -90,12 +120,54 @@ export default function TagFilterChips({
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
// A fold of toggle buttons, not a menu of items: each chip is a pressed/unpressed button in the
|
||||
// Tab order, which a role=menu with no menu items would hide from a screen reader.
|
||||
mode: "dialog",
|
||||
role: "dialog",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
const { setReference } = pop.refs;
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
// A toggle may move its chip between the row and the "+N" fold (two parents: the button is
|
||||
// remounted and the focus falls to <body>), or take it out of the list altogether (a stored pick of
|
||||
// a tag that no longer exists). A move along the row keeps its node, and React gives it the focus
|
||||
// back. The focus follows the chip: onto it where it now stands, onto "+N" when it went into a
|
||||
// closed fold, or onto the chip now in its old place when it is gone. A toggle in the open fold
|
||||
// closes it, so the focus never sits outside a popover left open. Held for the frame, since the fit
|
||||
// re-decides in layout effects after the toggle's render.
|
||||
const follow = useRef<{ key: string; at: number; closed: boolean } | null>(null);
|
||||
const foldRef = useRef<HTMLDivElement | null>(null);
|
||||
const moreRef = useRef<HTMLButtonElement | null>(null);
|
||||
const onToggled = (chipKey: string, from: HTMLElement) => {
|
||||
const closed = !!foldRef.current?.contains(from);
|
||||
follow.current = { key: chipKey, at: chips.findIndex((c) => c.key === chipKey), closed };
|
||||
requestAnimationFrame(() => (follow.current = null));
|
||||
if (closed) setOpen(false);
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
const target = follow.current;
|
||||
if (!target) return;
|
||||
const inBox = (box: HTMLElement | null) => [
|
||||
...(box?.querySelectorAll<HTMLElement>("[data-chip-key]") ?? []),
|
||||
];
|
||||
const row = inBox(rowRef.current);
|
||||
const find = (box: HTMLElement[]) => box.find((b) => b.dataset.chipKey === target.key);
|
||||
const gone = !chips.some((c) => c.key === target.key);
|
||||
const el =
|
||||
find(row) ??
|
||||
find(inBox(foldRef.current)) ??
|
||||
(gone ? (row[Math.min(target.at, row.length - 1)] ?? moreRef.current) : moreRef.current);
|
||||
if (el && document.activeElement !== el) el.focus();
|
||||
// The closing popover gives the focus back to "+N" in a microtask it queued as it unmounted
|
||||
// (seen in Chrome): this one, queued after it, has the last word.
|
||||
if (el && target.closed) queueMicrotask(() => el.isConnected && el.focus());
|
||||
});
|
||||
const setMore = (el: HTMLButtonElement | null) => {
|
||||
moreRef.current = el;
|
||||
setReference(el);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${chipLaneClass} flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
@@ -124,10 +196,10 @@ export default function TagFilterChips({
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{chips.slice(0, visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
<TagChip key={c.key} chip={c} onToggled={onToggled} />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<Chip ref={setReference} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
<Chip ref={setMore} aria-label={moreLabel} {...pop.getReferenceProps()}>
|
||||
+{hidden}
|
||||
</Chip>
|
||||
)}
|
||||
@@ -138,9 +210,11 @@ export default function TagFilterChips({
|
||||
ariaLabel={moreLabel}
|
||||
className={`${SURFACE.popover} flex max-w-[22rem] flex-wrap items-center gap-1.5 p-2 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} />
|
||||
))}
|
||||
<div ref={foldRef} className="contents">
|
||||
{chips.slice(visible).map((c) => (
|
||||
<TagChip key={c.key} chip={c} onToggled={onToggled} />
|
||||
))}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -186,3 +186,58 @@ describe("buildChannelColumns — Added", () => {
|
||||
expect(screen.getByText(formatDate("2026-10-12T08:00:00+00:00", "en"))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagFilterChips", () => {
|
||||
// An unsubscribe keeps a channel's tag links: the chip counts the followed ones, the rows the
|
||||
// filter shows, not every link the tag has.
|
||||
it("counts a tag's followed channels, and only a tag's", () => {
|
||||
const tag = {
|
||||
id: 3,
|
||||
name: "Tech",
|
||||
color: null,
|
||||
category: "topic",
|
||||
system: false,
|
||||
channel_count: 17,
|
||||
subscribed_count: 15,
|
||||
};
|
||||
const chips = tagFilterChips({
|
||||
userTags: [tag],
|
||||
tags: { isPending: false } as never,
|
||||
picked: ["99"],
|
||||
noTagOn: false,
|
||||
toggle: () => {},
|
||||
toggleNoTag: () => {},
|
||||
t: ((k: string) => k) as never,
|
||||
});
|
||||
expect(chips.find((c) => c.label === "Tech")?.count).toBe(15);
|
||||
expect(chips.find((c) => c.key === "t99")?.count).toBeUndefined();
|
||||
expect(chips[chips.length - 1]?.count).toBeUndefined();
|
||||
});
|
||||
|
||||
// The row folds from its end into "+N", so a picked tag late in the list was folded away: at 1024 px
|
||||
// "music" sat behind "+11" with nothing on the row saying a filter was on. The picks lead the row.
|
||||
it("puts the picked tags first, keeping their order, then the rest in theirs", () => {
|
||||
const tag = (id: number, name: string) => ({ id, name, system: false }) as Tag;
|
||||
const row = (picked: string[], noTagOn = false) =>
|
||||
tagFilterChips({
|
||||
userTags: [tag(1, "ai"), tag(2, "docu"), tag(3, "music"), tag(4, "nature")],
|
||||
tags: { isPending: false } as never,
|
||||
picked,
|
||||
noTagOn,
|
||||
toggle: () => {},
|
||||
toggleNoTag: () => {},
|
||||
t: ((k: string) => k) as never,
|
||||
}).map((c) => c.label + (c.active ? "*" : ""));
|
||||
expect(row(["4", "3"])).toEqual(["music*", "nature*", "ai", "docu", "channels.tags.noTag"]);
|
||||
expect(row(["99", "3"])).toEqual([
|
||||
"99*",
|
||||
"music*",
|
||||
"ai",
|
||||
"docu",
|
||||
"nature",
|
||||
"channels.tags.noTag",
|
||||
]);
|
||||
expect(row([], true)).toEqual(["channels.tags.noTag*", "ai", "docu", "music", "nature"]);
|
||||
expect(row([])).toEqual(["ai", "docu", "music", "nature", "channels.tags.noTag"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,11 +51,12 @@ export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
}
|
||||
|
||||
/** The manager's tag-filter chip row (the Tags column filter's values): any picked id that is not one
|
||||
* of the user's tags, then each user tag, then the "No tag" sentinel. A stored pick can outlive its
|
||||
* of the user's tags, then the picked ones of the user's tags and "No tag", then the rest, each group
|
||||
* in the tags' order with "No tag" last. The row folds from its end into "+N", so every pick leads it:
|
||||
* a filter that is on stays in sight, never only inside the fold. A stored pick can outlive its
|
||||
* tag (deleted here or on another device) and still narrows the table to nothing, so it stays a
|
||||
* visible, removable chip — FIRST, so the row's "+N" overflow never folds the reason away —
|
||||
* and so does every pick while the tag list loads or after it failed, named by lib/tagPicks' rule
|
||||
* (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
|
||||
* visible, removable chip, and so does every pick while the tag list loads or after it failed, named
|
||||
* by lib/tagPicks' rule (a placeholder, then the name, "Deleted tag" or the id), not hidden. */
|
||||
export function tagFilterChips(o: {
|
||||
userTags: Tag[];
|
||||
tags: TagList;
|
||||
@@ -67,17 +68,11 @@ export function tagFilterChips(o: {
|
||||
}): FilterChip[] {
|
||||
const mine = new Set(o.userTags.map((tg) => String(tg.id)));
|
||||
const others = o.picked.filter((id) => id !== NO_TAG_SENTINEL && !mine.has(id));
|
||||
return [
|
||||
...others.map((id) => ({
|
||||
key: `t${id}`,
|
||||
label: tagPickLabel(Number(id), o.tags, o.t),
|
||||
pending: o.tags.isPending,
|
||||
active: true,
|
||||
onToggle: () => o.toggle(Number(id)),
|
||||
})),
|
||||
const known: FilterChip[] = [
|
||||
...o.userTags.map((tg) => ({
|
||||
key: `t${tg.id}`,
|
||||
label: tg.name,
|
||||
count: tg.subscribed_count,
|
||||
active: o.picked.includes(String(tg.id)),
|
||||
onToggle: () => o.toggle(tg.id),
|
||||
})),
|
||||
@@ -88,6 +83,17 @@ export function tagFilterChips(o: {
|
||||
onToggle: o.toggleNoTag,
|
||||
},
|
||||
];
|
||||
return [
|
||||
...others.map((id) => ({
|
||||
key: `t${id}`,
|
||||
label: tagPickLabel(Number(id), o.tags, o.t),
|
||||
pending: o.tags.isPending,
|
||||
active: true,
|
||||
onToggle: () => o.toggle(Number(id)),
|
||||
})),
|
||||
...known.filter((c) => c.active),
|
||||
...known.filter((c) => !c.active),
|
||||
];
|
||||
}
|
||||
|
||||
/** Everything the Channels-manager column set closes over. The manager owns the state + mutations;
|
||||
|
||||
@@ -50,8 +50,10 @@ export function useChannelActions({
|
||||
const nameOf = (c: ManagedChannel) =>
|
||||
c.title == null ? msg("channels.row.thisChannel") : raw(c.title);
|
||||
|
||||
// A hide also changes the tags' counts: a tag counts only the channels the Subscriptions tab shows.
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
|
||||
@@ -36,6 +36,7 @@ const tag = (id: number, name: string): Tag => ({
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
subscribed_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
|
||||
@@ -194,6 +194,8 @@ export default function ChannelPage({
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
// A tag counts only the channels the Subscriptions tab shows, which a hide leaves out.
|
||||
qc.invalidateQueries({ queryKey: qk.tags() });
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { navItemAttrs } from "../../lib/listNav";
|
||||
import { JobThumb } from "../../features/downloads/downloadRow";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { fileMeta } from "./fileMeta";
|
||||
import { MediaCard, MediaPlayGlyph } from "../../components/ui/MediaCard";
|
||||
import {
|
||||
MEDIA_BADGE,
|
||||
@@ -46,7 +47,7 @@ export function FileCard({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const title = jobTitle(file);
|
||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||
const meta = fileMeta(file);
|
||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||
const media = file.asset?.media_kind !== "file";
|
||||
const thumb = <JobThumb job={file} box="h-full w-full" />;
|
||||
@@ -102,7 +103,7 @@ export function FileCard({
|
||||
<span className="flex flex-col gap-1">
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
||||
<SourceBadge file={file} />
|
||||
{channel && <span className="truncate">{channel}</span>}
|
||||
{meta && <span className="truncate">{meta}</span>}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-1 empty:hidden">
|
||||
<PhoneBadge file={file} />
|
||||
|
||||
@@ -41,6 +41,7 @@ import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
|
||||
import { fileMeta } from "./fileMeta";
|
||||
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||
import { useFileActions } from "./useFileActions";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
@@ -157,7 +158,6 @@ function NameCell({
|
||||
onRenamed: (name: string | null, byKey: boolean) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const channel = file.display_uploader || file.asset?.uploader || null;
|
||||
const clip = file.job_kind === "edit" || file.job_kind === "clip";
|
||||
if (renaming)
|
||||
return (
|
||||
@@ -180,7 +180,7 @@ function NameCell({
|
||||
<span title={jobTitle(file)}>{title}</span>
|
||||
)}
|
||||
<span className="flex min-w-0 items-center gap-1.5 text-xs text-muted">
|
||||
<span className="truncate">{channel ?? "—"}</span>
|
||||
<span className="truncate">{fileMeta(file) || "—"}</span>
|
||||
{clip && (
|
||||
<Badge>
|
||||
<Scissors />
|
||||
|
||||
@@ -24,7 +24,9 @@ vi.mock("../../components/ui/Modal", () => ({
|
||||
),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => h.confirm }));
|
||||
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
|
||||
const h = vi.hoisted(() => ({
|
||||
copyText: vi.fn(async (_t: string) => true),
|
||||
confirm: false,
|
||||
revokeDownloadLink: vi.fn(),
|
||||
links: null as unknown[] | null,
|
||||
@@ -72,6 +74,9 @@ afterEach(cleanup);
|
||||
beforeEach(() => {
|
||||
h.links = null;
|
||||
h.confirm = false;
|
||||
h.createDownloadLink.mockReset();
|
||||
h.copyText.mockReset();
|
||||
h.copyText.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
const iconClass = (button: HTMLElement) => button.querySelector("svg")?.getAttribute("class") ?? "";
|
||||
@@ -96,6 +101,157 @@ describe("ShareDialog · a link row's state colours", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShareDialog · opened to share", () => {
|
||||
const open = () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
const row = (id: number, url: string | null, expires_at: string | null = null) => ({
|
||||
id,
|
||||
token: "t",
|
||||
url,
|
||||
allow_download: true,
|
||||
has_password: false,
|
||||
expires_at,
|
||||
view_count: 0,
|
||||
created_at: null,
|
||||
});
|
||||
const origin = window.location.origin;
|
||||
const status = () => screen.getByRole("status").textContent;
|
||||
const lastWeek = new Date(Date.now() - 7 * 86400_000).toISOString();
|
||||
const nextWeek = new Date(Date.now() + 7 * 86400_000).toISOString();
|
||||
|
||||
it("copies the newest link it can show, and says so", async () => {
|
||||
h.links = [row(1, null), row(2, "/file/b"), row(3, "/file/c")];
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
|
||||
expect(h.copyText).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
|
||||
});
|
||||
|
||||
// The list keeps an expired link for its owner to see and revoke; the recipient of one lands on
|
||||
// "link gone".
|
||||
it("copies the newest link that still opens, and says an expired one has expired", async () => {
|
||||
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
|
||||
expect(h.copyText).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("downloads.share.expiredOn", { exact: false })).toBeTruthy();
|
||||
expect(screen.getByText("downloads.share.expiresOn", { exact: false })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("makes a link when every one it has expired, and copies that", async () => {
|
||||
h.links = [row(1, "/file/old", lastWeek)];
|
||||
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
|
||||
expect(h.copyText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// An expired link opens nothing: it is no link to hand out, and reads as dead at a glance.
|
||||
it("offers no Copy on an expired link, and marks it with a warning", async () => {
|
||||
h.links = [row(1, "/file/old", lastWeek), row(2, "/file/b", nextWeek)];
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/b`));
|
||||
expect(screen.getAllByRole("button", { name: "downloads.share.copy" })).toHaveLength(1);
|
||||
expect(screen.getByText("downloads.share.expiredOn", { exact: false }).className).toContain(
|
||||
"text-warn",
|
||||
);
|
||||
expect(screen.getAllByRole("link", { name: "downloads.share.openNewTab" })).toHaveLength(2);
|
||||
});
|
||||
|
||||
// Closed before the link it made came back, the dialog must not open next time on the list it
|
||||
// had and make another.
|
||||
it("makes one link, closed before it came back and opened again", async () => {
|
||||
h.links = [];
|
||||
let made: (l: unknown) => void = () => {};
|
||||
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const dialog = () => (
|
||||
<QueryClientProvider client={qc}>
|
||||
<ShareDialog job={{ id: 9 } as DownloadJob} onClose={() => {}} />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
const first = render(dialog());
|
||||
await waitFor(() => expect(h.createDownloadLink).toHaveBeenCalledTimes(1));
|
||||
first.unmount();
|
||||
made(row(4, "/file/new"));
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
h.links = [row(4, "/file/new")];
|
||||
render(dialog());
|
||||
await screen.findByDisplayValue(`${origin}/file/new`);
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(h.createDownloadLink).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("leaves a New link form the user opened while the first link was on its way", async () => {
|
||||
h.links = [];
|
||||
let made: (l: unknown) => void = () => {};
|
||||
h.createDownloadLink.mockReturnValue(new Promise((res) => (made = res)));
|
||||
open();
|
||||
fireEvent.click(await screen.findByText("downloads.share.newLink"));
|
||||
fireEvent.change(screen.getByPlaceholderText("downloads.share.password"), {
|
||||
target: { value: "s3cret" },
|
||||
});
|
||||
h.links = [row(4, "/file/new")];
|
||||
made(row(4, "/file/new"));
|
||||
await screen.findByDisplayValue(`${origin}/file/new`);
|
||||
expect(screen.getByPlaceholderText<HTMLInputElement>("downloads.share.password").value).toBe(
|
||||
"s3cret",
|
||||
);
|
||||
});
|
||||
|
||||
// The clipboard holds what was copied last: the link copied on opening stops saying so.
|
||||
it("stops claiming the opening copy once another link is copied", async () => {
|
||||
h.links = [row(2, "/file/b"), row(3, "/file/c")];
|
||||
open();
|
||||
await waitFor(() => expect(status()).toBe("downloads.share.linkCopied"));
|
||||
expect(screen.getAllByText("downloads.share.linkCopied")).toHaveLength(2); // the row + the region
|
||||
fireEvent.click(screen.getAllByRole("button", { name: "downloads.share.copy" })[1]!);
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenLastCalledWith(`${origin}/file/c`));
|
||||
expect(status()).toBe("");
|
||||
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
|
||||
});
|
||||
|
||||
it("copies the link it made when there was none", async () => {
|
||||
h.links = [];
|
||||
h.createDownloadLink.mockResolvedValue(row(4, "/file/new"));
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalledWith(`${origin}/file/new`));
|
||||
});
|
||||
|
||||
it("claims nothing when the browser refused the copy", async () => {
|
||||
h.links = [row(2, "/file/b")];
|
||||
h.copyText.mockResolvedValue(false);
|
||||
open();
|
||||
await waitFor(() => expect(h.copyText).toHaveBeenCalled());
|
||||
await screen.findByRole("button", { name: "downloads.share.copy" });
|
||||
expect(status()).toBe("");
|
||||
expect(screen.queryByText("downloads.share.linkCopied")).toBeNull();
|
||||
});
|
||||
|
||||
it("offers the link in a new tab, beside Copy", async () => {
|
||||
h.links = [row(2, "/file/b")];
|
||||
open();
|
||||
const openLink = await screen.findByRole("link", { name: "downloads.share.openNewTab" });
|
||||
expect(openLink.getAttribute("href")).toBe(`${origin}/file/b`);
|
||||
expect(openLink.getAttribute("target")).toBe("_blank");
|
||||
expect(openLink.getAttribute("rel")).toContain("noopener");
|
||||
});
|
||||
|
||||
it("has no Open for a link whose address cannot be shown", async () => {
|
||||
h.links = [row(1, null)];
|
||||
open();
|
||||
await screen.findByText("downloads.share.urlHidden");
|
||||
expect(screen.queryByRole("link", { name: "downloads.share.openNewTab" })).toBeNull();
|
||||
expect(h.copyText).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShareDialog · a link whose address cannot be shown again", () => {
|
||||
it("says so in place of the address, with nothing to copy", async () => {
|
||||
const link = (id: number, url: string | null) => ({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Copy, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import { Check, Copy, ExternalLink, Link2, Lock, Trash2, UserPlus, Eye } from "lucide-react";
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { msg, notify, raw } from "../../lib/notifications";
|
||||
@@ -12,15 +12,20 @@ import { api, type DownloadJob, type ShareLink, type ShareRecipient } from "../.
|
||||
import {
|
||||
fetchLinks,
|
||||
invalidateLinks,
|
||||
linkExpired,
|
||||
linksKey,
|
||||
liveLink,
|
||||
makeLink,
|
||||
type LinkTarget,
|
||||
} from "../../lib/downloads";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { IconLink } from "../../components/ui/LinkButton";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
import { Input, InnerInput } from "../../components/ui/Input";
|
||||
import { CheckRow } from "../../components/ui/NativeControls";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import UserPicker from "./UserPicker";
|
||||
import { useCopyFeedback } from "../../components/ui/useCopyFeedback";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
@@ -75,7 +80,19 @@ function UserShare({ job }: { job: DownloadJob }) {
|
||||
|
||||
// --- share by public link -------------------------------------------------------------------
|
||||
|
||||
function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }) {
|
||||
function LinkRow({
|
||||
link,
|
||||
copiedOnOpen,
|
||||
onCopied,
|
||||
onChanged,
|
||||
}: {
|
||||
link: ShareLink;
|
||||
/** The dialog put this link on the clipboard when it opened, and nothing was copied since. */
|
||||
copiedOnOpen: boolean;
|
||||
/** Its Copy was pressed: the clipboard holds this link now, not the one the dialog copied. */
|
||||
onCopied: () => void;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const { copied, copy: copyValue } = useCopyFeedback();
|
||||
@@ -84,7 +101,9 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
const fullUrl = link.url && window.location.origin + link.url;
|
||||
|
||||
const copy = () => {
|
||||
if (fullUrl) void copyValue(fullUrl);
|
||||
if (!fullUrl) return;
|
||||
onCopied();
|
||||
void copyValue(fullUrl);
|
||||
};
|
||||
const toggle = useMutation({
|
||||
mutationFn: () => api.updateDownloadLink(link.id, { allow_download: !link.allow_download }),
|
||||
@@ -108,10 +127,13 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
},
|
||||
});
|
||||
|
||||
// An expired link opens nothing, so it is no link to hand out: no Copy, and it reads as dead at a
|
||||
// glance (a warn badge, its address struck through). Open stays, to check it; Revoke, to clear it.
|
||||
const expired = linkExpired(link);
|
||||
const badges: string[] = [];
|
||||
if (link.has_password) badges.push(t("downloads.share.hasPassword"));
|
||||
if (link.allow_download) badges.push(t("downloads.share.downloadable"));
|
||||
if (link.expires_at)
|
||||
if (link.expires_at && !expired)
|
||||
badges.push(
|
||||
t("downloads.share.expiresOn", { date: formatDate(link.expires_at, i18n.language) }),
|
||||
);
|
||||
@@ -122,10 +144,33 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
<Link2 className="w-4 h-4 text-muted shrink-0" />
|
||||
{fullUrl ? (
|
||||
<>
|
||||
<InnerInput className="text-xs text-muted truncate" readOnly value={fullUrl} />
|
||||
<IconButton label={t("downloads.share.copy")} size="sm" variant="ghost" onClick={copy}>
|
||||
{copied ? <Check className="w-4 h-4 text-success" /> : <Copy className="w-4 h-4" />}
|
||||
</IconButton>
|
||||
<InnerInput
|
||||
className={`text-xs text-muted truncate ${expired ? "line-through" : ""}`}
|
||||
readOnly
|
||||
value={fullUrl}
|
||||
/>
|
||||
{!expired && (
|
||||
<IconButton
|
||||
label={t("downloads.share.copy")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={copy}
|
||||
>
|
||||
{copied || copiedOnOpen ? (
|
||||
<Check className="w-4 h-4 text-success" />
|
||||
) : (
|
||||
<Copy className="w-4 h-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
)}
|
||||
<IconLink
|
||||
label={t("downloads.share.openNewTab")}
|
||||
href={fullUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</IconLink>
|
||||
</>
|
||||
) : (
|
||||
// The server keeps no copy of this link's address it can read: the link still opens for
|
||||
@@ -175,10 +220,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
<Trash2 className="w-4 h-4 text-danger" />
|
||||
</IconButton>
|
||||
</div>
|
||||
{/* Seen here, heard from the dialog's own live region (`LinkShare`): one mounted already
|
||||
filled may never be announced. */}
|
||||
{copiedOnOpen && <p className="text-xs text-success">{t("downloads.share.linkCopied")}</p>}
|
||||
<div className="flex items-center gap-2 text-xs text-muted flex-wrap">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Eye className="w-3 h-3" /> {t("downloads.share.views", { count: link.view_count })}
|
||||
</span>
|
||||
{expired && link.expires_at && (
|
||||
<Badge tone="warn">
|
||||
{t("downloads.share.expiredOn", { date: formatDate(link.expires_at, i18n.language) })}
|
||||
</Badge>
|
||||
)}
|
||||
{badges.map((b) => (
|
||||
<span
|
||||
key={b}
|
||||
@@ -243,9 +296,18 @@ function LinkRow({ link, onChanged }: { link: ShareLink; onChanged: () => void }
|
||||
);
|
||||
}
|
||||
|
||||
/** Puts a link on the clipboard for the dialog that just opened, and answers whether it got there:
|
||||
* a browser may refuse a copy no click asked for, and then nothing may claim it. */
|
||||
async function copiedLink(link: ShareLink | undefined): Promise<number | null> {
|
||||
if (!link?.url) return null;
|
||||
return (await copyText(window.location.origin + link.url)) ? link.id : null;
|
||||
}
|
||||
|
||||
function LinkShare({ target }: { target: LinkTarget }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
// The link the dialog copied when it opened: sharing is almost always why it was opened.
|
||||
const [copiedOnOpen, setCopiedOnOpen] = useState<number | null>(null);
|
||||
const links = useQuery({
|
||||
queryKey: linksKey(target),
|
||||
queryFn: () => fetchLinks(target),
|
||||
@@ -265,19 +327,29 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
expires_days: expiryDays,
|
||||
password: password.trim() || undefined,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
// In the list at once, as the one-click Share puts it: closed before the refetch, the dialog
|
||||
// would open next time on the cached list without it and make another.
|
||||
onSuccess: (made) => {
|
||||
qc.setQueryData<ShareLink[]>(linksKey(target), (old) => [made, ...(old ?? [])]);
|
||||
invalidate();
|
||||
setCreating(false);
|
||||
// Back to the form's own default for the next link: downloadable.
|
||||
setAllowDownload(true);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
meta: { failTitle: "downloads.share.linkFailed" },
|
||||
});
|
||||
// The form's own success, not the mutation's: the link made on open goes through the same
|
||||
// mutation, and must not close and clear a form the user opened while it was on its way.
|
||||
const createFromForm = () =>
|
||||
create.mutate(undefined, {
|
||||
onSuccess: () => {
|
||||
setCreating(false);
|
||||
// Back to the form's own default for the next link: downloadable.
|
||||
setAllowDownload(true);
|
||||
setExpiryDays(null);
|
||||
setPassword("");
|
||||
},
|
||||
});
|
||||
|
||||
// Open with a link already generated: sharing is almost always the goal, so mint a default
|
||||
// (no password, no expiry, downloadable) link the moment the dialog finds none — as if the
|
||||
// (no password, no expiry, downloadable) link the moment the dialog finds none that opens — as if the
|
||||
// user had pressed "New link" → "Create". All further controls (password, download toggle, expiry,
|
||||
// extra links, revoke) stay available on the created link and via the "New link" button.
|
||||
const autoTried = useRef(false);
|
||||
@@ -290,11 +362,19 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
useEffect(() => {
|
||||
if (!links.data || autoTried.current) return;
|
||||
autoTried.current = true;
|
||||
if (links.data.length === 0) createLink();
|
||||
// The link to hand out is one that still opens (`liveLink`, as the one-click Share picks it):
|
||||
// with only expired ones, or none whose address can be shown, a new one is made as for none.
|
||||
const live = liveLink(links.data);
|
||||
if (!live)
|
||||
createLink(undefined, { onSuccess: (made) => void copiedLink(made).then(setCopiedOnOpen) });
|
||||
else void copiedLink(live).then(setCopiedOnOpen);
|
||||
}, [links.data, createLink]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p role="status" className="sr-only">
|
||||
{copiedOnOpen !== null ? t("downloads.share.linkCopied") : ""}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm font-medium mb-1">
|
||||
<Link2 className="w-4 h-4 text-muted" /> {t("downloads.share.linkSection")}
|
||||
</div>
|
||||
@@ -317,7 +397,13 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{links.data.map((l) => (
|
||||
<LinkRow key={l.id} link={l} onChanged={invalidate} />
|
||||
<LinkRow
|
||||
key={l.id}
|
||||
link={l}
|
||||
copiedOnOpen={copiedOnOpen === l.id}
|
||||
onCopied={() => setCopiedOnOpen(null)}
|
||||
onChanged={invalidate}
|
||||
/>
|
||||
))}
|
||||
{links.data.length === 0 && !creating && !create.isPending && (
|
||||
<p className="text-xs text-muted">{t("downloads.share.noLinks")}</p>
|
||||
@@ -361,7 +447,7 @@ function LinkShare({ target }: { target: LinkTarget }) {
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={() => create.mutate()}
|
||||
onClick={createFromForm}
|
||||
disabled={create.isPending}
|
||||
>
|
||||
{t("downloads.share.createLink")}
|
||||
|
||||
@@ -19,6 +19,8 @@ const h = vi.hoisted(() => ({
|
||||
uploadStarted: vi.fn(),
|
||||
uploadEnded: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
fileFolders: vi.fn(),
|
||||
createFolder: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
@@ -29,7 +31,12 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/api", () => ({
|
||||
api: { uploadFile: h.uploadFile, filesUsage: h.filesUsage },
|
||||
api: {
|
||||
uploadFile: h.uploadFile,
|
||||
filesUsage: h.filesUsage,
|
||||
fileFolders: h.fileFolders,
|
||||
createFolder: h.createFolder,
|
||||
},
|
||||
}));
|
||||
vi.mock("../../lib/downloads", () => ({ invalidateDownloads: vi.fn() }));
|
||||
// The rest of the module stays real: the dialog is a ui/Modal, which hosts its own toasts.
|
||||
@@ -47,12 +54,29 @@ import UploadDialog from "./UploadDialog";
|
||||
import { FilesActionsContext } from "./filesContext";
|
||||
import { msg, type NotifText } from "../../lib/notifications";
|
||||
|
||||
const folder = (id: number, kind: string, source_key: string | null, name: string | null) => ({
|
||||
id,
|
||||
kind,
|
||||
source_key,
|
||||
name,
|
||||
retention_days: null,
|
||||
count: 0,
|
||||
size_bytes: 0,
|
||||
created_at: null,
|
||||
has_link: false,
|
||||
});
|
||||
const FOLDERS = [
|
||||
folder(1, "source", "upload", null),
|
||||
folder(5, "own", null, "peat"),
|
||||
folder(9, "source", "site:reddit.com", null),
|
||||
];
|
||||
|
||||
const wrap = (
|
||||
onClose = vi.fn(),
|
||||
onClose: () => void = vi.fn(),
|
||||
folderId: number | null = null,
|
||||
initialFile: File | null = null,
|
||||
) => {
|
||||
const qc = new QueryClient();
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
const files = {
|
||||
setSearch: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
@@ -78,9 +102,207 @@ beforeEach(() => {
|
||||
h.notify.mockClear();
|
||||
h.filesUsage.mockClear();
|
||||
h.filesUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||
h.createFolder.mockReset();
|
||||
h.fileFolders.mockReset();
|
||||
h.fileFolders.mockResolvedValue(FOLDERS);
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("UploadDialog · where the file goes", () => {
|
||||
const pick = () =>
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(["x"], "a.pdf")] } });
|
||||
const target = () => screen.findByTestId("upload-target");
|
||||
const sentTo = async () => {
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadFile).toHaveBeenCalledTimes(1));
|
||||
return (h.uploadFile.mock.calls[0]![1] as { folderId: number | null }).folderId;
|
||||
};
|
||||
|
||||
it("says the folder it was opened on", async () => {
|
||||
wrap(vi.fn(), 5);
|
||||
expect((await target()).textContent).toContain("peat");
|
||||
});
|
||||
|
||||
it("says Uploads when it was opened on no folder, and sends there", async () => {
|
||||
wrap();
|
||||
expect((await target()).textContent).toContain("files.source.upload");
|
||||
pick();
|
||||
expect(await sentTo()).toBeNull();
|
||||
});
|
||||
|
||||
it("says Uploads before the user has a folder of that name yet", async () => {
|
||||
h.fileFolders.mockResolvedValue([FOLDERS[1]]);
|
||||
wrap();
|
||||
expect((await target()).textContent).toContain("files.source.upload");
|
||||
});
|
||||
|
||||
it("offers only the folders that take an upload", async () => {
|
||||
wrap();
|
||||
fireEvent.click(await target());
|
||||
expect(await screen.findByTestId("upload-target-5")).toBeTruthy();
|
||||
expect(screen.getByTestId("upload-target-1")).toBeTruthy();
|
||||
expect(screen.queryByTestId("upload-target-9")).toBeNull(); // a site's folder claims an origin
|
||||
});
|
||||
|
||||
it("sends to the folder picked", async () => {
|
||||
wrap();
|
||||
fireEvent.click(await target());
|
||||
fireEvent.click(await screen.findByTestId("upload-target-5"));
|
||||
pick();
|
||||
expect(await sentTo()).toBe(5);
|
||||
});
|
||||
|
||||
it("makes a new folder from the same list and sends there", async () => {
|
||||
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "New one" });
|
||||
wrap();
|
||||
fireEvent.click(await target());
|
||||
fireEvent.click(await screen.findByTestId("upload-target-new"));
|
||||
const field = await screen.findByLabelText("files.folder.newName");
|
||||
fireEvent.change(field, { target: { value: "New one" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("New one"));
|
||||
await waitFor(async () => expect((await target()).textContent).toContain("New one"));
|
||||
pick();
|
||||
expect(await sentTo()).toBe(7);
|
||||
});
|
||||
|
||||
it("will not upload while a new folder is on its way, to the wrong place", async () => {
|
||||
let made: (f: unknown) => void = () => {};
|
||||
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
|
||||
wrap();
|
||||
pick();
|
||||
fireEvent.click(await target());
|
||||
fireEvent.click(await screen.findByTestId("upload-target-new"));
|
||||
const field = await screen.findByLabelText("files.folder.newName");
|
||||
const submit = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(submit.disabled || submit.getAttribute("aria-disabled") === "true").toBe(true);
|
||||
fireEvent.change(field, { target: { value: "New one" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalled());
|
||||
fireEvent.click(submit);
|
||||
expect(h.uploadFile).not.toHaveBeenCalled();
|
||||
made({ ...FOLDERS[1], id: 7, name: "New one" });
|
||||
await waitFor(() => expect(submit.disabled).toBe(false));
|
||||
expect(await sentTo()).toBe(7);
|
||||
});
|
||||
|
||||
// The name field saves on blur, and a press blurs it before the press acts: one that closes the
|
||||
// dialog (Cancel, the ✕, the backdrop) makes no folder; one that leaves it open keeps the name.
|
||||
const naming = async (onClose: () => void) => {
|
||||
const r = wrap(onClose);
|
||||
fireEvent.click(await target());
|
||||
fireEvent.click(await screen.findByTestId("upload-target-new"));
|
||||
const field = await screen.findByLabelText("files.folder.newName");
|
||||
fireEvent.change(field, { target: { value: "Trips" } });
|
||||
return { r, field };
|
||||
};
|
||||
const pressOn = (el: Element, field: HTMLElement, click = true) => {
|
||||
fireEvent.pointerDown(el);
|
||||
fireEvent.mouseDown(el);
|
||||
fireEvent.blur(field);
|
||||
fireEvent.pointerUp(el);
|
||||
fireEvent.mouseUp(el);
|
||||
if (click) fireEvent.click(el);
|
||||
};
|
||||
const settled = () => new Promise((r) => setTimeout(r, 20)); // a mutation calls its function a tick later
|
||||
|
||||
it.each([
|
||||
["Cancel", () => screen.getByText("downloads.actions.cancel")],
|
||||
["the close button", () => screen.getByRole("button", { name: "common.close" })],
|
||||
["the backdrop", () => screen.getByRole("dialog")],
|
||||
])("dismissing with %s while naming makes no folder", async (_n, dismiss) => {
|
||||
let unmount = () => {};
|
||||
const { r, field } = await naming(() => unmount());
|
||||
unmount = r.unmount;
|
||||
pressOn(dismiss(), field);
|
||||
await settled();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(h.createFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A right-button press on the backdrop blurs the field but closes nothing: the name stands.
|
||||
it("a press that closes nothing makes the folder it left", async () => {
|
||||
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
const { field } = await naming(vi.fn());
|
||||
pressOn(screen.getByRole("dialog"), field, false);
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
|
||||
});
|
||||
|
||||
it("leaving the name for anything else in the dialog makes the folder", async () => {
|
||||
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
const { field } = await naming(vi.fn());
|
||||
pressOn(screen.getByText("downloads.upload.targetLabel"), field);
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
|
||||
});
|
||||
|
||||
it("leaving the name by Tab makes the folder at once", async () => {
|
||||
h.createFolder.mockResolvedValue({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
const { field } = await naming(vi.fn());
|
||||
fireEvent.blur(field);
|
||||
await waitFor(() => expect(h.createFolder).toHaveBeenCalledWith("Trips"));
|
||||
});
|
||||
|
||||
it("says a new folder is being made, then gives the keyboard back the list", async () => {
|
||||
let made: (f: unknown) => void = () => {};
|
||||
h.createFolder.mockReturnValue(new Promise((res) => (made = res)));
|
||||
wrap();
|
||||
fireEvent.click(await target());
|
||||
fireEvent.click(await screen.findByTestId("upload-target-new"));
|
||||
const field = await screen.findByLabelText("files.folder.newName");
|
||||
fireEvent.change(field, { target: { value: "Trips" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
const making = await screen.findByText("downloads.upload.makingFolder");
|
||||
// Said in a live region, and the keyboard is held on its place, not dropped to <body>.
|
||||
expect(making.closest('[role="status"]')).toBeTruthy();
|
||||
await waitFor(() => expect(document.activeElement).toBe(making.parentElement));
|
||||
made({ ...FOLDERS[1], id: 7, name: "Trips" });
|
||||
await waitFor(async () => expect(document.activeElement).toBe(await target()));
|
||||
});
|
||||
|
||||
// A failed refetch keeps the list it had: the target the file goes to stays shown.
|
||||
it("keeps the target when a later read of the folders fails", async () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FilesActionsContext.Provider
|
||||
value={{
|
||||
setSearch: vi.fn(),
|
||||
setView: vi.fn(),
|
||||
openFile: h.openFile,
|
||||
consumeFocus: vi.fn(),
|
||||
}}
|
||||
>
|
||||
<UploadDialog folderId={5} initialFile={null} onClose={vi.fn()} />
|
||||
</FilesActionsContext.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect((await target()).textContent).toContain("peat");
|
||||
h.fileFolders.mockRejectedValue(new Error("down"));
|
||||
await qc.refetchQueries({ queryKey: ["file-folders"] }).catch(() => {});
|
||||
await waitFor(() => expect(qc.getQueryState(["file-folders"])?.status).toBe("error"));
|
||||
expect((await target()).textContent).toContain("peat");
|
||||
expect(screen.queryByText("downloads.upload.targetFailed")).toBeNull();
|
||||
});
|
||||
|
||||
it("an empty name makes no folder and leaves the target", async () => {
|
||||
wrap(vi.fn(), 5);
|
||||
fireEvent.click(await target());
|
||||
fireEvent.click(await screen.findByTestId("upload-target-new"));
|
||||
fireEvent.keyDown(await screen.findByLabelText("files.folder.newName"), { key: "Enter" });
|
||||
expect((await target()).textContent).toContain("peat");
|
||||
expect(h.createFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("says when the folders could not be read, and offers Retry", async () => {
|
||||
h.fileFolders.mockRejectedValue(new Error("down"));
|
||||
wrap();
|
||||
expect(await screen.findByText("downloads.upload.targetFailed")).toBeTruthy();
|
||||
h.fileFolders.mockResolvedValue(FOLDERS);
|
||||
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
|
||||
expect((await target()).textContent).toContain("files.source.upload");
|
||||
});
|
||||
});
|
||||
|
||||
describe("UploadDialog", () => {
|
||||
it("shows the picked file's name and uploads it with the optional name", async () => {
|
||||
const onClose = vi.fn();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { api } from "../../lib/api";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { isAborted } from "../../lib/api/core";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
@@ -17,6 +17,10 @@ import { formatBytes } from "../../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||
import { DropZone } from "../../components/ui/DropZone";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import FilterSelect from "../../components/ui/FilterSelect";
|
||||
import { RenameField } from "../../components/ui/RenameField";
|
||||
import { StateMessage } from "../../components/ui/QueryState";
|
||||
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
|
||||
|
||||
// Local-file ingest. A general upload — ANY file type, no format list: media
|
||||
// (video/audio) joins the library with the full function set, a generic file is download-only. The
|
||||
@@ -30,6 +34,12 @@ const uploadedKey = (status: string) =>
|
||||
status === "done" ? "downloads.upload.added" : "downloads.upload.addedConverting";
|
||||
// The job the upload's own notice announces (a done one), for the queue not to say it again.
|
||||
const announces = (job: { id: number; status: string }) => (job.status === "done" ? job.id : null);
|
||||
// What stands in the target list for Uploads before the user has that folder (the first upload makes
|
||||
// it), and for the row that makes a folder.
|
||||
const UPLOADS = "uploads";
|
||||
const NEW_FOLDER = "new";
|
||||
const isUploads = (f: FileFolder) => f.kind === "source" && f.source_key === "upload";
|
||||
|
||||
export default function UploadDialog({
|
||||
folderId = null,
|
||||
initialFile = null,
|
||||
@@ -65,6 +75,82 @@ export default function UploadDialog({
|
||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||
const [pickSeq, setPickSeq] = useState(0);
|
||||
const { openFile } = useFilesActions();
|
||||
|
||||
// Where it goes: the folder the dialog was opened on, until the user says otherwise. Null = Uploads.
|
||||
const [target, setTarget] = useState(folderId);
|
||||
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
|
||||
const [naming, setNaming] = useState(false);
|
||||
const targetBox = useRef<HTMLDivElement>(null);
|
||||
const refocusTarget = useRef(false);
|
||||
// The name field saves on blur, and a press blurs it before the press does anything: a press on
|
||||
// Cancel, the ✕ or the backdrop that then closes the dialog is "never mind", not "make it". So a
|
||||
// name left by a press is made once the press is over, and only if the dialog is still here — its
|
||||
// close unmounts this and drops the wait. A name left without a press (Tab) is made at once.
|
||||
const pressing = useRef(false);
|
||||
const afterPress = useRef<(() => void) | null>(null);
|
||||
useEffect(() => {
|
||||
let wait = 0;
|
||||
const down = () => {
|
||||
pressing.current = true;
|
||||
};
|
||||
const up = () => {
|
||||
pressing.current = false;
|
||||
const run = afterPress.current;
|
||||
afterPress.current = null;
|
||||
// After the click the press ends in, where a dismissal closes the dialog.
|
||||
if (run) wait = window.setTimeout(run);
|
||||
};
|
||||
document.addEventListener("pointerdown", down, true);
|
||||
document.addEventListener("pointerup", up, true);
|
||||
document.addEventListener("pointercancel", up, true);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", down, true);
|
||||
document.removeEventListener("pointerup", up, true);
|
||||
document.removeEventListener("pointercancel", up, true);
|
||||
window.clearTimeout(wait);
|
||||
afterPress.current = null;
|
||||
};
|
||||
}, []);
|
||||
const makeFolder = useMutation({
|
||||
mutationFn: (folder: string) => api.createFolder(folder),
|
||||
onSuccess: (made) => {
|
||||
// In the list at once, so the target reads its name rather than waiting for the refetch.
|
||||
qc.setQueryData<FileFolder[]>(qk.fileFolders(), (old) => [...(old ?? []), made]);
|
||||
invalidateDownloads(qc);
|
||||
setTarget(made.id);
|
||||
},
|
||||
meta: { failTitle: "errors.writeFailed.folder.create" },
|
||||
});
|
||||
// A new folder's name ended by a key leaves the keyboard's focus nowhere: back on the list once it
|
||||
// is back, as the folder is made (or fails). Until then on its place, which says it is being made.
|
||||
useEffect(() => {
|
||||
if (naming || !refocusTarget.current) return;
|
||||
const box = targetBox.current;
|
||||
const list = box?.querySelector("button");
|
||||
if (!list) return box?.focus();
|
||||
refocusTarget.current = false;
|
||||
list.focus();
|
||||
}, [naming, makeFolder.isPending]);
|
||||
// Not while a folder is being named or made: the file would go to the last target, not that one.
|
||||
const targetSettling = naming || makeFolder.isPending;
|
||||
const targets = (foldersQ.data ?? []).filter(takesUploads);
|
||||
const uploadsRow = targets.find(isUploads);
|
||||
const targetOptions = [
|
||||
...(uploadsRow ? [] : [{ value: UPLOADS, label: t("files.source.upload") }]),
|
||||
...[...targets]
|
||||
.sort((a, b) => Number(isUploads(b)) - Number(isUploads(a)))
|
||||
.map((f) => ({ value: String(f.id), label: folderLabel(f, t) })),
|
||||
{ value: NEW_FOLDER, label: t("files.folder.new") },
|
||||
];
|
||||
const targetValue = String(target ?? uploadsRow?.id ?? UPLOADS);
|
||||
const pickTarget = ([value]: string[]) => {
|
||||
// After the menu has closed: it hands the focus back as it goes, which took it off a field already
|
||||
// there, and a name field that loses the focus is done, with no name.
|
||||
if (value === NEW_FOLDER) {
|
||||
window.setTimeout(() => setNaming(true));
|
||||
} else if (value === UPLOADS) setTarget(null);
|
||||
else setTarget(Number(value));
|
||||
};
|
||||
// A done file is in Files, opened on it; one the worker still converts is in the tray until it is.
|
||||
const openUploaded = (job: DownloadJob) => {
|
||||
if (job.status === "done") openFile(job.id, job.folder_id ?? null);
|
||||
@@ -86,7 +172,7 @@ export default function UploadDialog({
|
||||
// button holds focus it swallows the activation itself, and while it does not it is natively
|
||||
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
|
||||
// described as the guard. It is here for the next caller of `submit`, not for the only one.
|
||||
if (!file || busy || tooBig) return;
|
||||
if (!file || busy || tooBig || targetSettling) return;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
setPct(0);
|
||||
@@ -95,7 +181,7 @@ export default function UploadDialog({
|
||||
try {
|
||||
const job = await api.uploadFile(file, {
|
||||
displayName,
|
||||
folderId,
|
||||
folderId: target,
|
||||
onProgress: setPct,
|
||||
signal: ac.signal,
|
||||
});
|
||||
@@ -182,6 +268,55 @@ export default function UploadDialog({
|
||||
maxLength={DISPLAY_NAME_MAX}
|
||||
/>
|
||||
|
||||
<p className="block text-sm font-medium mt-4 mb-1">{t("downloads.upload.targetLabel")}</p>
|
||||
{foldersQ.isError && !foldersQ.data ? (
|
||||
// The file still goes where the dialog was opened on: only the list could not be read. A
|
||||
// failed REFETCH keeps the list it had (react-query sets isError beside the data): the
|
||||
// target the file goes to stays shown, as the rail does it.
|
||||
<StateMessage tone="error" compact onRetry={() => void foldersQ.refetch()}>
|
||||
{t("downloads.upload.targetFailed")}
|
||||
</StateMessage>
|
||||
) : naming ? (
|
||||
<RenameField
|
||||
initial=""
|
||||
label={t("files.folder.newName")}
|
||||
onDone={(folder, byKey) => {
|
||||
refocusTarget.current = byKey;
|
||||
setNaming(false);
|
||||
const name = folder?.trim();
|
||||
if (!name) return;
|
||||
if (!byKey && pressing.current) afterPress.current = () => makeFolder.mutate(name);
|
||||
else makeFolder.mutate(name);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
// The select's own height held while the list loads, so it does not push the form down.
|
||||
// It holds the keyboard's focus while a folder is made (tabIndex -1: never a Tab stop), and
|
||||
// says it in a live region, as the list it replaces is gone until then.
|
||||
<div ref={targetBox} tabIndex={-1} className="min-h-8 flex items-center outline-none">
|
||||
<span role="status" className="text-sm text-muted">
|
||||
{/* Upload waits for it (`targetSettling`): this says why. */}
|
||||
{makeFolder.isPending &&
|
||||
t("downloads.upload.makingFolder", { name: makeFolder.variables })}
|
||||
</span>
|
||||
{foldersQ.data && !busy && !makeFolder.isPending && (
|
||||
<FilterSelect
|
||||
hideLabel
|
||||
label={t("downloads.upload.targetLabel")}
|
||||
options={targetOptions}
|
||||
selected={[targetValue]}
|
||||
onChange={pickTarget}
|
||||
testId="upload-target"
|
||||
/>
|
||||
)}
|
||||
{busy && (
|
||||
<span className="text-sm text-muted">
|
||||
{targetOptions.find((o) => o.value === targetValue)?.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{busy && (
|
||||
<div className="mt-5" aria-live="polite">
|
||||
<div
|
||||
@@ -223,7 +358,7 @@ export default function UploadDialog({
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!file || busy || tooBig}
|
||||
disabled={!file || busy || tooBig || targetSettling}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
>
|
||||
{t("downloads.upload.submit")}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// A crop's numbers count the picture as the browser reports it, its shown size, which differs from the
|
||||
// file's own on a video with wide pixels. The size goes along, or the server cuts the wrong part.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
|
||||
import VideoEditor from "./VideoEditor";
|
||||
import { api, type DownloadJob } from "../../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// Wide pixels: stored 960 across, shown 1920 — the browser reports the shown size, the asset the
|
||||
// stored one, and the two must not be mistaken for each other.
|
||||
const job = {
|
||||
id: 7,
|
||||
display_name: "Talk",
|
||||
asset: { duration_s: 100, width: 960, height: 1080, title: "Talk" },
|
||||
} as unknown as DownloadJob;
|
||||
|
||||
describe("VideoEditor crop", () => {
|
||||
it("sends the size the crop was measured on with it", async () => {
|
||||
vi.spyOn(HTMLVideoElement.prototype, "videoWidth", "get").mockReturnValue(1920);
|
||||
vi.spyOn(HTMLVideoElement.prototype, "videoHeight", "get").mockReturnValue(1080);
|
||||
const enqueue = vi
|
||||
.spyOn(api, "enqueueEdit")
|
||||
.mockResolvedValue({ id: 8 } as unknown as DownloadJob);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<VideoEditor job={job} onClose={() => {}} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.cropOff" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "editor.create" }));
|
||||
await waitFor(() => expect(enqueue).toHaveBeenCalledTimes(1));
|
||||
const crop = enqueue.mock.calls[0]?.[0].edit_spec.crop;
|
||||
expect(crop).toMatchObject({ frame_w: 1920, frame_h: 1080, w: 1536, h: 864 });
|
||||
});
|
||||
});
|
||||
@@ -226,14 +226,16 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
|
||||
const willJoin = output === "join" && kept.length > 1;
|
||||
const reencode = cropOn || accurate;
|
||||
|
||||
const cropPx = (): { x: number; y: number; w: number; h: number } | undefined => {
|
||||
const cropPx = (): EditSpec["crop"] => {
|
||||
const v = videoRef.current;
|
||||
const vw = v?.videoWidth || vdim.w;
|
||||
const vh = v?.videoHeight || vdim.h;
|
||||
if (!cropOn) return undefined;
|
||||
// crop fractions are relative to the picture (the overlay is anchored to it), so this is a
|
||||
// straight fraction→source-px scale; see cropGeometry for the even-rounding + in-bounds clamp.
|
||||
return cropToSourcePx(crop, vw, vh) ?? undefined;
|
||||
const px = cropToSourcePx(crop, vw, vh);
|
||||
// The size they count in goes along: it is the shown one, and the file's own can differ.
|
||||
return px ? { ...px, frame_w: vw, frame_h: vh } : undefined;
|
||||
};
|
||||
|
||||
const create = useMutation({
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { fileMeta } from "./fileMeta";
|
||||
import type { LibraryFile } from "../../lib/api";
|
||||
|
||||
const file = (uploader: string | null, container: string | null) =>
|
||||
({ display_uploader: uploader, asset: { uploader: null, container } }) as unknown as LibraryFile;
|
||||
|
||||
describe("fileMeta", () => {
|
||||
it("names who made it and its format", () => {
|
||||
expect(fileMeta(file("Someone", "mp4"))).toBe("Someone · MP4");
|
||||
});
|
||||
it("has the format alone when nobody is named", () => {
|
||||
expect(fileMeta(file(null, "gz"))).toBe("GZ");
|
||||
});
|
||||
it("has the name alone when no format is known", () => {
|
||||
expect(fileMeta(file("Someone", null))).toBe("Someone");
|
||||
});
|
||||
it("is empty when neither is known", () => {
|
||||
expect(fileMeta(file(null, null))).toBe("");
|
||||
});
|
||||
it("reads the file's own uploader when none was typed", () => {
|
||||
const f = { display_uploader: null, asset: { uploader: "Site", container: "webm" } };
|
||||
expect(fileMeta(f as unknown as LibraryFile)).toBe("Site · WEBM");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { LibraryFile } from "../../lib/api";
|
||||
|
||||
/** A file's line under its name, in the table and on its card: who made it and its format. */
|
||||
export function fileMeta(file: LibraryFile): string {
|
||||
const who = file.display_uploader || file.asset?.uploader;
|
||||
const format = file.asset?.container?.toUpperCase();
|
||||
return [who, format].filter(Boolean).join(" · ");
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Discover's See all opens the list its rail is the start of, sorted the way the rail is: Recently
|
||||
// added newest first, Continue watching in progress by last watched. The rails only call back; what
|
||||
// each opens is PlexBrowse's, against the Plex store's show and sort, which outlive the Discover /
|
||||
// Browse toggle — so a sort picked earlier must not survive the press.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("react-i18next")>()),
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
}));
|
||||
vi.mock("./plexSavedViews", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./plexSavedViews")>()),
|
||||
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
|
||||
}));
|
||||
// The rails themselves are PlexDiscover's own test: here only their two See all presses.
|
||||
vi.mock("./PlexDiscover", () => ({
|
||||
default: (p: { onSeeAllRecent: () => void; onSeeAllContinue: () => void }) => (
|
||||
<div>
|
||||
<button onClick={p.onSeeAllRecent}>recent</button>
|
||||
<button onClick={p.onSeeAllContinue}>continue</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import PlexBrowse from "./PlexBrowse";
|
||||
import { api, EMPTY_PLEX_FILTERS } from "../../lib/api";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
import {
|
||||
PlexActionsContext,
|
||||
PlexStateContext,
|
||||
type PlexActions,
|
||||
type PlexState,
|
||||
} from "./plexContext";
|
||||
import type { SortState } from "../../lib/columnSort";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
beforeEach(() => window.history.replaceState({}, "", "/"));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const TITLE: SortState = { key: "title", dir: "asc" };
|
||||
const slot = document.body.appendChild(document.createElement("div"));
|
||||
|
||||
// The store's show and sort, real enough for PlexBrowse's own effect to act on them.
|
||||
function mount(start: { show: string; sort: SortState }) {
|
||||
vi.spyOn(api, "plexLibrary").mockResolvedValue({ items: [], total: 0, offset: 0 } as never);
|
||||
const now = { state: null as PlexState | null, actions: null as PlexActions | null };
|
||||
function Store() {
|
||||
const [show, setShow] = useState(start.show);
|
||||
const [sort, setSort] = useState<SortState>(start.sort);
|
||||
const state: PlexState = {
|
||||
scope: "both",
|
||||
show,
|
||||
sort,
|
||||
filters: EMPTY_PLEX_FILTERS,
|
||||
q: "",
|
||||
field: "all",
|
||||
playlistOpen: null,
|
||||
jumpTo: null,
|
||||
} as PlexState;
|
||||
const actions = {
|
||||
setShow,
|
||||
setSort,
|
||||
setScope: () => {},
|
||||
setFilters: () => {},
|
||||
setQ: () => {},
|
||||
setField: () => {},
|
||||
setPlaylistOpen: () => {},
|
||||
setJumpTo: () => {},
|
||||
} as PlexActions;
|
||||
now.state = state;
|
||||
now.actions = actions;
|
||||
return (
|
||||
<PlexStateContext.Provider value={state}>
|
||||
<PlexActionsContext.Provider value={actions}>
|
||||
<PlexBrowse />
|
||||
</PlexActionsContext.Provider>
|
||||
</PlexStateContext.Provider>
|
||||
);
|
||||
}
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PageToolbarSlot.Provider value={slot}>
|
||||
<Store />
|
||||
</PageToolbarSlot.Provider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return now;
|
||||
}
|
||||
|
||||
describe("PlexBrowse · Discover's See all", () => {
|
||||
it("Recently added opens every title, newest first, over a sort picked before", async () => {
|
||||
const store = mount({ show: "in_progress", sort: TITLE });
|
||||
fireEvent.click(await screen.findByText("recent"));
|
||||
expect(store.state!.show).toBe("all");
|
||||
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
|
||||
});
|
||||
|
||||
// Entering in-progress keeps the sort from before it, to give back on the way out: leaving for
|
||||
// Recently added must not hand it back over newest first.
|
||||
it("Recently added from in progress does not give back the sort from before it", async () => {
|
||||
const store = mount({ show: "all", sort: TITLE });
|
||||
act(() => store.actions!.setShow("in_progress"));
|
||||
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
|
||||
fireEvent.click(await screen.findByText("recent"));
|
||||
expect(store.state!.show).toBe("all");
|
||||
expect(store.state!.sort).toEqual({ key: "added", dir: "desc" });
|
||||
});
|
||||
|
||||
it("Continue watching opens in progress, last watched first", async () => {
|
||||
const store = mount({ show: "all", sort: TITLE });
|
||||
fireEvent.click(await screen.findByText("continue"));
|
||||
expect(store.state!.show).toBe("in_progress");
|
||||
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
|
||||
});
|
||||
|
||||
// Already in progress, with another sort picked there: no entering edge brings the sort.
|
||||
it("Continue watching sorts by last watched when in progress was already on", async () => {
|
||||
const store = mount({ show: "all", sort: TITLE });
|
||||
act(() => store.actions!.setShow("in_progress"));
|
||||
act(() => store.actions!.setSort(TITLE));
|
||||
fireEvent.click(await screen.findByText("continue"));
|
||||
expect(store.state!.show).toBe("in_progress");
|
||||
expect(store.state!.sort).toEqual({ key: "lastwatched", dir: "desc" });
|
||||
// …and the sort from before in-progress still comes back on the way out.
|
||||
act(() => store.actions!.setShow("all"));
|
||||
expect(store.state!.sort).toEqual(TITLE);
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,9 @@ const LASTWATCHED_SORT: NonNullable<SortState> = Object.freeze({
|
||||
dir: "desc" as const,
|
||||
});
|
||||
|
||||
// What the Recently added rail's See all opens on.
|
||||
const ADDED_SORT: NonNullable<SortState> = Object.freeze({ key: "added", dir: "desc" as const });
|
||||
|
||||
// Watch-state options for the toolbar pill (moved out of the filter rail — it's a top-level "what am
|
||||
// I looking at" control now, like the feed's Watch-state pill, not a rail widget).
|
||||
const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const;
|
||||
@@ -579,7 +582,22 @@ function PlexBrowseView({ sub }: { sub: ReturnType<typeof useHistorySubview<Sub>
|
||||
onRestartCard={onRestartCard}
|
||||
onInfoCard={onInfo}
|
||||
onGenre={(g) => applyFilter({ genres: [g] })}
|
||||
onSeeAllRecent={() => sub.open({ kind: "grid" })}
|
||||
// Each opens the list its rail is the start of: the watch state goes with it, and
|
||||
// entering in-progress brings the last-watched sort (the effect above).
|
||||
onSeeAllRecent={() => {
|
||||
// Newest first is the list's point, so no earlier sort comes back as it leaves in-progress.
|
||||
preInProgressSort.current = null;
|
||||
setSort(ADDED_SORT);
|
||||
setShow("all");
|
||||
sub.open({ kind: "grid" });
|
||||
}}
|
||||
onSeeAllContinue={() => {
|
||||
// Already in progress (with a sort picked since), no edge brings the sort: set it here,
|
||||
// keeping the sort from before in-progress for the way out.
|
||||
if (show === "in_progress") setSort(LASTWATCHED_SORT);
|
||||
else setShow("in_progress");
|
||||
sub.open({ kind: "grid" });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -105,6 +105,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.continueWatching");
|
||||
@@ -144,6 +145,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -179,12 +181,38 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={onGenre}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByText("Drama"));
|
||||
expect(onGenre).toHaveBeenCalledWith("Drama");
|
||||
});
|
||||
|
||||
it("fires onSeeAllContinue from the Continue watching rail's see-all", async () => {
|
||||
const onSeeAllContinue = vi.fn();
|
||||
const onSeeAllRecent = vi.fn();
|
||||
plexDiscover.mockResolvedValue({ ...fullPayload, recently_added: [] });
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
wrap(
|
||||
<PlexDiscover
|
||||
scope="both"
|
||||
onOpenCard={noop}
|
||||
onRestartCard={() => {}}
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={onSeeAllRecent}
|
||||
onSeeAllContinue={onSeeAllContinue}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "common.seeAllOf:plex.discover.continueWatching",
|
||||
}),
|
||||
);
|
||||
expect(onSeeAllContinue).toHaveBeenCalledOnce();
|
||||
expect(onSeeAllRecent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fires onSeeAllRecent from the recently-added rail's see-all", async () => {
|
||||
plexDiscover.mockResolvedValue(fullPayload);
|
||||
plexSurprise.mockResolvedValue({ scope: "both", items: [] });
|
||||
@@ -197,9 +225,13 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={onSeeAllRecent}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "common.seeAll" }));
|
||||
// Each See all is named with its rail: Continue watching, above it, has its own.
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "common.seeAllOf:plex.discover.recentlyAdded" }),
|
||||
);
|
||||
expect(onSeeAllRecent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -214,6 +246,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("Random Pick");
|
||||
@@ -250,6 +283,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
@@ -297,6 +331,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -338,6 +373,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findByText("plex.discover.empty");
|
||||
@@ -363,6 +399,7 @@ describe("PlexDiscover", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(await screen.findByLabelText("plex.markWatched"));
|
||||
@@ -386,6 +423,7 @@ describe("PlexDiscover · keyboard", () => {
|
||||
onInfoCard={noop}
|
||||
onGenre={noop}
|
||||
onSeeAllRecent={noop}
|
||||
onSeeAllContinue={noop}
|
||||
/>,
|
||||
);
|
||||
await screen.findAllByText("Random Pick");
|
||||
|
||||
@@ -101,6 +101,7 @@ export default function PlexDiscover({
|
||||
onInfoCard,
|
||||
onGenre,
|
||||
onSeeAllRecent,
|
||||
onSeeAllContinue,
|
||||
}: {
|
||||
scope: string;
|
||||
onOpenCard: (c: PlexCard) => void;
|
||||
@@ -108,6 +109,7 @@ export default function PlexDiscover({
|
||||
onInfoCard: (c: PlexCard) => void;
|
||||
onGenre: (genre: string) => void;
|
||||
onSeeAllRecent: () => void;
|
||||
onSeeAllContinue: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -233,6 +235,7 @@ export default function PlexDiscover({
|
||||
<CardRail
|
||||
title={t("plex.discover.continueWatching")}
|
||||
ariaLabel={t("plex.discover.continueWatching")}
|
||||
onSeeAll={onSeeAllContinue}
|
||||
cards={d.continue_watching}
|
||||
{...railProps}
|
||||
/>
|
||||
|
||||
@@ -426,3 +426,17 @@ describe("a collection strip's card", () => {
|
||||
await vi.waitFor(() => expect(onOpenInfo).toHaveBeenCalledWith("m2"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlexInfo size", () => {
|
||||
const show = (over: Partial<PlexItemDetail>) =>
|
||||
wrap(<PlexInfo onPlayItem={noop} detail={{ ...detail, ...over }} variant="page" />);
|
||||
it("names the file's size beside the runtime", () => {
|
||||
show({ size_bytes: 15_000_000_000 });
|
||||
expect(screen.getByText(/^1[45](\.\d)? GB$/)).toBeTruthy();
|
||||
});
|
||||
it("names none when it is not known", () => {
|
||||
show({ size_bytes: null });
|
||||
expect(screen.getByText("Test Movie")).toBeTruthy();
|
||||
expect(screen.queryByText(/ GB$/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ExternalLink, FolderPlus, ListPlus, Play, RotateCcw, Star, X } from "lucide-react";
|
||||
import { api, type PlexCard, type PlexFilters, type PlexItemDetail } from "../../lib/api";
|
||||
import { formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
|
||||
import { formatBytes, formatCompact, formatDecimal, formatRuntime } from "../../lib/format";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { DetailCustomizeMenu, Filterable, PrefToggle } from "./plexDetailUi";
|
||||
import { DetailAside, DetailAsideToggles, DetailKeywords } from "./plexDetailAside";
|
||||
@@ -452,6 +452,9 @@ export default function PlexInfo({
|
||||
{detail.duration_seconds ? (
|
||||
<span className={mutedCls}>{formatRuntime(detail.duration_seconds, lang)}</span>
|
||||
) : null}
|
||||
{detail.size_bytes ? (
|
||||
<span className={mutedCls}>{formatBytes(detail.size_bytes, lang)}</span>
|
||||
) : null}
|
||||
{detail.studio && (
|
||||
<Filterable
|
||||
tag
|
||||
|
||||
@@ -1121,6 +1121,80 @@ describe("PlexPlayer — clip mode", () => {
|
||||
expect(screen.getByTestId("clip-panel")).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("the wait before an auto-skip's countdown", () => {
|
||||
const withCredits = {
|
||||
...item("a", 0, "b"),
|
||||
markers: [{ type: "credits", start_s: 2900, end_s: 2995, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const advance = (ms: number) =>
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(ms);
|
||||
});
|
||||
|
||||
// Into the marker with the clock already faked, so the countdown's interval is the fake one's.
|
||||
const enterMarker = (paused: boolean) => {
|
||||
vi.useFakeTimers();
|
||||
const v = video();
|
||||
v._paused = paused;
|
||||
v._t = 2910;
|
||||
act(() => void v.dispatchEvent(new Event("timeupdate")));
|
||||
};
|
||||
|
||||
it("holds the countdown for 3 s after the marker starts, then runs it", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
|
||||
// Leaving a final marker writes "watched" as it skips, in the same tick: the next item's
|
||||
// session comes only after promises this clock does not run.
|
||||
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
|
||||
await playingAt("/media/a", 2800);
|
||||
enterMarker(false);
|
||||
advance(3500); // past what the delay alone would have waited
|
||||
expect(left).not.toHaveBeenCalled();
|
||||
advance(1000);
|
||||
expect(left).toHaveBeenCalledWith("a", "watched");
|
||||
});
|
||||
|
||||
it("a delay of 0 is still immediate", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const { session } = mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
|
||||
await playingAt("/media/a", 2910);
|
||||
await waitFor(() => expect(lastStart(session)?.[0]).toBe("b"));
|
||||
});
|
||||
|
||||
it("waits out the pause: a paused video does not use up the 3 s", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 1 });
|
||||
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
mount({ details: { a: withCredits, b: item("b") }, queue: ["a", "b"] });
|
||||
await playingAt("/media/a", 2800);
|
||||
enterMarker(true);
|
||||
advance(10_000);
|
||||
expect(left).not.toHaveBeenCalled();
|
||||
video()._paused = false;
|
||||
advance(3500); // the whole wait is still ahead, then the 1 s countdown
|
||||
expect(left).not.toHaveBeenCalled();
|
||||
advance(1000);
|
||||
expect(left).toHaveBeenCalledWith("a", "watched");
|
||||
});
|
||||
|
||||
// An 8 s intro with the default 5 s countdown: 3 s of wait on top would run past its end, and the
|
||||
// countdown stops there with nothing skipped.
|
||||
it("shortens the wait on a marker too short for it, so the skip still fires", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 5 });
|
||||
const short = {
|
||||
...withCredits,
|
||||
markers: [{ type: "credits", start_s: 2909, end_s: 2917, final: true }],
|
||||
} as unknown as PlexItemDetail;
|
||||
const left = vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
|
||||
mount({ details: { a: short, b: item("b") }, queue: ["a", "b"] });
|
||||
await playingAt("/media/a", 2800);
|
||||
enterMarker(false); // at 2910: 6 s before the marker stops counting
|
||||
advance(5000);
|
||||
expect(left).not.toHaveBeenCalled(); // a wait is still kept: 0.5 s of it here
|
||||
advance(700);
|
||||
expect(left).toHaveBeenCalledWith("a", "watched");
|
||||
});
|
||||
});
|
||||
|
||||
it("does not auto-skip while a clip is marked: the skip would carry the player off it", async () => {
|
||||
prefs({ autoSkipCredits: true, autoSkipDelay: 0 });
|
||||
const withCredits = {
|
||||
|
||||
@@ -24,6 +24,20 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("WatchPage share paths", () => {
|
||||
it.each(["/watch/tok-p", "/file/tok-p", "/folder/tok-p", "/file/tok-p/extra"])(
|
||||
"reads the token off %s",
|
||||
async (path) => {
|
||||
window.history.replaceState({}, "", path);
|
||||
mockMeta({ needs_password: false, audio: false, file_url: "/f.mp4", title: "A clip" });
|
||||
render(<WatchPage />);
|
||||
await waitFor(() =>
|
||||
expect(fetch).toHaveBeenCalledWith("/api/public/watch/tok-p", expect.anything()),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("WatchPage background play", () => {
|
||||
it("hands its <video> to the background-play hook, so Brave iOS keeps it playing", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-bg");
|
||||
@@ -267,6 +281,35 @@ describe("WatchPage honest failures", () => {
|
||||
return page;
|
||||
}
|
||||
|
||||
it.each([
|
||||
[{ kind: "file", video: true }, "This video is password-protected", "Watch"],
|
||||
[{ kind: "file", video: false }, "This file is password-protected", "Open"],
|
||||
[{ kind: "folder", video: false }, "This folder is password-protected", "Open"],
|
||||
[{}, "This video is password-protected", "Watch"],
|
||||
])("a password link that opens %o asks in its words", async (kind, title, button) => {
|
||||
window.history.replaceState({}, "", "/file/tok-lock");
|
||||
mockMeta({ needs_password: true, ...kind });
|
||||
const page = render(<WatchPage />);
|
||||
await page.findByText(title);
|
||||
expect(page.getByRole("button", { name: button })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("a repeated Enter sends one try while it is on its way", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-enter");
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(res(200, { needs_password: true }));
|
||||
fetchMock.mockImplementation(() => new Promise(() => {}));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const page = render(<WatchPage />);
|
||||
const field = await page.findByPlaceholderText("Enter password");
|
||||
fireEvent.change(field, { target: { value: "guess" } });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
fireEvent.keyDown(field, { key: "Enter" });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("too many password tries says so, and keeps the form", async () => {
|
||||
window.history.replaceState({}, "", "/watch/tok-429");
|
||||
const page = await tryPassword(async () => res(429));
|
||||
|
||||
@@ -28,7 +28,7 @@ import { SURFACE } from "../components/ui/surfaces";
|
||||
import { IconLink, LinkButton } from "../components/ui/LinkButton";
|
||||
import { T, lang } from "./watchStrings";
|
||||
|
||||
// Standalone, login-free page for a shared /watch/<token> link: one file, or a folder's list of them.
|
||||
// Standalone, login-free page for a share link (/watch/, /file/, /folder/): one file, or a folder's list.
|
||||
// Its own entry (watchMain.tsx, watch.html): no auth, no providers, none of the app's bundle. Uses
|
||||
// plain fetch and a tiny self-contained i18n dict (`watchStrings`: the public viewer has no app
|
||||
// language preference).
|
||||
@@ -64,7 +64,7 @@ type Meta = FileMeta | FolderMeta;
|
||||
// What the link answers before it is opened: a password link says only which kind it is.
|
||||
type Answer =
|
||||
| (Meta & { needs_password?: false })
|
||||
| { needs_password: true; kind?: "file" | "folder" };
|
||||
| { needs_password: true; kind?: "file" | "folder"; video?: boolean };
|
||||
|
||||
// Compact display of a URL: drop the protocol + trailing slash so it fits on one line.
|
||||
function displayUrl(url: string): string {
|
||||
@@ -83,16 +83,23 @@ async function notFoundState(r: Response): Promise<"preparing" | "gone"> {
|
||||
return body?.detail?.reason === "video_preparing" ? "preparing" : "gone";
|
||||
}
|
||||
|
||||
/** The token of a share path: /watch/ for a video, /file/ or /folder/ for the rest, any of them for
|
||||
* any link (`main.SHARE_PREFIXES`). */
|
||||
function shareToken(path: string): string {
|
||||
return /^\/(?:watch|file|folder)\/([^/?#]+)/.exec(path)?.[1] ?? "";
|
||||
}
|
||||
|
||||
export default function WatchPage() {
|
||||
const token = window.location.pathname.split("/watch/")[1]?.split(/[/?#]/)[0] ?? "";
|
||||
const token = shareToken(window.location.pathname);
|
||||
// Seeded from the URL rather than corrected by the effect below: with no token there is nothing
|
||||
// to load, and "loading" was committed and painted before the effect said so.
|
||||
const [status, setStatus] = useState<
|
||||
"loading" | "ready" | "password" | "gone" | "preparing" | "error"
|
||||
>(token ? "loading" : "gone");
|
||||
const [meta, setMeta] = useState<Meta | null>(null);
|
||||
// A password link's kind, for its title, until it is unlocked.
|
||||
const [locked, setLocked] = useState<"file" | "folder">("file");
|
||||
// What a password link opens, for its prompt's words, until it is unlocked. A server from before
|
||||
// it said `video` only had video links' words, so a file without it reads as one.
|
||||
const [locked, setLocked] = useState<"video" | "file" | "folder">("video");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const errorId = useId();
|
||||
@@ -118,7 +125,7 @@ export default function WatchPage() {
|
||||
.then((m) => {
|
||||
if (typeof m === "string") setStatus(m);
|
||||
else if (m.needs_password) {
|
||||
setLocked(m.kind ?? "file");
|
||||
setLocked(m.kind === "folder" ? "folder" : m.video === false ? "file" : "video");
|
||||
setStatus("password");
|
||||
} else {
|
||||
setMeta(m);
|
||||
@@ -240,7 +247,11 @@ export default function WatchPage() {
|
||||
<div className={`${SURFACE.panel} w-full max-w-sm p-6`}>
|
||||
<div className="flex items-center gap-2 font-medium mb-4">
|
||||
<Lock className="w-4 h-4 text-accent" />{" "}
|
||||
{locked === "folder" ? T.passwordTitleFolder : T.passwordTitle}
|
||||
{locked === "folder"
|
||||
? T.passwordTitleFolder
|
||||
: locked === "file"
|
||||
? T.passwordTitleFile
|
||||
: T.passwordTitle}
|
||||
</div>
|
||||
<Input
|
||||
className="w-full"
|
||||
@@ -248,7 +259,9 @@ export default function WatchPage() {
|
||||
value={password}
|
||||
autoFocus
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && password && unlock()}
|
||||
// Not again while one is on its way, as the button: a held or repeated Enter would
|
||||
// spend the link's tries (and its view count) once per press.
|
||||
onKeyDown={(e) => e.key === "Enter" && password && !unlocking && unlock()}
|
||||
placeholder={T.passwordPlaceholder}
|
||||
aria-describedby={error ? errorId : undefined}
|
||||
/>
|
||||
@@ -266,7 +279,7 @@ export default function WatchPage() {
|
||||
disabled={!password || unlocking}
|
||||
className="mt-4"
|
||||
>
|
||||
{T.watch}
|
||||
{locked === "video" ? T.watch : T.open}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -28,3 +28,12 @@ describe("the watch page's document language", () => {
|
||||
expect(document.documentElement.lang).toBe(want);
|
||||
});
|
||||
});
|
||||
|
||||
// One button, two kinds of link: it says the same kind of word for both, as the app's own buttons
|
||||
// do in Hungarian — a noun (Letöltés, Megnyitás), not a first-person verb on one of them.
|
||||
describe("the password form's button", () => {
|
||||
it("reads as a noun for a video and for anything else in Hungarian", async () => {
|
||||
const { T } = await pageIn("hu-HU");
|
||||
expect([T.watch, T.open]).toEqual(["Megtekintés", "Megnyitás"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,8 @@ const STR = {
|
||||
crashHint: "Reload the page to try again.",
|
||||
reload: "Reload",
|
||||
passwordTitleFolder: "This folder is password-protected",
|
||||
passwordTitleFile: "This file is password-protected",
|
||||
open: "Open",
|
||||
folderEmpty: "This folder is empty.",
|
||||
play: "Play",
|
||||
files: (n: number) => (n === 1 ? "1 file" : `${n} files`),
|
||||
@@ -37,7 +39,7 @@ const STR = {
|
||||
goneHint: "Lehet, hogy lejárt, vagy a tulajdonos visszavonta.",
|
||||
passwordTitle: "Ez a videó jelszóval védett",
|
||||
passwordPlaceholder: "Add meg a jelszót",
|
||||
watch: "Megnézem",
|
||||
watch: "Megtekintés",
|
||||
wrong: "Hibás jelszó.",
|
||||
download: "Letöltés",
|
||||
file: "Fájl",
|
||||
@@ -55,6 +57,8 @@ const STR = {
|
||||
crashHint: "Töltsd újra az oldalt, és próbáld újra.",
|
||||
reload: "Újratöltés",
|
||||
passwordTitleFolder: "Ez a mappa jelszóval védett",
|
||||
passwordTitleFile: "Ez a fájl jelszóval védett",
|
||||
open: "Megnyitás",
|
||||
folderEmpty: "Ez a mappa üres.",
|
||||
play: "Lejátszás",
|
||||
files: (n: number) => `${n} fájl`,
|
||||
@@ -83,6 +87,8 @@ const STR = {
|
||||
crashHint: "Lade die Seite neu und versuche es erneut.",
|
||||
reload: "Neu laden",
|
||||
passwordTitleFolder: "Dieser Ordner ist passwortgeschützt",
|
||||
passwordTitleFile: "Diese Datei ist passwortgeschützt",
|
||||
open: "Öffnen",
|
||||
folderEmpty: "Dieser Ordner ist leer.",
|
||||
play: "Abspielen",
|
||||
files: (n: number) => (n === 1 ? "1 Datei" : `${n} Dateien`),
|
||||
|
||||
@@ -99,6 +99,29 @@ function chain(seen: Map<string, string | null>, mod: string): string {
|
||||
return path.join(" → ");
|
||||
}
|
||||
|
||||
describe("the paths a share link opens on", () => {
|
||||
// The server's list: each of the places below that has to know them follows it.
|
||||
const main = readFileSync(join(SRC, "..", "..", "backend", "app", "main.py"), "utf8");
|
||||
const prefixes = [
|
||||
...(/SHARE_PREFIXES = \(([^)]*)\)/.exec(main)?.[1] ?? "").matchAll(/"(\w+)\/"/g),
|
||||
].map((m) => m[1]);
|
||||
|
||||
it("are read off the server", () => {
|
||||
expect(prefixes).toEqual(["watch", "file", "folder"]);
|
||||
});
|
||||
|
||||
it("are all kept out of search indexes", () => {
|
||||
const robots = readFileSync(join(SRC, "..", "public", "robots.txt"), "utf8");
|
||||
for (const p of prefixes) expect(robots).toContain(`Disallow: /${p}/`);
|
||||
});
|
||||
|
||||
it("all boot the player alone in `vite dev` and all give up their token", () => {
|
||||
const vite = readFileSync(join(SRC, "..", "vite.config.ts"), "utf8");
|
||||
const page = readFileSync(join(SRC, "watch", "WatchPage.tsx"), "utf8");
|
||||
for (const src of [vite, page]) expect(src).toContain(`(?:${prefixes.join("|")})`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the /watch entry", () => {
|
||||
it("loads nothing the app alone may load", () => {
|
||||
const seen = reach(join(SRC, "watchMain.tsx"));
|
||||
|
||||
@@ -32,18 +32,18 @@ const proxy = {
|
||||
"/healthz": proxyOpts,
|
||||
};
|
||||
|
||||
// Two HTML entries: the app (index.html) and a public /watch/<token> link (watch.html), which loads
|
||||
// Two HTML entries: the app (index.html) and a public share link (watch.html), which loads
|
||||
// only the player page. In a build the backend picks the file by path (`main.py`); this does the
|
||||
// same for `vite dev`, whose history fallback would otherwise hand /watch/… the app.
|
||||
const watchEntry = (): Plugin => ({
|
||||
name: "siftlode-watch-entry",
|
||||
configureServer(server) {
|
||||
server.middlewares.use((req, _res, next) => {
|
||||
if (req.url?.startsWith("/watch/")) req.url = "/watch.html";
|
||||
if (req.url && /^\/(?:watch|file|folder)\//.test(req.url)) req.url = "/watch.html";
|
||||
next();
|
||||
});
|
||||
},
|
||||
// The server answers every /watch/ link with the built watch.html, so a build without it would
|
||||
// The server answers every share link with the built watch.html, so a build without it would
|
||||
// ship a 500 for each one, and nothing downstream reads `dist` before prod does. Fail it here —
|
||||
// after Vite's own HTML plugin has emitted the pages (`enforce: "post"`), or none is there yet.
|
||||
enforce: "post",
|
||||
|
||||
Reference in New Issue
Block a user