release: 0.96.0 (feature/e28-design-constitution)
gate / frontend (push) Failing after 6m12s
gate / backend (push) Successful in 7m22s
@@ -46,11 +46,13 @@ how it gets built. Concretely:
|
||||
behind each option and the weight of the decision. These decision points are where the product owner
|
||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||
choose from before building.
|
||||
choose from before building. How the UI looks and behaves is decided in
|
||||
[`docs/DESIGN.md`](docs/DESIGN.md) (its variants render in the dev-only `/design-lab`): build to it,
|
||||
and change it only through its decision log.
|
||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||
arguments, push back or propose an alternative — don't comply blindly.
|
||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||
bugs; do a real visual check of any UI change.
|
||||
bugs; do a real visual check of any UI change, and run DESIGN.md's four-step Conformance check.
|
||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||
|
||||
@@ -79,7 +81,7 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| ------------------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||
| ORM | SQLAlchemy 2.0 `select()` | `db.query(...)` |
|
||||
| Request body | a Pydantic model | `payload: dict` |
|
||||
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||
| Request SIZE | nothing — `bodylimit` caps every route (SEC-20); need more? add a ceiling to `ROUTE_MAX_BODY_BYTES` | `capped_body` on a route that does not read raw bytes itself |
|
||||
| Ownership check | `owned_or_404` / `get_or_404` | ad-hoc `_own_playlist`/`_owned`/`_own_job` |
|
||||
| Admin gate import | `app.auth` | the `app.routes.admin` re-export |
|
||||
| Upstream failure status | `422` with JSON detail | `502` (reserve 502 for raw byte-proxies) |
|
||||
@@ -90,10 +92,13 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
| Job status word | the `downloads/status.py` StrEnum | loose `"canceled"`/`"cancelled"` literals |
|
||||
| Current time | inline `datetime.now(timezone.utc)` | new `now_utc`/`_now` helpers |
|
||||
| FE per-account state | `usePref` | raw `readAccount`/`writeAccount` or bespoke hooks |
|
||||
| FE action button | `ui/Button` — pick a `SIZES` rung | a hand-rolled `<button className="px-3 py-2 …">` |
|
||||
| FE button | `ui/Button` (a `SIZES` rung) · `IconButton` · `Pressable` (a clickable card/row) · `Chip` · `DragHandle` | a raw `<button>` or `FocusSafeButton` outside the control primitives (lint-enforced) |
|
||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||
| FE text field / checkbox / slider | `ui/Input` · `Textarea` · `InnerInput` in a `fieldClass` frame · `Checkbox`/`CheckRow` · `Slider` | a raw `<input>`/`<textarea>` anywhere but those primitives (lint-enforced) |
|
||||
| FE glass surface | a role: `SURFACE.panel` / `card` / `menu` / `popover` / `tooltip` (`ui/surfaces.ts`) | a bare `glass`/`glass-card`/`glass-menu` beside a hand-picked radius (lint-enforced) |
|
||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||
| FE control | `SegmentedToggle`; a long or user's list: `FilterSelect` | a native `<select>` (lint-enforced) |
|
||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)` | a bare `catch {}` or `notify()` with no error attached |
|
||||
|
||||
@@ -12,7 +12,9 @@ class Base(DeclarativeBase):
|
||||
|
||||
# Pool sized above the sync-endpoint threadpool (~40) so a burst of short DB sessions can't starve on
|
||||
# checkout. The image proxies deliberately DON'T hold a connection during their slow external fetch
|
||||
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones.
|
||||
# (see routes/plex/), so this headroom is only for the many brief sessions, not long ones. The one
|
||||
# long hold — a playlist sync's advisory claim, for a whole push — opens its own unpooled connection
|
||||
# (sync/playlists.py `_claim_engine`) instead of drawing a third one from here.
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
pool_pre_ping=True,
|
||||
|
||||
@@ -32,6 +32,7 @@ window still took the full 145 s and emitted the whole track), so windows are ex
|
||||
zero-based clock and shifted back to absolute time here with `shift_vtt`.
|
||||
"""
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
@@ -217,7 +218,11 @@ _FULL_END_S = 1_000_000_000.0 # stand-in for "to the end of the file" (JSON can
|
||||
|
||||
|
||||
def cache_key(rating_key: str, sub_ord: int) -> str:
|
||||
return f"sub_{rating_key}_{sub_ord}"
|
||||
# Versioned whenever `decode_subtitle` changes what a track decodes to — `sub2_` when legacy code
|
||||
# pages were cached as U+FFFD, `sub3_` when a stray byte, an untagged file or Serbian still went
|
||||
# to the wrong code page. The cache has no eviction, so every track is read again once rather
|
||||
# than a broken one being served for good; the older files are just left behind.
|
||||
return f"sub3_{rating_key}_{sub_ord}"
|
||||
|
||||
|
||||
def _vtt_path(key: str) -> Path:
|
||||
@@ -904,15 +909,90 @@ def _full_pass_start(rating_key: str, targets: dict[int, int]) -> float:
|
||||
return max(0.0, float(f"{min(ends) - WINDOW_PRE_S:.3f}"))
|
||||
|
||||
|
||||
# The legacy code page a subtitle in this language (Plex's ISO 639-2 `languageCode`) was most likely
|
||||
# saved in. Serbian sits here, not with Cyrillic: its releases are overwhelmingly Latin script in
|
||||
# Windows-1250, like Croatian and Bosnian.
|
||||
_CP1250 = {
|
||||
"hun",
|
||||
"ces",
|
||||
"cze",
|
||||
"pol",
|
||||
"slk",
|
||||
"slo",
|
||||
"slv",
|
||||
"hrv",
|
||||
"bos",
|
||||
"srp",
|
||||
"ron",
|
||||
"rum",
|
||||
"sqi",
|
||||
"alb",
|
||||
}
|
||||
_CP1251 = {"rus", "ukr", "bel", "bul", "mkd", "mac"}
|
||||
# Western European languages keep Windows-1252 (cp1250 has no à/è/ñ). An UNTAGGED file falls to
|
||||
# Windows-1250: its ASCII and á/é/í/ó/ö/ú/ü match Windows-1252 byte for byte, and it also gets ő/ű
|
||||
# right, which Windows-1252 turns into õ/û — the library this reads is mostly Hungarian.
|
||||
_CP1252 = {
|
||||
"eng",
|
||||
"fre",
|
||||
"fra",
|
||||
"ger",
|
||||
"deu",
|
||||
"spa",
|
||||
"ita",
|
||||
"por",
|
||||
"dut",
|
||||
"nld",
|
||||
"swe",
|
||||
"dan",
|
||||
"nor",
|
||||
"fin",
|
||||
"ice",
|
||||
"isl",
|
||||
"cat",
|
||||
}
|
||||
# A file that is almost all valid UTF-8 is UTF-8 with a few stray bytes (a pasted Windows-1252 quote),
|
||||
# not a legacy file: accents in a legacy code page are nearly never valid UTF-8 sequences, so a real
|
||||
# legacy file has hardly any. At least this many valid multi-byte characters per invalid byte.
|
||||
_UTF8_VALID_PER_STRAY = 10
|
||||
|
||||
|
||||
def decode_subtitle(raw: bytes, language_code: str | None) -> str:
|
||||
"""A sidecar subtitle file as text. SubRip carries no charset, and a Hungarian release is
|
||||
typically Windows-1250: decoded as UTF-8 with `replace`, every ő/ű/é came out as U+FFFD (found
|
||||
live, 1391 of them in one episode). So: a BOM decides; else valid UTF-8 stays UTF-8 (a legacy
|
||||
code page with accents is almost never valid UTF-8), and so does NEARLY valid UTF-8 — one stray
|
||||
byte must not send a whole UTF-8 file to a legacy code page; else the language's legacy code page."""
|
||||
if raw.startswith(codecs.BOM_UTF8):
|
||||
return raw[len(codecs.BOM_UTF8) :].decode("utf-8", "replace")
|
||||
if raw.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
||||
return raw.decode("utf-16", "replace")
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# silent-ok: not strictly UTF-8 — nearly-UTF-8 or a legacy code page, told apart below.
|
||||
pass
|
||||
lenient = raw.decode("utf-8", "replace")
|
||||
stray = lenient.count("�")
|
||||
valid = sum(1 for ch in lenient if ch > "" and ch != "�")
|
||||
if valid >= stray * _UTF8_VALID_PER_STRAY:
|
||||
return lenient
|
||||
lang = (language_code or "").lower()
|
||||
legacy = "cp1251" if lang in _CP1251 else "cp1252" if lang in _CP1252 else "cp1250"
|
||||
return raw.decode(legacy, "replace")
|
||||
|
||||
|
||||
def _sidecar_vtt(db: Session, stream: dict) -> str:
|
||||
"""An external subtitle file, fetched through Plex and converted to WebVTT."""
|
||||
"""An external subtitle file, fetched through Plex and converted to WebVTT. Its bytes are made
|
||||
UTF-8 first (`decode_subtitle`), on the ffmpeg path too: ffmpeg reads a text subtitle as UTF-8."""
|
||||
with PlexClient(db) as plex:
|
||||
raw = plex.raw_get(stream["key"])
|
||||
text = decode_subtitle(raw, stream.get("languageCode"))
|
||||
codec = (stream.get("codec") or stream.get("format") or "").lower()
|
||||
if codec in ("srt", "subrip", ""):
|
||||
return srt_to_vtt(raw.decode("utf-8", "replace"))
|
||||
return srt_to_vtt(text)
|
||||
with tempfile.NamedTemporaryFile(suffix="." + (codec or "sub"), delete=False) as fh:
|
||||
fh.write(raw)
|
||||
fh.write(text.encode("utf-8"))
|
||||
tmp = fh.name
|
||||
try:
|
||||
return ffmpeg_to_vtt(tmp, None)
|
||||
|
||||
@@ -28,10 +28,13 @@ def not_found(what: str = "generic") -> HTTPException:
|
||||
return HTTPException(status_code=404, detail={"code": "not_found", "reason": what})
|
||||
|
||||
|
||||
def get_or_404(db: Session, model: type[M], id_: Any, *, what: str = "generic") -> M:
|
||||
def get_or_404(
|
||||
db: Session, model: type[M], id_: Any, *, what: str = "generic", lock: bool = False
|
||||
) -> M:
|
||||
"""Fetch a row by primary key or raise 404. Replaces the `db.get(Model, id); if None: 404`
|
||||
idiom repeated across routes."""
|
||||
obj = db.get(model, id_)
|
||||
idiom repeated across routes. `lock` takes it FOR UPDATE (bypassing the identity map), so two
|
||||
requests that read-then-write its children (a playlist's item positions) run one after the other."""
|
||||
obj = db.get(model, id_, with_for_update=True if lock else None)
|
||||
if obj is None:
|
||||
raise not_found(what)
|
||||
return obj
|
||||
@@ -45,12 +48,13 @@ def owned_or_404(
|
||||
*,
|
||||
owner_attr: str = "user_id",
|
||||
what: str = "generic",
|
||||
lock: bool = False,
|
||||
) -> M:
|
||||
"""Fetch a row by primary key that `user` owns, or raise 404. A row owned by someone
|
||||
"""Fetch a row by primary key that `user` owns, or raise 404 (`lock`: see `get_or_404`). A row owned by someone
|
||||
else 404s (NOT 403) on purpose — a 403 would confirm the id exists to a stranger.
|
||||
It replaced the near-identical per-route ownership helpers, and is called directly — a thin
|
||||
`_own_x` wrapper around it is the ad-hoc shape CLAUDE.md's patterns table rules out."""
|
||||
obj = get_or_404(db, model, id_, what=what)
|
||||
obj = get_or_404(db, model, id_, what=what, lock=lock)
|
||||
if getattr(obj, owner_attr) != user.id:
|
||||
raise not_found(what)
|
||||
return obj
|
||||
|
||||
@@ -28,7 +28,7 @@ from app.models import (
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||
from app.sync.explore import explore_ingest_page
|
||||
from app.sync.runner import backfill_channel_now, run_recent_backfill
|
||||
from app.sync.subscriptions import apply_channel_details
|
||||
from app.sync.subscriptions import apply_channel_details, details_stale
|
||||
from app.utils import iso
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
from app.refusal import refused
|
||||
@@ -190,7 +190,7 @@ def discover_channels(
|
||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||
YouTubeClient(db, user) as yt,
|
||||
):
|
||||
apply_channel_details(db, yt.get_channels(need))
|
||||
apply_channel_details(db, yt.get_channels(need), need)
|
||||
db.commit()
|
||||
# Match _discovery_rows' ORDER BY exactly: video-count DESC, then title (NULLs LAST,
|
||||
# as Postgres orders them — coercing NULL→"" would wrongly float untitled channels up).
|
||||
@@ -296,17 +296,20 @@ def channel_detail(
|
||||
"""One channel's "About" detail for the channel page header/About tab, plus this user's
|
||||
relationship to it (subscribed / explored) and how many of its videos are in the catalog.
|
||||
Lazily fills the About metadata (view count, join date, banner, links) the first time it's
|
||||
needed — a stub, or a channel last enriched before those fields existed (published_at is a
|
||||
reliable sentinel: every real channel has a creation date). The enrich uses the API key
|
||||
(1 unit, cached after), so it's skipped for the quota-less demo account."""
|
||||
needed (a stub) and refreshes it once it is stale (`DETAILS_MAX_AGE`), which is how a channel nobody subscribes to (an explored one, outside the
|
||||
subscription import) gets a banner that still resolves. The enrich uses the API key (1 unit,
|
||||
cached after), so it's skipped for the quota-less demo account."""
|
||||
channel = get_or_404(db, Channel, channel_id, what="channel")
|
||||
if channel.published_at is None and not user.is_demo:
|
||||
# No `published_at is None` sentinel: a terminated channel never gets one, so it re-fetched on
|
||||
# every view; rows enriched before that field existed are long past DETAILS_MAX_AGE anyway.
|
||||
stale = details_stale(channel, datetime.now(timezone.utc))
|
||||
if stale and not user.is_demo:
|
||||
try:
|
||||
with (
|
||||
quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER),
|
||||
YouTubeClient(db, user) as yt,
|
||||
):
|
||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||
db.commit()
|
||||
channel = db.get(Channel, channel_id)
|
||||
except YouTubeError as exc:
|
||||
@@ -412,12 +415,10 @@ def explore_channel(
|
||||
YouTubeClient(db, user) as yt,
|
||||
):
|
||||
# Enrich for the About panel + the uploads playlist id (needed to ingest videos).
|
||||
if (
|
||||
channel.details_synced_at is None
|
||||
or channel.uploads_playlist_id is None
|
||||
or channel.published_at is None
|
||||
):
|
||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||
# Stale, not "a field is missing": a terminated channel never gets an uploads playlist
|
||||
# or a creation date, and asking again on every explore would spend quota on a miss.
|
||||
if details_stale(channel, datetime.now(timezone.utc)):
|
||||
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||
db.commit()
|
||||
channel = db.get(Channel, channel_id)
|
||||
result = explore_ingest_page(db, yt, channel, page_token)
|
||||
@@ -590,7 +591,7 @@ def subscribe(
|
||||
# must not block recording the subscription the user asked for.
|
||||
if channel.details_synced_at is None:
|
||||
try:
|
||||
apply_channel_details(db, yt.get_channels([channel_id]))
|
||||
apply_channel_details(db, yt.get_channels([channel_id]), [channel_id])
|
||||
except YouTubeError as exc:
|
||||
log.warning(
|
||||
"subscribe: stub enrich failed: %s",
|
||||
|
||||
@@ -4,9 +4,8 @@ mirror the user's own YouTube playlists in (`/sync-youtube`) and push/revert loc
|
||||
out (`/{id}/push-plan`, `/{id}/revert-youtube`), behind the optional write scope."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, and_
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, aliased
|
||||
|
||||
from app import quota
|
||||
@@ -15,14 +14,18 @@ from app.db import get_db
|
||||
from app.feedquery import feed_columns, serialize
|
||||
from app.models import Channel, Playlist, PlaylistItem, User, Video, VideoState
|
||||
from app.sync.playlists import (
|
||||
PlaylistGone,
|
||||
lock_playlists,
|
||||
merge_playlist,
|
||||
plan_merge,
|
||||
recompute_dirty,
|
||||
pull_user_playlists,
|
||||
repull_playlist,
|
||||
sync_user_playlists,
|
||||
sync_claim,
|
||||
user_sync_claim,
|
||||
)
|
||||
from app.ratelimit import RateLimiter
|
||||
from app.routes._common import get_or_404, owned_or_404, youtube_refusal
|
||||
from app.routes._common import get_or_404, not_found, owned_or_404, youtube_refusal
|
||||
from app.youtube.client import YouTubeClient, YouTubeError
|
||||
|
||||
router = APIRouter(prefix="/api/playlists", tags=["playlists"])
|
||||
@@ -79,7 +82,10 @@ def _next_playlist_position(db: Session, user_id: int) -> int:
|
||||
|
||||
|
||||
def _next_item_position(db: Session, playlist_id: int) -> int:
|
||||
"""Append position for a new item at the end of a playlist."""
|
||||
"""Append position for a new item at the end of a playlist. Locks the playlist row first: two
|
||||
adds (two tabs, the rail and the page) otherwise both read the same max and land on one
|
||||
position, and the list's order between them is then arbitrary."""
|
||||
lock_playlists(db, playlist_id)
|
||||
return (
|
||||
db.scalar(
|
||||
select(func.coalesce(func.max(PlaylistItem.position), -1)).where(
|
||||
@@ -92,7 +98,10 @@ def _next_item_position(db: Session, playlist_id: int) -> int:
|
||||
|
||||
def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||
"""Append a video to a playlist if not already present (idempotent). Returns whether it
|
||||
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
||||
was added. `mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later).
|
||||
Locked BEFORE the presence check: two adds of one video both saw "absent" and the second one's
|
||||
insert hit uq_playlist_video as a 500."""
|
||||
lock_playlists(db, pl.id)
|
||||
exists = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||
@@ -113,6 +122,7 @@ def _add_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) ->
|
||||
def _remove_item(db: Session, pl: Playlist, video_id: str, *, mark_dirty: bool) -> bool:
|
||||
"""Remove a video from a playlist if present (idempotent). Returns whether it was removed.
|
||||
`mark_dirty` recomputes the YouTube-sync dirty flag (skip for Watch later)."""
|
||||
lock_playlists(db, pl.id)
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == pl.id, PlaylistItem.video_id == video_id
|
||||
@@ -321,13 +331,18 @@ def sync_youtube(
|
||||
Also runs automatically on the scheduler; this is the manual 'sync now'."""
|
||||
if not has_read_scope(user):
|
||||
raise _refused(403, "youtube_read_scope")
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return {"ok": True, **sync_user_playlists(db, user)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
with user_sync_claim(db, user.id) as free:
|
||||
if not free:
|
||||
# Another run (the scheduler, another tab) is pulling right now: "0 synced" would lie.
|
||||
raise _refused(409, "sync_all_busy")
|
||||
# After the claim, like the re-pull's: a refused attempt must not spend one of the pulls.
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return {"ok": True, **pull_user_playlists(db, user)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/revert-youtube")
|
||||
@@ -344,13 +359,20 @@ def revert_youtube(
|
||||
raise _refused(400, "not_on_youtube")
|
||||
if not has_read_scope(user):
|
||||
raise _refused(403, "youtube_read_scope")
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
with sync_claim(db, pl.id) as free:
|
||||
if not free:
|
||||
raise _refused(409, "sync_busy")
|
||||
# The cooldown is spent only by a pull that runs: checked after the claim, so clicking
|
||||
# Revert while a push is in flight does not use up the three pulls per ten minutes.
|
||||
_check_pull_cooldown(user)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PULL):
|
||||
return {"ok": True, **repull_playlist(db, user, pl)}
|
||||
except PlaylistGone:
|
||||
raise not_found("playlist")
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
def _pushable(db: Session, user: User, playlist_id: int) -> Playlist:
|
||||
@@ -392,25 +414,32 @@ def push(
|
||||
neither side loses an item). Refuses if the estimated quota exceeds what's left today, so a
|
||||
big sync can't strand half-applied. Write scope required (require_write_scope)."""
|
||||
pl = _pushable(db, user, playlist_id)
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||
live = None
|
||||
if pl.yt_playlist_id:
|
||||
with YouTubeClient(db, user) as yt:
|
||||
live = (
|
||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||
)
|
||||
plan = plan_merge(db, user, pl, live=live)
|
||||
if plan["units_estimate"] > quota.remaining_today(db):
|
||||
raise _refused(429, "quota_exceeded")
|
||||
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
# One sync of a list at a time, across tabs, devices and the scheduler (`sync_claim`): the
|
||||
# client's own in-flight flag only covers the menus of one page.
|
||||
with sync_claim(db, pl.id) as free:
|
||||
if not free:
|
||||
raise _refused(409, "sync_busy")
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_PUSH):
|
||||
# Read the live playlist ONCE (linked playlists only) and share it with both the
|
||||
# plan and the merge, halving read-quota and closing the second read→write TOCTOU
|
||||
# window. A fresh export (no yt_playlist_id) reads nothing.
|
||||
live = None
|
||||
if pl.yt_playlist_id:
|
||||
with YouTubeClient(db, user) as yt:
|
||||
live = (
|
||||
list(yt.iter_playlist_items_with_ids(pl.yt_playlist_id)),
|
||||
yt.get_playlist_snippet(pl.yt_playlist_id),
|
||||
)
|
||||
plan = plan_merge(db, user, pl, live=live)
|
||||
if plan["units_estimate"] > quota.remaining_today(db):
|
||||
raise _refused(429, "quota_exceeded")
|
||||
return {"ok": True, **merge_playlist(db, user, pl, live=live)}
|
||||
except PlaylistGone:
|
||||
raise not_found("playlist")
|
||||
except YouTubeError as exc:
|
||||
db.rollback()
|
||||
raise youtube_refusal(exc)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
@@ -427,7 +456,8 @@ def get_playlist(
|
||||
.join(Channel, Channel.id == Video.channel_id)
|
||||
.outerjoin(state, and_(state.video_id == Video.id, state.user_id == user.id))
|
||||
.where(PlaylistItem.playlist_id == pl.id)
|
||||
.order_by(PlaylistItem.position)
|
||||
# `id` breaks a position tie the same way on every read (the renumbering add does too).
|
||||
.order_by(PlaylistItem.position, PlaylistItem.id)
|
||||
).all()
|
||||
return {
|
||||
"id": pl.id,
|
||||
@@ -451,7 +481,8 @@ def rename_playlist(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
# Locked: the new name moves `dirty`, which the scheduled mirror refresh reads under this lock.
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
||||
if "name" in body.model_fields_set:
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
@@ -491,33 +522,65 @@ def delete_playlist(
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
if pl.kind == "watch_later":
|
||||
raise _refused(400, "watch_later_undeletable")
|
||||
if on_youtube:
|
||||
if not pl.yt_playlist_id:
|
||||
raise _refused(400, "not_on_youtube")
|
||||
if not has_write_scope(user):
|
||||
raise _refused(403, "youtube_editing_off")
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt.delete_playlist(pl.yt_playlist_id)
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc)
|
||||
db.delete(pl) # items cascade
|
||||
db.commit()
|
||||
# Not in the middle of a sync of it (`sync_claim`): a push that read the list before the delete
|
||||
# and wrote YouTube after it planned from no items at all, so it emptied the YouTube playlist a
|
||||
# local-only delete means to keep. The push checks for a vanished list too (`PlaylistGone`);
|
||||
# this refuses the delete while a sync runs rather than race it.
|
||||
with sync_claim(db, pl.id) as free:
|
||||
if not free:
|
||||
raise _refused(409, "sync_busy")
|
||||
if on_youtube:
|
||||
if not pl.yt_playlist_id:
|
||||
raise _refused(400, "not_on_youtube")
|
||||
if not has_write_scope(user):
|
||||
raise _refused(403, "youtube_editing_off")
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.PLAYLISTS_DELETE):
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt.delete_playlist(pl.yt_playlist_id)
|
||||
except YouTubeError as exc:
|
||||
raise youtube_refusal(exc)
|
||||
# The playlist first, as every item write: the cascade deletes the items before the list row.
|
||||
lock_playlists(db, pl.id)
|
||||
db.delete(pl) # items cascade
|
||||
db.commit()
|
||||
return {"deleted": playlist_id}
|
||||
|
||||
|
||||
class AddItemIn(VideoRefIn):
|
||||
# Where the video lands among the list's items, in their stored order (0 = first). Omitted, it
|
||||
# is appended. The list page's "Undo" of a removal puts the row back where it was with this —
|
||||
# ONE request, because append-then-reorder leaves it at the end whenever the second call fails.
|
||||
position: int | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/items")
|
||||
def add_item(
|
||||
playlist_id: int,
|
||||
body: VideoRefIn,
|
||||
body: AddItemIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
video_id = body.video_id
|
||||
get_or_404(db, Video, video_id, what="video")
|
||||
_add_item(db, pl, video_id, mark_dirty=True)
|
||||
added = _add_item(db, pl, video_id, mark_dirty=False)
|
||||
if added and body.position is not None:
|
||||
# Renumber the whole list with the new row at `position` (clamped to the end): positions
|
||||
# can have gaps after removals, so "index N" is only meaningful over the ordered rows.
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(PlaylistItem)
|
||||
.where(PlaylistItem.playlist_id == pl.id)
|
||||
.order_by(PlaylistItem.position, PlaylistItem.id)
|
||||
).scalars()
|
||||
)
|
||||
new = next(r for r in rows if r.video_id == video_id)
|
||||
rows.remove(new)
|
||||
rows.insert(min(body.position, len(rows)), new)
|
||||
for pos, row in enumerate(rows):
|
||||
row.position = pos
|
||||
recompute_dirty(db, pl)
|
||||
db.commit()
|
||||
return {"ok": True, "playlist_id": pl.id, "video_id": video_id}
|
||||
|
||||
@@ -562,6 +625,8 @@ def move_item(
|
||||
raise HTTPException(
|
||||
status_code=400, detail={"code": "playlist_move", "reason": "same_playlist"}
|
||||
)
|
||||
# Both lists, in id order: a move A→B beside a move B→A would otherwise lock crosswise.
|
||||
lock_playlists(db, src.id, dst.id)
|
||||
item = db.scalar(
|
||||
select(PlaylistItem).where(
|
||||
PlaylistItem.playlist_id == src.id, PlaylistItem.video_id == video_id
|
||||
@@ -581,20 +646,11 @@ def move_item(
|
||||
if already is None:
|
||||
# Re-parent the existing row rather than delete+insert: `added_at` ("when I saved this")
|
||||
# survives the move, and there is no window where the video belongs to neither list. The
|
||||
# check above is not a lock: a second tab's add or the scheduler's YouTube merge can insert
|
||||
# the same (playlist, video) in between, and uq_playlist_video then refuses the re-parent.
|
||||
# That is the already-present outcome arriving late, not a failure — answer it the same way
|
||||
# instead of a raw 500.
|
||||
try:
|
||||
with db.begin_nested():
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
db.flush()
|
||||
dst_changed = True
|
||||
# silent-ok: the conflict IS the answer — the target already holds the video, which is the
|
||||
# idempotent outcome; the savepoint put the row back in the source list, dropped below.
|
||||
except IntegrityError:
|
||||
pass
|
||||
# check above holds: both lists are locked, and an insert into the target (another tab's
|
||||
# add, a YouTube merge) waits for the lock — its foreign key needs the target row.
|
||||
item.playlist_id = dst.id
|
||||
item.position = _next_item_position(db, dst.id)
|
||||
dst_changed = True
|
||||
if not dst_changed:
|
||||
db.delete(item)
|
||||
# The source always changed; the target only when the row landed in it. `_touch` on a target
|
||||
@@ -619,7 +675,8 @@ def reorder_items(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist")
|
||||
# Locked: a concurrent add between reading the items and writing the order would collide.
|
||||
pl = owned_or_404(db, Playlist, playlist_id, user, what="playlist", lock=True)
|
||||
order = body.video_ids
|
||||
items = {
|
||||
it.video_id: it
|
||||
|
||||
@@ -3,8 +3,10 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select, update
|
||||
from typing import Annotated
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.auth import current_user
|
||||
@@ -217,7 +219,8 @@ def playlist_add_item(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
# Locked (like every add / reorder here): two adds otherwise read the same max position.
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
it = by_rating_key(db, PlexItem, body.item_rating_key)
|
||||
if it is None:
|
||||
raise not_found("item")
|
||||
@@ -249,7 +252,9 @@ def playlist_remove_item(
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
# Locked like the adds and the reorder: the list row first, then its items — a remove that
|
||||
# locked an item row before a reorder locked the list deadlocked against it.
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
it = by_rating_key(db, PlexItem, item_rating_key)
|
||||
if it is not None:
|
||||
db.execute(
|
||||
@@ -285,16 +290,75 @@ def _items_by_rks(db: Session, rks: list) -> list[PlexItem]:
|
||||
return out
|
||||
|
||||
|
||||
class PlexItemAddBulkIn(PlexItemKeysIn):
|
||||
# Where each key goes back (E28 S3e2, the list page's "Undo" of a removal): an index over the
|
||||
# ORDERED rows, one per key. The two-call shape (append, then send the whole order) leaves the rows
|
||||
# at the end whenever the second call fails, and the order it sends would overwrite anything
|
||||
# another tab changed in between.
|
||||
positions: list[Annotated[int, Field(ge=0)]] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _one_position_per_key(self):
|
||||
if self.positions is not None and len(self.positions) != len(self.item_rating_keys):
|
||||
raise ValueError("positions must pair one-to-one with item_rating_keys")
|
||||
return self
|
||||
|
||||
|
||||
def _insert_at(db: Session, pid: int, placed: list[tuple[int, PlexItem]]) -> int:
|
||||
"""Insert new rows at their indices (ascending, so each index is where the row stood when the
|
||||
rows before it were back), renumber the whole list 0..n-1, and say how many were added. An item
|
||||
already in the list does not move: the add stays idempotent."""
|
||||
rows = list(
|
||||
db.execute(
|
||||
select(PlexPlaylistItem)
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).scalars()
|
||||
)
|
||||
present = {r.item_id for r in rows}
|
||||
added = 0
|
||||
for at, it in sorted(placed, key=lambda p: p[0]):
|
||||
if it.id in present:
|
||||
continue
|
||||
row = PlexPlaylistItem(playlist_id=pid, item_id=it.id, position=0)
|
||||
db.add(row)
|
||||
rows.insert(min(at, len(rows)), row)
|
||||
present.add(it.id)
|
||||
added += 1
|
||||
for i, r in enumerate(rows):
|
||||
r.position = i
|
||||
return added
|
||||
|
||||
|
||||
@router.post("/playlists/{pid}/items/bulk")
|
||||
def playlist_add_bulk(
|
||||
pid: int,
|
||||
body: PlexItemKeysIn,
|
||||
body: PlexItemAddBulkIn,
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Append many items at once (a whole season/show). Already-present items are skipped; the input
|
||||
order is preserved for the newly-added ones. Returns how many were added + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
order is preserved for the newly-added ones. With `positions`, each key is put back at its index
|
||||
instead of appended. Returns how many were added + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
if body.positions is not None:
|
||||
by_rk = {it.rating_key: it for it in _items_by_rks(db, body.item_rating_keys)}
|
||||
placed = [
|
||||
(at, by_rk[str(rk)])
|
||||
for rk, at in zip(body.item_rating_keys, body.positions, strict=True)
|
||||
if str(rk) in by_rk
|
||||
]
|
||||
added = _insert_at(db, pid, placed)
|
||||
if added:
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
total = (
|
||||
db.scalar(
|
||||
select(func.count(PlexPlaylistItem.id)).where(PlexPlaylistItem.playlist_id == pid)
|
||||
)
|
||||
or 0
|
||||
)
|
||||
return {"ok": True, "added": added, "item_count": total}
|
||||
items = _items_by_rks(db, body.item_rating_keys)
|
||||
existing = set(
|
||||
db.execute(
|
||||
@@ -337,7 +401,7 @@ def playlist_remove_bulk(
|
||||
) -> dict:
|
||||
"""Remove many items at once (a whole season/show, or a multi-select). Returns how many were
|
||||
removed + the new total."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
ids = [it.id for it in _items_by_rks(db, body.item_rating_keys)]
|
||||
removed = 0
|
||||
if ids:
|
||||
@@ -367,7 +431,7 @@ def reorder_playlist(
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Set the playlist order from an explicit list of item rating_keys (0-based positions)."""
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist")
|
||||
pl = owned_or_404(db, PlexPlaylist, pid, user, what="playlist", lock=True)
|
||||
order = body.item_rating_keys
|
||||
id_by_rk = {
|
||||
it.rating_key: it.id
|
||||
@@ -375,14 +439,27 @@ def reorder_playlist(
|
||||
select(PlexItem).where(PlexItem.rating_key.in_(order or [""]))
|
||||
).scalars()
|
||||
}
|
||||
for pos, rk in enumerate(order):
|
||||
iid = id_by_rk.get(rk)
|
||||
if iid is not None:
|
||||
db.execute(
|
||||
update(PlexPlaylistItem)
|
||||
.where(PlexPlaylistItem.playlist_id == pid, PlexPlaylistItem.item_id == iid)
|
||||
.values(position=pos)
|
||||
)
|
||||
rows = {
|
||||
r.item_id: r
|
||||
for r in db.execute(
|
||||
select(PlexPlaylistItem).where(PlexPlaylistItem.playlist_id == pid)
|
||||
).scalars()
|
||||
}
|
||||
pos = 0
|
||||
seen: set[int] = set()
|
||||
for rk in order:
|
||||
row = rows.get(id_by_rk.get(rk, -1))
|
||||
if row is not None and row.item_id not in seen:
|
||||
row.position = pos
|
||||
pos += 1
|
||||
seen.add(row.item_id)
|
||||
# Rows the payload omitted (added in another tab meanwhile) keep their relative order after
|
||||
# the given ones, so no two rows share a position (the YouTube reorder does the same).
|
||||
for row in sorted(
|
||||
(r for iid, r in rows.items() if iid not in seen), key=lambda r: (r.position, r.id)
|
||||
):
|
||||
row.position = pos
|
||||
pos += 1
|
||||
pl.updated_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
|
||||
@@ -130,7 +130,7 @@ def _ingest_candidates(
|
||||
try:
|
||||
with quota.attribute(user.id, quota.QuotaAction.CHANNELS_DISCOVER):
|
||||
details = yt.get_channels(new_channel_ids)
|
||||
apply_channel_details(db, details)
|
||||
apply_channel_details(db, details, new_channel_ids)
|
||||
db.commit()
|
||||
except YouTubeError:
|
||||
# The stubs remain and the details can be backfilled later — but until they are, those
|
||||
|
||||
@@ -8,13 +8,15 @@ ingested (with a stub channel) so the mirror is faithful."""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from typing import TypeVar
|
||||
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy import Engine, create_engine, delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from app import logs, progress, quota
|
||||
from app.auth import has_read_scope
|
||||
@@ -27,6 +29,87 @@ log = logs.get_logger(__name__)
|
||||
# Cost of a single YouTube write op (playlists/playlistItems insert/update/delete).
|
||||
WRITE_UNIT_COST = 50
|
||||
|
||||
# The advisory-lock namespaces ("PL" per playlist, "PU" per user), so a key cannot meet another
|
||||
# feature's lock on the same number (Postgres keeps the two-int keys apart from the one-bigint ones).
|
||||
_SYNC_LOCK_NS = 0x504C
|
||||
_USER_SYNC_LOCK_NS = 0x5055
|
||||
|
||||
# The claims' own connections, outside the app pool: a claim is held for a whole push (seconds of
|
||||
# YouTube calls) beside the request's pooled session and quota's, so a burst of pushes drawing a
|
||||
# third pooled connection each ran the pool dry and stalled every other request for pool_timeout.
|
||||
# NullPool opens one per claim and closes it after, so a lock can never ride back into a pool either.
|
||||
_claim_engines: dict[str, Engine] = {}
|
||||
|
||||
|
||||
def _claim_engine(db: Session) -> Engine:
|
||||
url = db.get_bind().engine.url
|
||||
key = url.render_as_string(hide_password=False)
|
||||
eng = _claim_engines.get(key)
|
||||
if eng is None:
|
||||
eng = _claim_engines[key] = create_engine(url, poolclass=NullPool, hide_parameters=True)
|
||||
return eng
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _advisory_claim(db: Session, ns: int, key: int) -> Iterator[bool]:
|
||||
"""Try a session-level advisory lock for the block on a connection of its own; yields whether it
|
||||
was free. It survives the block's commits, and a crashed worker's lock dies with its connection
|
||||
— no claim column that a crash leaves stuck."""
|
||||
conn = _claim_engine(db).connect()
|
||||
held = False
|
||||
try:
|
||||
held = bool(conn.scalar(select(func.pg_try_advisory_lock(ns, key))))
|
||||
conn.commit()
|
||||
yield held
|
||||
finally:
|
||||
try:
|
||||
if held:
|
||||
conn.scalar(select(func.pg_advisory_unlock(ns, key)))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def sync_claim(db: Session, playlist_id: int) -> AbstractContextManager[bool]:
|
||||
"""Claim one playlist's YouTube sync (push, re-pull, the scheduled mirror refresh) for the block;
|
||||
yields whether it was free. A push reads YouTube, then writes both sides for seconds and commits
|
||||
step by step, so a row lock ends at its first commit and two tabs (or a push and the scheduler)
|
||||
interleave into a doubled YouTube item or a baseline recorded over the other's writes."""
|
||||
return _advisory_claim(db, _SYNC_LOCK_NS, playlist_id)
|
||||
|
||||
|
||||
class PlaylistGone(Exception):
|
||||
"""The list a sync was working on was deleted meanwhile (another tab, the mirror drop). Its
|
||||
items went with it, so a plan made now reads "every item removed here" — and would delete them
|
||||
all from the YouTube playlist the reader chose to keep. The route answers 404."""
|
||||
|
||||
|
||||
def lock_playlists(db: Session, *playlist_ids: int) -> set[int]:
|
||||
"""Row-lock playlists before touching their items, several in id order. Every item write takes
|
||||
the PLAYLIST first — the route edits, a push, a re-pull, the scheduled mirror refresh and a
|
||||
delete alike: a writer that locked an item row first and the playlist second (a push's item
|
||||
replace beside a remove) deadlocks against one that went the other way, and the loser is a
|
||||
500. The rows are re-read, so `dirty`, the name and the sync baseline are the ones a finished
|
||||
writer committed. Held to the caller's next commit — so no commit may come between this and
|
||||
the write it guards. Returns the ids it found: a list deleted meanwhile is not among them."""
|
||||
rows = db.scalars(
|
||||
select(Playlist)
|
||||
.where(Playlist.id.in_(sorted(set(playlist_ids))))
|
||||
.order_by(Playlist.id)
|
||||
.with_for_update()
|
||||
.execution_options(populate_existing=True)
|
||||
).all() # consumed: rows fetched are what refreshes the loaded objects
|
||||
return {pl.id for pl in rows}
|
||||
|
||||
|
||||
def _lock_or_gone(db: Session, pl: Playlist) -> None:
|
||||
"""`lock_playlists` for a sync that goes on to write YouTube or the items: a list deleted since
|
||||
it was read raises `PlaylistGone` (rolled back) instead of being planned as an empty one."""
|
||||
pl_id = pl.id # read first: the rollback expires `pl`, and its row is gone
|
||||
if pl_id not in lock_playlists(db, pl_id):
|
||||
db.rollback()
|
||||
raise PlaylistGone(pl_id)
|
||||
|
||||
|
||||
def fingerprint(name: str, ids: list[str]) -> str:
|
||||
"""A stable checksum of a playlist's name + ordered video ids. Stored as the synced
|
||||
@@ -91,11 +174,18 @@ def parse_unavailable(pl: Playlist) -> set[str]:
|
||||
return _load_baseline(pl)[1]
|
||||
|
||||
|
||||
def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None = None) -> None:
|
||||
def _set_baseline(
|
||||
pl: Playlist,
|
||||
ids: list[str],
|
||||
unavailable: list[str] | None = None,
|
||||
*,
|
||||
name: str | None = None,
|
||||
) -> None:
|
||||
"""Record `ids` as both the hash (drives `dirty`, over the local ids only) and the raw ordered
|
||||
set (drives merge). `unavailable` = un-ingestable-but-live YouTube ids to carry forward so the
|
||||
next merge skips them; stored only when non-empty (keeps the common case a plain list)."""
|
||||
pl.synced_fingerprint = fingerprint(pl.name, ids)
|
||||
next merge skips them; stored only when non-empty (keeps the common case a plain list). `name`:
|
||||
the title YouTube now has, when that may differ from the list's own (see the push)."""
|
||||
pl.synced_fingerprint = fingerprint(pl.name if name is None else name, ids)
|
||||
if unavailable:
|
||||
pl.synced_video_ids = json.dumps({"ids": ids, "unavailable": list(unavailable)})
|
||||
else:
|
||||
@@ -103,6 +193,25 @@ def _set_baseline(pl: Playlist, ids: list[str], unavailable: list[str] | None =
|
||||
pl.dirty = False
|
||||
|
||||
|
||||
def _set_baseline_under_lock(
|
||||
db: Session,
|
||||
pl: Playlist,
|
||||
ids: list[str],
|
||||
unavailable: list[str] | None = None,
|
||||
*,
|
||||
name: str,
|
||||
) -> None:
|
||||
"""A push's closing baseline: `ids` and `name` are what YouTube now holds. The push spent
|
||||
seconds on YouTube writes since it replaced the local items, so an edit may have landed
|
||||
meanwhile — lock the list and derive `dirty` from what is there, or the edit would read as
|
||||
synced and never reach YouTube. The name too: the lock re-reads a rename made meanwhile, and a
|
||||
baseline taken over THAT name matched it, so the list read clean with YouTube's title still the
|
||||
old one (E28 review round 4)."""
|
||||
_lock_or_gone(db, pl)
|
||||
_set_baseline(pl, ids, unavailable, name=name)
|
||||
recompute_dirty(db, pl)
|
||||
|
||||
|
||||
def recompute_dirty(db: Session, pl: Playlist) -> None:
|
||||
"""Set `dirty` by comparing the current state to the synced baseline. Unlinked lists
|
||||
(and Watch later) are never dirty; a missing baseline counts as dirty (unknown YT state)."""
|
||||
@@ -159,15 +268,13 @@ def _ensure_videos(db: Session, yt: YouTubeClient, video_ids: list[str]) -> None
|
||||
db.commit()
|
||||
|
||||
|
||||
def _replace_items_from_youtube(
|
||||
db: Session, yt: YouTubeClient, pl: Playlist, ids: list[str]
|
||||
) -> list[str]:
|
||||
def _replace_items_from_youtube(db: Session, pl: Playlist, ids: list[str]) -> list[str]:
|
||||
"""Mirror a YouTube playlist's ordered video ids into `pl`'s local items (authoritative
|
||||
replace): ensure the videos exist in the catalog, keep only those present in their YT
|
||||
order (de-duplicated), swap the items out, and reset the synced fingerprint + dirty flag.
|
||||
`pl.name` must already be set to the desired name. The caller commits. Returns the final
|
||||
ordered ids."""
|
||||
_ensure_videos(db, yt, ids)
|
||||
replace): keep only those present in the catalog in their YT order (de-duplicated), swap the
|
||||
items out, and reset the synced fingerprint + dirty flag. The caller has ingested `ids`
|
||||
(`_ensure_videos`, which commits) and THEN taken `lock_playlists`, set `pl.name`, and commits
|
||||
after: an ingest in here re-fetched every id YouTube will not return (a private or deleted
|
||||
video) and its commit let go of the lock before the replace. Returns the final ordered ids."""
|
||||
present = (
|
||||
set(db.execute(select(Video.id).where(Video.id.in_(ids))).scalars().all()) if ids else set()
|
||||
)
|
||||
@@ -196,6 +303,21 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
the user's local playlists and the built-in Watch later untouched."""
|
||||
if not has_read_scope(user):
|
||||
return {"synced": 0, "reason": "no read scope"}
|
||||
with user_sync_claim(db, user.id) as free:
|
||||
if not free:
|
||||
return {"synced": 0, "reason": "busy"}
|
||||
return pull_user_playlists(db, user)
|
||||
|
||||
|
||||
def user_sync_claim(db: Session, user_id: int) -> AbstractContextManager[bool]:
|
||||
"""Claim a user's bulk mirror pull for the block; yields whether it was free. Two runs at once
|
||||
(the scheduler beside a manual "sync now", two tabs) both saw a YouTube list unmirrored and
|
||||
each created its mirror — a duplicate row."""
|
||||
return _advisory_claim(db, _USER_SYNC_LOCK_NS, user_id)
|
||||
|
||||
|
||||
def pull_user_playlists(db: Session, user: User) -> dict:
|
||||
"""The mirror pull itself; the caller holds `user_sync_claim`."""
|
||||
synced = 0
|
||||
with YouTubeClient(db, user) as yt:
|
||||
yt_playlists = list(yt.iter_my_playlists())
|
||||
@@ -219,11 +341,15 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
# Drop mirrors whose YouTube playlist no longer exists.
|
||||
for ytid, pl in existing.items():
|
||||
if ytid not in yt_ids:
|
||||
db.delete(pl)
|
||||
db.flush()
|
||||
# Drop mirrors whose YouTube playlist no longer exists — locked first, as every item write.
|
||||
gone = [pl for ytid, pl in existing.items() if ytid not in yt_ids]
|
||||
if gone:
|
||||
lock_playlists(db, *(pl.id for pl in gone))
|
||||
for pl in gone:
|
||||
db.delete(pl)
|
||||
# Committed now: a run where no list below commits (all dirty, or all claimed) would
|
||||
# otherwise drop the deletions with the session.
|
||||
db.commit()
|
||||
|
||||
for idx, p in enumerate(yt_playlists):
|
||||
ytid = p.get("id")
|
||||
@@ -245,12 +371,27 @@ def sync_user_playlists(db: Session, user: User) -> dict:
|
||||
)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
else:
|
||||
with sync_claim(db, pl.id) as free:
|
||||
if not free:
|
||||
# The user is pushing or re-pulling this very list: it writes the same rows.
|
||||
# The next scheduled run refreshes it.
|
||||
continue
|
||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||
# Ingest first — it commits — so the row lock below is held from the dirty check
|
||||
# to the commit that replaces the items.
|
||||
_ensure_videos(db, yt, ids)
|
||||
# `dirty` read above is a snapshot from before the YouTube calls (sessions keep
|
||||
# loaded rows past a commit): a remove or reorder made meanwhile must not be
|
||||
# replaced by YouTube's order. Item writes lock the playlist row, so this waits for
|
||||
# one in flight and sees what it committed.
|
||||
lock_playlists(db, pl.id)
|
||||
if pl.dirty:
|
||||
db.rollback()
|
||||
continue
|
||||
pl.name = p.get("title") or pl.name
|
||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||
ids = list(yt.iter_my_playlist_video_ids(ytid))
|
||||
_replace_items_from_youtube(db, yt, pl, ids)
|
||||
db.commit()
|
||||
# Mirror is authoritative: replace the items to match YouTube's order.
|
||||
_replace_items_from_youtube(db, pl, ids)
|
||||
db.commit()
|
||||
synced += 1
|
||||
return {"synced": synced}
|
||||
|
||||
@@ -488,7 +629,8 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
breaker = _Breaker(playlist_id=pl.id)
|
||||
with YouTubeClient(db, user) as yt:
|
||||
if not pl.yt_playlist_id:
|
||||
pl.yt_playlist_id = yt.create_playlist(pl.name)
|
||||
pushed_name = pl.name
|
||||
pl.yt_playlist_id = yt.create_playlist(pushed_name)
|
||||
db.commit()
|
||||
created = 1
|
||||
# Brand-new playlist: just append every local video in order.
|
||||
@@ -501,7 +643,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
if not isinstance(added, _Failed):
|
||||
inserted += 1
|
||||
if not failures:
|
||||
_set_baseline(pl, local)
|
||||
_set_baseline_under_lock(db, pl, local, name=pushed_name)
|
||||
db.commit()
|
||||
return {
|
||||
"created": created,
|
||||
@@ -542,6 +684,14 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
# merged order authoritatively. Do this first so local reflects the merge even if a later
|
||||
# YouTube write trips the breaker.
|
||||
_ensure_videos(db, yt, mp.add_to_local)
|
||||
# `local` was read before the YouTube calls above: an add or remove committed meanwhile
|
||||
# (another tab) would be undone by the replace below. Lock the list — the ingest's commit is
|
||||
# behind us — and plan again from what is there now. Nothing new needs ingesting: a video
|
||||
# the re-read adds is already a local row. A list deleted meanwhile stops here, before a plan
|
||||
# over its now-empty items removes them all from YouTube.
|
||||
_lock_or_gone(db, pl)
|
||||
local = _local_video_ids(db, pl)
|
||||
mp = merge_plan(parse_baseline(pl), local, yt_ids, effective_unavailable)
|
||||
# Only the ids that actually exist in the catalog can become local rows (a private/deleted
|
||||
# YouTube video won't ingest); keep the merged order, dropping any that didn't materialize.
|
||||
present = (
|
||||
@@ -555,10 +705,12 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
_set_local_items(db, pl, final_local)
|
||||
db.commit()
|
||||
|
||||
# --- YouTube side (metered, breaker-guarded).
|
||||
if snippet is not None and (snippet.get("title") or "") != pl.name:
|
||||
# --- YouTube side (metered, breaker-guarded). The name as of the lock above: the closing
|
||||
# baseline records THIS one, whatever a rename made during the writes below says.
|
||||
pushed_name = pl.name
|
||||
if snippet is not None and (snippet.get("title") or "") != pushed_name:
|
||||
try:
|
||||
yt.update_playlist_title(pl.yt_playlist_id, pl.name)
|
||||
yt.update_playlist_title(pl.yt_playlist_id, pushed_name)
|
||||
except YouTubeError as exc:
|
||||
# All the caller passes on is the word "title", which says the rename did not
|
||||
# happen but not that YouTube refused it, or why.
|
||||
@@ -626,7 +778,7 @@ def merge_playlist(db: Session, user: User, pl: Playlist, *, live=None) -> dict:
|
||||
(effective_unavailable | {v for v in mp.add_to_local if v not in present})
|
||||
& yt_id_set
|
||||
)
|
||||
_set_baseline(pl, final_local, unavailable)
|
||||
_set_baseline_under_lock(db, pl, final_local, unavailable, name=pushed_name)
|
||||
db.commit()
|
||||
return {
|
||||
"created": created,
|
||||
@@ -652,9 +804,11 @@ def repull_playlist(db: Session, user: User, pl: Playlist) -> dict:
|
||||
snippet = yt.get_playlist_snippet(pl.yt_playlist_id)
|
||||
if snippet is None:
|
||||
raise YouTubeError("Playlist no longer exists on YouTube")
|
||||
pl.name = snippet.get("title") or pl.name
|
||||
ids = list(yt.iter_my_playlist_video_ids(pl.yt_playlist_id))
|
||||
ordered = _replace_items_from_youtube(db, yt, pl, ids)
|
||||
_ensure_videos(db, yt, ids)
|
||||
_lock_or_gone(db, pl)
|
||||
pl.name = snippet.get("title") or pl.name
|
||||
ordered = _replace_items_from_youtube(db, pl, ids)
|
||||
db.commit()
|
||||
return {"items": len(ordered)}
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Import the authenticated user's YouTube subscriptions and channel metadata."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app import logs
|
||||
@@ -47,8 +48,37 @@ def _external_links(branding: dict) -> list | None:
|
||||
return out or None
|
||||
|
||||
|
||||
def apply_channel_details(db: Session, items: list[dict]) -> None:
|
||||
# How old a channel's metadata may get before it is fetched again. Banners, avatars, descriptions and
|
||||
# counts change on YouTube, and an old banner URL stops resolving at all — without a refresh a channel
|
||||
# kept whatever it had the day it was first seen. A week keeps counts roughly current at almost no
|
||||
# cost: `channels.list` is 1 quota unit per 50 channels, and only the stale ones are asked for.
|
||||
DETAILS_MAX_AGE = timedelta(days=7)
|
||||
|
||||
|
||||
def details_stale(channel: Channel, now: datetime) -> bool:
|
||||
"""Whether `channel`'s metadata is missing or older than DETAILS_MAX_AGE (see `details_stale_clause`)."""
|
||||
synced = channel.details_synced_at
|
||||
return synced is None or synced < now - DETAILS_MAX_AGE
|
||||
|
||||
|
||||
def details_stale_clause(now: datetime):
|
||||
"""`details_stale` as a WHERE clause, so the batch import and a single channel agree on "stale"."""
|
||||
return or_(
|
||||
Channel.details_synced_at.is_(None),
|
||||
Channel.details_synced_at < now - DETAILS_MAX_AGE,
|
||||
)
|
||||
|
||||
|
||||
def apply_channel_details(db: Session, items: list[dict], asked: Iterable[str]) -> None:
|
||||
"""Write `channels.list`'s answer onto the rows. `asked` is every id the call asked for: one
|
||||
YouTube did not return (terminated, deleted) is stamped as synced too — otherwise it stays
|
||||
stale forever and every view / import asks for it again, spending quota on a known miss."""
|
||||
now = datetime.now(timezone.utc)
|
||||
returned = {item["id"] for item in items}
|
||||
for cid in set(asked) - returned:
|
||||
missing = db.get(Channel, cid)
|
||||
if missing is not None:
|
||||
missing.details_synced_at = now
|
||||
for item in items:
|
||||
channel = db.get(Channel, item["id"])
|
||||
if channel is None:
|
||||
@@ -92,7 +122,7 @@ def should_prune(fetched_count: int, existing_count: int) -> bool:
|
||||
def import_subscriptions(db: Session, user: User) -> dict:
|
||||
"""Pull all subscriptions for `user`, upsert channels + subscription links, prune
|
||||
channels the user has unsubscribed from on YouTube, and fetch channel details for
|
||||
any channel we don't have details for yet."""
|
||||
any channel whose details are missing or stale (`DETAILS_MAX_AGE`)."""
|
||||
fetched: dict[str, dict] = {}
|
||||
new_channels = 0
|
||||
|
||||
@@ -145,12 +175,12 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
||||
removed += 1
|
||||
db.commit()
|
||||
|
||||
# Fetch details for channels that don't have them yet.
|
||||
# Fetch details for channels that don't have them yet, or had them too long ago.
|
||||
need_details = (
|
||||
db.execute(
|
||||
select(Channel).where(
|
||||
Channel.id.in_(fetched.keys()),
|
||||
Channel.details_synced_at.is_(None),
|
||||
details_stale_clause(datetime.now(timezone.utc)),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
@@ -158,8 +188,9 @@ def import_subscriptions(db: Session, user: User) -> dict:
|
||||
)
|
||||
detailed = 0
|
||||
if need_details:
|
||||
items = yt.get_channels([c.id for c in need_details])
|
||||
apply_channel_details(db, items)
|
||||
ids = [c.id for c in need_details]
|
||||
items = yt.get_channels(ids)
|
||||
apply_channel_details(db, items, ids)
|
||||
detailed = len(items)
|
||||
db.commit()
|
||||
|
||||
|
||||
@@ -100,7 +100,7 @@ def test_the_single_playlist_repull_shares_the_pull_cooldown(db, as_user, make_u
|
||||
db.commit()
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {"ok": True})
|
||||
monkeypatch.setattr(plroutes, "sync_user_playlists", lambda d, u: {"ok": True})
|
||||
monkeypatch.setattr(plroutes, "pull_user_playlists", lambda d, u: {"ok": True})
|
||||
|
||||
c = as_user(user)
|
||||
codes = [c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code for _ in range(4)]
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""A channel's metadata is fetched again once it is stale, not only the first time it is seen.
|
||||
|
||||
Banners, avatars, descriptions and counts change on YouTube, and an old banner URL stops resolving:
|
||||
a channel that kept the metadata of the day it was first imported showed a dead banner forever. The
|
||||
subscription import and the channel page both refresh what is older than `DETAILS_MAX_AGE`, and
|
||||
leave fresh metadata alone (each refresh spends quota). DB lane.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.models import Channel, Subscription
|
||||
from app.routes import channels
|
||||
from app.sync import subscriptions
|
||||
from app.sync.subscriptions import DETAILS_MAX_AGE, import_subscriptions
|
||||
|
||||
NOW = datetime.now(timezone.utc)
|
||||
FRESH = NOW - timedelta(days=1)
|
||||
STALE = NOW - DETAILS_MAX_AGE - timedelta(hours=1)
|
||||
|
||||
|
||||
def _item(cid: str) -> dict:
|
||||
return {
|
||||
"id": cid,
|
||||
"snippet": {"title": f"{cid} renamed", "publishedAt": "2010-01-01T00:00:00Z"},
|
||||
"brandingSettings": {"image": {"bannerExternalUrl": f"https://yt3.example/{cid}-new"}},
|
||||
}
|
||||
|
||||
|
||||
def _youtube(asked: list[list[str]], subscribed: list[str], gone: tuple[str, ...] = ()):
|
||||
class _Client:
|
||||
def __init__(self, db, user):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def iter_subscriptions(self):
|
||||
for cid in subscribed:
|
||||
yield {"channel_id": cid, "title": cid, "yt_subscription_id": f"sub-{cid}"}
|
||||
|
||||
def get_channels(self, ids):
|
||||
asked.append(sorted(ids))
|
||||
# A terminated / deleted channel is simply absent from `channels.list`'s answer.
|
||||
return [_item(cid) for cid in ids if cid not in gone]
|
||||
|
||||
return _Client
|
||||
|
||||
|
||||
def _channel(db, cid: str, synced: datetime | None) -> None:
|
||||
db.add(
|
||||
Channel(
|
||||
id=cid,
|
||||
title=cid,
|
||||
banner_url=f"https://yt3.example/{cid}-old",
|
||||
published_at=datetime(2010, 1, 1, tzinfo=timezone.utc),
|
||||
details_synced_at=synced,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_the_import_refreshes_stale_and_missing_details_and_leaves_fresh_ones(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
_channel(db, "UCfresh", FRESH)
|
||||
_channel(db, "UCstale", STALE)
|
||||
_channel(db, "UCnever", None)
|
||||
db.flush()
|
||||
for cid in ("UCfresh", "UCstale", "UCnever"):
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid))
|
||||
db.commit()
|
||||
asked: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
subscriptions, "YouTubeClient", _youtube(asked, ["UCfresh", "UCstale", "UCnever"])
|
||||
)
|
||||
|
||||
result = import_subscriptions(db, user)
|
||||
|
||||
assert asked == [["UCnever", "UCstale"]]
|
||||
assert result["channels_detailed"] == 2
|
||||
stale = db.get(Channel, "UCstale")
|
||||
assert stale is not None
|
||||
assert stale.banner_url == "https://yt3.example/UCstale-new"
|
||||
assert stale.details_synced_at is not None and stale.details_synced_at > STALE
|
||||
fresh = db.get(Channel, "UCfresh")
|
||||
assert fresh is not None and fresh.banner_url == "https://yt3.example/UCfresh-old"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("synced", "refreshed"), [(STALE, True), (FRESH, False)])
|
||||
def test_the_channel_page_refreshes_a_stale_channel_only(
|
||||
db, make_user, monkeypatch, synced, refreshed
|
||||
):
|
||||
user = make_user()
|
||||
_channel(db, "UCpage", synced)
|
||||
db.commit()
|
||||
asked: list[list[str]] = []
|
||||
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, []))
|
||||
|
||||
body = channels.channel_detail("UCpage", user=user, db=db)
|
||||
|
||||
assert asked == ([["UCpage"]] if refreshed else [])
|
||||
suffix = "new" if refreshed else "old"
|
||||
assert body["banner_url"] == f"https://yt3.example/UCpage-{suffix}"
|
||||
|
||||
|
||||
def test_a_channel_youtube_no_longer_returns_is_not_asked_for_on_every_view(
|
||||
db, make_user, monkeypatch
|
||||
):
|
||||
"""A terminated channel is missing from `channels.list`'s answer, so nothing was stamped and the
|
||||
channel page asked for it again (1 quota unit) on every single view. The miss is stamped like a
|
||||
fetch, so the next ask waits for `DETAILS_MAX_AGE` like any other channel."""
|
||||
user = make_user()
|
||||
db.add(Channel(id="UCgone", title="UCgone"))
|
||||
db.commit()
|
||||
asked: list[list[str]] = []
|
||||
monkeypatch.setattr(channels, "YouTubeClient", _youtube(asked, [], gone=("UCgone",)))
|
||||
|
||||
channels.channel_detail("UCgone", user=user, db=db)
|
||||
channels.channel_detail("UCgone", user=user, db=db)
|
||||
|
||||
assert asked == [["UCgone"]]
|
||||
|
||||
|
||||
def test_the_import_stamps_a_channel_youtube_no_longer_returns(db, make_user, monkeypatch):
|
||||
user = make_user()
|
||||
_channel(db, "UCgone", STALE)
|
||||
db.flush()
|
||||
db.add(Subscription(user_id=user.id, channel_id="UCgone"))
|
||||
db.commit()
|
||||
asked: list[list[str]] = []
|
||||
monkeypatch.setattr(
|
||||
subscriptions, "YouTubeClient", _youtube(asked, ["UCgone"], gone=("UCgone",))
|
||||
)
|
||||
|
||||
import_subscriptions(db, user)
|
||||
import_subscriptions(db, user)
|
||||
|
||||
assert asked == [["UCgone"]]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""E28 S3e2 — adding a video AT a position: the list page's "Undo" of a removal.
|
||||
|
||||
The row has to come back where it was in ONE request. The two-call shape (append, then send the
|
||||
whole order) leaves it at the end of the list whenever the second call fails, and the reorder would
|
||||
also overwrite any change another tab made in between. Positions can have gaps after removals, so
|
||||
the position is an index over the ORDERED rows, and the list is renumbered around it.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
|
||||
|
||||
def _seed(db, user, ids, *, positions=None, **kw):
|
||||
db.add(Channel(id="UCtest", title="Test"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in [*ids, "vX"]:
|
||||
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||
pl = Playlist(user_id=user.id, name="L", **kw)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, vid in enumerate(ids):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=vid, position=(positions or range(99))[i]))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _ids(db, pl_id):
|
||||
db.expire_all()
|
||||
return [
|
||||
r.video_id
|
||||
for r in db.execute(
|
||||
PlaylistItem.__table__.select()
|
||||
.where(PlaylistItem.playlist_id == pl_id)
|
||||
.order_by(PlaylistItem.position)
|
||||
).all()
|
||||
]
|
||||
|
||||
|
||||
def _add(client, pl_id, body):
|
||||
return client.post(f"/api/playlists/{pl_id}/items", json=body)
|
||||
|
||||
|
||||
def test_a_position_puts_the_video_back_in_its_place(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": 1})
|
||||
assert r.status_code == 200
|
||||
assert _ids(db, pl.id) == ["a", "vX", "b", "c"]
|
||||
|
||||
|
||||
def test_the_position_counts_rows_not_stored_numbers(db, make_user, as_user):
|
||||
# A removal leaves a gap (0, 5, 9): index 2 is before the THIRD row, not "position 2".
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 5, 9])
|
||||
_add(as_user(user), pl.id, {"video_id": "vX", "position": 2})
|
||||
assert _ids(db, pl.id) == ["a", "b", "vX", "c"]
|
||||
|
||||
|
||||
def test_a_position_past_the_end_appends(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b"])
|
||||
_add(as_user(user), pl.id, {"video_id": "vX", "position": 40})
|
||||
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||
|
||||
|
||||
def test_no_position_still_appends(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b"])
|
||||
_add(as_user(user), pl.id, {"video_id": "vX"})
|
||||
assert _ids(db, pl.id) == ["a", "b", "vX"]
|
||||
|
||||
|
||||
def test_a_video_already_in_the_list_does_not_move(db, make_user, as_user):
|
||||
# The add is idempotent; a position must not turn it into a reorder.
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
_add(as_user(user), pl.id, {"video_id": "c", "position": 0})
|
||||
assert _ids(db, pl.id) == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_a_negative_position_is_refused(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a"])
|
||||
r = _add(as_user(user), pl.id, {"video_id": "vX", "position": -1})
|
||||
assert r.status_code == 422
|
||||
assert _ids(db, pl.id) == ["a"]
|
||||
|
||||
|
||||
def test_putting_a_removed_video_back_leaves_a_linked_list_clean(db, make_user, as_user):
|
||||
# Remove + undo is no change at all: a YouTube-linked list must not stay "unsynced" after it.
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"], yt_playlist_id="PLx")
|
||||
client = as_user(user)
|
||||
from app.sync.playlists import current_fingerprint
|
||||
|
||||
pl.synced_fingerprint = current_fingerprint(db, pl)
|
||||
pl.dirty = False
|
||||
db.commit()
|
||||
client.delete(f"/api/playlists/{pl.id}/items/b")
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, pl.id).dirty is True
|
||||
_add(client, pl.id, {"video_id": "b", "position": 1})
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, pl.id).dirty is False
|
||||
@@ -13,6 +13,9 @@ Two properties are load-bearing and neither is visible from the happy path alone
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import OperationalError
|
||||
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
|
||||
|
||||
@@ -269,12 +272,12 @@ def test_a_target_that_already_had_it_is_not_recently_changed(db, make_user, as_
|
||||
assert db.get(Playlist, dst.id).updated_at == old
|
||||
|
||||
|
||||
def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
|
||||
db, make_user, as_user, monkeypatch
|
||||
):
|
||||
"""The already-in-target check is a read, not a lock: another tab's add (or the scheduler's
|
||||
YouTube merge) can commit the same video to the target in between, and the re-parent then
|
||||
hits uq_playlist_video. That is the idempotent outcome arriving late — not a raw 500."""
|
||||
def test_no_add_can_land_in_the_target_while_a_move_holds_it(db, make_user):
|
||||
"""The already-in-target check is a read; it holds because the move locks both lists first and
|
||||
an insert into the target — another tab's add, a YouTube merge — needs the target row for its
|
||||
foreign key, so it waits for the move's commit instead of hitting uq_playlist_video under it."""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.routes import playlists as plroutes
|
||||
|
||||
@@ -282,16 +285,10 @@ def test_a_concurrent_add_to_the_target_lands_as_the_already_present_move(
|
||||
_seed_videos(db, ["v1"])
|
||||
src = _playlist(db, user, "Source", ["v1"])
|
||||
dst = _playlist(db, user, "Target", [])
|
||||
real = plroutes._next_item_position
|
||||
|
||||
def add_first(session, playlist_id):
|
||||
with SessionLocal() as other: # the other tab, committing between check and re-parent
|
||||
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
|
||||
other.commit()
|
||||
return real(session, playlist_id)
|
||||
|
||||
monkeypatch.setattr(plroutes, "_next_item_position", add_first)
|
||||
assert _move(as_user(user), src.id, "v1", dst.id).status_code == 200
|
||||
db.expire_all()
|
||||
assert _ids(db, src.id) == []
|
||||
assert _ids(db, dst.id) == ["v1"]
|
||||
plroutes.lock_playlists(db, src.id, dst.id) # what the move holds until its commit
|
||||
with SessionLocal() as other:
|
||||
other.execute(text("SET LOCAL lock_timeout = '300ms'"))
|
||||
other.add(PlaylistItem(playlist_id=dst.id, video_id="v1", position=0))
|
||||
with pytest.raises(OperationalError, match="lock timeout"):
|
||||
other.flush()
|
||||
db.rollback()
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""E28 review — every request that reads a playlist's item positions and writes new ones locks the
|
||||
playlist row first.
|
||||
|
||||
Without it two adds (two tabs; the rail and the page) both read the same `max(position)` and land
|
||||
on one position, and a reorder racing an add collides with it — the list's order between the two
|
||||
rows is then whatever the planner returns. A true two-connection race is not reproducible on
|
||||
demand, so the test holds the mechanism: the parent row is taken `FOR UPDATE` by each such route.
|
||||
A Plex reorder that omits a row (added in another tab meanwhile) must not collide with it either.
|
||||
|
||||
Round 2: the lock also comes FIRST — before the request reads or writes a single item row. A remove
|
||||
that deleted an item and then touched the playlist beside a reorder that went the other way was a
|
||||
deadlock (a 500 for one of them), and an add that checked "already there?" before the lock let two
|
||||
adds of one video both insert (a 500 on the unique key).
|
||||
"""
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
|
||||
from app.models import (
|
||||
Channel,
|
||||
Playlist,
|
||||
PlaylistItem,
|
||||
PlexItem,
|
||||
PlexLibrary,
|
||||
PlexPlaylist,
|
||||
PlexPlaylistItem,
|
||||
Video,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def locks(db):
|
||||
"""The tables each request took a row lock on (`SELECT … FROM <t> … FOR UPDATE`)."""
|
||||
seen: list[str] = []
|
||||
|
||||
def _record(conn, cursor, statement, *_):
|
||||
if "FOR UPDATE" in statement:
|
||||
seen.extend(re.findall(r"FROM\s+(\w+)", statement)[:1])
|
||||
|
||||
engine = db.get_bind()
|
||||
event.listen(engine, "before_cursor_execute", _record)
|
||||
yield seen
|
||||
event.remove(engine, "before_cursor_execute", _record)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def statements(db):
|
||||
"""Every statement a request ran, in order."""
|
||||
seen: list[str] = []
|
||||
|
||||
def _record(conn, cursor, statement, *_):
|
||||
seen.append(" ".join(statement.split()))
|
||||
|
||||
engine = db.get_bind()
|
||||
event.listen(engine, "before_cursor_execute", _record)
|
||||
yield seen
|
||||
event.remove(engine, "before_cursor_execute", _record)
|
||||
|
||||
|
||||
def _locked_before_items(seen: list[str], parent: str, items: str) -> str | None:
|
||||
"""The first statement touching `items` must come after a `FOR UPDATE` of `parent`; returns
|
||||
that lock statement (None when it came too late or never)."""
|
||||
lock = None
|
||||
for st in seen:
|
||||
if lock is None and "FOR UPDATE" in st and re.search(rf"FROM {parent}\b", st):
|
||||
lock = st
|
||||
if re.search(rf"\b{items}\b", st):
|
||||
return lock
|
||||
return lock
|
||||
|
||||
|
||||
def _yt(db, user):
|
||||
db.add(Channel(id="UCtest", title="Test"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ("v1", "v2", "v3"):
|
||||
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||
pl = Playlist(user_id=user.id, name="L")
|
||||
other = Playlist(user_id=user.id, name="M")
|
||||
db.add_all([pl, other])
|
||||
db.flush()
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id="v1", position=0))
|
||||
db.commit()
|
||||
return pl.id, other.id
|
||||
|
||||
|
||||
def _plex(db, user):
|
||||
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
items = [PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk) for rk in "abcd"]
|
||||
db.add_all(items)
|
||||
pl = PlexPlaylist(user_id=user.id, title="L")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, it in enumerate(items[:3]):
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=it.id, position=i))
|
||||
db.commit()
|
||||
return pl.id
|
||||
|
||||
|
||||
YT = Playlist.__table__.name
|
||||
PLEX = PlexPlaylist.__table__.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v2"}),
|
||||
lambda c, pid, other: c.post(
|
||||
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
||||
),
|
||||
lambda c, pid, other: c.post("/api/playlists/watch-later", json={"video_id": "v2"}),
|
||||
lambda c, pid, other: c.post(
|
||||
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
||||
),
|
||||
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||
],
|
||||
ids=["add", "add-at", "watch-later", "move", "reorder"],
|
||||
)
|
||||
def test_a_youtube_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
||||
user = make_user()
|
||||
pid, other = _yt(db, user)
|
||||
r = call(as_user(user), pid, other)
|
||||
assert r.status_code == 200, r.text
|
||||
assert YT in locks
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "d"}),
|
||||
lambda c, pid: c.post(
|
||||
f"/api/plex/playlists/{pid}/items/bulk", json={"item_rating_keys": ["d"]}
|
||||
),
|
||||
lambda c, pid: c.post(
|
||||
f"/api/plex/playlists/{pid}/items/bulk",
|
||||
json={"item_rating_keys": ["d"], "positions": [0]},
|
||||
),
|
||||
lambda c, pid: c.put(
|
||||
f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b", "a"]}
|
||||
),
|
||||
],
|
||||
ids=["add", "bulk", "bulk-at", "reorder"],
|
||||
)
|
||||
def test_a_plex_playlist_write_locks_the_playlist(db, make_user, as_user, locks, call):
|
||||
user = make_user()
|
||||
pid = _plex(db, user)
|
||||
r = call(as_user(user), pid)
|
||||
assert r.status_code == 200, r.text
|
||||
assert PLEX in locks
|
||||
|
||||
|
||||
def test_a_plex_reorder_that_omits_a_row_does_not_collide_with_it(db, make_user, as_user):
|
||||
"""The row "a" was added in another tab after this page loaded its order ["c", "b"]: it keeps
|
||||
its place after the given rows instead of sharing position 0 with "c"."""
|
||||
user = make_user()
|
||||
pid = _plex(db, user)
|
||||
r = as_user(user).put(f"/api/plex/playlists/{pid}/order", json={"item_rating_keys": ["c", "b"]})
|
||||
assert r.status_code == 200
|
||||
db.expire_all()
|
||||
rows = db.query(PlexPlaylistItem).filter_by(playlist_id=pid).all()
|
||||
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
|
||||
got = sorted((r.position, by_id[r.item_id]) for r in rows)
|
||||
assert got == [(0, "c"), (1, "b"), (2, "a")]
|
||||
|
||||
|
||||
YT_ITEMS = PlaylistItem.__table__.name
|
||||
PLEX_ITEMS = PlexPlaylistItem.__table__.name
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda c, pid, other: c.post(f"/api/playlists/{pid}/items", json={"video_id": "v1"}),
|
||||
lambda c, pid, other: c.post(
|
||||
f"/api/playlists/{pid}/items", json={"video_id": "v2", "position": 0}
|
||||
),
|
||||
lambda c, pid, other: c.delete(f"/api/playlists/{pid}/items/v1"),
|
||||
lambda c, pid, other: c.post(
|
||||
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
||||
),
|
||||
lambda c, pid, other: c.put(f"/api/playlists/{pid}/order", json={"video_ids": ["v1"]}),
|
||||
# the ORM cascade deletes the items before the list row
|
||||
lambda c, pid, other: c.delete(f"/api/playlists/{pid}"),
|
||||
],
|
||||
ids=["add-present", "add-at", "remove", "move", "reorder", "delete"],
|
||||
)
|
||||
def test_a_youtube_item_write_locks_the_playlist_before_any_item(
|
||||
db, make_user, as_user, statements, call
|
||||
):
|
||||
user = make_user()
|
||||
pid, other = _yt(db, user)
|
||||
c = as_user(user)
|
||||
statements.clear()
|
||||
r = call(c, pid, other)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||
|
||||
|
||||
def test_a_move_locks_both_lists_in_one_ordered_statement(db, make_user, as_user, statements):
|
||||
"""Two moves A→B and B→A each locking "its" list first would lock crosswise."""
|
||||
user = make_user()
|
||||
pid, other = _yt(db, user)
|
||||
statements.clear()
|
||||
r = as_user(user).post(
|
||||
f"/api/playlists/{pid}/items/v1/move", json={"target_playlist_id": other}
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
lock = _locked_before_items(statements, YT, YT_ITEMS)
|
||||
assert lock and "ORDER BY" in lock, lock
|
||||
# BOTH lists in that one statement: one bound id per list in its IN (…) (a single-id lock
|
||||
# renders the same IN and ORDER BY)
|
||||
listed = re.search(r" IN \((.*?)\) ORDER BY", lock)
|
||||
assert listed and len(re.findall(r"%\(\w+\)s", listed.group(1))) == 2, lock
|
||||
|
||||
|
||||
def test_watch_later_add_and_remove_lock_it_before_any_item(db, make_user, as_user, statements):
|
||||
user = make_user()
|
||||
_yt(db, user)
|
||||
c = as_user(user)
|
||||
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
|
||||
statements.clear()
|
||||
assert c.post("/api/playlists/watch-later", json={"video_id": "v2"}).status_code == 200
|
||||
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||
statements.clear()
|
||||
assert c.delete("/api/playlists/watch-later/v2").status_code == 200
|
||||
assert _locked_before_items(statements, YT, YT_ITEMS), statements
|
||||
|
||||
|
||||
def test_a_rename_locks_the_playlist(db, make_user, as_user, locks):
|
||||
"""A rename moves `dirty`, which the scheduled mirror refresh re-reads under this lock."""
|
||||
user = make_user()
|
||||
pid, _ = _yt(db, user)
|
||||
r = as_user(user).patch(f"/api/playlists/{pid}", json={"name": "New"})
|
||||
assert r.status_code == 200, r.text
|
||||
assert YT in locks
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"call",
|
||||
[
|
||||
lambda c, pid: c.delete(f"/api/plex/playlists/{pid}/items/a"),
|
||||
lambda c, pid: c.post(
|
||||
f"/api/plex/playlists/{pid}/items/remove-bulk", json={"item_rating_keys": ["a", "b"]}
|
||||
),
|
||||
lambda c, pid: c.post(f"/api/plex/playlists/{pid}/items", json={"item_rating_key": "a"}),
|
||||
],
|
||||
ids=["remove", "remove-bulk", "add-present"],
|
||||
)
|
||||
def test_a_plex_item_write_locks_the_playlist_before_any_item(
|
||||
db, make_user, as_user, statements, call
|
||||
):
|
||||
user = make_user()
|
||||
pid = _plex(db, user)
|
||||
c = as_user(user)
|
||||
statements.clear()
|
||||
r = call(c, pid)
|
||||
assert r.status_code == 200, r.text
|
||||
assert _locked_before_items(statements, PLEX, PLEX_ITEMS), statements
|
||||
@@ -0,0 +1,210 @@
|
||||
"""E28 review C1 — one YouTube sync of a playlist at a time, whoever starts it.
|
||||
|
||||
A push reads YouTube and then writes both sides for seconds, committing step by step, so the
|
||||
client's in-flight flag (one page's menus) and a row lock (gone at the first commit) both leave two
|
||||
tabs, or a push and the scheduled mirror refresh, free to interleave. The claim is an advisory lock
|
||||
on a connection of its own; the test holds it from outside, the way a second request would.
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.auth import require_write_scope
|
||||
from app.db import SessionLocal
|
||||
from app.models import Playlist
|
||||
from app.routes import playlists as plroutes
|
||||
from app.sync import playlists as plsync
|
||||
|
||||
|
||||
def _linked(db, user, *, source="local"):
|
||||
pl = Playlist(user_id=user.id, name="L", source=source, yt_playlist_id="PL123")
|
||||
db.add(pl)
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
class FakeYT:
|
||||
"""YouTube as far as these routes read it: two playlists, none with items."""
|
||||
|
||||
def __init__(self, *_):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def iter_my_playlists(self):
|
||||
return [{"id": "PL123", "title": "Renamed"}, {"id": "PL999", "title": "New"}]
|
||||
|
||||
def iter_my_playlist_video_ids(self, _ytid):
|
||||
return []
|
||||
|
||||
def iter_playlist_items_with_ids(self, _ytid):
|
||||
return []
|
||||
|
||||
def get_playlist_snippet(self, _ytid):
|
||||
return {}
|
||||
|
||||
|
||||
def test_push_and_revert_refuse_while_the_list_is_claimed(
|
||||
db, as_user, make_user, override_dep, monkeypatch
|
||||
):
|
||||
user = make_user()
|
||||
pl = _linked(db, user)
|
||||
override_dep(require_write_scope, lambda: user)
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
ran: list[str] = []
|
||||
monkeypatch.setattr(plroutes, "YouTubeClient", FakeYT)
|
||||
monkeypatch.setattr(plroutes, "plan_merge", lambda d, u, p, live: {"units_estimate": 0})
|
||||
monkeypatch.setattr(plroutes, "merge_playlist", lambda d, u, p, live: ran.append("push") or {})
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: ran.append("revert") or {})
|
||||
c = as_user(user)
|
||||
|
||||
with plsync.sync_claim(db, pl.id) as free:
|
||||
assert free
|
||||
for path in ("push", "revert-youtube"):
|
||||
r = c.post(f"/api/playlists/{pl.id}/{path}")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
|
||||
# a delete too: a push mid-way would plan the deleted list as emptied (E28 round 4)
|
||||
r = c.delete(f"/api/playlists/{pl.id}")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_busy"}
|
||||
assert ran == []
|
||||
assert db.get(Playlist, pl.id) is not None
|
||||
|
||||
# Released on the way out: the same list syncs again, and a claim is per list.
|
||||
assert c.post(f"/api/playlists/{pl.id}/push").status_code == 200
|
||||
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
|
||||
assert ran == ["push", "revert"]
|
||||
|
||||
|
||||
def test_a_claim_is_released_when_its_block_raises(db, make_user):
|
||||
pl = _linked(db, make_user())
|
||||
try:
|
||||
with plsync.sync_claim(db, pl.id) as free:
|
||||
assert free
|
||||
raise RuntimeError("boom")
|
||||
except RuntimeError:
|
||||
pass
|
||||
# Asked of pg_locks, not by claiming again: a lock leaked on a pooled connection would be granted
|
||||
# again to that same connection (advisory locks are re-entrant per session).
|
||||
held = db.scalar(
|
||||
text("SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND objid = :id"),
|
||||
{"id": pl.id},
|
||||
)
|
||||
assert held == 0
|
||||
|
||||
|
||||
def test_a_claim_draws_no_connection_from_the_app_pool(db, make_user):
|
||||
"""A claim is held for a whole push beside the request's pooled session; drawn from the pool
|
||||
too, a burst of pushes ran it dry and stalled every other request for pool_timeout."""
|
||||
from app.db import engine
|
||||
|
||||
pl = _linked(db, make_user())
|
||||
before = engine.pool.checkedout()
|
||||
with plsync.sync_claim(db, pl.id) as free:
|
||||
assert free
|
||||
assert engine.pool.checkedout() == before
|
||||
|
||||
|
||||
def test_the_scheduled_refresh_skips_a_mirror_being_synced(db, make_user, monkeypatch):
|
||||
user = make_user()
|
||||
busy = _linked(db, user, source="youtube")
|
||||
replaced: list[int] = []
|
||||
|
||||
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
|
||||
monkeypatch.setattr(
|
||||
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
|
||||
)
|
||||
|
||||
with plsync.sync_claim(db, busy.id):
|
||||
out = plsync.sync_user_playlists(db, user)
|
||||
|
||||
assert out == {"synced": 1}
|
||||
assert busy.id not in replaced and len(replaced) == 1
|
||||
db.refresh(busy)
|
||||
assert busy.name == "L"
|
||||
|
||||
|
||||
def test_refused_reverts_do_not_spend_the_pull_cooldown(
|
||||
db, as_user, make_user, override_dep, monkeypatch
|
||||
):
|
||||
"""Round 2: the cooldown (three pulls per ten minutes) is checked after the claim, so clicking
|
||||
Revert while a push runs is refused as busy without using up a pull."""
|
||||
user = make_user()
|
||||
pl = _linked(db, user)
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "repull_playlist", lambda d, u, p: {})
|
||||
c = as_user(user)
|
||||
with plsync.sync_claim(db, pl.id):
|
||||
for _ in range(4):
|
||||
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 409
|
||||
assert c.post(f"/api/playlists/{pl.id}/revert-youtube").status_code == 200
|
||||
|
||||
|
||||
def test_a_bulk_pull_runs_once_per_user_at_a_time(db, as_user, make_user, monkeypatch):
|
||||
"""Two bulk pulls at once both saw a YouTube list unmirrored and each created its mirror."""
|
||||
user = make_user()
|
||||
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plroutes, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
|
||||
monkeypatch.setattr(plsync, "_replace_items_from_youtube", lambda d, p, ids: None)
|
||||
c = as_user(user)
|
||||
with plsync.user_sync_claim(db, user.id):
|
||||
assert plsync.sync_user_playlists(db, user) == {"synced": 0, "reason": "busy"}
|
||||
for _ in range(4):
|
||||
r = c.post("/api/playlists/sync-youtube")
|
||||
assert r.status_code == 409
|
||||
assert r.json()["detail"] == {"code": "playlist", "reason": "sync_all_busy"}
|
||||
assert c.post("/api/playlists/sync-youtube").json() == {"ok": True, "synced": 2}
|
||||
|
||||
|
||||
def test_a_gone_mirror_is_deleted_even_when_no_list_refreshes(db, make_user, monkeypatch):
|
||||
"""Every remaining mirror has unpushed edits, so nothing below the deletion commits."""
|
||||
user = make_user()
|
||||
gone = Playlist(user_id=user.id, name="Gone", source="youtube", yt_playlist_id="PLgone")
|
||||
kept = Playlist(user_id=user.id, name="K", source="youtube", yt_playlist_id="PL123", dirty=True)
|
||||
other = Playlist(
|
||||
user_id=user.id, name="O", source="youtube", yt_playlist_id="PL999", dirty=True
|
||||
)
|
||||
db.add_all([gone, kept, other])
|
||||
db.commit()
|
||||
gone_id = gone.id
|
||||
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plsync, "YouTubeClient", FakeYT)
|
||||
|
||||
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
|
||||
db.rollback() # what the request's session close does to an uncommitted deletion
|
||||
assert db.get(Playlist, gone_id) is None
|
||||
|
||||
|
||||
def test_a_mirror_edited_during_the_pull_is_not_replaced(db, make_user, monkeypatch):
|
||||
"""The loop's `dirty` is a snapshot from before the YouTube calls; a remove committed by another
|
||||
request meanwhile must win over YouTube's order."""
|
||||
user = make_user()
|
||||
pl = _linked(db, user, source="youtube")
|
||||
replaced: list[int] = []
|
||||
|
||||
class EditedMeanwhile(FakeYT):
|
||||
def iter_my_playlists(self):
|
||||
return [{"id": "PL123", "title": "Renamed"}]
|
||||
|
||||
def iter_my_playlist_video_ids(self, _ytid):
|
||||
with SessionLocal() as other:
|
||||
other.get(Playlist, pl.id).dirty = True
|
||||
other.commit()
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(plsync, "has_read_scope", lambda u: True)
|
||||
monkeypatch.setattr(plsync, "YouTubeClient", EditedMeanwhile)
|
||||
monkeypatch.setattr(
|
||||
plsync, "_replace_items_from_youtube", lambda d, p, ids: replaced.append(p.id)
|
||||
)
|
||||
|
||||
assert plsync.sync_user_playlists(db, user) == {"synced": 0}
|
||||
assert replaced == []
|
||||
db.expire_all()
|
||||
assert db.get(Playlist, pl.id).name == "L"
|
||||
@@ -0,0 +1,292 @@
|
||||
"""E28 review round 3 — the YouTube sync paths take the playlist row BEFORE they touch an item, and
|
||||
hold it to the commit that writes them.
|
||||
|
||||
Round 2 made every route edit lock the playlist first. The sync paths were left item-first (a push's
|
||||
and a re-pull's item replace, a delete's cascade), so a push beside a remove could deadlock (a 500),
|
||||
and the scheduled mirror refresh re-ingested inside its lock — its commit let go of the lock before
|
||||
the replace, so a remove made then was undone and the list read as synced. A push also replaced the
|
||||
items from a snapshot read before its YouTube calls, and marked the list clean over an edit made
|
||||
during its YouTube writes. The "other tab" here is a second session that commits while the push is
|
||||
talking to YouTube — when the push holds no lock, as a real one does not.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import delete, event, text
|
||||
|
||||
from app.db import SessionLocal
|
||||
from app.models import Channel, Playlist, PlaylistItem, Video
|
||||
from app.sync import playlists as pl_mod
|
||||
|
||||
ITEMS = PlaylistItem.__table__.name
|
||||
LISTS = Playlist.__table__.name
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def trace(db):
|
||||
"""Every statement and every COMMIT on the test engine, in order."""
|
||||
seen: list[str] = []
|
||||
|
||||
def _stmt(conn, cursor, statement, *_):
|
||||
seen.append(" ".join(statement.split()))
|
||||
|
||||
def _commit(conn):
|
||||
seen.append("COMMIT")
|
||||
|
||||
engine = db.get_bind()
|
||||
event.listen(engine, "before_cursor_execute", _stmt)
|
||||
event.listen(engine, "commit", _commit)
|
||||
yield seen
|
||||
event.remove(engine, "before_cursor_execute", _stmt)
|
||||
event.remove(engine, "commit", _commit)
|
||||
|
||||
|
||||
def _held_to_the_item_write(seen: list[str], verb: str) -> bool:
|
||||
"""The first `<verb> … playlist_items` comes after a `FOR UPDATE` of playlists, with no COMMIT
|
||||
in between (a commit ends the lock)."""
|
||||
locked = False
|
||||
for st in seen:
|
||||
if "FOR UPDATE" in st and re.search(rf"FROM {LISTS}\b", st):
|
||||
locked = True
|
||||
elif st == "COMMIT":
|
||||
locked = False
|
||||
elif st.startswith(verb) and re.search(rf"\b{ITEMS}\b", st):
|
||||
return locked
|
||||
return False
|
||||
|
||||
|
||||
class FakeYT:
|
||||
"""YouTube for the sync paths: one playlist, its items, and ids the Data API will not return."""
|
||||
|
||||
def __init__(self, items, *, unavailable=(), title="Mix", on_get=None, on_add=None):
|
||||
self.items = [(f"it{i}", v) for i, v in enumerate(items)]
|
||||
self.unavailable = set(unavailable)
|
||||
self.title = title
|
||||
self.get_calls = 0
|
||||
self._on_get = on_get
|
||||
self._on_add = on_add
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *_):
|
||||
return False
|
||||
|
||||
def iter_my_playlists(self):
|
||||
return [{"id": "PLyt", "title": self.title}]
|
||||
|
||||
def iter_my_playlist_video_ids(self, _pid):
|
||||
return [v for _, v in self.items]
|
||||
|
||||
def iter_playlist_items_with_ids(self, _pid):
|
||||
return [{"item_id": i, "video_id": v} for i, v in self.items]
|
||||
|
||||
def get_playlist_snippet(self, _pid):
|
||||
return {"title": self.title}
|
||||
|
||||
def get_videos(self, ids):
|
||||
self.get_calls += 1
|
||||
if self._on_get:
|
||||
self._on_get()
|
||||
return [
|
||||
{"id": v, "snippet": {"channelId": "UCtest", "publishedAt": "2020-01-01T00:00:00Z"}}
|
||||
for v in ids
|
||||
if v not in self.unavailable
|
||||
]
|
||||
|
||||
def create_playlist(self, title, *_, **__):
|
||||
return "PLyt"
|
||||
|
||||
def update_playlist_title(self, _pid, title):
|
||||
self.title = title
|
||||
|
||||
def add_playlist_item(self, _pid, vid, position=None):
|
||||
if self._on_add:
|
||||
self._on_add()
|
||||
iid = f"it{len(self.items) + 100}"
|
||||
self.items.append((iid, vid))
|
||||
return iid
|
||||
|
||||
def delete_playlist_item(self, item_id):
|
||||
self.items = [r for r in self.items if r[0] != item_id]
|
||||
|
||||
def move_playlist_item(self, item_id, _pid, _vid, position):
|
||||
row = next(r for r in self.items if r[0] == item_id)
|
||||
self.items.remove(row)
|
||||
self.items.insert(position, row)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_yt(monkeypatch):
|
||||
def install(fake):
|
||||
monkeypatch.setattr(pl_mod, "YouTubeClient", lambda *_: fake)
|
||||
return fake
|
||||
|
||||
return install
|
||||
|
||||
|
||||
def _seed(db, ids):
|
||||
db.add(Channel(id="UCtest", title="Test"))
|
||||
now = datetime.now(timezone.utc)
|
||||
for v in ids:
|
||||
db.add(Video(id=v, channel_id="UCtest", published_at=now))
|
||||
db.commit()
|
||||
|
||||
|
||||
def _playlist(db, user, local, *, source="local", baseline=None):
|
||||
pl = Playlist(
|
||||
user_id=user.id,
|
||||
name="Mix",
|
||||
source=source,
|
||||
yt_playlist_id="PLyt",
|
||||
synced_video_ids=None if baseline is None else pl_mod._dump_ids(baseline),
|
||||
)
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for pos, v in enumerate(local):
|
||||
db.add(PlaylistItem(playlist_id=pl.id, video_id=v, position=pos))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _other_tab(pl_id, *, add=None, remove=None, rename=None, drop=False):
|
||||
"""A second session's edit, committed at once — what another tab's request does. It runs on
|
||||
the push's own thread: a lock the push wrongly held here would be waited on forever, so the
|
||||
wait is bounded and a regression fails instead of hanging the gate."""
|
||||
with SessionLocal() as s:
|
||||
s.execute(text("SET LOCAL lock_timeout = '2s'"))
|
||||
if drop:
|
||||
s.execute(delete(Playlist).where(Playlist.id == pl_id))
|
||||
if rename:
|
||||
s.get(Playlist, pl_id).name = rename
|
||||
if add:
|
||||
pos = len(s.query(PlaylistItem).filter_by(playlist_id=pl_id).all())
|
||||
s.add(PlaylistItem(playlist_id=pl_id, video_id=add, position=pos))
|
||||
if remove:
|
||||
s.query(PlaylistItem).filter_by(playlist_id=pl_id, video_id=remove).delete()
|
||||
s.commit()
|
||||
|
||||
|
||||
def test_the_mirror_refresh_holds_its_lock_through_a_list_with_a_private_video(
|
||||
db, make_user, fake_yt, trace
|
||||
):
|
||||
user = make_user()
|
||||
_seed(db, ["a"])
|
||||
pl = _playlist(db, user, ["a"], source="youtube")
|
||||
fake = fake_yt(FakeYT(["a", "ghost"], unavailable={"ghost"}))
|
||||
trace.clear()
|
||||
pl_mod.pull_user_playlists(db, user)
|
||||
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||
# the private video is asked for once, not again under the lock
|
||||
assert fake.get_calls == 1
|
||||
assert pl_mod._local_video_ids(db, pl) == ["a"]
|
||||
|
||||
|
||||
def test_the_mirror_drop_locks_the_list_before_its_items(db, make_user, fake_yt, trace):
|
||||
user = make_user()
|
||||
_seed(db, ["a"])
|
||||
pl = _playlist(db, user, ["a"], source="youtube")
|
||||
pl.yt_playlist_id = "PLgone"
|
||||
db.commit()
|
||||
fake_yt(FakeYT([]))
|
||||
trace.clear()
|
||||
pl_mod.pull_user_playlists(db, user)
|
||||
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||
|
||||
|
||||
def test_a_repull_ingests_before_its_lock_and_holds_it_to_the_replace(
|
||||
db, make_user, fake_yt, trace
|
||||
):
|
||||
user = make_user()
|
||||
_seed(db, ["a"])
|
||||
pl = _playlist(db, user, ["a"])
|
||||
fake = fake_yt(FakeYT(["b", "a", "ghost"], unavailable={"ghost"}, title="Renamed"))
|
||||
trace.clear()
|
||||
pl_mod.repull_playlist(db, user, pl)
|
||||
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||
assert fake.get_calls == 1
|
||||
db.refresh(pl)
|
||||
assert (pl.name, pl.dirty) == ("Renamed", False)
|
||||
assert pl_mod._local_video_ids(db, pl) == ["b", "a"]
|
||||
|
||||
|
||||
def test_a_push_locks_the_list_before_it_replaces_the_items(db, make_user, fake_yt, trace):
|
||||
user = make_user()
|
||||
_seed(db, ["a", "b"])
|
||||
pl = _playlist(db, user, ["a", "b"], baseline=["a"])
|
||||
fake_yt(FakeYT(["a", "y"]))
|
||||
trace.clear()
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
assert _held_to_the_item_write(trace, "DELETE"), trace
|
||||
|
||||
|
||||
def test_an_add_made_while_a_push_reads_youtube_survives_it(db, make_user, fake_yt):
|
||||
user = make_user()
|
||||
_seed(db, ["a", "x"])
|
||||
pl = _playlist(db, user, ["a"], baseline=["a"])
|
||||
# 'y' is new on YouTube, so the push ingests it — and the other tab adds 'x' meanwhile.
|
||||
fake = fake_yt(FakeYT(["a", "y"], on_get=lambda: _other_tab(pl.id, add="x")))
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
local = pl_mod._local_video_ids(db, pl)
|
||||
assert set(local) == {"a", "x", "y"}
|
||||
assert "x" in [v for _, v in fake.items] # and it reached YouTube as a local add
|
||||
|
||||
|
||||
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
|
||||
def test_a_rename_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
|
||||
"""The closing baseline is YouTube's title, not the one the lock re-reads: over the new name it
|
||||
matched the rename, and the list read synced with YouTube still on the old title."""
|
||||
user = make_user()
|
||||
_seed(db, ["a", "b"])
|
||||
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
|
||||
if not linked:
|
||||
pl.yt_playlist_id = None
|
||||
db.commit()
|
||||
fake = fake_yt(
|
||||
FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, rename="Mix 2"))
|
||||
)
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
db.refresh(pl)
|
||||
assert (pl.name, fake.title) == ("Mix 2", "Mix")
|
||||
assert pl.dirty is True # the rename has not reached YouTube; the list must say so
|
||||
|
||||
|
||||
def test_a_push_stops_at_a_list_deleted_while_it_read_youtube(db, make_user, fake_yt):
|
||||
"""A delete landing mid-push left a plan over no items: every item "removed here", so the push
|
||||
emptied the YouTube playlist the delete meant to keep."""
|
||||
user = make_user()
|
||||
_seed(db, ["a", "b"])
|
||||
pl = _playlist(db, user, ["a", "b"])
|
||||
# a video YouTube would not return last time, retried now — the read the delete lands in
|
||||
pl.synced_video_ids = json.dumps({"ids": ["a", "b"], "unavailable": ["ghost"]})
|
||||
db.commit()
|
||||
fake = fake_yt(
|
||||
FakeYT(
|
||||
["a", "b", "ghost"], unavailable={"ghost"}, on_get=lambda: _other_tab(pl.id, drop=True)
|
||||
)
|
||||
)
|
||||
raised = None
|
||||
try:
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
except Exception as exc: # noqa: BLE001 — the regression raises too, AFTER its YouTube writes
|
||||
raised = exc
|
||||
assert [v for _, v in fake.items] == ["a", "b", "ghost"] # YouTube untouched
|
||||
assert isinstance(raised, pl_mod.PlaylistGone) # which the route answers with a 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize("linked", [True, False], ids=["linked", "first-push"])
|
||||
def test_an_edit_made_during_a_push_s_youtube_writes_stays_dirty(db, make_user, fake_yt, linked):
|
||||
user = make_user()
|
||||
_seed(db, ["a", "b"])
|
||||
pl = _playlist(db, user, ["a", "b"], baseline=["a"] if linked else None)
|
||||
if not linked:
|
||||
pl.yt_playlist_id = None
|
||||
db.commit()
|
||||
fake_yt(FakeYT(["a"] if linked else [], on_add=lambda: _other_tab(pl.id, remove="a")))
|
||||
pl_mod.merge_playlist(db, user, pl)
|
||||
db.refresh(pl)
|
||||
assert pl_mod._local_video_ids(db, pl) == ["b"]
|
||||
assert pl.dirty is True # the removal has not reached YouTube; the list must say so
|
||||
@@ -0,0 +1,113 @@
|
||||
"""E28 S3e2 — putting removed Plex playlist rows back WHERE THEY WERE: the list page's "Undo".
|
||||
|
||||
A removal can take one row, a season or a whole show, and a season's episodes need not sit together
|
||||
(a reorder inside the show interleaves them), so each key carries its own index — over the ORDERED
|
||||
rows, since positions have gaps after a removal. One request: an append plus a second reorder call
|
||||
leaves the rows at the end whenever that call fails, and would overwrite another tab's changes.
|
||||
"""
|
||||
|
||||
from app.models import PlexItem, PlexLibrary, PlexPlaylist, PlexPlaylistItem
|
||||
|
||||
|
||||
def _seed(db, user, rks, *, positions=None, extra=("x", "y")):
|
||||
lib = PlexLibrary(plex_key="p1", title="Movies", kind="movie", enabled=True)
|
||||
db.add(lib)
|
||||
db.flush()
|
||||
items = {}
|
||||
for rk in [*rks, *extra]:
|
||||
it = PlexItem(rating_key=rk, library_id=lib.id, kind="movie", title=rk.upper())
|
||||
db.add(it)
|
||||
items[rk] = it
|
||||
pl = PlexPlaylist(user_id=user.id, title="L")
|
||||
db.add(pl)
|
||||
db.flush()
|
||||
for i, rk in enumerate(rks):
|
||||
pos = (positions or range(99))[i]
|
||||
db.add(PlexPlaylistItem(playlist_id=pl.id, item_id=items[rk].id, position=pos))
|
||||
db.commit()
|
||||
return pl
|
||||
|
||||
|
||||
def _order(db, pid):
|
||||
db.expire_all()
|
||||
rows = db.execute(
|
||||
PlexPlaylistItem.__table__.select()
|
||||
.where(PlexPlaylistItem.playlist_id == pid)
|
||||
.order_by(PlexPlaylistItem.position, PlexPlaylistItem.id)
|
||||
).all()
|
||||
by_id = {it.id: it.rating_key for it in db.query(PlexItem).all()}
|
||||
return [by_id[r.item_id] for r in rows], [r.position for r in rows]
|
||||
|
||||
|
||||
def _add(client, pid, keys, positions=None):
|
||||
body = {"item_rating_keys": keys}
|
||||
if positions is not None:
|
||||
body["positions"] = positions
|
||||
return client.post(f"/api/plex/playlists/{pid}/items/bulk", json=body)
|
||||
|
||||
|
||||
def test_one_row_goes_back_to_its_index(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
r = _add(as_user(user), pl.id, ["x"], [1])
|
||||
assert r.status_code == 200
|
||||
assert r.json()["added"] == 1
|
||||
assert _order(db, pl.id) == (["a", "x", "b", "c"], [0, 1, 2, 3])
|
||||
|
||||
|
||||
def test_scattered_rows_go_back_to_each_of_their_indices(db, make_user, as_user):
|
||||
# A season whose episodes were interleaved with another season's: removed rows at 1 and 3 of
|
||||
# a, x, b, y, c. Given in any order, each lands where it stood.
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
_add(as_user(user), pl.id, ["y", "x"], [3, 1])
|
||||
assert _order(db, pl.id)[0] == ["a", "x", "b", "y", "c"]
|
||||
|
||||
|
||||
def test_indices_count_rows_not_stored_numbers(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"], positions=[0, 7, 20])
|
||||
_add(as_user(user), pl.id, ["x"], [2])
|
||||
assert _order(db, pl.id) == (["a", "b", "x", "c"], [0, 1, 2, 3])
|
||||
|
||||
|
||||
def test_an_index_past_the_end_appends(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a"])
|
||||
_add(as_user(user), pl.id, ["x"], [50])
|
||||
assert _order(db, pl.id)[0] == ["a", "x"]
|
||||
|
||||
|
||||
def test_a_row_already_in_the_list_does_not_move(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c"])
|
||||
r = _add(as_user(user), pl.id, ["c"], [0])
|
||||
assert r.json()["added"] == 0
|
||||
assert _order(db, pl.id)[0] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_without_positions_it_still_appends(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b"])
|
||||
_add(as_user(user), pl.id, ["y", "x"])
|
||||
assert _order(db, pl.id)[0] == ["a", "b", "y", "x"]
|
||||
|
||||
|
||||
def test_positions_must_pair_with_the_keys(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a"])
|
||||
assert _add(as_user(user), pl.id, ["x", "y"], [0]).status_code == 422
|
||||
assert _add(as_user(user), pl.id, ["x"], [-1]).status_code == 422
|
||||
assert _order(db, pl.id)[0] == ["a"]
|
||||
|
||||
|
||||
def test_remove_then_put_back_is_no_change(db, make_user, as_user):
|
||||
user = make_user()
|
||||
pl = _seed(db, user, ["a", "b", "c", "d"], extra=())
|
||||
client = as_user(user)
|
||||
client.post(
|
||||
f"/api/plex/playlists/{pl.id}/items/remove-bulk", json={"item_rating_keys": ["b", "d"]}
|
||||
)
|
||||
assert _order(db, pl.id)[0] == ["a", "c"]
|
||||
_add(client, pl.id, ["b", "d"], [1, 3])
|
||||
assert _order(db, pl.id)[0] == ["a", "b", "c", "d"]
|
||||
@@ -0,0 +1,115 @@
|
||||
"""A sidecar subtitle in a legacy code page must render its accents, not U+FFFD.
|
||||
|
||||
Found live on Arrow S03E15 (a Hungarian .srt beside the .mp4): the served WebVTT held 1391 U+FFFD and
|
||||
not one accented letter, because `_sidecar_vtt` decoded the bytes as UTF-8 with `errors="replace"`
|
||||
while the file was Windows-1250 (ő = 0xF5, ű = 0xFB). Legacy-code-page .srt files are the norm for
|
||||
Hungarian releases, and SubRip carries no charset of its own, so the stream's language is the only hint.
|
||||
No Postgres: Plex and ffmpeg are stubbed.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.plex import subtitles
|
||||
|
||||
HU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nMiért nem szóltál? Ősz, űr.\r\n"
|
||||
FR = "1\r\n00:00:01,000 --> 00:00:02,000\r\nDéjà vu, garçon.\r\n"
|
||||
RU = "1\r\n00:00:01,000 --> 00:00:02,000\r\nПривет, мир.\r\n"
|
||||
|
||||
|
||||
class _Plex:
|
||||
def __init__(self, raw: bytes):
|
||||
self.raw = raw
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
def raw_get(self, key: str) -> bytes:
|
||||
return self.raw
|
||||
|
||||
|
||||
def _sidecar(monkeypatch, raw: bytes, lang: str | None, codec: str = "srt") -> str:
|
||||
monkeypatch.setattr(subtitles, "PlexClient", lambda db: _Plex(raw))
|
||||
stream = {"key": "/library/streams/1", "codec": codec, "languageCode": lang}
|
||||
return subtitles._sidecar_vtt(cast(Session, None), stream)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "encoding", "lang"),
|
||||
[
|
||||
(HU, "cp1250", "hun"), # the file that was found broken
|
||||
(FR, "cp1252", "fre"), # a Western legacy file
|
||||
(RU, "cp1251", "rus"), # a Cyrillic one
|
||||
(HU, "utf-8", "hun"), # already UTF-8: untouched
|
||||
(HU, "utf-8-sig", "hun"), # UTF-8 with a BOM
|
||||
(HU, "utf-16", "hun"), # UTF-16 (carries its BOM)
|
||||
],
|
||||
ids=["cp1250-hu", "cp1252-fr", "cp1251-ru", "utf8", "utf8-bom", "utf16"],
|
||||
)
|
||||
def test_a_sidecar_srt_is_decoded_in_its_own_encoding(monkeypatch, text, encoding, lang):
|
||||
vtt = _sidecar(monkeypatch, text.encode(encoding), lang)
|
||||
assert "\ufffd" not in vtt
|
||||
assert vtt.startswith("WEBVTT\n\n")
|
||||
assert text.splitlines()[2] in vtt
|
||||
|
||||
|
||||
def test_a_non_srt_sidecar_reaches_ffmpeg_as_utf8(monkeypatch):
|
||||
seen: dict[str, bytes] = {}
|
||||
|
||||
def fake_ffmpeg(src: str, sub_ord):
|
||||
seen["bytes"] = Path(src).read_bytes()
|
||||
return "WEBVTT\n"
|
||||
|
||||
monkeypatch.setattr(subtitles, "ffmpeg_to_vtt", fake_ffmpeg)
|
||||
_sidecar(monkeypatch, "[Events]\nDialogue: Ősz\n".encode("cp1250"), "hun", codec="ass")
|
||||
assert seen["bytes"].decode("utf-8") == "[Events]\nDialogue: Ősz\n"
|
||||
|
||||
|
||||
def test_a_track_cached_before_the_encoding_fix_is_not_served(tmp_path, monkeypatch):
|
||||
"""The cache has no eviction, so a mis-decoded track would have stayed broken for good: the key
|
||||
is versioned, and a track cached under the old one is read again (once) instead of served."""
|
||||
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||
(tmp_path / "sub_14881_1.vtt").write_text(
|
||||
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAz�rt\n", "utf-8"
|
||||
)
|
||||
assert subtitles.cached_track("14881", 1)[0] is None
|
||||
|
||||
|
||||
def test_one_stray_byte_does_not_send_a_utf8_file_to_a_legacy_code_page(monkeypatch):
|
||||
"""A UTF-8 release with ONE bad byte (a cut-and-paste Windows-1252 quote) failed strict UTF-8 as a
|
||||
whole, and the whole file was then read as Windows-1250: every correct ő/é became mojibake. Nearly
|
||||
all of it is valid UTF-8, so it stays UTF-8 and only the one byte is replaced."""
|
||||
raw = (HU * 20).encode("utf-8") + b"\x93quoted\x94\r\n"
|
||||
vtt = _sidecar(monkeypatch, raw, "hun")
|
||||
assert "Miért nem szóltál? Ősz, űr." in vtt
|
||||
assert "Miért" not in vtt
|
||||
|
||||
|
||||
def test_an_untagged_legacy_sidecar_is_read_as_central_european(monkeypatch):
|
||||
"""No language tag: the fallback was Windows-1252, which turns a Hungarian ő/ű into õ/û."""
|
||||
vtt = _sidecar(monkeypatch, HU.encode("cp1250"), None)
|
||||
assert "Ősz, űr." in vtt
|
||||
|
||||
|
||||
def test_a_latin_serbian_sidecar_is_read_as_central_european(monkeypatch):
|
||||
"""Serbian releases are overwhelmingly Latin script in Windows-1250 (like hrv/bos); read as
|
||||
Windows-1251 every č/ć/š/ž/đ turned into Cyrillic noise."""
|
||||
sr = "1\r\n00:00:01,000 --> 00:00:02,000\r\nŠta ćeš, đače? Čekaj.\r\n"
|
||||
vtt = _sidecar(monkeypatch, sr.encode("cp1250"), "srp")
|
||||
assert "Šta ćeš, đače? Čekaj." in vtt
|
||||
|
||||
|
||||
def test_a_track_cached_under_the_previous_decoder_is_not_served(tmp_path, monkeypatch):
|
||||
"""The decoder changed again (stray-byte UTF-8, cp1250 fallbacks): the key is versioned once more
|
||||
so a track the old rules mis-decoded is read again instead of served for good."""
|
||||
monkeypatch.setattr(subtitles, "CACHE_DIR", tmp_path)
|
||||
(tmp_path / "sub2_14881_1.vtt").write_text(
|
||||
"WEBVTT\n\n1\n00:00:01.000 --> 00:00:02.000\nAzõrt\n", "utf-8"
|
||||
)
|
||||
assert subtitles.cached_track("14881", 1)[0] is None
|
||||
@@ -311,8 +311,9 @@ def test_covered_ranges_coalesce_and_answer_membership():
|
||||
def test_a_cached_vtt_from_an_older_build_counts_as_complete(tmp_path):
|
||||
"""Before this change the cache only ever held whole tracks, and none of them has a sidecar.
|
||||
Reading one as "covers nothing" would re-extract every already-cached track after the upgrade."""
|
||||
(tmp_path / "sub_98614_0.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
|
||||
assert subtitles.read_meta("sub_98614_0") == ([[0.0, 1_000_000_000.0]], True)
|
||||
key = subtitles.cache_key("98614", 0)
|
||||
(tmp_path / f"{key}.vtt").write_text(_vtt(_cue("00:01.000", "00:02.000", "x")), "utf-8")
|
||||
assert subtitles.read_meta(key) == ([[0.0, 1_000_000_000.0]], True)
|
||||
|
||||
|
||||
# --- the deep-review round: the seams between the window and the full pass ----------------------
|
||||
|
||||
@@ -37,7 +37,7 @@ def test_a_failure_is_sorted_by_what_the_user_can_do(exc, reason):
|
||||
("route", "target"),
|
||||
[
|
||||
("/api/sync/subscriptions", "app.routes.sync.import_subscriptions"),
|
||||
("/api/playlists/sync-youtube", "app.routes.playlists.sync_user_playlists"),
|
||||
("/api/playlists/sync-youtube", "app.routes.playlists.pull_user_playlists"),
|
||||
],
|
||||
)
|
||||
def test_a_manual_sync_youtube_refuses_is_a_422_not_a_500(
|
||||
|
||||
@@ -0,0 +1,638 @@
|
||||
# DESIGN.md — how Siftlode looks and behaves
|
||||
|
||||
This is the one written source of truth for the UI: principles, tokens, the canonical solution per pattern,
|
||||
and the interaction rules. Screens are measured against it. A pattern that has more than one look in the
|
||||
code is either a decision still open here (marked **Pending**) or a violation to fix.
|
||||
|
||||
**Status:** written in E28; every chapter is _Decided_ and in force. The design lab (below) renders each rule's
|
||||
chosen form. A new question opens a **Pending** item here, with its variants in the lab, until the product
|
||||
owner settles it.
|
||||
|
||||
## How this document changes
|
||||
|
||||
- A rule changes only through an explicit decision recorded in the [decision log](#decision-log): what was
|
||||
decided, the options it was chosen from, and why. No mid-sprint taste changes. A new idea goes on the
|
||||
list for the next design round.
|
||||
- Every rule rests on a named standard: Nielsen's heuristics, WCAG 2.2 AA, WAI-ARIA APG, Apple HIG or
|
||||
Material. The tag is in brackets after the rule.
|
||||
- A rule stated here should also be enforced in code (an eslint rule, a primitive's type, a test). A rule
|
||||
that lives only in prose drifts. That is the finding that started this document.
|
||||
|
||||
## Conformance — the check before a UI change is done
|
||||
|
||||
A screen is measured against this document, not against taste. Every change that renders something new
|
||||
runs this check before it is called done (E28 S3g):
|
||||
|
||||
1. **Primitives at a named rung.** Each control goes through its primitive (lint refuses the raw element,
|
||||
and a `<div role="button">` with it), at a rung or role this document names: a `Button` size, an
|
||||
`IconButton` size, a `Chip` size, the `SegmentedToggle` rung, a `SURFACE` role. The lint holds the
|
||||
element, not the rung, so the rung is checked by eye and by measure.
|
||||
2. **The pattern's chapter, re-read.** The change matches the chapter of every pattern it touches, or it
|
||||
opens a **Pending** item and a log row. It never quietly adds a second look.
|
||||
3. **Measured.** On the changed pages, the rendered elements are grouped by kind (button, icon button,
|
||||
chip, field, tab, segment, menu row, surface) and by their computed look (radius, height, type, fill,
|
||||
edge). Each distinct look maps to a rung or role named here; a look that maps to none is a violation
|
||||
or a new decision. The S3g baseline across the 15 pages at 1920 × 1080: every button, icon button,
|
||||
chip, field, tab, segment and menu row is drawn by its primitive. What the measure still counts as
|
||||
hand-drawn is by design: text links (no primitive), surfaces painted by their `SURFACE` role (a class,
|
||||
which the measure cannot trace to a component) and the playlist table's own rows.
|
||||
4. **Seen.** A screenshot of every changed state, thin edges at 1:1; the tablet widths (1024 × 768, a
|
||||
narrow split screen) when the layout can reflow. Phones are the E29 epic's, not this check's.
|
||||
|
||||
## The design lab
|
||||
|
||||
`npm run dev`, then open **`http://localhost:5173/design-lab`**. The lab renders each open decision's variants
|
||||
with the app's real CSS, tokens, glass and themes, and has its own switches for theme, scheme, text size and
|
||||
backdrop. It is dev-only: `main.tsx` reaches it behind `import.meta.env.DEV`, and the build plugin
|
||||
`frontend/devOnlyGuard.ts` fails a production build that would contain it. Once a decision is taken, the
|
||||
losing variants are deleted and the chosen one stays as the reference rendering: the lab is the living
|
||||
style guide.
|
||||
|
||||
## Principles
|
||||
|
||||
1. **Glass over a calm background is the look.** Surfaces are translucent layers (`.glass`,
|
||||
`.glass-card`, `.glass-menu`, `.glass-media`) over a per-scheme backdrop. Depth comes from the
|
||||
**header + fade**: a crisp sticky header with a short gradient just below it, so content dissolves as it
|
||||
slides under. The reference is `DataTable`'s sticky header. A flat opaque box where a glass surface
|
||||
belongs is a violation.
|
||||
2. **Built for one known, daily user, on a desktop and a tablet.** Density is welcome where a mouse is
|
||||
the pointer; on touch every target grows. Nothing is designed for phones.
|
||||
3. **One primitive per pattern.** Where a `components/ui/` primitive exists and is used, the looks don't
|
||||
drift. The drift lives in hand-rolled markup. A new generic control goes into `ui/`, and a screen
|
||||
composes primitives rather than restyling them.
|
||||
4. **Measured, not eyeballed.** Contrast, target sizes and layout at the contract sizes are measured in a
|
||||
real browser before a UI change is called done.
|
||||
5. **Honest states.** An error never renders as an empty state; loading, empty and error are three
|
||||
different screens (`QueryState` / `StateMessage`).
|
||||
|
||||
## Device adaptation — _Decided_
|
||||
|
||||
- **Supported:** tablet portrait and landscape and every size above them (from 768px wide). Phones are not a
|
||||
target. Measure a UI change at 1024×768, 820×1180 (portrait) and a desktop size.
|
||||
- **Adapt by capability, never by user agent:** `@media (pointer: coarse)` and `(hover: none)` decide touch
|
||||
behaviour; container queries decide how a panel lays itself out. Breakpoints are named tokens, never
|
||||
literal pixel values scattered in call sites. [HIG, WCAG 1.4.10]
|
||||
- **Touch never loses a function.** Anything revealed on hover is visible at rest on `hover: none` (the
|
||||
`.reveal` class), or it is removed on touch because the same action is reachable another way
|
||||
(`.reveal-hover`). [WCAG 1.4.13, Nielsen #6]
|
||||
- **Targets:** at least 2.75rem (44px) on a coarse pointer, as a real box, not an invisible hit area:
|
||||
overlapping hit areas fire the wrong control. It covers every control primitive, not only `Button`
|
||||
(A3, under Controls). [HIG, WCAG 2.5.8]
|
||||
- **Text inputs** use at least 1rem text on a coarse pointer, so a tablet browser doesn't zoom the page
|
||||
when an input gets focus.
|
||||
- **Side panels (C2):** on touch, and at any width below 768 (a narrow window, split screen), the rail tab
|
||||
or the band's "Filters · n" button slides the panel **over** the content with a scrim; a tap outside, Esc
|
||||
or Back closes it, and the content never reflows. Pinning is its own button in the panel header, offered
|
||||
only where a pinned panel leaves the content room (≥ 1024 wide). The mouse keeps hover-to-peek and
|
||||
click-to-pin. A filter is never unreachable at any supported width. Built in S3f3: `ui/SidePanel`
|
||||
opens `ui/Modal`'s `drawer-start` placement (the dialog's own Esc, Back, scrim, focus trap and scroll
|
||||
lock); the tab shows at every width and is 44 wide on touch; a pin saved on a desktop is left alone
|
||||
below 1024, not honoured. [Nielsen #7, WCAG 1.4.10]
|
||||
- **A row answers to its own width.** A list row that must fit a narrow column (the download row, the
|
||||
playlist table, the Channels tag row) stacks by a container query on its own box, not by the
|
||||
window: the same list sits beside a rail, in a split-screen tablet and in a zoomed-in browser. A
|
||||
line of facts (the download row's "Ready · MP4 · 18.9 MB · 1:11 · 1w ago") is one line cut at its
|
||||
end, never a flex row that breaks its pieces apart.
|
||||
- **A table gives its secondary columns back.** By the table's own width, as a row does: below 42rem
|
||||
a column marked `narrowHidden` steps aside, below 64rem (a 1024 window) one marked `tabletHidden`
|
||||
does, so the name keeps its room. The Channels table drops Prio, About and Length there (at 1024 the
|
||||
name had 60 px); the row's "…" still sets the priority.
|
||||
- **Players on touch (C3):** see [Players](#players).
|
||||
|
||||
## Tokens
|
||||
|
||||
- **Colour:** only semantic tokens (`--bg --surface --card --fg --muted --accent --accent-fg --danger
|
||||
--warn --success --info --border`, each with a `-fg` partner where text sits on it), and no palette class
|
||||
(`text-amber-500`: lint-refused). A score's gold is `--rating` (theme-scoped, 4.5:1 on the page and on its
|
||||
own chip), never `--warn`: a score is not a warning. What sits on a picture looks the same in both themes,
|
||||
because the picture does: the broadcast red `--live` under `--live-fg` (never `--danger`: "live" is not an
|
||||
error), `--rating-media` on a poster's scrim, and `--media-chrome` for the always-dark player's menus
|
||||
(owner decision F16). Black and white are the media scrims and their text. Five schemes × dark
|
||||
and light (B2): **Midnight** (blue), **Forest** (cyan), **Slate** (fuchsia), **Matrix** (moss) each earn their
|
||||
place with their own hue family; **Fjord** (Midnight's navy with a calm steel-blue accent) is the one kept
|
||||
exception, the calm twin of the vivid Midnight. A new scheme still needs its own hue family. Normal text ≥ 4.5:1 and a control's edge ≥ 3:1 in **every** scheme and mode.
|
||||
[WCAG 1.4.3, 1.4.11] The input edge token is set under Controls (A4). The ≥ 3:1 edge is the field
|
||||
family's (a field, the form's dropdown trigger), whose edge is the only thing that says "type here". A
|
||||
labelled pill (a chip, a toolbar trigger, a tab, a segment) keeps the faint `--border`: its text names it,
|
||||
and 1.4.11 does not ask an edge of a control its label already identifies (owner decision F14).
|
||||
- **The accent never looks like a status.** A scheme's accent is at least 30° of OKLCH hue away from
|
||||
`--danger`, `--warn` and `--success`, and passes 4.5:1 both as text on `--bg` and under `--accent-fg` on
|
||||
the fill, in dark and light. A primary button must never read as a destructive one. A new scheme is
|
||||
measured against this before it ships (the lab's B2 tiles do the arithmetic). [WCAG 1.4.1, 1.4.3]
|
||||
- **Held in code:** `frontend/src/lib/theme.contract.test.ts` measures every scheme × mode in `index.css`
|
||||
against the two rules above (text 4.5:1, a control edge 3:1, the status and rating colours as text, the accent's
|
||||
hue gap), and the media colours against what they sit on. A scheme or a retuned colour that breaks them does not pass the gate.
|
||||
- **No per-user colour tuning.** The schemes are the only colour choice; the old floating glass tuner was
|
||||
retired (E28), because free tuning is exactly the drift these rules exist to stop.
|
||||
- **Backdrops:** one per scheme and mode (`public/backdrops/`), soft pools in the accent's family, with a
|
||||
grey `overlay` grain that breaks the gradient banding (measured by pixel-scanning a canvas, never a
|
||||
screenshot, which rescales).
|
||||
- **State layers (B3):** hover and pressed are a layer, not a colour swap: the text colour mixed at **10%**
|
||||
(hover) / **16%** (pressed) over whatever the control sits on, so it reads on the page, a card and glass
|
||||
alike. A filled control mixes its own text colour into its fill at 14% / 24%. Opacity dimming and
|
||||
`bg-card` hovers are violations (invisible on a light card). Selected stays the solid accent. The one
|
||||
exception is an edged pill (a chip, a toolbar or form trigger, a segment): its hover is the accent edge
|
||||
(`.pill:hover`, `glass-hover`), which reads on every surface too (owner decision F14).
|
||||
A card that is one row with actions answers as a whole: the layer is the card's, under its badges and
|
||||
buttons, and the row's open button spans the card beneath the actions (a hover that stopped at the first
|
||||
badge read as broken; owner, E28 — `AdminUsers` user rows).
|
||||
- **Tokens as utilities:** the decided values live once in `index.css` — `--hover` / `--press`,
|
||||
`--edge-strong`, `--touch-target`, and the utilities `state-layer`, `state-filled`, `state-danger`,
|
||||
`edge-strong`, `fade-below`, `fade-below-panel`, `fade-above-panel`, `fade-edges-x`. A primitive or a lab
|
||||
rendering uses them; restating one as an arbitrary class is a violation.
|
||||
- **Type and size:** everything is rem-derived off `--font-scale` (the Settings text-size slider), so a
|
||||
px font size or a px-frozen box is an eslint error.
|
||||
- **Radius:** chips and segmented controls `rounded-full`; tabs `rounded-lg`. Buttons, icon buttons and
|
||||
form fields `rounded-lg`; toolbar triggers and search a pill (Controls). Surfaces: see Surfaces.
|
||||
- **Layers:** the fixed z-index scale in `index.css` (`base < menu < chrome < paneltab < panel < rail <
|
||||
overlay < popover < tooltip`), plus `under`, a surface's own layer under its content inside its `isolate`.
|
||||
No ad-hoc z-index. [maintainability]
|
||||
|
||||
## Controls — _Decided_ (batches A and B)
|
||||
|
||||
The chosen rendering of each rule is in the lab under "Decided". The primitives are built to it in S3.
|
||||
|
||||
- **Buttons (A1):** `rounded-lg` (r8): a control's corner is smaller than the surface it sits on (card r12,
|
||||
dialog r16). The rung follows the context: `md` in forms and dialog footers, `sm` in toolbars and panel
|
||||
headers, `xs` inside a table row. Glass is a `Button` variant, not a hand-built glass button. [HIG, Material]
|
||||
- **Density (B1): compact, by explicit height.** `md` 2rem (32), `sm` 1.75rem (28), `xs` 1.5rem (24); icon
|
||||
buttons 1.75rem / 2rem. A toolbar's search pill, dropdown trigger, `sm` button and small icon button are
|
||||
the same 28, so the row lines up. Nothing goes under 24 (WCAG 2.5.8); touch grows it to 44 (A3).
|
||||
- **Icon buttons (A2):** two sizes (28 / 32), r8: the smaller in rows and dense toolbars, the larger on its own. On a
|
||||
picture, a round, dark translucent button, readable on any thumbnail. Round means "floats over something":
|
||||
on media, or the one floating button over the page (back to top, a 48 glass circle, the FAB); a button
|
||||
inside a pill field is round too, so the radii nest. A text
|
||||
button on a picture is a pill (the card's Continue in the accent, Restart dark translucent, C5), and
|
||||
the one 48px glyph is the Play in the middle of a not-started card (C5).
|
||||
- **Touch (A3):** on a coarse pointer every control primitive (`Button`, `SegmentedToggle`, `FilterSelect`,
|
||||
`Tabs`, `Switch`, chips, the nav rail) grows to 2.75rem (44px) through one token, as a real box. Mouse
|
||||
sizes don't change. [HIG, WCAG 2.5.8]
|
||||
- **Input (A4):** one bordered input primitive, `ui/Input` (`Textarea` for several lines; a frame that also
|
||||
holds an icon or chips is painted with `fieldClass` around an `InnerInput`). The edge is
|
||||
`color-mix(in srgb, var(--fg) 55%, var(--bg))`, ≥ 3:1 against the page in every scheme and mode; focus
|
||||
turns it into a 2px accent edge, `invalid` into the danger edge. A form field is r8 at the `md` height
|
||||
(32); a toolbar search, a field in a popover or an inline rename is `sm` (28), the toolbar search a pill;
|
||||
the page's one hero field is `lg` (40): the header search, a glass pill floating over the page (the
|
||||
YouTube/Gmail shape), and the command palette's field;
|
||||
a field in a table row or the pager `xs` (24); an inline rename of a page title keeps the heading's type
|
||||
(`heading`). Text is 1rem and the box 44 on a coarse pointer. [WCAG 1.4.11]
|
||||
- **Dropdown trigger (A5):** `ui/SelectTrigger`. A pill in a toolbar (where the pill means "a filter or a
|
||||
choice"), r8 and full width in a form, where it wears the field's frame (see-through, the ≥ 3:1 edge) and
|
||||
lines up with the inputs. Open is the accent edge and the chevron turned over.
|
||||
- **Checkbox, switch, slider (A6):** the browser's own controls tinted with `accent-color` (`Checkbox`,
|
||||
`CheckRow`, `Slider`); `ColorInput` is the browser's colour well, which `accent-color` does not reach (it
|
||||
tints only checkboxes, radios, ranges and progress bars). The whole labelled row is the 44px target. Keyboard, screen reader and
|
||||
forced-colours support come from the browser. The switch stays `form`'s `Switch`: no browser has a native
|
||||
one yet. A raw `<input>` or `<textarea>` outside those primitives is a lint error (a hidden file picker
|
||||
passes). A checkable row that cannot hold an `<input>` — a menu row, a whole-row button whose role says
|
||||
checkbox — draws the ONE `ui/MenuCheck` in its leading icon slot: a box filled with the accent for a set
|
||||
(tags, playlists, a filter's values; a minus for a group only partly in), a bare tick for one choice (a
|
||||
language, a sort, a priority step), an unticked choice keeping the slot empty so the labels line up.
|
||||
- **In-place editing:** a field that replaces its trigger (a title rename, the priority number) hands the
|
||||
focus back to that trigger when Enter or Escape closes it (`useReturnFocus`), never to `<body>`.
|
||||
[WCAG 2.4.3]
|
||||
|
||||
Also in force:
|
||||
|
||||
- **One primitive per button family** (E28 S3b): a labelled action is `Button` (a rung from the ladder,
|
||||
plus `lg` 40 for a page's single hero call to action, and nothing else); a glyph is `IconButton` (a
|
||||
required `label`); a card, row, tile or menu row whose content is the control is `Pressable`; a filter or
|
||||
tag chip is `Chip`; a sortable row's grip is `DragHandle`. On the always-dark player chrome both
|
||||
`Button` and `IconButton` use the `overlay` variant (white, a white layer), whatever the theme; the Plex
|
||||
control row is IconButton's `player` rung (36, a 20px glyph, round, 44 on touch). A pressed toggle is
|
||||
`pressed`: the solid accent on a `Button`, the accent glyph on an `IconButton`. A raw `<button>` (or a bare
|
||||
`FocusSafeButton`) outside the control primitives (eslint `BUTTON_PRIMITIVES`) is a lint error. A text-less button must have an accessible name, and the type enforces
|
||||
it. [WCAG 4.1.2]
|
||||
- **The chip family** (`Chip`, E28 S3b): rounded-full, never under 24 (A3 grows it to 44 on touch).
|
||||
`filter` is a selectable option, solid accent when `pressed`; the "+N" and "More…" triggers are filter
|
||||
chips too, and while their popover is open they show it as an open trigger does, the accent edge and
|
||||
text (not the solid fill, which would say "selected"); `token` is an applied narrowing, the accent tint, the whole chip removing
|
||||
it; `tag` is a user's tag, the solid accent; `suggest` is a suggestion not yet taken (dashed accent);
|
||||
`ghost` the quiet "add a tag" (dashed neutral). A chip whose label is not the control but carries its
|
||||
own × is `DismissibleChip`: two sibling controls at most, never a button in a button. An overflowing chip
|
||||
lane measures a hidden mirror painted with `chipClass`, so the mirror cannot drift from the chips.
|
||||
- **Unavailable with a reason** stays focusable: `aria-disabled` plus the reason in the label or tooltip,
|
||||
never native `disabled`, which drops it from the tab order with its explanation. [WCAG 2.1.1]
|
||||
- A choice between a few values is `SegmentedToggle`; a long list or a user's own list is `FilterSelect`.
|
||||
Never a native `<select>` (lint-enforced). Its rung is the toolbar's, 28 with `text-xs`, so it lines up
|
||||
with the triggers and the pager's buttons; the 24 rung with the smallest type is only a side panel's
|
||||
captioned match toggle (S3g: the old small default had put a pager and a toolbar count on 10px type).
|
||||
- **A multi-value filter's any/all switch** (tags, topics, languages, genres, collections, actors) is
|
||||
`ui/MatchToggle`, on the panel right above its picker, from two picks on — never in the picker's
|
||||
popover, where a closed picker hid that "all" was on. One wording: Match · Any / All. [Nielsen #1]
|
||||
- **The pager** is four glyphs — first, previous, next, last — round the page box (`IconButton`s, their
|
||||
names as the tooltip); no words, at any width.
|
||||
- A single-select row of filter chips goes through `FilterChipGroup` (its options are `Chip`s).
|
||||
- A button that starts slow work shows `loading` (a spinner, `aria-busy`) and can't be pressed twice.
|
||||
[Nielsen #1]
|
||||
- **A glyph means one thing.** Play (▷) starts playback, or resumes a paused transfer; a retry is the
|
||||
refresh arrow (`RefreshCw`); a reset or revert is the counter-clockwise arrow (`RotateCcw`). A failed
|
||||
download's Retry had worn the Play glyph, which promised a video. [Nielsen #4]
|
||||
|
||||
## Surfaces and page chrome — _Decided_ (batch B)
|
||||
|
||||
- **Surface per role (B5):** a dialog or panel is `.glass` r16; a card or picker `.glass-card` r12; a menu
|
||||
`.glass-menu` r12 with 4px padding and r8 items (12 − 4, so the corners nest); a tooltip `.glass-menu` r8.
|
||||
Translucency comes only from the tiers (the app backdrop, Plex media, light); a per-site alpha override is a
|
||||
violation. A call site names the ROLE (`SURFACE.panel` / `drawer` / `card` / `menu` / `popover` / `tooltip`,
|
||||
`ui/surfaces.ts`), which brings the tier AND the radius; a page section is a panel, and what floats over
|
||||
undimmed content (a menu, a hover card, a status popover, a toast, a tooltip) is `.glass-menu`. A bare glass
|
||||
class is a lint error. A role's depth is its own shadow (the top highlight + the drop); a `shadow-*` size beside
|
||||
a role would replace both and is a lint error too — a field that floats takes `shadow-float`, the same shadow.
|
||||
- **Static vs pressable (owner, S3d):** a label that does nothing (a topic, a keyword, a status, a count) is a
|
||||
flat, tonal `Badge` with no edge; everything that does something has an edge (a chip, a trigger, a field).
|
||||
A genre that filters the library is a chip; the same genre where it can't is a badge. A badge's `tone`
|
||||
(neutral / accent / warn) is its own colour at 6%, measured at 4.5:1 in every scheme; a `<span>` drawn with a
|
||||
round edge is a lint error (F29). A toggle button states itself through `pressed` (the solid accent), never a
|
||||
bare `aria-pressed`, which the type refuses (F28).
|
||||
- **A link that looks like a button** (a download, a sign-in that leaves the app) is `LinkButton` /
|
||||
`IconLink`: still an `<a>`, wearing the button's own face.
|
||||
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
||||
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
|
||||
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
|
||||
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; a console
|
||||
whose toolbar lives inside its frame (the server log) makes that toolbar `sticky` with `fade-below`
|
||||
instead. The band holds controls only: an intro paragraph scrolls with the content (in the band it
|
||||
ate three lines of a short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
||||
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
||||
view with its own visible title (a channel, a Plex show, a playlist) renders it as `PageHeading`
|
||||
and the hidden one steps aside, so a page has exactly one `h1` (a hand `<h1>` inside the shell is
|
||||
refused by a contract test). A section's caption is `SectionLabel`, an `h2` (an `h3` in a dialog),
|
||||
never a styled `div`. The first Tab stop is "Skip to content" (`SkipLink`), which focuses the
|
||||
scroller's `<main>`. [WCAG 2.4.1, 1.3.1]
|
||||
- **The header + fade edge (B9):** a crisp band and a 1rem gradient of the colour it sits on, so content
|
||||
dissolves as it slides under; it shows only once something is under it, never over a list at rest. It goes on sticky toolbars and table headers, tabs over a scrolling panel, a
|
||||
long dialog's header and footer, and the clipped ends of an overflowing chip row. Not on thumbnails (they
|
||||
are the content) and not on buttons (it becomes a dated gloss). Built as: a sticky element takes the
|
||||
`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
|
||||
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).
|
||||
- **Nav rail (B7):** the `ui/NavRail` primitive, slim: 3.75rem wide. "You are here" is a soft accent tint plus a
|
||||
3px edge bar. Labels under the icons are optional, a per-user switch in Settings (off by default); they add
|
||||
height, never width, so each module has a short rail label that fits, and the full name stays in the
|
||||
tooltip. No wide padding around the icons. The switch is Settings → Appearance → "Labels in the side
|
||||
menu"; with the labels on, a row's accessible name is its visible short label (WCAG 2.5.3), and an
|
||||
unread count is part of the name, not only drawn.
|
||||
- **Loading (B8):** a first load of a grid, list or table shows its skeleton (`LoadingState shape="grid" |
|
||||
"poster" | "list" | "table"`, built from the `Skeleton` primitive), so
|
||||
nothing jumps when the data lands; a control's own work shows its spinner (`loading`). Neither appears
|
||||
before ~300ms. [Nielsen #1]
|
||||
- **The public watch page (B6)** follows these same rules (glass, `Button`, the pressed-pill toggle, the
|
||||
scheme's backdrop); only the video keeps the browser's own controls, because a stranger's device is
|
||||
unknown.
|
||||
|
||||
Already in force:
|
||||
|
||||
- The glass role is load-bearing: a dropdown or menu is `.glass-menu` (it floats over undimmed content, so a
|
||||
card alpha would bleed through), a dialog is `.glass`, and a grid card is `.glass-card`.
|
||||
- Every overlay is a `Modal`/`Overlay` or an anchored popover from the one popover engine. Escape closes the
|
||||
topmost layer only, and focus returns to the control that opened it. [WCAG 2.4.3, 2.1.2]
|
||||
|
||||
## Action placement — _Decided_
|
||||
|
||||
Which actions are visible and which live in a menu.
|
||||
|
||||
- **One menu, several triggers.** An object's actions are ONE list. The visible "…" button, a right-click,
|
||||
a long-press (touch) and Shift+F10 / the Menu key all open the same `ContextMenu` built from that list.
|
||||
A second, hand-made menu for the same object is a violation.
|
||||
- **Nothing is reachable only by a gesture.** Every menu action also has a visible path: the "…" button,
|
||||
the page toolbar, or the object's detail view. A right-click is a shortcut, never the only door.
|
||||
[Nielsen #6, #7]
|
||||
- **What stays visible:** the object's primary action (play or open) and any state the user needs to see
|
||||
(watched, saved, downloading). **What goes into the menu:** rare, secondary and destructive actions.
|
||||
Destructive ones keep their confirmation. [Nielsen #8, #5]
|
||||
- **On touch, a card shows fewer actions and a "…" button.** The rest are in the menu.
|
||||
- **The classification (C1)** says, per object, what is visible at rest, on hover and on touch; its full
|
||||
table is the lab's C1 reference, and S3 turns it into each object's one action list in code. In short:
|
||||
- A video or Plex card: play and its state (watched, progress) at rest; the everyday toggles (watched,
|
||||
save, add to playlist) and "…" on hover; on touch the state and a 44px "…" beside the title. Details,
|
||||
download, hide and reset live in the menu.
|
||||
- A row (playlist item, channel, download): its primary action and its values (priority, tags, error)
|
||||
stay visible; destructive and rare row buttons (unsubscribe, delete, cancel, remove) move into the menu.
|
||||
A playlist item's X column goes: Delete key plus an undo toast.
|
||||
- Downloads, Notifications and the Scheduler get a menu (they have none today); a page's destructive
|
||||
header action ("Clear everything", "Purge discovery") goes into the header's "…".
|
||||
- **Built as:** one `useContextMenu` per list; the row spreads `triggerProps` (right-click, long-press,
|
||||
Shift+F10) and its "…" is `ui/MoreButton` with `buttonProps` (the same menu, under the button; a second
|
||||
press closes it). An object's list is one builder (`useVideoActions` for a video, `usePlexCardActions`
|
||||
for a Plex title, composed by their lists and by Home's rails); a row that opens a panel ("Add to playlist…", "Details") anchors it to
|
||||
`menu.anchor`, the element the menu came from. "Copy title" / "Copy link" come from `useCopyItems` and
|
||||
run inside the click (`inGesture`, Safari's clipboard rule). The card's hover row sits on its picture
|
||||
(the duration steps aside while it shows); on touch the toggles are gone and a 44px "…" sits beside
|
||||
the title (`.touch-only`). The hover chrome and what steps aside for it are one pair of class strings
|
||||
(`ui/reveal.ts`), shared by the video and the Plex cards. A table or rail ROW shows its "…" at the
|
||||
row's end on hover or focus and always on touch (`.reveal`). A playlist's rows take Remove from the
|
||||
Delete key too (the `listDelete` key scope, registered only by a list that can remove) and answer it
|
||||
with an undo toast whose Undo puts the row back at its index in one request; a playlist itself has
|
||||
one list (`usePlaylistActions.items`) that its rail row and its page header's "…" both open. A
|
||||
subscribed channel's row ends in its "…" (`useChannelActions`: open, pin, Tags, Priority, YouTube,
|
||||
copy, full history, hide with an undo toast, unsubscribe behind its confirm), docked beside the name
|
||||
in the card layout. Its Tags submenu lists the reader's tags A–Z, each a check that toggles in place
|
||||
(the menu stays open for the next); its Priority submenu offers the fixed steps 10 · 5 · 3 · 2 · 1 · 0
|
||||
(an off-scale current value shows ticked in its place), and a pick re-sorts the list at once — only
|
||||
that row moves — with the pager and the focus (its "…") following it. The row's stepper still sets any
|
||||
value and leaves the order until the next load. A Plex playlist's title, show group and season each open the page's one row menu
|
||||
(a title's is its `usePlexCardActions` list plus the removal); removals take the same undo toast,
|
||||
`j`/`k` walk the visible rows and Delete removes the walked one; its rename and delete sit in the
|
||||
header's "…". A toast that carries an action lives at least 8 s, whatever the toast setting. A
|
||||
download row keeps play (pause / resume / retry in the queue) and the save link, a library row also
|
||||
Share (the link copy, ahead of save), and ends in its "…" (`useDownloadActions`: the editors,
|
||||
managing the shares, re-download, the copies, cancel / delete / remove
|
||||
behind their confirms); a link inside a row keeps the browser's own right-click menu. A notification
|
||||
row keeps its link and its unread mark, shows its dismiss on hover (in the "…" on touch), and its
|
||||
"…" holds find / undo for an activity notice. The notification center's band keeps Mark all read
|
||||
and puts Clear everything, now behind a confirm, in its "…"; the Scheduler's strip keeps Run all
|
||||
and Pause and puts Purge discovery in its "…". A list whose row objects are rebuilt each render or replaced by a
|
||||
poll passes `useContextMenu({ keyOf })` (its id), so the "…" still closes its own menu. A menu's
|
||||
groups are `ui/menuGroups`: a rule between
|
||||
two non-empty groups only.
|
||||
- **Background menu:** a right-click on EMPTY content area (not over text, inputs, links or media) may open
|
||||
a page menu. It only mirrors page-scope actions that already exist in the toolbar (sort, view, quick
|
||||
filters, select all); it adds no new function. Shift+right-click falls through to the browser's own menu.
|
||||
Desktop only: on touch a long-press on the background clashes with scrolling and text selection, so the
|
||||
toolbar stays the touch path. Built (`ui/PageMenu`; PageShell listens, `lib/backgroundTarget` decides
|
||||
"empty") on five pages: the **feed** (sort, reshuffle, view, watch state, content types, source, clear
|
||||
filters), the **Plex library grid** (sort, watch state, clear filters), the **channel manager** (layout,
|
||||
the cards' sort, sync status, edit tags, clear the tag filter, read subscriptions from YouTube), a
|
||||
**playlist** (play all, group by channel, reset view, save order, undo / redo, and the list's own "…")
|
||||
and the **notification center** (mark all read). Never a destructive row (delete, revert, clear
|
||||
everything) and never a long quota run (backfill): a stray right-click must not be one click from those.
|
||||
The tab-only pages keep the browser's menu; `pageMenuScope.contract.test.ts` holds the list.
|
||||
|
||||
### Text stays selectable — _Decided_
|
||||
|
||||
Wherever there is text, the user can select it and copy it, and no click action overrides that.
|
||||
|
||||
- A title or a name inside a click surface stays selectable: the card, row, person, channel or log line
|
||||
that opens is `selectable` (`ui/FocusSafeButton`, under `Pressable` and `Button`). Chrome selects no
|
||||
text inside a `<button>` unless it is `user-select: text` (measured), so the flag sets that and the
|
||||
click guard below. A control's own label (a menu row, an option, a tab, a nav item, a switch) is not
|
||||
a title and stays plain; `pressableSelectable.contract.test.ts` makes every new `Pressable` choose.
|
||||
- A click that ends a text selection does nothing: if the selection inside the element is non-empty, or
|
||||
the pointer moved past the drag slop, no play or open fires.
|
||||
- A right-click on selected words opens the browser's own menu (Copy, Search, Translate), not the
|
||||
object's; a right-click beside the selection still opens the object's menu (`ui/useContextMenu`).
|
||||
- On touch the long-press belongs to the context menu, so the object's menu offers **Copy title** and
|
||||
**Copy link**.
|
||||
|
||||
## Media card — _Decided_ (S3h)
|
||||
|
||||
A film, a show, an episode, a video or a pinned channel is ONE card wherever it appears: the Plex grid,
|
||||
the feed, Home's shelves and hero, the Plex rails, the media-info page's collection strip. A place may
|
||||
pick a density step; it never changes the anatomy or loses an action. [Nielsen #4]
|
||||
|
||||
- **Anatomy (the owner's pick C).** One frame: the picture, the title, the meta line pinned to the
|
||||
frame's bottom edge, so a one-line title leaves no hole above the meta. What is ON the picture: the
|
||||
length (a film's runtime, a show's season count, a video's duration) as a dark badge in the corner,
|
||||
the watched mark, the progress bar along the bottom. Built as `ui/MediaCard` + the classes in
|
||||
`ui/mediaCardStyles` (`MEDIA_PICTURE`, `MEDIA_IMG`, `MEDIA_BADGE`, `MEDIA_HOVER_LAYER`, `MEDIA_BAR`,
|
||||
`mediaTitleClass`, `mediaPictureBox`).
|
||||
- **Play only where the click plays.** The hover layer dims the picture and shows `MediaPlayGlyph`, the
|
||||
one round media Play (the button family's `media` variant), only on a card whose click starts
|
||||
playback; a show without a next episode opens instead and shows none. The hover row (watched, save or
|
||||
add, "…") docks at the picture's bottom-right; the length badge steps aside while it shows.
|
||||
- **Density.** A grid card: a two-line title and the full meta (Plex: year · ★ score; a video: the
|
||||
channel, then views · age). On a shelf (any horizontal strip) the title is one line, cut with an
|
||||
ellipsis, over ONE meta line (Plex: year · ★; a video: the channel; a tile: its count), so every card on
|
||||
the strip is the same height. A Home shelf slot is 232px: at 200 the Play and the hover row
|
||||
overlapped by 2px, at 232 they stand 6px apart; a landscape shelf picture takes the smaller Play.
|
||||
- **A mixed shelf is landscape.** Beside videos a Plex title shows its wide art, not its poster; a
|
||||
rail's slot is a grid cell, so each card fills the height the strip stretches to. A carousel turns
|
||||
the cards' content-visibility off (`mediaRail.contract.test.ts`): an off-screen card's reserved height
|
||||
had become the strip's.
|
||||
- **The preview (the owner's pick C).** Resting the mouse on a Plex card for ~0.9 s opens a panel beside
|
||||
it: the whole title, year · length · ★, and the synopsis in short lines (`ui/MediaPreview` +
|
||||
`useMediaPreview`). Mouse only (a keyboard walk or a tap never opens it; "…" → Media info has the same),
|
||||
passive (it never takes a click), and any press on the card closes it. Long text never goes in a
|
||||
native `title` tooltip.
|
||||
- **Home's hero is a card** (the owner's pick A): the same hover row and Play on its picture, the same
|
||||
whole menu on right-click, long-press and a touch "…" in its button row. In a wide window it stands
|
||||
three quarters wide and centred, with even padding; its text is one centred stack — kicker, title,
|
||||
subtitle (dropped when it is only the year), one fact line, a summary only when there is one.
|
||||
- **Home's shelf tiles act like their object.** A pinned channel or playlist: Play when it has a next
|
||||
video, ↗ to open it, "…" with an Open row. A show carries its next episode: the Play, the watched
|
||||
toggle and add act on that episode ("Next: S6·E16" is its meta line), and an ⓘ opens the show. A
|
||||
channel's Home menu is play, open and the shelf's own rows; its tags, priority, hide and unsubscribe
|
||||
stay on the Channels page.
|
||||
- **The compact list row (the owner's pick B).** The title leads; a faint "…" is always at the row's end;
|
||||
on hover or focus the quick toggles float in beside it over the date, which steps aside (the Gmail
|
||||
row). On touch only the "…".
|
||||
- **The media-info page (layout B).** Under the hero a main column — the collection strips (the same
|
||||
card), then the crew, then the cast — beside a 20rem sidebar of facts (details, keywords). Every
|
||||
section is the reader's to hide; a column with nothing in it is not drawn and the other takes the
|
||||
width. A show's page uses the same meta row: a value that filters is a chip, a plain fact is flat
|
||||
text, spaced by the gap, no "·".
|
||||
- **Browse tiles** (genres, playlists, collections) answer the pointer as a card does: the edge takes
|
||||
the accent, the shadow lifts and the art zooms.
|
||||
- **A missing picture says what the item is.** No picture at all (a link that failed before its
|
||||
details, a poster that never came) shows a quiet glyph — a film, a note, a file — never an empty box,
|
||||
which reads as "still loading". A download with no name yet is titled by its short link, once: no
|
||||
channel line, no second link line.
|
||||
- **Reading measure.** Prose (a synopsis, a biography, a channel's description) is capped at ~65
|
||||
characters a line (the `measure` utility) wherever its container can be wider. [WCAG 1.4.8]
|
||||
|
||||
## Interaction rules — _Decided_
|
||||
|
||||
- **Keyboard:** everything works by keyboard, in visual order, with no traps. A shortcut is a row in
|
||||
`lib/keymap.ts`; list and grid navigation is `NavGrid`/`keyboardNav`. [WCAG 2.1.1, 2.4.3, 2.1.2]
|
||||
- **Focus:** every control has a visible focus ring (the base ring in `index.css`; don't add a second). A
|
||||
sticky or fixed layer never covers the focused element. A list item the keyboard lands on rings 2 px
|
||||
outside itself where it has a gap round it (a card); a table row, a log line or a row flush in a
|
||||
clipping card rings inside itself, following its corners, since outside the ring ran under the table
|
||||
head or was clipped away. [WCAG 2.4.7, 2.4.11]
|
||||
- **Menus and tabs follow WAI-ARIA APG:** arrow keys move, Home/End jump, Escape closes and returns focus;
|
||||
tabs carry `tablist`/`tab`/`tabpanel` roles.
|
||||
- **Feedback:** a finished action is confirmed briefly (a toast that leaves by itself); anything that must
|
||||
be read or acted on is not only in a toast. Status changes are announced through a live region.
|
||||
[Nielsen #1, WCAG 2.2.1, 4.1.3]
|
||||
- **Errors:** a failed write goes through `meta.failTitle` / `notifyActionError`, never a silent `catch`.
|
||||
An error sits next to what failed and says how to recover; a field error is under its field and linked
|
||||
with `aria-describedby`. Colour is never the only signal. [Nielsen #9, WCAG 3.3.1, 1.4.1]
|
||||
- **Loading:** anything slower than ~300ms shows a loading state; a near-instant load doesn't flash one.
|
||||
Space is reserved for late content so the layout doesn't jump; the form is under Surfaces (B8).
|
||||
- **Reordering:** anything you can drag also has a button or menu way to move it (Top / Up / Down / Bottom),
|
||||
and a drag handle carries `touch-action: none` so a finger drags instead of scrolling. [WCAG 2.5.7]
|
||||
A list whose rows have a "…" (a YouTube playlist, a Plex playlist: a block among the blocks, an
|
||||
episode within its show) puts the four moves there (`ui/useOrderItems`); the saved views' editor
|
||||
shows them as four buttons with the item's place beside the selected view; a side panel in edit mode
|
||||
gives each group an Up / Down pair (`ui/OrderButtons`, `compact`). A move that would go nowhere is
|
||||
shown off, not hidden. A grip also picks up from the keyboard (space, the arrows, space).
|
||||
- **Motion:** 150–300ms micro-interactions, transform/opacity only; `prefers-reduced-motion` is respected.
|
||||
[WCAG 2.3.3]
|
||||
|
||||
## Players — _Decided_ (C3, C5, built in S3f)
|
||||
|
||||
- **A tap on the Plex video only shows or hides the controls** (C3); play/pause is the button. The mouse keeps
|
||||
click-to-pause. Measured live: before this, a tap with the bar hidden both revealed it and paused. [Nielsen #3]
|
||||
- **The hide timer starts with playback,** not with the first touch (before, on touch, the bar stayed up until
|
||||
the first tap however long the video played).
|
||||
- **The bars stay up while the pointer rests on them** (F22, owner); leaving starts a fresh 3 s. YouTube and
|
||||
Plex Web do the same: before, the row faded out from under a mouse parked on Next.
|
||||
- **Volume is the native `Slider`** (A6); the hand-drawn bar had no hover, focus, keyboard or screen-reader
|
||||
state. A pointer drag gives the focus back, so Space and the arrows keep driving the player.
|
||||
- **The seek bar:** the track stays thin inside a 2.75rem (44px) hit area with `touch-action: none`, so a finger
|
||||
drag scrubs instead of panning; the scrub head is always visible on touch. Controls are 44 on touch. [WCAG 2.5.8]
|
||||
- **The Plex control set (C5, the owner's model):** one row that fits a portrait tablet.
|
||||
- No Stop (closing the player is stopping), no Restart, no ±10 s buttons.
|
||||
- **Back:** more than 3 s in → back to the start; within the first 3 s → the previous item, at its resume
|
||||
point if it has one, else its start. The first item has only the restart.
|
||||
- **Next** shows only when there is a next item (an episode, a playlist queue). It starts that item at its
|
||||
resume point and leaves one behind on the current item, which does not count as watched unless it is
|
||||
already inside the finish margin (the credits).
|
||||
- **±10 s:** the keyboard keeps ← / →; on touch a double-tap on the left or right third of the picture skips
|
||||
∓10 s, while a single tap still only shows the controls. Touch never loses a function. [WCAG 2.5.1]
|
||||
- The row: play/pause, Back, Next, mute, the time, audio & subtitles, a fixed **⋯** (player settings, media
|
||||
info, stats, keyboard shortcuts, download the original file), fullscreen. The volume slider is
|
||||
mouse only: a tablet's volume is its buttons.
|
||||
- **Restarting lives on the media card** (C5): not started → Play; started → **Continue** (primary) +
|
||||
**Restart**, the video card's own pair and words (`MediaResumeButtons`, one component for every card and
|
||||
Home's hero). A poster stacks the pair; a landscape picture sets it side by side, icon-only on a small one.
|
||||
On touch a tap continues and the card's "…" has "Play from the beginning" under its Play row. The restart
|
||||
applies to the item the player opened on (`fromStart`); every item it moves on to resumes.
|
||||
- **The YouTube modal:** its chrome uses the `Button` rungs (44 on touch), the side arrows stay inside the
|
||||
screen, and the description opens from an ⓘ button, because a hover popover never opens on a tap (measured
|
||||
live). [WCAG 1.4.13]
|
||||
|
||||
## Dialogs — _Decided_ (C4, built in S3f)
|
||||
|
||||
- **Initial focus by purpose** (`Modal`'s required `initialFocus`, so no dialog inherits "the first
|
||||
focusable", which is always the ✕): `field` — a form opens on the body's first control, and one that
|
||||
loads after opening moves the focus there when it arrives, as long as the focus still rests on the ✕;
|
||||
`action` — a confirm, where the least destructive button takes `autoFocus`; `close` — anything to read,
|
||||
browse or manage. [WAI-ARIA APG]
|
||||
- **The header and the footer are fixed; only the body scrolls,** with the B9 fade at each edge, so the
|
||||
primary action stays on screen when the on-screen keyboard takes half of it (the viewport's
|
||||
`interactive-widget=resizes-content` makes the keyboard shrink the layout, where Chrome would otherwise
|
||||
slide it over a fixed dialog). A fade shows only while content is under it (the body's scroll timeline):
|
||||
a dialog that fits draws none. [Nielsen #3, WCAG 2.4.11]
|
||||
- **The footer is the dialog's commit row** — Cancel / Save, Start, Retry / Close, a confirm's choices —
|
||||
placed with `ModalFooter` from any depth of the body. A section's own buttons (a sub-form inside a
|
||||
manager, a list row's Delete) stay in the body; a manager with no commit row has no footer.
|
||||
|
||||
## Copy — _Decided_ (S3g)
|
||||
|
||||
- Every user-facing string is in both Hungarian and English with zero key drift, and a user-facing error is
|
||||
a structured `{code, reason}` translated on the client. Hungarian speaks to the user as "te".
|
||||
- **One name per feature.** The nav rail's label is the feature's name; page titles, menus, shortcuts,
|
||||
settings tabs and onboarding use the same words. The rail may shorten them ("Config" for
|
||||
"Configuration", "Listák" for "Lejátszási listák"), never swap in another word. [Nielsen #4]
|
||||
- **One verb per meaning.** Delete / Törlés: gone for good. Remove / Eltávolítás: out of a list, the
|
||||
thing itself survives. Dismiss / Elrejtés: a notice leaves the view. Discard / Elvetés: unsaved edits
|
||||
are thrown away. A confirm uses its action's verb. [Nielsen #4]
|
||||
- **Sentence case.** Capitals only for brand names (YouTube, Plex, Google) and the app's places used as
|
||||
places ("Hide from Feed", "Megnézés a Könyvtárban"). [Material, HIG]
|
||||
- **"…" when the command asks first.** A label ends in "…" when it opens a dialog, form or picker before
|
||||
anything happens ("Edit tags…"); not on a submenu (its chevron says it) or an immediate action. Progress
|
||||
text ("Loading…") keeps its own. [HIG, Windows]
|
||||
- **Messages.** A toast or error of one sentence has no full stop and no "✓": "Couldn't sync
|
||||
subscriptions" / "Nem sikerült szinkronizálni". Two sentences keep their stops. Help text is prose.
|
||||
- **Quotes** around a name are curly: “abc” in English, „abc” in Hungarian.
|
||||
- **Dates**, in the UI language, only from `lib/format.ts` (lint: `local/date-format`): recency in a list
|
||||
is relative ("3d ago" / "3 napja") through `ui/RelativeTime`, which carries the exact minute on hover;
|
||||
a date is "Sep 27, 2026" / "2026. szept. 27."; a moment "Sep 27, 2026, 08:30 AM" /
|
||||
"2026. szept. 27. 08:30"; seconds only in the server log, the audit and the recorder.
|
||||
- **Units.** A figure with a unit (a length, a countdown, a range of days) is the browser's short form
|
||||
(`Intl`), through `formatUnit` / `formatRuntime`: "90 min", "2 hr 13 min" / "90 p", "2 ó 13 p"; a fixed
|
||||
label holding one is written in that same form ("Under 90 min"). Never a bare letter ("90m", "24h",
|
||||
"7d"; test-held). In a sentence, and in a list of time spans named in words ("24 hours", "1 week"), the
|
||||
unit is a word. A clock-style length on a picture stays "2:34:11".
|
||||
- A label says what happens ("Delete playlist"), not "OK"; a destructive confirm names the object.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
- A flat `bg-card` / `bg-surface` box where a glass surface belongs.
|
||||
- A hand-rolled control that restyles what a primitive already does.
|
||||
- Information or actions that exist only on hover, or only behind a right-click.
|
||||
- A click handler on text that swallows the user's text selection.
|
||||
- A colour literal, a px font size, a px-frozen box.
|
||||
- An empty state shown for an error.
|
||||
|
||||
## 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-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 |
|
||||
@@ -32,6 +32,10 @@ For a fast frontend edit loop, run the Vite dev server with HMR against that API
|
||||
cd frontend && npm install && npm run dev # http://localhost:5173, proxying the API on :8080
|
||||
```
|
||||
|
||||
The dev server also serves the **design lab** at `http://localhost:5173/design-lab`: the open design
|
||||
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
||||
(see [`DESIGN.md`](DESIGN.md)).
|
||||
|
||||
## The gate
|
||||
|
||||
A change is done when all of this is green. CI runs the same checks server-side
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
// The design lab (`src/design-lab/`) is a dev-only page: main.tsx reaches it behind
|
||||
// `import.meta.env.DEV`, which Vite folds to `false` in a production build, so the dynamic import
|
||||
// is dead code and the chunk is never emitted. That rule used to be a sentence; this makes it a
|
||||
// build failure. If a future edit imports the lab unguarded (a static import, a shared helper, a
|
||||
// re-export), the production build refuses instead of shipping the lab to every visitor.
|
||||
const DEV_ONLY_DIR = "/src/design-lab/";
|
||||
|
||||
export function devOnlyGuard(): Plugin {
|
||||
return {
|
||||
name: "siftlode-dev-only-guard",
|
||||
apply: "build",
|
||||
generateBundle(_options, bundle) {
|
||||
for (const [fileName, output] of Object.entries(bundle)) {
|
||||
if (output.type !== "chunk") continue;
|
||||
const leaked = Object.keys(output.modules).find((id) =>
|
||||
id.replaceAll("\\", "/").includes(DEV_ONLY_DIR),
|
||||
);
|
||||
if (leaked) {
|
||||
this.error(
|
||||
`dev-only module ${leaked} reached the production bundle (${fileName}). ` +
|
||||
"Import src/design-lab only behind `import.meta.env.DEV`.",
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const focusSafeDisabled = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Use ui/FocusSafeButton (or ui/Button).",
|
||||
raw: "A raw <button disabled> throws a focused user to <body> when it turns disabled. Build the primitive on ui/FocusSafeButton; anywhere else use ui/Button, IconButton or Pressable.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
@@ -82,6 +82,199 @@ const focusSafeDisabled = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3g (docs/DESIGN.md "Copy"): the app shows a date in three forms plus the relative one, all
|
||||
// built in lib/format.ts in the UI language. A bare `toLocaleString()` formats in the BROWSER's locale
|
||||
// and as numbers with seconds; seven call sites had drifted into five shapes. Outside lib/format.ts
|
||||
// a `toLocale*String` call or an `Intl.DateTimeFormat` / `Intl.RelativeTimeFormat` is refused (a
|
||||
// number goes through formatNumber, which the same rule covers).
|
||||
const dateFormat = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
date: "Format dates and numbers through lib/format.ts (formatDate, formatDateTime, formatDateTimeSec, relativeTime, formatNumber), in the UI language.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
// lib/format.ts is where the forms live; a test computes its expectations; the Plex player's
|
||||
// on-screen clock (lib/plexPlayerPrefs.ts) is a clock face with its own 12/24h switch, not a date.
|
||||
if (/\/src\/lib\/(format|plexPlayerPrefs)\.ts$|\.test\.tsx?$/.test(path)) return {};
|
||||
return {
|
||||
"CallExpression > MemberExpression.callee[property.name=/^toLocale(Date|Time)?String$/]"(
|
||||
node,
|
||||
) {
|
||||
context.report({ node, messageId: "date" });
|
||||
},
|
||||
"MemberExpression[object.name='Intl'][property.name=/^(DateTimeFormat|RelativeTimeFormat)$/]"(
|
||||
node,
|
||||
) {
|
||||
context.report({ node, messageId: "date" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// E28 S3b: one primitive per button family (docs/DESIGN.md). 268 hand-rolled buttons in 79 files had
|
||||
// drifted into as many looks, heights and hover styles; they were swept onto ui/Button, IconButton,
|
||||
// Pressable, Chip and DragHandle, and this keeps the next one out. The raw element lives only in the
|
||||
// files that ARE a control primitive — not in the rest of components/ui/, whose composites (a pager, a
|
||||
// side panel, a toast) are built from the primitives like any feature component.
|
||||
const BUTTON_PRIMITIVES = new Set([
|
||||
"FocusSafeButton.tsx", // the focus-keeping <button> everything stands on
|
||||
"Button.tsx",
|
||||
"IconButton.tsx",
|
||||
"Pressable.tsx",
|
||||
"Chip.tsx",
|
||||
"DragHandle.tsx",
|
||||
"AnchoredPopover.tsx", // the menu row: floating-ui's roving focus reads its native `disabled`
|
||||
"SegmentedToggle.tsx",
|
||||
"Tabs.tsx",
|
||||
"form.tsx", // Switch
|
||||
"PriorityStepper.tsx",
|
||||
"SortControl.tsx",
|
||||
"SelectTrigger.tsx", // the dropdown trigger (A5)
|
||||
"FilterSelect.tsx", // its option rows
|
||||
"Combobox.tsx", // the field's chevron (A4)
|
||||
]);
|
||||
const buttonPrimitive = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
raw: "A raw <{{name}}> outside the control primitives: use ui/Button (a labelled action), IconButton (a glyph), Pressable (a card or row that is the control), Chip or DragHandle.",
|
||||
role: 'A <{{name}} role="button"> is a hand-built button (its own Enter/Space, no focus-safe disabled, no touch target): use ui/Pressable for a surface that is the control.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||
if (path.includes("/src/components/ui/") && BUTTON_PRIMITIVES.has(file)) return {};
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== "JSXIdentifier") return;
|
||||
const name = node.name.name;
|
||||
if (name === "button" || name === "FocusSafeButton")
|
||||
context.report({ node, messageId: "raw", data: { name } });
|
||||
// S3g: the same button spelled as a div with a role slipped past the rule above (my own
|
||||
// S3h Plex picture). A component (capitalised) may take a role for its own reasons.
|
||||
else if (
|
||||
/^[a-z]/.test(name) &&
|
||||
node.attributes.some(
|
||||
(a) =>
|
||||
a.type === "JSXAttribute" &&
|
||||
a.name.name === "role" &&
|
||||
a.value?.type === "Literal" &&
|
||||
a.value.value === "button",
|
||||
)
|
||||
)
|
||||
context.report({ node, messageId: "role", data: { name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// The same for a text field and the native checkbox / slider / colour well (docs/DESIGN.md A4, A6;
|
||||
// E28 S3c): ~100 hand-styled boxes carried six edges, four radii and two focus looks between them. A
|
||||
// file picker has no look (it is hidden behind a Button), so it passes.
|
||||
const INPUT_PRIMITIVES = new Set([
|
||||
"Input.tsx",
|
||||
"NativeControls.tsx",
|
||||
"PriorityStepper.tsx", // the number box laid over the stepper's own face
|
||||
]);
|
||||
const inputPrimitive = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
raw: "A raw <{{name}}> outside the field primitives: use ui/Input, Textarea or InnerInput (inside a fieldClass frame), or Checkbox / CheckRow / Slider / ColorInput.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||
if (path.includes("/src/components/ui/") && INPUT_PRIMITIVES.has(file)) return {};
|
||||
return {
|
||||
JSXOpeningElement(node) {
|
||||
if (node.name.type !== "JSXIdentifier") return;
|
||||
const name = node.name.name;
|
||||
if (name !== "input" && name !== "textarea") return;
|
||||
const type = node.attributes.find(
|
||||
(a) => a.type === "JSXAttribute" && a.name.name === "type",
|
||||
);
|
||||
const v = type?.value?.type === "Literal" ? type.value.value : null;
|
||||
if (name === "input" && (v === "file" || v === "hidden")) return;
|
||||
context.report({ node, messageId: "raw", data: { name } });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// B5 (docs/DESIGN.md; E28 S3d): a glass surface is a ROLE from ui/surfaces.ts, which brings its radius.
|
||||
// ~140 sites had paired the three glass tiers with five radii and three menu paddings. A class string
|
||||
// (two or more tokens, so `variant="glass"` passes) holding a bare glass tier is refused outside the
|
||||
// role table and the control primitives that paint a glass BUTTON face.
|
||||
const SURFACE_OWNERS = new Set([
|
||||
"surfaces.ts",
|
||||
"buttonStyles.ts", // the `glass` button face
|
||||
"SelectTrigger.tsx",
|
||||
"fieldStyles.ts", // the header search floats over the page
|
||||
]);
|
||||
const GLASS_TIER = /^glass(-card|-menu)?$/;
|
||||
// A glass surface's depth is its own box-shadow, whose inset top highlight a `shadow-*` utility would
|
||||
// replace wholesale (the header search and the debug pill lost theirs). A shadow COLOUR only sets a
|
||||
// variable, and `shadow-float` IS the surface's shadow, so both pass.
|
||||
const SHADOW_SIZE = /^shadow-(xs|sm|md|lg|xl|2xl|\[.+\]|\(.+\))$/;
|
||||
const isShadowSize = (w) => SHADOW_SIZE.test(w.slice(w.lastIndexOf(":") + 1));
|
||||
const isSurfaceRole = (n) =>
|
||||
n?.type === "MemberExpression" && n.object.type === "Identifier" && n.object.name === "SURFACE";
|
||||
const surfaceRole = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
raw: "A bare `{{tier}}` class: use a role from ui/surfaces.ts (SURFACE.panel / drawer / card / menu / popover / tooltip), which brings the radius too.",
|
||||
shadow:
|
||||
"`{{shadow}}` on a glass surface replaces its own shadow and drops the top highlight; the role's depth is the intended one.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const path = context.filename.split("\\").join("/");
|
||||
const file = path.slice(path.lastIndexOf("/") + 1);
|
||||
if (path.includes("/src/components/ui/") && SURFACE_OWNERS.has(file)) return {};
|
||||
if (path.includes("/src/lib/")) return {}; // no classes live there, only prose ("a glass tuner")
|
||||
const check = (node, text) => {
|
||||
if (typeof text !== "string") return;
|
||||
const words = text.split(/\s+/).filter(Boolean);
|
||||
// A lone word is a value (`variant="glass"`); a class string has company, or is a template
|
||||
// fragment that a space joins to its neighbour.
|
||||
if (words.length < 2 && text === text.trim()) return;
|
||||
const tier = words.find((w) => GLASS_TIER.test(w));
|
||||
if (tier) context.report({ node, messageId: "raw", data: { tier } });
|
||||
};
|
||||
// A role plus a shadow size in one class list: `${SURFACE.x} shadow-lg` or clsx(SURFACE.x, "shadow-lg").
|
||||
const shadowBeside = (node, parts) => {
|
||||
if (!parts.some(isSurfaceRole)) return;
|
||||
for (const part of parts) {
|
||||
const text =
|
||||
part.type === "TemplateElement"
|
||||
? part.value.cooked
|
||||
: part.type === "Literal" && typeof part.value === "string"
|
||||
? part.value
|
||||
: "";
|
||||
const shadow = text.split(/\s+/).find(isShadowSize);
|
||||
if (shadow) context.report({ node, messageId: "shadow", data: { shadow } });
|
||||
}
|
||||
};
|
||||
return {
|
||||
Literal(node) {
|
||||
check(node, node.value);
|
||||
},
|
||||
TemplateElement(node) {
|
||||
check(node, node.value.cooked);
|
||||
},
|
||||
TemplateLiteral(node) {
|
||||
shadowBeside(node, [...node.quasis, ...node.expressions]);
|
||||
},
|
||||
CallExpression(node) {
|
||||
shadowBeside(node, node.arguments);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
// The words a screen reader or a tooltip says are user-facing text like any other, and the HU/EN
|
||||
// parity rule covers them too. Five `aria-label="remove"`-style literals survived every lane (the
|
||||
// Plex playlist rows, a filter chip, the player's reset) and a whole opt-in panel was English only —
|
||||
@@ -152,11 +345,46 @@ const sortableA11y = {
|
||||
};
|
||||
},
|
||||
};
|
||||
// An <a> whose content is only a glyph is a link that looks like a button (DESIGN.md): `IconLink`, with
|
||||
// a name, a 24+ box and the icon button's layer. The E28 audit found five bare 14px ones. Text or an
|
||||
// expression anywhere in its CHILDREN (not its attributes: every link has an `href={…}`) is a text
|
||||
// link or a card, and passes; a self-closing <a> is a `Trans` slot that i18n fills with text.
|
||||
const glyphLink = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
messages: {
|
||||
glyph:
|
||||
"a glyph-only <a> is a link that looks like a button: use ui/LinkButton's IconLink (a name, a 24+ box, the state layer).",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const hasText = (children) =>
|
||||
children.some(
|
||||
(c) =>
|
||||
(c.type === "JSXText" && /\S/.test(c.value)) ||
|
||||
(c.type === "JSXExpressionContainer" && c.expression.type !== "JSXEmptyExpression") ||
|
||||
((c.type === "JSXElement" || c.type === "JSXFragment") && hasText(c.children)),
|
||||
);
|
||||
return {
|
||||
JSXElement(node) {
|
||||
const open = node.openingElement;
|
||||
if (open.name.type !== "JSXIdentifier" || open.name.name !== "a" || open.selfClosing)
|
||||
return;
|
||||
if (!hasText(node.children)) context.report({ node: open, messageId: "glyph" });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const localRules = {
|
||||
rules: {
|
||||
"glyph-link": glyphLink,
|
||||
"sortable-a11y": sortableA11y,
|
||||
"jsx-comment-text": jsxCommentText,
|
||||
"focus-safe-disabled": focusSafeDisabled,
|
||||
"button-primitive": buttonPrimitive,
|
||||
"date-format": dateFormat,
|
||||
"input-primitive": inputPrimitive,
|
||||
"surface-role": surfaceRole,
|
||||
"literal-a11y-text": literalA11yText,
|
||||
},
|
||||
};
|
||||
@@ -167,6 +395,43 @@ const BOX_PX =
|
||||
"/(^|[\\s:])(min-|max-)?(w|h|size|left|right|top|bottom|inset|gap|p[xytrbl]?|m[xytrbl]?)-\\[[\\d.]+px\\]/";
|
||||
const BOX_PX_MESSAGE =
|
||||
"px-frozen box size doesn't scale with --font-scale: use the spacing scale (w-37.5, max-w-60), not w-[Npx].";
|
||||
// A stacking order outside the layer scale (DESIGN.md Tokens: `base < menu < chrome < … < tooltip` in
|
||||
// index.css). A numeric `z-10` equals a level today but doesn't follow it when the scale moves; name it.
|
||||
const Z_RAW = "/(^|[\\s:])-?z-([1-9]\\d*|\\[[^\\]]+\\])(?![\\w-])/";
|
||||
const Z_RAW_MESSAGE =
|
||||
"z-index off the layer scale: use a named level (z-under, z-base, z-menu, z-chrome, z-panel, z-rail, z-overlay, z-popover, z-tooltip), not z-N or z-[…].";
|
||||
|
||||
// A hover that swaps a colour instead of laying the B3 state layer (DESIGN.md Tokens): `bg-card`,
|
||||
// `bg-border` and `bg-surface` vanish on a light card, an opacity dim reads as "disabled", and
|
||||
// `bg-(--hover)` restates the `state-layer` utility by hand. `group-hover:` (a reveal on a parent's
|
||||
// hover, C1) and an edged pill's accent hover (F14) are not matched.
|
||||
const HOVER_SWAP =
|
||||
"/(^|[\\s:])hover:(bg-(card|border|surface|muted)(?![\\w-])|bg-\\(--(hover|press)\\)|opacity-)/";
|
||||
const HOVER_SWAP_MESSAGE =
|
||||
"hover by a colour swap: use the B3 layer (`state-layer`, Pressable `layer`, `state-filled` on a fill), not hover:bg-card / bg-border / opacity-N.";
|
||||
|
||||
// A palette colour (DESIGN.md Tokens "only semantic tokens", audit F16): nothing measures `text-amber-500`,
|
||||
// so a gold that reads on dark ships at 2:1 on light. Black and white are not matched: they are the
|
||||
// media scrims and the text on them, the same in every theme.
|
||||
const PALETTE =
|
||||
"/(^|[\\s:])(text|bg|border|fill|stroke|from|via|to|ring|outline|decoration|divide|caret|shadow)-(red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose|slate|gray|zinc|neutral|stone)-\\d/";
|
||||
const PALETTE_MESSAGE =
|
||||
"a palette colour: use a semantic token (text-rating, bg-live, bg-media-chrome, text-danger, …; DESIGN.md Tokens), not text-amber-500.";
|
||||
|
||||
// A static label with an edge (DESIGN.md "Static vs pressable", audit F29): a <span> drawn round with a
|
||||
// border reads as a chip, is clicked and does nothing. A label that does nothing is ui/Badge (flat).
|
||||
// A small `rounded` box (a check mark drawn inside a menu row) is a glyph, not a label: only the label
|
||||
// radii match.
|
||||
const EDGED_LABEL = "/^(?=.*(^|\\s)rounded-(full|md|lg|xl|2xl)(\\s|$))(?=.*(^|\\s)border(\\s|$))/";
|
||||
const EDGED_LABEL_MESSAGE =
|
||||
"a static label with an edge reads as a pressable chip: use ui/Badge (flat, tonal) for a label that does nothing.";
|
||||
|
||||
// A class name split by an interpolation: the text right after `${…}` starts with a variant (`hover:`),
|
||||
// or the interpolation IS the variant and the text goes on with its class (`${NARROW}:w-24`, E28 S3f3).
|
||||
// A clock (`${m}:00`) and a URL (`${u}://`) are not classes.
|
||||
const SPLIT_CLASS = "/^([a-z][\\w-]*:[\\w\\[!-]|:[a-z\\[!])/";
|
||||
const SPLIT_CLASS_MESSAGE =
|
||||
"a class split by an interpolation (`${x}hover:…`): Tailwind never sees it whole and emits no CSS; write the class literally (a shared guard is the `act:` variant).";
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ["dist", "node_modules", "playwright-report", "test-results", "e2e/.auth"] },
|
||||
@@ -211,7 +476,12 @@ export default tseslint.config(
|
||||
"react-hooks/refs": "off",
|
||||
"local/jsx-comment-text": "error",
|
||||
"local/focus-safe-disabled": "error",
|
||||
"local/button-primitive": "error",
|
||||
"local/date-format": "error",
|
||||
"local/input-primitive": "error",
|
||||
"local/surface-role": "error",
|
||||
"local/literal-a11y-text": "error",
|
||||
"local/glyph-link": "error",
|
||||
"local/sortable-a11y": "error",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||
@@ -243,12 +513,19 @@ export default tseslint.config(
|
||||
{
|
||||
selector: "Literal[value=/(^|\\s)enabled:hover:/]",
|
||||
message:
|
||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||||
},
|
||||
{
|
||||
selector: "TemplateElement[value.raw=/(^|\\s)enabled:hover:/]",
|
||||
message:
|
||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `enabled:not-aria-disabled:hover:`.",
|
||||
"`enabled:hover:` misses a focused FocusSafeButton (aria-disabled): use `act:hover:` (index.css).",
|
||||
},
|
||||
// Tailwind emits CSS only for a class it finds WHOLE in the source. A variant glued onto an
|
||||
// interpolated prefix (`${ACT}hover:text-fg`) is never found, so it ships with no rule: every
|
||||
// hover colour of the button and chip primitives was dead that way (E28 audit).
|
||||
{
|
||||
selector: `TemplateLiteral > TemplateElement:not(:first-child)[value.raw=${SPLIT_CLASS}]`,
|
||||
message: SPLIT_CLASS_MESSAGE,
|
||||
},
|
||||
// The canonical "FE control" row (CLAUDE.md): a short fixed set is a SegmentedToggle, a
|
||||
// longer one or a user's own list a FilterSelect. Six native selects had drifted in; the
|
||||
@@ -277,6 +554,20 @@ export default tseslint.config(
|
||||
// 150px at the default size) — 32 of these were swept in the 2026-09 debt burn.
|
||||
{ selector: `Literal[value=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||
{ selector: `TemplateElement[value.raw=${BOX_PX}]`, message: BOX_PX_MESSAGE },
|
||||
{ selector: `Literal[value=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||||
{ selector: `TemplateElement[value.raw=${Z_RAW}]`, message: Z_RAW_MESSAGE },
|
||||
{ selector: `Literal[value=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||||
{ selector: `TemplateElement[value.raw=${HOVER_SWAP}]`, message: HOVER_SWAP_MESSAGE },
|
||||
{ selector: `Literal[value=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||||
{ selector: `TemplateElement[value.raw=${PALETTE}]`, message: PALETTE_MESSAGE },
|
||||
{
|
||||
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] Literal[value=${EDGED_LABEL}]`,
|
||||
message: EDGED_LABEL_MESSAGE,
|
||||
},
|
||||
{
|
||||
selector: `JSXOpeningElement[name.name='span'] JSXAttribute[name.name='className'] TemplateElement[value.raw=${EDGED_LABEL}]`,
|
||||
message: EDGED_LABEL_MESSAGE,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -357,6 +648,13 @@ export default tseslint.config(
|
||||
files: ["src/components/ui/FocusSafeButton.tsx", "src/components/ui/AnchoredPopover.tsx"],
|
||||
rules: { "local/focus-safe-disabled": "off" },
|
||||
},
|
||||
{
|
||||
// The design lab (src/design-lab, E28) is a dev-only page that never reaches the production
|
||||
// bundle (devOnlyGuard fails the build if it does). Its copy is the developer's and the product
|
||||
// owner's working text, not the product's, so the translate-every-label rule does not apply.
|
||||
files: ["src/design-lab/**/*.{ts,tsx}"],
|
||||
rules: { "local/literal-a11y-text": "off" },
|
||||
},
|
||||
{
|
||||
// Tests stand doubles in for the network (`vi.stubGlobal("fetch", …)`) and may reach it directly.
|
||||
// …and render throwaway markup whose labels no user ever hears.
|
||||
@@ -365,6 +663,11 @@ export default tseslint.config(
|
||||
"no-restricted-globals": "off",
|
||||
"no-restricted-properties": "off",
|
||||
"local/literal-a11y-text": "off",
|
||||
// A test renders a raw button as a stand-in trigger or a focus target, not as product UI.
|
||||
"local/button-primitive": "off",
|
||||
"local/input-primitive": "off",
|
||||
"local/surface-role": "off",
|
||||
"local/glyph-link": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
<html lang="en" data-theme="dark" data-scheme="midnight">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<!-- resizes-content: the on-screen keyboard shrinks the layout viewport, not only the visual one,
|
||||
so a dialog's pinned footer (DESIGN C4) sits above the keyboard instead of under it. Chrome's
|
||||
default since 108 only shrinks the visual viewport; Safari ignores the key. -->
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<!-- OG:START — the server replaces this block for /watch/{token} so shared links unfurl with
|
||||
the video's title/channel/thumbnail; every other route keeps these generic defaults. -->
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a1620" stop-opacity=".9"/><stop offset="100%" stop-color="#7a1620" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff3b46" stop-opacity=".4"/><stop offset="100%" stop-color="#ff3b46" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a0d24" stop-opacity=".9"/><stop offset="100%" stop-color="#3a0d24" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2a1010" stop-opacity=".85"/><stop offset="100%" stop-color="#2a1010" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#1d4a66" stop-opacity=".95"/><stop offset="100%" stop-color="#1d4a66" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#337599" stop-opacity=".5"/><stop offset="100%" stop-color="#337599" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2d57" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2d57" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#173c52" stop-opacity=".85"/><stop offset="100%" stop-color="#173c52" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#0d0708"/>
|
||||
<rect width="1600" height="1000" fill="#080d1a"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f5245" stop-opacity=".95"/><stop offset="100%" stop-color="#0f5245" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0e4a5a" stop-opacity=".95"/><stop offset="100%" stop-color="#0e4a5a" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#134a2c" stop-opacity=".9"/><stop offset="100%" stop-color="#134a2c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0d3a52" stop-opacity=".85"/><stop offset="100%" stop-color="#0d3a52" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#06120e"/>
|
||||
<g filter="url(#s)">
|
||||
@@ -14,5 +14,5 @@
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#f0a3aa" stop-opacity=".85"/><stop offset="100%" stop-color="#f0a3aa" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#ff5b64" stop-opacity=".4"/><stop offset="100%" stop-color="#ff5b64" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#e096a8" stop-opacity=".8"/><stop offset="100%" stop-color="#e096a8" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#eec2c2" stop-opacity=".8"/><stop offset="100%" stop-color="#eec2c2" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#a8c6dc" stop-opacity=".9"/><stop offset="100%" stop-color="#a8c6dc" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#5aa1cb" stop-opacity=".5"/><stop offset="100%" stop-color="#5aa1cb" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b9c6e0" stop-opacity=".85"/><stop offset="100%" stop-color="#b9c6e0" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#a9d0dc" stop-opacity=".8"/><stop offset="100%" stop-color="#a9d0dc" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#faeef0"/>
|
||||
<rect width="1600" height="1000" fill="#eaf0f6"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,11 +1,11 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7fd0bf" stop-opacity=".9"/><stop offset="100%" stop-color="#7fd0bf" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#2dd4bf" stop-opacity=".5"/><stop offset="100%" stop-color="#2dd4bf" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd3e0" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd3e0" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#22d3ee" stop-opacity=".5"/><stop offset="100%" stop-color="#22d3ee" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#8fc9a5" stop-opacity=".85"/><stop offset="100%" stop-color="#8fc9a5" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#88c2d6" stop-opacity=".8"/><stop offset="100%" stop-color="#88c2d6" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#e9f5f0"/>
|
||||
<g filter="url(#s)">
|
||||
@@ -14,5 +14,5 @@
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fd8ae" stop-opacity=".9"/><stop offset="100%" stop-color="#8fd8ae" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#7fc9c9" stop-opacity=".85"/><stop offset="100%" stop-color="#7fc9c9" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#96d69c" stop-opacity=".8"/><stop offset="100%" stop-color="#96d69c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#d9e2c4" stop-opacity=".9"/><stop offset="100%" stop-color="#d9e2c4" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#cdd6bf" stop-opacity=".85"/><stop offset="100%" stop-color="#cdd6bf" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c3cfb3" stop-opacity=".8"/><stop offset="100%" stop-color="#c3cfb3" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#eaf5ec"/>
|
||||
<rect width="1600" height="1000" fill="#eef0e8"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,7 +5,7 @@
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#b3a8ec" stop-opacity=".85"/><stop offset="100%" stop-color="#b3a8ec" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#9fd6e0" stop-opacity=".8"/><stop offset="100%" stop-color="#9fd6e0" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#edf1fc"/>
|
||||
<g filter="url(#s)">
|
||||
@@ -14,5 +14,5 @@
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e6c393" stop-opacity=".9"/><stop offset="100%" stop-color="#e6c393" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#dba98c" stop-opacity=".85"/><stop offset="100%" stop-color="#dba98c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#cabf9e" stop-opacity=".8"/><stop offset="100%" stop-color="#cabf9e" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#e0c2ea" stop-opacity=".9"/><stop offset="100%" stop-color="#e0c2ea" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#d6c0dc" stop-opacity=".85"/><stop offset="100%" stop-color="#d6c0dc" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#c9ccd6" stop-opacity=".8"/><stop offset="100%" stop-color="#c9ccd6" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#f4efe6"/>
|
||||
<rect width="1600" height="1000" fill="#f3f1f5"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#8fb6e8" stop-opacity=".9"/><stop offset="100%" stop-color="#8fb6e8" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#94a0e0" stop-opacity=".85"/><stop offset="100%" stop-color="#94a0e0" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#8fc3d2" stop-opacity=".8"/><stop offset="100%" stop-color="#8fc3d2" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .35 0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#e9f1fc"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.04"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#0f4a30" stop-opacity=".95"/><stop offset="100%" stop-color="#0f4a30" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#46b87c" stop-opacity=".45"/><stop offset="100%" stop-color="#46b87c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#0c3a3a" stop-opacity=".9"/><stop offset="100%" stop-color="#0c3a3a" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#164a26" stop-opacity=".85"/><stop offset="100%" stop-color="#164a26" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#34431f" stop-opacity=".95"/><stop offset="100%" stop-color="#34431f" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#86a05a" stop-opacity=".35"/><stop offset="100%" stop-color="#86a05a" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1d2a18" stop-opacity=".9"/><stop offset="100%" stop-color="#1d2a18" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#22301c" stop-opacity=".85"/><stop offset="100%" stop-color="#22301c" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#020805"/>
|
||||
<rect width="1600" height="1000" fill="#0a0c09"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -5,7 +5,7 @@
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3b2f80" stop-opacity=".9"/><stop offset="100%" stop-color="#3b2f80" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#145e6b" stop-opacity=".85"/><stop offset="100%" stop-color="#145e6b" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#070b16"/>
|
||||
<g filter="url(#s)">
|
||||
@@ -14,5 +14,5 @@
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#7a4d18" stop-opacity=".95"/><stop offset="100%" stop-color="#7a4d18" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e8913c" stop-opacity=".45"/><stop offset="100%" stop-color="#e8913c" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#5a2f14" stop-opacity=".9"/><stop offset="100%" stop-color="#5a2f14" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#403820" stop-opacity=".85"/><stop offset="100%" stop-color="#403820" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#5a2a6a" stop-opacity=".95"/><stop offset="100%" stop-color="#5a2a6a" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#e879f9" stop-opacity=".45"/><stop offset="100%" stop-color="#e879f9" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#3a2050" stop-opacity=".9"/><stop offset="100%" stop-color="#3a2050" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#2c3040" stop-opacity=".85"/><stop offset="100%" stop-color="#2c3040" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#141009"/>
|
||||
<rect width="1600" height="1000" fill="#111217"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.35" style="mix-blend-mode:overlay"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.5 KiB |
@@ -1,18 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000" preserveAspectRatio="xMidYMid slice">
|
||||
<defs>
|
||||
<radialGradient id="a" cx="20%" cy="18%" r="55%"><stop offset="0%" stop-color="#123b6b" stop-opacity=".95"/><stop offset="100%" stop-color="#123b6b" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="b" cx="86%" cy="26%" r="52%"><stop offset="0%" stop-color="#38b6ff" stop-opacity=".45"/><stop offset="100%" stop-color="#38b6ff" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="c" cx="68%" cy="92%" r="60%"><stop offset="0%" stop-color="#1f2a72" stop-opacity=".9"/><stop offset="100%" stop-color="#1f2a72" stop-opacity="0"/></radialGradient>
|
||||
<radialGradient id="d" cx="8%" cy="88%" r="55%"><stop offset="0%" stop-color="#0f5566" stop-opacity=".85"/><stop offset="100%" stop-color="#0f5566" stop-opacity="0"/></radialGradient>
|
||||
<filter id="s"><feGaussianBlur stdDeviation="60"/></filter>
|
||||
<filter id="g"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2" stitchTiles="stitch"/><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .5 0"/></filter>
|
||||
</defs>
|
||||
<rect width="1600" height="1000" fill="#040914"/>
|
||||
<g filter="url(#s)">
|
||||
<rect width="1600" height="1000" fill="url(#a)"/>
|
||||
<rect width="1600" height="1000" fill="url(#c)"/>
|
||||
<rect width="1600" height="1000" fill="url(#d)"/>
|
||||
<rect width="1600" height="1000" fill="url(#b)"/>
|
||||
</g>
|
||||
<rect width="1600" height="1000" filter="url(#g)" opacity="0.05"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -18,9 +18,17 @@ import reactCompiler from "babel-plugin-react-compiler";
|
||||
// reasons, both about the gate: a string is invisible to knip, which then reports the dependency as
|
||||
// unused, and — worse — a typo in that string is a silent no-op that ships an uncompiled bundle,
|
||||
// while a bad import is a resolve error on the tsc lane.
|
||||
//
|
||||
// The price of the import: plugin-react finds the compiler only by that string, so it doesn't know the
|
||||
// compiler is there. Its one compiler-specific switch is Babel's `retainLines`, which it turns on for
|
||||
// dev (and so for vitest) unless the compiler is present. With the compiler's re-printed JSX that eats
|
||||
// the space at the end of a text line ("for example <code>" rendered "for example/api/feed"). The
|
||||
// production build never had it on. `generatorOpts` wins over the top-level option, so it is switched
|
||||
// off here, for every mode. Pinned by src/jsxWhitespace.dom.test.tsx.
|
||||
export const reactWithCompiler = () =>
|
||||
react({
|
||||
babel: {
|
||||
plugins: [[reactCompiler, { target: "19" }]],
|
||||
generatorOpts: { retainLines: false },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -32,7 +32,8 @@ import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import GlassTuner from "./components/GlassTuner";
|
||||
import { SkipLink } from "./components/ui/SkipLink";
|
||||
import { pageTitleKey } from "./lib/pageMeta";
|
||||
import ChatDock from "./components/ChatDock";
|
||||
import DebugRecorder from "./components/DebugRecorder";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
@@ -45,8 +46,8 @@ import DemoBanner from "./components/DemoBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls its whole ~900-line changelog into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
// split. FRONTEND_VERSION equals RELEASE_NOTES[0].version by release convention (both bump together).
|
||||
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
|
||||
import { FRONTEND_VERSION } from "./lib/version";
|
||||
import { applyDiagnostics } from "./lib/diagnostics";
|
||||
|
||||
@@ -368,7 +369,8 @@ export default function App() {
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
|
||||
|
||||
return (
|
||||
<div className="h-screen flex text-fg">
|
||||
<div className="h-dvh flex text-fg">
|
||||
<SkipLink label={t("nav.skipToContent")} />
|
||||
<NavSidebar
|
||||
me={me!}
|
||||
onOpenAbout={() => setAboutOpen(true)}
|
||||
@@ -429,6 +431,7 @@ export default function App() {
|
||||
</PageShell>
|
||||
) : (
|
||||
<PageShell
|
||||
title={t(pageTitleKey(activePage))}
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||
@@ -475,8 +478,6 @@ export default function App() {
|
||||
<ErrorDialog />
|
||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
||||
<GlassTuner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export default function About({
|
||||
|
||||
return (
|
||||
<Modal
|
||||
initialFocus="close"
|
||||
title={
|
||||
<span className="flex items-center gap-2">
|
||||
<Info className="w-5 h-5 text-accent" /> {t("about.title")}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { api } from "../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
/**
|
||||
* A channel-description cell: one truncated line in the table, revealing the FULL channel "About"
|
||||
@@ -56,7 +57,7 @@ export default function AboutCell({ text, channelId }: { text: string | null; ch
|
||||
</span>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass w-120 overflow-y-auto px-3.5 py-3 rounded-lg animate-[fadeIn_0.12s_ease]"
|
||||
className={`${SURFACE.popover} w-120 overflow-y-auto px-3.5 py-3 animate-[fadeIn_0.12s_ease]`}
|
||||
// min(design cap, available space): keeps the 28rem cap on a tall viewport, shrinks to fit on a
|
||||
// short one (high zoom). --fit-max-h is set by the fitHeight `size` middleware once positioned.
|
||||
style={{ maxHeight: "min(28rem, var(--fit-max-h, 28rem))" }}
|
||||
|
||||
@@ -13,7 +13,9 @@ import { copyText } from "../lib/clipboard";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import type { AdminUserAccess, AdminUserRow } from "../lib/api/admin";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
|
||||
// The admin's answer to "why can this person not get in?", and the place every recovery action for
|
||||
// one account lives (E12 · S5, design option C).
|
||||
@@ -103,8 +105,14 @@ export default function AccountPanel({
|
||||
const a: AdminUserAccess | undefined = q.data;
|
||||
|
||||
return (
|
||||
<Modal title={displayName(user)} onClose={onClose} placement="drawer" maxWidth="max-w-md">
|
||||
<div className="text-2xs text-muted truncate -mt-2 mb-4">{user.email}</div>
|
||||
<Modal
|
||||
initialFocus="close"
|
||||
title={displayName(user)}
|
||||
onClose={onClose}
|
||||
placement="drawer"
|
||||
maxWidth="max-w-md"
|
||||
>
|
||||
<div className="text-2xs text-muted truncate mb-4">{user.email}</div>
|
||||
|
||||
{q.isError && !a ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
@@ -114,10 +122,10 @@ export default function AccountPanel({
|
||||
<LoadingState />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("users.account.access")}
|
||||
</div>
|
||||
<div className="glass-card rounded-xl p-3 space-y-2 mb-5">
|
||||
</SectionLabel>
|
||||
<div className={`${SURFACE.card} p-3 space-y-2 mb-5`}>
|
||||
<AccessRow
|
||||
icon={Mail}
|
||||
label={t("users.account.email")}
|
||||
@@ -191,29 +199,33 @@ export default function AccountPanel({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-2xs uppercase tracking-wider text-muted mb-2">
|
||||
<SectionLabel level={3} className="mb-2">
|
||||
{t("users.account.recovery")}
|
||||
</div>
|
||||
</SectionLabel>
|
||||
<div className="space-y-2">
|
||||
{a.can_activate && (
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<Check />}
|
||||
loading={activate.isPending}
|
||||
onClick={() => activate.mutate()}
|
||||
disabled={activate.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
<Check className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.activate")}</span>
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<Send />}
|
||||
loading={resend.isPending}
|
||||
onClick={() => resend.mutate()}
|
||||
// The demo account's address is never confirmed, and the server refuses to mail it — as
|
||||
// it refuses it a set-password link below (deep-review E11 round 3).
|
||||
disabled={user.is_demo || !a.email_configured || a.email_verified || resend.isPending}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs disabled:opacity-45 transition"
|
||||
disabled={user.is_demo || !a.email_configured || a.email_verified}
|
||||
>
|
||||
<Send className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">{t("users.account.resend")}</span>
|
||||
{/* Says WHY it is unavailable instead of just greying out — the no-mail case is the
|
||||
whole reason the link below exists, so naming it here is the explanation. */}
|
||||
@@ -223,18 +235,21 @@ export default function AccountPanel({
|
||||
{a.email_configured && a.email_verified && (
|
||||
<span className="text-muted">{t("users.account.alreadyConfirmed")}</span>
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
fullWidth
|
||||
icon={<KeyRound />}
|
||||
loading={issue.isPending}
|
||||
disabled={user.is_demo}
|
||||
onClick={() => issue.mutate()}
|
||||
disabled={issue.isPending || user.is_demo}
|
||||
className="w-full glass-card glass-hover flex items-center gap-2 px-3 py-2.5 rounded-lg text-xs border border-accent/40 text-accent disabled:opacity-50 transition"
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
<KeyRound className="w-4 h-4 shrink-0" />
|
||||
<span className="flex-1 text-left">
|
||||
{link ? t("users.account.setPasswordAgain") : t("users.account.setPassword")}
|
||||
</span>
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{link && (
|
||||
@@ -243,13 +258,14 @@ export default function AccountPanel({
|
||||
<div className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-3 py-2 text-2xs font-mono truncate">
|
||||
{link.url}
|
||||
</div>
|
||||
<button
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={copied ? <Check /> : <Copy />}
|
||||
onClick={copy}
|
||||
className="shrink-0 glass-card glass-hover inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs border border-accent/40 text-accent transition"
|
||||
className="border border-accent/40 text-accent"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
{copied ? t("users.account.copied") : t("users.account.copy")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-2xs text-muted leading-relaxed">{t("users.account.linkNote")}</p>
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,10 @@ import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Chip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||
@@ -22,23 +26,19 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
// the "fit some + N more" treatment used on wider lanes.
|
||||
const NARROW_PX = 420;
|
||||
|
||||
const chipCls =
|
||||
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
|
||||
|
||||
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
<Chip
|
||||
variant="token"
|
||||
size="md"
|
||||
data-testid={testId}
|
||||
data-filter-key={f.key}
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className={chipCls}
|
||||
removeLabel={t("feed.chips.remove", { name: f.label })}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,22 +69,20 @@ function OverflowPopover({
|
||||
const name = ariaLabel ?? label;
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Chip
|
||||
variant="token"
|
||||
size="md"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={name}
|
||||
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
|
||||
open ? "border-accent text-accent" : "hover:border-accent"
|
||||
}`}
|
||||
icon={icon ? <Filter className="size-3.5" aria-hidden="true" /> : undefined}
|
||||
>
|
||||
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
|
||||
{label}
|
||||
</button>
|
||||
</Chip>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={name}
|
||||
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-48 max-w-72 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
|
||||
{chips.map((f) => (
|
||||
@@ -93,7 +91,7 @@ function OverflowPopover({
|
||||
role="menuitem"
|
||||
onSelect={f.remove}
|
||||
label={f.label}
|
||||
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
|
||||
className="group flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm text-fg transition"
|
||||
>
|
||||
<span className="flex-1 truncate">{f.label}</span>
|
||||
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
@@ -101,17 +99,17 @@ function OverflowPopover({
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-1 border-t border-border px-1 pt-1.5">
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link-muted"
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={() => {
|
||||
onClearAll();
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
className="text-xs underline underline-offset-2"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
@@ -191,13 +189,13 @@ export default function ActiveFilterChips({
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className={chipCls}
|
||||
className={chipClass("token", "md") + " pr-1.5"}
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
))}
|
||||
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
|
||||
<span ref={plusRef} className={chipClass("token", "md")}>
|
||||
+{n}
|
||||
</span>
|
||||
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
|
||||
@@ -230,13 +228,14 @@ export default function ActiveFilterChips({
|
||||
/>
|
||||
)}
|
||||
{layout.mode === "all" && n > 1 && (
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
data-testid="active-filter-clear-all"
|
||||
onClick={onClearAll}
|
||||
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
|
||||
className="ml-0.5 text-sm underline underline-offset-2"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { useState } from "react";
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { ListPlus, Plus, Youtube } from "lucide-react";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { api, type Playlist } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { playlistName } from "../lib/playlistName";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// A small anchored menu for toggling a video's membership in the user's playlists, with inline
|
||||
// "new playlist" creation. Same shape as the channel tag editor (R8 S1's pilot): radio-style
|
||||
@@ -16,26 +20,82 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
// the input swallowing non-Escape keys, since character and arrow keys belong to the text field.
|
||||
export default function AddToPlaylist({
|
||||
videoId,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
}: {
|
||||
videoId: string;
|
||||
className?: string;
|
||||
variant?: "ghost" | "outline" | "overlay" | "media";
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fade = useScrollFade();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pop = useMembershipPopover(open, setOpen);
|
||||
return (
|
||||
<>
|
||||
<IconButton
|
||||
ref={pop.refs.setReference}
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...pop.getReferenceProps({
|
||||
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
||||
// reach it.
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
})}
|
||||
label={t("playlists.addToPlaylist")}
|
||||
>
|
||||
<ListPlus />
|
||||
</IconButton>
|
||||
<MembershipPanel videoId={videoId} pop={pop} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
/** The same panel, opened from an object's menu ("Add to playlist…", E28 C1) rather than its own
|
||||
* button: anchored to the element the menu came from, and gone when it closes. */
|
||||
export function AddToPlaylistAt({
|
||||
videoId,
|
||||
anchor,
|
||||
onClose,
|
||||
}: {
|
||||
videoId: string;
|
||||
anchor: Element;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const pop = useMembershipPopover(true, (next) => {
|
||||
if (!next) onClose();
|
||||
});
|
||||
const { setPositionReference } = pop.refs;
|
||||
useLayoutEffect(() => setPositionReference(anchor), [anchor, setPositionReference]);
|
||||
return <MembershipPanel videoId={videoId} pop={pop} />;
|
||||
}
|
||||
|
||||
function useMembershipPopover(open: boolean, onOpenChange: (open: boolean) => void) {
|
||||
return useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
onOpenChange,
|
||||
offset: 6,
|
||||
// The menu holds a "new playlist" text field — keep typed chars and pointer-hover out of the
|
||||
// roving list so they don't steal the caret.
|
||||
hostsInput: true,
|
||||
});
|
||||
}
|
||||
|
||||
function MembershipPanel({
|
||||
videoId,
|
||||
pop,
|
||||
}: {
|
||||
videoId: string;
|
||||
pop: ReturnType<typeof useAnchoredPopover>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const fade = useScrollFade();
|
||||
const open = pop.open;
|
||||
const [newName, setNewName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const membership = useQuery({
|
||||
queryKey: qk.playlistMembership(videoId),
|
||||
@@ -90,28 +150,13 @@ export default function AddToPlaylist({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps({
|
||||
// The trigger sits on a feed card that opens the player on click, so the toggle must not
|
||||
// reach it.
|
||||
onClick: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
},
|
||||
})}
|
||||
title={t("playlists.addToPlaylist")}
|
||||
aria-label={t("playlists.addToPlaylist")}
|
||||
className={className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"}
|
||||
>
|
||||
<ListPlus className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface popover={pop} className="glass-menu w-60 rounded-xl p-2">
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.popover} w-60 p-2`}>
|
||||
{/* A React portal still propagates events up the REACT tree, so a click inside this panel
|
||||
would otherwise reach the feed card that hosts the trigger and start playback. */}
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">{t("playlists.addToPlaylist")}</div>
|
||||
<div className="text-xs text-muted px-1.5 pb-1.5">
|
||||
{t("playlists.addToPlaylistTitle")}
|
||||
</div>
|
||||
<div
|
||||
ref={fade.ref}
|
||||
style={fade.style}
|
||||
@@ -135,13 +180,7 @@ export default function AddToPlaylist({
|
||||
label={playlistName(pl, t)}
|
||||
className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition"
|
||||
>
|
||||
<span
|
||||
className={`grid place-items-center w-4 h-4 rounded border ${
|
||||
pl.has_video ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{pl.has_video && <Check className="w-3 h-3" />}
|
||||
</span>
|
||||
<MenuCheck kind="many" on={!!pl.has_video} />
|
||||
<span className="truncate flex-1 text-left">{playlistName(pl, t)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube className="w-3 h-3 shrink-0 text-muted" />
|
||||
@@ -151,7 +190,9 @@ export default function AddToPlaylist({
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 border-t border-border mt-1.5 pt-1.5">
|
||||
<Plus className="w-3.5 h-3.5 text-muted shrink-0" />
|
||||
<input
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -165,7 +206,6 @@ export default function AddToPlaylist({
|
||||
}
|
||||
}}
|
||||
placeholder={t("playlists.newPlaylist")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
@@ -6,11 +7,11 @@ import {
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import { Copy, Radio, Search, X } from "lucide-react";
|
||||
import { Copy, Radio, Search } from "lucide-react";
|
||||
|
||||
import { api, type LogFacet, type LogQuery, type ServerLogRow } from "../lib/api";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { formatDateTime } from "../lib/format";
|
||||
import { formatDateTimeSec } from "../lib/format";
|
||||
import { nextNavId, notifyNav, rememberNavPosition } from "../lib/history";
|
||||
import { LEVEL_GROUPS, type LevelGroup } from "../lib/log";
|
||||
import {
|
||||
@@ -31,8 +32,14 @@ import NavGrid from "./ui/NavGrid";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { FilterFoldButton } from "./ui/FilterFold";
|
||||
import { useFilterFold } from "../lib/useFilterFold";
|
||||
import { useStickyTop } from "./ui/stickyTop";
|
||||
import { Section } from "./ui/form";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { DismissibleChip } from "./ui/Chip";
|
||||
import { InnerInput } from "./ui/Input";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
|
||||
// Admin → Szervernapló: the server's stored log (`log_events`, E22.6). One list, newest first; a line
|
||||
// opens in place to its full record, and its request id / job id narrow the list to that request or
|
||||
@@ -73,6 +80,14 @@ export default function AdminLogs() {
|
||||
logsParamsToState(new URLSearchParams(window.location.search)),
|
||||
);
|
||||
const [live, setLiveState] = useState(false);
|
||||
// The toolbar sticks at the scroller's top (./ui/stickyTop), and folds its facet row when short.
|
||||
useStickyTop();
|
||||
const fold = useFilterFold();
|
||||
const facetFilters =
|
||||
state.sources.length +
|
||||
(state.user ? 1 : 0) +
|
||||
(state.event ? 1 : 0) +
|
||||
(state.window !== DEFAULT_LOGS_STATE.window ? 1 : 0);
|
||||
// Read by `getNextPageParam` when a page is fetched, not when it renders: while live, a refetch
|
||||
// stops after the newest page, however many pages the cache holds — a "Load older" that lands
|
||||
// after Live was switched on, or a filter set revisited with older pages still cached.
|
||||
@@ -241,104 +256,119 @@ export default function AdminLogs() {
|
||||
|
||||
<div
|
||||
data-testid="admin-logs"
|
||||
className="overflow-hidden rounded-xl border border-border bg-bg"
|
||||
// overflow-clip, not -hidden: a hidden box is a scroll container, and the toolbar would stick
|
||||
// to it instead of to the page.
|
||||
className="overflow-clip rounded-xl border border-border bg-bg"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<label className="flex min-w-[12rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||
<input
|
||||
type="search"
|
||||
value={state.q}
|
||||
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
|
||||
placeholder={t("adminLogs.search")}
|
||||
aria-label={t("adminLogs.search")}
|
||||
className="min-w-0 flex-1 bg-transparent outline-none"
|
||||
{/* The console's toolbar is the page's band (B4, B9): it sticks at the top with the fade
|
||||
under it while the lines scroll, so a filter is in reach at line 500. */}
|
||||
<div className="sticky top-0 z-base bg-bg fade-below">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<label
|
||||
className={clsx(
|
||||
fieldClass({ size: "sm", pill: true }),
|
||||
"flex min-w-[12rem] flex-1 items-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||
<InnerInput
|
||||
type="search"
|
||||
value={state.q}
|
||||
onChange={(e) => update({ q: e.target.value.slice(0, 200) })}
|
||||
placeholder={t("adminLogs.search")}
|
||||
aria-label={t("adminLogs.search")}
|
||||
/>
|
||||
</label>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("adminLogs.levelLabel")}
|
||||
value={state.group}
|
||||
onChange={(group) => update({ group })}
|
||||
options={GROUPS.map((g) => ({
|
||||
value: g,
|
||||
label: t(`settings.myLog.filters.${g}`),
|
||||
count: groupCount(g),
|
||||
}))}
|
||||
/>
|
||||
</label>
|
||||
<FilterChipGroup
|
||||
ariaLabel={t("adminLogs.levelLabel")}
|
||||
value={state.group}
|
||||
onChange={(group) => update({ group })}
|
||||
options={GROUPS.map((g) => ({
|
||||
value: g,
|
||||
label: t(`settings.myLog.filters.${g}`),
|
||||
count: groupCount(g),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterSelect
|
||||
label={t("adminLogs.source")}
|
||||
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
||||
selected={[state.sources[0] ?? ""]}
|
||||
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
||||
testId="logs-source"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.account")}
|
||||
options={facetOptions(facets.data?.users, state.user, accountLabel)}
|
||||
selected={[state.user ?? ""]}
|
||||
onChange={([user]) => update({ user: user || null })}
|
||||
testId="logs-user"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.event")}
|
||||
options={facetOptions(facets.data?.events, state.event, (v) =>
|
||||
v === "none" ? t("adminLogs.noEvent") : v,
|
||||
)}
|
||||
selected={[state.event ?? ""]}
|
||||
onChange={([event]) => update({ event: event || null })}
|
||||
testId="logs-event"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.window")}
|
||||
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
|
||||
selected={[state.window]}
|
||||
onChange={([window]) => update({ window: window as LogWindow })}
|
||||
testId="logs-window"
|
||||
/>
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<Button
|
||||
variant={polling ? "outline-accent" : "outline"}
|
||||
size="xs"
|
||||
icon={<Radio className="h-3.5 w-3.5" />}
|
||||
aria-pressed={polling}
|
||||
onClick={toggleLive}
|
||||
>
|
||||
{t("adminLogs.live")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
// While a filter change loads, the rows on screen are the previous filter's.
|
||||
disabled={rows.length === 0 || logs.isPlaceholderData}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{t("adminLogs.copy")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(state.requestId || state.jobId !== null) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
||||
{state.requestId && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ requestId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.jobId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ jobId: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={facetFilters} />
|
||||
{!fold.folded && (
|
||||
<>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.source")}
|
||||
options={facetOptions(facets.data?.sources, state.sources[0] ?? null, (v) => v)}
|
||||
selected={[state.sources[0] ?? ""]}
|
||||
onChange={([source]) => update({ sources: source ? [source] : [] })}
|
||||
testId="logs-source"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.account")}
|
||||
options={facetOptions(facets.data?.users, state.user, accountLabel)}
|
||||
selected={[state.user ?? ""]}
|
||||
onChange={([user]) => update({ user: user || null })}
|
||||
testId="logs-user"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.event")}
|
||||
options={facetOptions(facets.data?.events, state.event, (v) =>
|
||||
v === "none" ? t("adminLogs.noEvent") : v,
|
||||
)}
|
||||
selected={[state.event ?? ""]}
|
||||
onChange={([event]) => update({ event: event || null })}
|
||||
testId="logs-event"
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("adminLogs.window")}
|
||||
options={WINDOWS.map((w) => ({ value: w, label: t(`adminLogs.windows.${w}`) }))}
|
||||
selected={[state.window]}
|
||||
onChange={([window]) => update({ window: window as LogWindow })}
|
||||
testId="logs-window"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<span className="ml-auto flex gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Radio className="h-3.5 w-3.5" />}
|
||||
pressed={polling}
|
||||
onClick={toggleLive}
|
||||
>
|
||||
{t("adminLogs.live")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
icon={<Copy className="h-3.5 w-3.5" />}
|
||||
// While a filter change loads, the rows on screen are the previous filter's.
|
||||
disabled={rows.length === 0 || logs.isPlaceholderData}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{t("adminLogs.copy")}
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{(state.requestId || state.jobId !== null) && (
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border px-2 py-1.5 text-xs">
|
||||
{state.requestId && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotRequest", { id: state.requestId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ requestId: null })}
|
||||
/>
|
||||
)}
|
||||
{state.jobId !== null && (
|
||||
<PivotChip
|
||||
label={t("adminLogs.pivotJob", { id: state.jobId })}
|
||||
clearLabel={t("adminLogs.clearPivot")}
|
||||
onClear={() => update({ jobId: null })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{failure && (
|
||||
<StateMessage
|
||||
@@ -361,7 +391,7 @@ export default function AdminLogs() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : logs.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center font-sans text-sm text-muted">
|
||||
{t("adminLogs.empty")}
|
||||
@@ -376,7 +406,7 @@ export default function AdminLogs() {
|
||||
level={row.level}
|
||||
event={row.event ?? "—"}
|
||||
msg={row.msg}
|
||||
title={formatDateTime(row.ts)}
|
||||
title={formatDateTimeSec(row.ts)}
|
||||
open={open === row.id}
|
||||
onToggle={() => setOpen(open === row.id ? null : row.id)}
|
||||
>
|
||||
@@ -421,18 +451,9 @@ function PivotChip({
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<span className="chip py-0.5 pl-2.5 pr-1">
|
||||
<span className="font-mono">{label}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
aria-label={clearLabel}
|
||||
title={clearLabel}
|
||||
className="rounded-full p-0.5 hover:bg-accent/20"
|
||||
>
|
||||
<X className="h-3 w-3" aria-hidden />
|
||||
</button>
|
||||
</span>
|
||||
<DismissibleChip onDismiss={onClear} dismissLabel={clearLabel} className="font-mono">
|
||||
{label}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -449,12 +470,12 @@ function RowDetail({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pivotCls =
|
||||
"font-mono text-accent underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
"font-mono underline decoration-dotted underline-offset-2 hover:decoration-solid";
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<dl className="grid grid-cols-[max-content_minmax(0,1fr)] gap-x-3 gap-y-0.5 font-sans text-xs">
|
||||
<dt>{t("adminLogs.fields.when")}</dt>
|
||||
<dd className="tabular-nums">{formatDateTime(row.ts)}</dd>
|
||||
<dd className="tabular-nums">{formatDateTimeSec(row.ts)}</dd>
|
||||
<dt>{t("adminLogs.fields.source")}</dt>
|
||||
<dd className="font-mono">
|
||||
{row.source} · {row.logger}
|
||||
@@ -465,14 +486,14 @@ function RowDetail({
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.request")}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showRequest")}
|
||||
onClick={() => onRequest(row.request_id!)}
|
||||
>
|
||||
{row.request_id}
|
||||
</button>
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
@@ -480,14 +501,14 @@ function RowDetail({
|
||||
<>
|
||||
<dt>{t("adminLogs.fields.job")}</dt>
|
||||
<dd>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link"
|
||||
className={pivotCls}
|
||||
title={t("adminLogs.showJob")}
|
||||
onClick={() => onJob(row.job_id!)}
|
||||
>
|
||||
{row.job_id}
|
||||
</button>
|
||||
</Button>
|
||||
</dd>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -98,6 +98,24 @@ describe("the last sign-in column (E22 S1)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// E28 (owner): the hover ended wherever the row's first badge or button began, because the layer was
|
||||
// the name button's own. The card is the row: it paints the layer under its content, and the name
|
||||
// button's hit layer spans the card, under the actions that sit above it.
|
||||
describe("a user row", () => {
|
||||
it("answers as a whole card, with its actions above the card's hit layer", async () => {
|
||||
mount();
|
||||
const open = await screen.findByTitle("users.account.open");
|
||||
const card = open.parentElement!;
|
||||
expect(card.className).toMatch(/(^|\s)isolate(\s|$)/);
|
||||
expect(card.className).toMatch(/(^|\s)hover:before:bg-\(--hover\)(\s|$)/);
|
||||
expect(open.className).toMatch(/(^|\s)after:inset-0(\s|$)/);
|
||||
expect(open.className).not.toMatch(/selectable|hover:/);
|
||||
const actions = [...card.querySelectorAll("button")].filter((b) => b !== open);
|
||||
expect(actions.length).toBeGreaterThan(0);
|
||||
for (const b of actions) expect(b.className).toMatch(/(^|\s)relative(\s|$)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a user action that fails", () => {
|
||||
it("says a user who is gone, and refetches the list", async () => {
|
||||
const users = mount();
|
||||
|
||||
@@ -28,7 +28,9 @@ import { notify } from "../lib/notifications";
|
||||
import { formatDateTime, relativeTime } from "../lib/format";
|
||||
import { invalidateUserAccess } from "../lib/adminUsers";
|
||||
import { ADMIN_USERS_TAB_KEY } from "../lib/adminUsersTab";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { Section } from "./ui/form";
|
||||
@@ -38,11 +40,14 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import AccountPanel from "./AccountPanel";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { useAccountPersistedState } from "../lib/storage";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { notifyActionError, notifyKnownFailure } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Admin-only user & access management: roles, access requests (the Invite whitelist), and the
|
||||
// demo whitelist + reset. Each section is its own tab (persisted across reloads); the Access tab
|
||||
@@ -50,10 +55,18 @@ import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
// (The tab key + a "focus the Access tab" helper live in lib/adminUsersTab so callers can
|
||||
// pre-select the tab without importing this lazy-loaded page — see that file.)
|
||||
|
||||
// A user row's B3 layer (DESIGN.md "State layers"), painted by the card under its content, and the name
|
||||
// button's stretched hit layer over the whole card. Why the row is built so: at the row below.
|
||||
const ROW_LAYER =
|
||||
"relative isolate before:absolute before:inset-0 before:z-under before:rounded-[inherit] before:transition-colors before:content-[''] hover:before:bg-(--hover) has-[[data-row-open]:active]:before:bg-(--press)";
|
||||
const ROW_OPEN =
|
||||
"focus-visible:outline-none after:absolute after:inset-0 after:rounded-xl after:content-[''] focus-visible:after:outline-2 focus-visible:after:-outline-offset-2 focus-visible:after:outline-accent";
|
||||
|
||||
export default function AdminUsers() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useAccountPersistedState(ADMIN_USERS_TAB_KEY, "roles");
|
||||
const panel = useTabPanel();
|
||||
const tabs = [
|
||||
{ id: "roles", label: t("users.tabs.roles") },
|
||||
// Both kinds of "waiting on the admin": a request needing a decision, and a sign-up whose
|
||||
@@ -72,10 +85,10 @@ export default function AdminUsers() {
|
||||
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-3xl w-full mx-auto">
|
||||
<Tabs tabs={tabs} active={active} onChange={setTab} />
|
||||
<Tabs idBase={panel.idBase} tabs={tabs} active={active} onChange={setTab} />
|
||||
</div>
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
<div {...panel.panelProps(active)} className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
{active === "roles" && <UsersRoles me={me} />}
|
||||
{active === "access" && (
|
||||
<>
|
||||
@@ -89,24 +102,6 @@ export default function AdminUsers() {
|
||||
);
|
||||
}
|
||||
|
||||
function Badge({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone: "accent" | "muted" | "warning";
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const cls =
|
||||
tone === "accent"
|
||||
? "border-accent/40 text-accent"
|
||||
: tone === "warning"
|
||||
? "border-warn/40 text-warn"
|
||||
: "border-border text-muted";
|
||||
return (
|
||||
<span className={`shrink-0 text-2xs px-2 py-0.5 rounded-full border ${cls}`}>{children}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function UsersRoles({ me }: { me: Me }) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -221,7 +216,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("users.roles.empty")}</p>
|
||||
) : (
|
||||
@@ -229,28 +224,39 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
{rows.map((u) => {
|
||||
const self = u.id === me.id;
|
||||
return (
|
||||
<div key={u.id} className="glass-card flex items-center gap-2.5 p-2.5 rounded-xl">
|
||||
<button
|
||||
// The whole card is the row, so the whole card answers (owner, E28): the name's button
|
||||
// stretches an invisible layer over the card (`after:inset-0`), so a click on a badge or
|
||||
// a gap opens the account too, and the B3 hover layer is the card's own `::before`, under
|
||||
// the badges and buttons (`isolate` + `z-under`). The actions are `relative`, above the
|
||||
// stretched layer. The hover used to be the name button's own and ended at the first badge.
|
||||
<div
|
||||
key={u.id}
|
||||
className={`${SURFACE.card} ${ROW_LAYER} flex items-center gap-2.5 p-2.5`}
|
||||
>
|
||||
<Pressable
|
||||
selectable
|
||||
data-row-open=""
|
||||
onClick={() => setPanelFor(u)}
|
||||
title={t("users.account.open")}
|
||||
className="selectable cursor-pointer min-w-0 flex-1 text-left rounded-lg -m-1 p-1"
|
||||
className={`min-w-0 flex-1 cursor-pointer ${ROW_OPEN}`}
|
||||
>
|
||||
<div className="text-sm truncate">
|
||||
{displayName(u)}
|
||||
{self && <span className="text-muted"> · {t("users.roles.you")}</span>}
|
||||
</div>
|
||||
<div className="text-2xs text-muted truncate">{u.email}</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
{u.role === "admin" && <Badge tone="accent">{t("users.roles.admin")}</Badge>}
|
||||
{u.is_demo && <Badge tone="muted">{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warning">{t("users.roles.suspended")}</Badge>}
|
||||
{!u.is_active && <Badge tone="warning">{t("users.roles.pending")}</Badge>}
|
||||
{u.is_demo && <Badge>{t("users.roles.demo")}</Badge>}
|
||||
{u.is_suspended && <Badge tone="warn">{t("users.roles.suspended")}</Badge>}
|
||||
{!u.is_active && <Badge tone="warn">{t("users.roles.pending")}</Badge>}
|
||||
{/* A Google sign-in proves the email, so "unverified" only applies to a
|
||||
password-only account that hasn't clicked its verification link. */}
|
||||
{u.is_active && !u.email_verified && !u.has_google && (
|
||||
<Badge tone="warning">{t("users.roles.unverified")}</Badge>
|
||||
<Badge tone="warn">{t("users.roles.unverified")}</Badge>
|
||||
)}
|
||||
<Tooltip
|
||||
always={u.is_demo || self}
|
||||
hint={
|
||||
u.is_demo
|
||||
? t("users.roles.demoLocked")
|
||||
@@ -264,15 +270,19 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
className="shrink-0"
|
||||
className="relative shrink-0"
|
||||
onClick={() => onToggle(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
// Locked (demo / self) is unavailable WITH a reason, the tooltip's: aria-disabled keeps it
|
||||
// focusable so a keyboard user reaches the reason. A pending write is transient.
|
||||
aria-disabled={u.is_demo || self}
|
||||
disabled={pendingIds.has(u.id)}
|
||||
icon={<Shield className="w-3.5 h-3.5" />}
|
||||
>
|
||||
{u.role === "admin" ? t("users.roles.makeUser") : t("users.roles.makeAdmin")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
always={u.is_demo || self}
|
||||
hint={
|
||||
u.is_demo
|
||||
? t("users.suspend.demoLocked")
|
||||
@@ -283,36 +293,41 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.suspend.action")
|
||||
}
|
||||
>
|
||||
<Button
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onSuspend(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={
|
||||
<IconButton
|
||||
noTitle
|
||||
label={
|
||||
u.is_suspended ? t("users.suspend.undoAction") : t("users.suspend.action")
|
||||
}
|
||||
icon={
|
||||
u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
size="sm"
|
||||
variant={u.is_suspended ? "outline-warn" : "outline"}
|
||||
className="relative"
|
||||
onClick={() => onSuspend(u)}
|
||||
aria-disabled={u.is_demo || self}
|
||||
disabled={pendingIds.has(u.id)}
|
||||
>
|
||||
{u.is_suspended ? (
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
) : (
|
||||
<Ban className="w-4 h-4" />
|
||||
)}
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
always={u.is_demo || self}
|
||||
hint={u.is_demo || self ? t("users.delete.locked") : t("users.delete.action")}
|
||||
>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("users.delete.action")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
className="relative"
|
||||
onClick={() => onDelete(u)}
|
||||
disabled={u.is_demo || self || pendingIds.has(u.id)}
|
||||
aria-label={t("users.delete.action")}
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
aria-disabled={u.is_demo || self}
|
||||
disabled={pendingIds.has(u.id)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* How long ago this account last signed in. LAST in the row on purpose: the badges and
|
||||
the role button before it vary in width row to row, so only the row's own right
|
||||
@@ -327,7 +342,7 @@ function UsersRoles({ me }: { me: Me }) {
|
||||
: t("users.roles.lastLoginNone")
|
||||
}
|
||||
>
|
||||
<span className="hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
|
||||
<span className="relative hidden sm:inline-block w-20 shrink-0 text-right text-2xs text-muted tabular-nums">
|
||||
{u.last_login_at ? relativeTime(u.last_login_at) : "—"}
|
||||
</span>
|
||||
</Tooltip>
|
||||
@@ -392,7 +407,7 @@ function PendingAccess() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : rows.isLoading && !rows.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{list.map((u) => (
|
||||
@@ -427,7 +442,7 @@ function PendingAccessRowView({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{row.email}</div>
|
||||
<div className="text-2xs text-muted flex items-center gap-2 mt-0.5 flex-wrap">
|
||||
@@ -459,15 +474,16 @@ function PendingAccessRowView({
|
||||
</div>
|
||||
{!row.email_verified && (
|
||||
<Tooltip hint={t("users.pendingAccess.resendHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("users.pendingAccess.resend")}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onResend}
|
||||
disabled={busy}
|
||||
aria-label={t("users.pendingAccess.resend")}
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{row.can_activate && (
|
||||
@@ -660,7 +676,7 @@ function AdminInvites() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : invites.isLoading && !invites.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : pending.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("settings.invites.noPending")}</p>
|
||||
) : (
|
||||
@@ -685,12 +701,12 @@ function AdminInvites() {
|
||||
}}
|
||||
className="flex items-center gap-2 mt-3"
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
className="flex-1"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder={t("settings.invites.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -729,18 +745,15 @@ function AdminInvites() {
|
||||
: t("settings.invites.inviteHint")
|
||||
}
|
||||
>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
noTitle
|
||||
onClick={() => onInvite(i)}
|
||||
disabled={invite.isPending}
|
||||
className={`shrink-0 p-1 rounded-md disabled:opacity-50 transition ${
|
||||
i.invited_at
|
||||
? "text-muted/50 hover:text-fg"
|
||||
: "text-muted hover:text-accent"
|
||||
}`}
|
||||
aria-label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
label={t("settings.invites.inviteLabel", { email: i.email })}
|
||||
>
|
||||
<Mails className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
{/* Already invited: the glyph fades, the action stays (a resend). */}
|
||||
<Mails className={i.invited_at ? "opacity-50" : ""} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* An entry with an account behind it stays read-only: deleting it would revoke that
|
||||
@@ -748,26 +761,24 @@ function AdminInvites() {
|
||||
{i.has_account ? (
|
||||
// `disabled` would drop it from the tab order AND suppress the title, so the
|
||||
// reason reached nobody — same call as the Playlists drag handle.
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1 text-muted/50 cursor-not-allowed"
|
||||
label={t("settings.invites.deleteHasAccount")}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
noTitle
|
||||
variant="danger"
|
||||
onClick={() => onDelete(i)}
|
||||
disabled={remove.isPending}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-danger disabled:opacity-50 transition"
|
||||
aria-label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||
label={t("settings.invites.deleteLabel", { email: i.email })}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -831,7 +842,7 @@ function AdminDemo() {
|
||||
{rows.length > 0 && (
|
||||
<div className="space-y-1.5 mb-3">
|
||||
{rows.map((r) => (
|
||||
<div key={r.id} className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div key={r.id} className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{r.email}</div>
|
||||
{r.added_by && (
|
||||
@@ -841,15 +852,16 @@ function AdminDemo() {
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.demo.removeHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.demo.remove")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={() => onRemove(r)}
|
||||
disabled={remove.isPending}
|
||||
aria-label={t("settings.demo.remove")}
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
@@ -863,12 +875,12 @@ function AdminDemo() {
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
<Input
|
||||
className="flex-1"
|
||||
type="email"
|
||||
value={newEmail}
|
||||
onChange={(e) => setNewEmail(e.target.value)}
|
||||
placeholder={t("settings.demo.addPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<Button variant="glass" size="md" className="shrink-0" type="submit">
|
||||
<UserPlus className="w-4 h-4" /> {t("settings.demo.add")}
|
||||
@@ -877,7 +889,11 @@ function AdminDemo() {
|
||||
|
||||
<div className="mt-4 pt-3 border-t border-border">
|
||||
<Tooltip hint={t("settings.demo.resetHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<RotateCcw />}
|
||||
loading={reset.isPending}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await confirm({
|
||||
@@ -889,12 +905,9 @@ function AdminDemo() {
|
||||
)
|
||||
reset.mutate();
|
||||
}}
|
||||
disabled={reset.isPending}
|
||||
className="glass-card glass-hover flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<RotateCcw className={`w-4 h-4 ${reset.isPending ? "animate-spin" : ""}`} />
|
||||
{t("settings.demo.reset")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Section>
|
||||
@@ -916,7 +929,7 @@ function InviteRow({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="glass-card flex items-center gap-2 p-2.5 rounded-xl">
|
||||
<div className={`${SURFACE.card} flex items-center gap-2 p-2.5`}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm truncate">{inv.email}</div>
|
||||
{inv.requested_at && (
|
||||
@@ -928,26 +941,28 @@ function InviteRow({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip hint={t("settings.invites.approveHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.invites.approve")}
|
||||
size="sm"
|
||||
variant="outline-accent"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onApprove}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.invites.approve")}
|
||||
icon={<Check className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip hint={t("settings.invites.denyHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.invites.deny")}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDeny}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.invites.deny")}
|
||||
icon={<X className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/* Deny records a decision and keeps the row; delete forgets the address. Once an account
|
||||
exists for it the row is that account's approval gate, so it is managed from Users & roles
|
||||
@@ -955,27 +970,26 @@ function InviteRow({
|
||||
silently missing a button their other rows have. */}
|
||||
{inv.has_account ? (
|
||||
// aria-disabled, not `disabled`: the reason has to stay reachable by keyboard.
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="outline"
|
||||
aria-disabled
|
||||
onClick={(e) => e.preventDefault()}
|
||||
title={t("settings.invites.deleteHasAccount")}
|
||||
aria-label={t("settings.invites.deleteHasAccount")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border/50 text-muted/50 cursor-not-allowed"
|
||||
label={t("settings.invites.deleteHasAccount")}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
) : (
|
||||
<Tooltip hint={t("settings.invites.deleteHint")}>
|
||||
<Button
|
||||
<IconButton
|
||||
noTitle
|
||||
label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
size="sm"
|
||||
variant="outline-danger"
|
||||
size="icon-sm"
|
||||
className="shrink-0"
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.invites.deleteLabel", { email: inv.email })}
|
||||
icon={<Trash2 className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -62,7 +62,7 @@ export default function AuditLog() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="table" />
|
||||
) : rows.length === 0 ? (
|
||||
<p className="text-sm text-muted">{t("audit.empty")}</p>
|
||||
) : (
|
||||
@@ -73,6 +73,8 @@ export default function AuditLog() {
|
||||
rowKey={(r) => String(r.id)}
|
||||
persistKey={LS.auditTable}
|
||||
controlsPosition="top"
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-5xl mx-auto"
|
||||
controlsLeading={
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
|
||||
@@ -30,7 +30,7 @@ import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
* Suspense fallback after it and the first page's own loading state keep one clock (see LoadingState). */
|
||||
export function BootScreen() {
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center">
|
||||
<div className="min-h-dvh grid place-items-center">
|
||||
<LoadingState since={0} delayMs={400} />
|
||||
</div>
|
||||
);
|
||||
@@ -121,7 +121,7 @@ export default function BootGate({ children }: { children: ReactNode }) {
|
||||
: null;
|
||||
if (failed)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center">
|
||||
<div className="min-h-dvh grid place-items-center">
|
||||
<StateMessage tone="error" onRetry={() => failed.refetch()}>
|
||||
{t("common.somethingWrong")}
|
||||
</StateMessage>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import type { ChannelDetail } from "../lib/api";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import { Badge } from "./ui/Badge";
|
||||
|
||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||
function displayName(code: string, lang: string, type: "region" | "language"): string {
|
||||
@@ -50,7 +51,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
return (
|
||||
<>
|
||||
{ch.description ? (
|
||||
<p className="text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
<p className="measure text-sm whitespace-pre-wrap wrap-break-word leading-relaxed">
|
||||
{linkify(ch.description)}
|
||||
</p>
|
||||
) : (
|
||||
@@ -101,9 +102,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{/* Future search epic: these topic chips become clickable filters. */}
|
||||
{topics.map((tp) => (
|
||||
<span key={tp} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{tp}
|
||||
</span>
|
||||
<Badge key={tp}>{tp}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
@@ -113,9 +112,7 @@ export default function ChannelAboutContent({ ch }: { ch: ChannelDetail }) {
|
||||
<span className="text-muted w-24 shrink-0 pt-0.5">{t("channel.keywords")}</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{keywords.map((kw, i) => (
|
||||
<span key={i} className="glass-card px-2 py-0.5 rounded-full text-xs">
|
||||
{kw}
|
||||
</span>
|
||||
<Badge key={i}>{kw}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../lib/subscriptionCache";
|
||||
import { UserPlus } from "lucide-react";
|
||||
import { api, type DiscoveredChannel } from "../lib/api";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
@@ -23,7 +24,7 @@ import Pager from "./ui/Pager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useChannelLayout } from "./channelsContext";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
|
||||
// that they don't subscribe to. Subscribing here only creates the subscription + enriches
|
||||
@@ -58,9 +59,7 @@ export default function ChannelDiscovery({
|
||||
onSuccess: (_data, c) => {
|
||||
// The channel moves from "discovery" to "subscribed", and its videos will start
|
||||
// arriving — refresh both lists plus the per-user status.
|
||||
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
invalidateSubscription(qc, c.id);
|
||||
const name = c.title ?? c.id;
|
||||
// Name the channel and ship a typed payload so the inbox can offer "open in the
|
||||
// Channel manager" / "open on YouTube" links — kept after reload, when live
|
||||
@@ -93,8 +92,8 @@ export default function ChannelDiscovery({
|
||||
);
|
||||
|
||||
const columns: Column<DiscoveredChannel>[] = [
|
||||
// Actions first, mirroring the Subscriptions table (where it sits right after Prio) so the two
|
||||
// tabs' action clusters line up in the same place.
|
||||
// Subscribe first: it is this table's primary action, the one thing a discovery row is for (the
|
||||
// Subscriptions table has no such button — its row ends in the channel's "…", E28 C1).
|
||||
{
|
||||
key: "actions",
|
||||
header: t("channels.cols.actions"),
|
||||
@@ -104,20 +103,21 @@ export default function ChannelDiscovery({
|
||||
cardLabel: false,
|
||||
render: (c) => (
|
||||
<Tooltip
|
||||
always={!canWrite}
|
||||
hint={
|
||||
canWrite ? t("channels.discovery.subscribeHint") : t("channels.discovery.needWriteHint")
|
||||
}
|
||||
>
|
||||
<FocusSafeButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton).
|
||||
<Button
|
||||
variant="glass"
|
||||
size="xs"
|
||||
icon={<UserPlus />}
|
||||
onClick={() => onSubscribe(c)}
|
||||
disabled={!canWrite}
|
||||
busy={subscribe.isPending}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 aria-disabled:opacity-50 transition"
|
||||
aria-disabled={!canWrite}
|
||||
loading={subscribe.isPending}
|
||||
>
|
||||
<UserPlus className="w-3.5 h-3.5" />
|
||||
{t("channels.discovery.subscribe")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
@@ -201,34 +201,33 @@ export default function ChannelDiscovery({
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
|
||||
{/* The table controls are the page's band (B4); only the rows scroll (sticky header). The intro
|
||||
scrolls with them: in the band it held two lines of a short window for text read once. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-start justify-between gap-4">
|
||||
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||
{layout === "cards" && (
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<Pager {...pagerProps} />
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Cards has no column headers, so a dropdown drives the sort + a pager (table uses its own). */}
|
||||
{layout === "cards" && (
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-end gap-3">
|
||||
<Pager {...pagerProps} />
|
||||
<ColumnSortControl
|
||||
columns={columns}
|
||||
sort={sort}
|
||||
onChange={setSort}
|
||||
label={t("channels.sort.label")}
|
||||
defaultLabel={t("channels.sort.default")}
|
||||
ascLabel={t("channels.sort.asc")}
|
||||
descLabel={t("channels.sort.desc")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-4 max-w-384 mx-auto">
|
||||
<p className="text-xs text-muted leading-relaxed pt-2">{t("channels.discovery.intro")}</p>
|
||||
{query.isError && !query.data ? (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : query.isLoading ? (
|
||||
<LoadingState label={t("channels.discovery.loading")} />
|
||||
<LoadingState shape="list" label={t("channels.discovery.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={rows}
|
||||
|
||||
@@ -85,3 +85,20 @@ describe("ChannelLayoutGrid · keyboard", () => {
|
||||
expect(opened).toEqual(["UCb"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChannelLayoutGrid · the card\'s "…"', () => {
|
||||
it("docks it in the heading row beside the name, not in the meta line", () => {
|
||||
const columns: Column<Ch>[] = [
|
||||
{ key: "channel", header: "Channel", cardPrimary: true, render: (c) => <b>{c.title}</b> },
|
||||
{ key: "stored", header: "Stored", render: () => <i>12</i> },
|
||||
{ key: "actions", header: "", render: (c) => <u data-testid={`more-${c.id}`}>…</u> },
|
||||
];
|
||||
const { getByTestId, getByText } = render(
|
||||
<ChannelLayoutGrid rows={rows} columns={columns} rowKey={(c) => c.id} moreKey="actions" />,
|
||||
);
|
||||
const heading = getByText("Alpha").closest("div")!.parentElement!;
|
||||
expect(heading.contains(getByTestId("more-UCa"))).toBe(true);
|
||||
// Exactly once: a slotted column is not also painted in the meta line.
|
||||
expect(document.querySelectorAll('[data-testid="more-UCa"]')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { type Column } from "./ui/DataTable";
|
||||
import VirtualGrid from "./ui/VirtualGrid";
|
||||
import { CHANNEL_LAYOUT_SPEC } from "../lib/channelLayout";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel manager's `cards` layout (E4 S3), built by reusing the SAME `Column<T>` render closures
|
||||
// the DataTable draws — so the subscribed and discovery tables (which have different columns) both get
|
||||
@@ -27,6 +28,9 @@ interface ChannelLayoutGridProps<T> {
|
||||
aboutKey?: string;
|
||||
/** Column key rendered as the action cluster, pinned to the card's bottom edge. */
|
||||
actionsKey?: string;
|
||||
/** Column key docked at the top-right corner after the lead: the card's "…" (E28 C1), which
|
||||
* belongs beside the name it acts on, not under the card's last line. */
|
||||
moreKey?: string;
|
||||
rowClassName?: (row: T) => string;
|
||||
/** Extra DOM props per card — same escape hatch, same single consumer, as `DataTable.rowProps`, so
|
||||
* a channel answers the right-click in the card layout exactly as it does in the table. */
|
||||
@@ -41,6 +45,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
leadKey,
|
||||
aboutKey,
|
||||
actionsKey,
|
||||
moreKey,
|
||||
rowClassName,
|
||||
rowProps,
|
||||
emptyText,
|
||||
@@ -48,11 +53,12 @@ export default function ChannelLayoutGrid<T>({
|
||||
const { t } = useTranslation();
|
||||
const spec = CHANNEL_LAYOUT_SPEC.cards;
|
||||
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey].filter(Boolean) as string[]);
|
||||
const slotted = new Set([leadKey, aboutKey, actionsKey, moreKey].filter(Boolean) as string[]);
|
||||
const primary = columns.filter((c) => c.cardPrimary);
|
||||
const lead = leadKey ? columns.find((c) => c.key === leadKey) : undefined;
|
||||
const about = aboutKey ? columns.find((c) => c.key === aboutKey) : undefined;
|
||||
const actions = actionsKey ? columns.find((c) => c.key === actionsKey) : undefined;
|
||||
const more = moreKey ? columns.find((c) => c.key === moreKey) : undefined;
|
||||
// Everything else flows in the meta line: not the heading, not a slotted column.
|
||||
const meta = columns.filter((c) => !c.cardPrimary && !slotted.has(c.key));
|
||||
|
||||
@@ -60,7 +66,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
<div
|
||||
{...rowProps?.(row)}
|
||||
{...navItemAttrs(index)}
|
||||
className={`glass-card rounded-xl p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
className={`${SURFACE.card} p-3 h-full flex flex-col gap-2 ${rowClassName?.(row) ?? ""}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1 font-medium">
|
||||
@@ -69,6 +75,7 @@ export default function ChannelLayoutGrid<T>({
|
||||
))}
|
||||
</div>
|
||||
{lead && <div className="shrink-0">{lead.render(row)}</div>}
|
||||
{more && <div className="shrink-0 -my-1">{more.render(row)}</div>}
|
||||
</div>
|
||||
{about && <div className="min-w-0 text-sm">{about.render(row)}</div>}
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 text-sm min-w-0">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink } from "lucide-react";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import { channelYouTubeUrl } from "../lib/format";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
|
||||
// Avatar + channel name + "open on YouTube" link, shared by the Channel manager and the
|
||||
// discovery list (and anywhere else that lists a channel). When `onView` is given the name
|
||||
@@ -28,7 +29,8 @@ export default function ChannelLink({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Avatar src={thumbnailUrl} fallback={title ?? ""} className="w-8 h-8 rounded-full shrink-0" />
|
||||
{onView ? (
|
||||
<button
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={onView}
|
||||
// The channel's "open" element: in a keyboard-walkable list (lib/listNav) Enter on the row or
|
||||
// card clicks this.
|
||||
@@ -46,26 +48,24 @@ export default function ChannelLink({
|
||||
}}
|
||||
// title = the full name, so a truncated fluid-column name is still readable on hover.
|
||||
title={name}
|
||||
className="text-sm font-medium truncate hover:text-accent text-left min-w-0"
|
||||
className="min-h-6 min-w-0 truncate text-sm font-medium hover:text-accent pointer-coarse:min-h-(--touch-target)"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span title={name} className="text-sm font-medium truncate min-w-0">
|
||||
{name}
|
||||
</span>
|
||||
)}
|
||||
<Tooltip hint={t("channels.row.openOnYouTube")}>
|
||||
<a
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-muted hover:text-accent shrink-0"
|
||||
aria-label={t("channels.row.openOnYouTube")}
|
||||
>
|
||||
<ExternalLink className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</Tooltip>
|
||||
<IconLink
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
className="shrink-0"
|
||||
>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The channel banner is served from Google's image CDN (yt3.googleusercontent.com), which refuses a
|
||||
// request that carries a Referer from our origin — every time, not only under load (measured
|
||||
// 2026-09-26: 6/6 refused with the page's referrer or its bare origin, 8/8 served without one). So the
|
||||
// banner <img> must send none, as ui/Avatar does for the same CDN; with a referrer the banner never
|
||||
// showed and the page fell back to the blank bar.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, cleanup, waitFor } 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" } }),
|
||||
}));
|
||||
vi.mock("./navigationContext", () => ({
|
||||
useNavigationActions: () => ({ closeChannel: vi.fn(), openChannel: vi.fn() }),
|
||||
}));
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("./ChannelAbout", () => ({ default: () => null }));
|
||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
import { api, type ChannelDetail, type Me } 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();
|
||||
});
|
||||
|
||||
const CH = "UCqoAEDirJPjEUFcF2FklnBA";
|
||||
|
||||
describe("ChannelPage — the banner", () => {
|
||||
it("is requested without a referrer, which Google's image CDN refuses", async () => {
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "StarTalk",
|
||||
banner_url: "https://yt3.googleusercontent.com/banner",
|
||||
subscribed: true,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const { container } = render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const banner = await waitFor(() => {
|
||||
const img = container.querySelector<HTMLImageElement>('img[src^="https://yt3."]');
|
||||
expect(img).not.toBeNull();
|
||||
return img!;
|
||||
});
|
||||
expect(banner.getAttribute("referrerpolicy")).toBe("no-referrer");
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
// ui/Button in E11 S2), so a busy toggle blocks the click with aria-disabled instead — focusable, and
|
||||
// still pressed only once.
|
||||
import type { ReactNode } from "react";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeAll, afterEach, onTestFinished } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
@@ -97,3 +97,53 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// The banner is retried on error (googleusercontent drops a burst); while an attempt fails and the
|
||||
// next waits, a visible <img> shows the browser's broken-image glyph (seen live, E28). It stays
|
||||
// invisible until a source has actually loaded.
|
||||
describe("ChannelPage — the banner", () => {
|
||||
it("is invisible until it loads, and again while a failed attempt is retried", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
onTestFinished(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
vi.spyOn(api, "channelDetail").mockResolvedValue({
|
||||
id: CH,
|
||||
title: "LEMMiNO",
|
||||
subscribed: true,
|
||||
blocked: false,
|
||||
hidden: false,
|
||||
deep_requested: false,
|
||||
backfill_done: true,
|
||||
stored_videos: 10,
|
||||
banner_url: "https://yt3.example/banner",
|
||||
} as unknown as ChannelDetail);
|
||||
vi.spyOn(api, "tags").mockResolvedValue([] as never);
|
||||
const { container } = render(
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<ChannelPage
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const banner = () =>
|
||||
container.querySelector<HTMLImageElement>('img[src^="https://yt3.example/banner"]')!;
|
||||
await waitFor(() => expect(banner()).toBeTruthy());
|
||||
expect(banner().classList.contains("opacity-0")).toBe(true);
|
||||
fireEvent.error(banner());
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
});
|
||||
expect(banner().getAttribute("src")).toContain("?r=1");
|
||||
expect(banner().classList.contains("opacity-0")).toBe(true);
|
||||
fireEvent.load(banner());
|
||||
expect(banner().classList.contains("opacity-0")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { channelDetailPollMs, useBackfillFeedRefresh } from "../lib/channelDetailPoll";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { invalidateSubscription } from "../lib/subscriptionCache";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { PriorityStepper } from "./ui/PriorityStepper";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
@@ -33,11 +35,13 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
|
||||
import type { FeedView } from "../lib/feedView";
|
||||
import { channelYouTubeUrl, formatViews } from "../lib/format";
|
||||
import { channelYouTubeUrl, formatDate, formatViews } from "../lib/format";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { Button } from "./ui/Button";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { PageHeading } from "./ui/Headings";
|
||||
|
||||
// A dedicated channel page: an "About"-style header (banner, avatar, stats, subscribe) over the
|
||||
// channel's videos (the catalog filtered to this channel). For an un-subscribed channel it
|
||||
@@ -74,18 +78,22 @@ export default function ChannelPage({
|
||||
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
|
||||
const globalFilters = useFeedFilters();
|
||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||
const panel = useTabPanel();
|
||||
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
|
||||
// fixed band so only the video grid scrolls. This node sits at the bottom of that chrome and is
|
||||
// where the channelScoped Feed portals ITS toolbar (via the overridden PageToolbarSlot below), so
|
||||
// the toolbar lands under the tabs inside the fixed band rather than in the scroller.
|
||||
const [chromeSlot, setChromeSlot] = useState<HTMLDivElement | null>(null);
|
||||
// The banner URL is valid, but googleusercontent intermittently throttles it when the channel
|
||||
// page fires the banner + avatar + ~16 thumbnails in one burst — the request is dropped and the
|
||||
// browser then NEGATIVE-CACHES the miss, so the banner stays broken even though a retry would
|
||||
// succeed. So on error we retry a few times with a cache-buster (forcing a fresh request), and
|
||||
// only fall back to the blank bar if it keeps failing (a genuinely dead URL — rare).
|
||||
// The banner comes from Google's image CDN, which refuses any request carrying a Referer from our
|
||||
// origin — every time, not only in a burst (measured 2026-09-26) — so the <img> sends none
|
||||
// (`referrerPolicy`, as ui/Avatar). A load can still drop, and the browser NEGATIVE-CACHES the miss,
|
||||
// so on error we retry a few times with a cache-buster (forcing a fresh request), and only fall
|
||||
// back to the blank bar if it keeps failing (a genuinely dead URL — rare).
|
||||
const MAX_BANNER_RETRIES = 3;
|
||||
const [bannerAttempt, setBannerAttempt] = useState(0);
|
||||
// The banner source that has actually loaded. Until then the <img> is invisible: an attempt that
|
||||
// fails shows the browser's broken-image glyph for as long as the retry waits (seen live, E28).
|
||||
const [bannerLoaded, setBannerLoaded] = useState<string | null>(null);
|
||||
|
||||
// A LIVE query: while THIS user's full-history backfill is running, poll so the stored count (and
|
||||
// the N/S/L breakdown) climb on their own — the immediate backfill fills it in seconds, and the
|
||||
@@ -174,12 +182,7 @@ export default function ChannelPage({
|
||||
const subscribe = useMutation({
|
||||
mutationFn: () => api.subscribeChannel(channelId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
// Match ChannelDiscovery: the channel leaves discovery and the manager's stats move.
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.discoveredChannels() });
|
||||
invalidateSubscription(qc, channelId);
|
||||
notify({ level: "info", message: t("channel.subscribed", { name: ch?.title ?? "" }) });
|
||||
},
|
||||
// A 403 (missing write scope) otherwise fails silently on the channel page (no wizard handle
|
||||
@@ -188,11 +191,7 @@ export default function ChannelPage({
|
||||
});
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: () => api.unsubscribeChannel(channelId),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(channelId) });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
},
|
||||
onSuccess: () => invalidateSubscription(qc, channelId),
|
||||
onError: (err) => notifyYouTubeActionError(err, t("channels.notify.unsubscribeFailed")),
|
||||
});
|
||||
// "Get full history": the catalog only holds the recent-backfill/RSS subset of a channel's
|
||||
@@ -280,12 +279,7 @@ export default function ChannelPage({
|
||||
|
||||
const name = ch?.title ?? initialName ?? channelId;
|
||||
const ytUrl = channelYouTubeUrl(channelId, ch?.handle);
|
||||
const joined = ch?.published_at
|
||||
? new Date(ch.published_at).toLocaleDateString(i18n.language, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
})
|
||||
: null;
|
||||
const joined = ch?.published_at ? formatDate(ch.published_at, i18n.language) : null;
|
||||
// Handle + stats on one compact meta line under the name (saves the separate stats row).
|
||||
const metaParts = [
|
||||
ch?.handle ? `@${ch.handle.replace(/^@/, "")}` : null,
|
||||
@@ -317,12 +311,15 @@ export default function ChannelPage({
|
||||
// The channel page owns no scroller of its own (PageShell provides the one app scroller). Its
|
||||
// whole chrome is fixed — wrapped in <PageToolbar> it portals into the shell's fixed band, so the
|
||||
// banner/identity/tabs/toolbar stay put and only the video grid scrolls.
|
||||
const bannerSrc = ch?.banner_url
|
||||
? `${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<PageToolbar>
|
||||
{/* Banner + back — OPTION C: inset, rounded card (not full-bleed) */}
|
||||
<div className="relative px-4 sm:px-6 pt-3">
|
||||
{ch?.banner_url && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||
{bannerSrc && bannerAttempt <= MAX_BANNER_RETRIES ? (
|
||||
// Match YouTube's banner: the stored bannerExternalUrl is the full 16:9 template at a
|
||||
// low default size, so request a crisp wide version (=w1707) and object-cover the
|
||||
// desktop "safe area" — the centre 2560×423 (~6:1) band.
|
||||
@@ -334,9 +331,13 @@ export default function ChannelPage({
|
||||
// key forces a fresh <img> per attempt; the ?r= cache-buster on a retry bypasses the
|
||||
// browser's negative cache (googleusercontent accepts an extra query param).
|
||||
key={bannerAttempt}
|
||||
src={`${ch.banner_url}=w1707${bannerAttempt ? `?r=${bannerAttempt}` : ""}`}
|
||||
src={bannerSrc}
|
||||
referrerPolicy="no-referrer"
|
||||
alt=""
|
||||
className="w-full h-full object-cover object-center"
|
||||
className={`w-full h-full object-cover object-center ${
|
||||
bannerLoaded === bannerSrc ? "" : "opacity-0"
|
||||
}`}
|
||||
onLoad={() => setBannerLoaded(bannerSrc)}
|
||||
onError={() => {
|
||||
if (bannerAttempt <= MAX_BANNER_RETRIES) {
|
||||
window.setTimeout(
|
||||
@@ -356,14 +357,15 @@ export default function ChannelPage({
|
||||
style={{ aspectRatio: "2560 / 423", maxHeight: "150px" }}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
<Button
|
||||
variant="media"
|
||||
data-testid="channel-back"
|
||||
icon={<ArrowLeft />}
|
||||
onClick={closeChannel}
|
||||
className="absolute top-5 left-7 sm:left-9 z-base inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-black/45 text-white hover:bg-black/65 backdrop-blur-xs"
|
||||
className="absolute top-5 left-7 sm:left-9 z-base backdrop-blur-xs"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("channel.back")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-4 sm:px-6">
|
||||
@@ -377,9 +379,9 @@ export default function ChannelPage({
|
||||
/>
|
||||
<div className="flex-1 min-w-0 pb-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">
|
||||
<PageHeading data-testid="channel-title" className="text-xl font-semibold truncate">
|
||||
{name}
|
||||
</h1>
|
||||
</PageHeading>
|
||||
{ch?.blocked ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
|
||||
{t("channel.blockedBadge")}
|
||||
@@ -416,6 +418,7 @@ export default function ChannelPage({
|
||||
within a few pixels of each other instead of across the page. */}
|
||||
<div className="flex items-center gap-2 mt-3 border-b border-border pb-2">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
className="flex gap-1"
|
||||
active={tab}
|
||||
onChange={(id) => setTab(id as "videos" | "about")}
|
||||
@@ -425,25 +428,25 @@ export default function ChannelPage({
|
||||
]}
|
||||
/>
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<a
|
||||
<IconLink
|
||||
href={ytUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={t("channels.row.openOnYouTube")}
|
||||
aria-label={t("channels.row.openOnYouTube")}
|
||||
className="inline-flex items-center justify-center w-9 h-9 rounded-lg text-muted hover:text-fg border border-border"
|
||||
label={t("channels.row.openOnYouTube")}
|
||||
size="md"
|
||||
variant="outline"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
<ExternalLink />
|
||||
</IconLink>
|
||||
{ch?.subscribed && (
|
||||
<Button
|
||||
<IconButton
|
||||
label={t("channel.showInFeed")}
|
||||
size="md"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={onShowInFeed}
|
||||
title={t("channel.showInFeed")}
|
||||
aria-label={t("channel.showInFeed")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<SlidersHorizontal className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<div className="inline-flex items-center gap-1.5 h-9 px-2.5 rounded-lg border border-border">
|
||||
@@ -488,39 +491,32 @@ export default function ChannelPage({
|
||||
</Button>
|
||||
)}
|
||||
{!me.is_demo && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
// Busy blocks the click, not the focus (FocusSafeButton). Nor does a busy toggle
|
||||
// light up on hover, as if a click would do something.
|
||||
size="md"
|
||||
variant={ch?.blocked ? "danger-soft" : "outline-danger"}
|
||||
onClick={() => block.mutate()}
|
||||
busy={block.isPending}
|
||||
title={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
aria-label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
ch?.blocked
|
||||
? "border-danger text-danger"
|
||||
: "border-border text-muted not-aria-disabled:hover:text-danger not-aria-disabled:hover:border-danger"
|
||||
}`}
|
||||
loading={block.isPending}
|
||||
label={ch?.blocked ? t("channel.unblock") : t("channel.block")}
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<Ban />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* Hide-from-feed toggle — subscription-scoped, so offered only on a followed channel
|
||||
(mirrors the manager's Eye/EyeOff). Keeps the subscription; only drops its videos
|
||||
from the feed. EyeOff = currently hidden (click to unhide). */}
|
||||
{ch?.subscribed && !me.is_demo && (
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
size="md"
|
||||
variant={ch.hidden ? "outline-accent" : "outline"}
|
||||
onClick={() => toggleHidden.mutate()}
|
||||
busy={toggleHidden.isPending}
|
||||
loading={toggleHidden.isPending}
|
||||
label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
title={ch.hidden ? t("channel.hiddenHint") : t("channel.hideHint")}
|
||||
aria-label={ch.hidden ? t("channel.unhide") : t("channel.hide")}
|
||||
className={`inline-flex items-center justify-center w-9 h-9 rounded-lg border aria-disabled:opacity-50 transition ${
|
||||
ch.hidden
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted not-aria-disabled:hover:text-fg not-aria-disabled:hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{ch.hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</FocusSafeButton>
|
||||
{ch.hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
)}
|
||||
{!me.is_demo &&
|
||||
!ch?.blocked &&
|
||||
@@ -568,48 +564,45 @@ export default function ChannelPage({
|
||||
{/* Tab content — the feed's toolbar portals up into the fixed chrome via this slot override;
|
||||
only the grid (and About) render here, in the scroller. */}
|
||||
<PageToolbarSlot.Provider value={chromeSlot}>
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<button
|
||||
// Busy blocks the click, not the focus (see Block above).
|
||||
onClick={() => !exploring && runExplore(nextToken)}
|
||||
aria-disabled={exploring || undefined}
|
||||
aria-busy={exploring || undefined}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-sm border border-border not-aria-disabled:hover:bg-surface aria-disabled:opacity-50"
|
||||
>
|
||||
{exploring ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
)}
|
||||
{t("channel.loadMore")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div {...panel.panelProps(tab)}>
|
||||
{tab === "videos" ? (
|
||||
<>
|
||||
<Feed
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
view={view}
|
||||
setView={setView}
|
||||
canRead={me.can_read}
|
||||
isDemo={me.is_demo}
|
||||
ytSearch={null}
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
channelScoped
|
||||
/>
|
||||
{!ch?.subscribed && nextToken && (
|
||||
<div className="flex justify-center pb-6">
|
||||
<Button
|
||||
// Busy blocks the click, not the focus (see Block above).
|
||||
variant="outline"
|
||||
icon={<RefreshCw />}
|
||||
loading={exploring}
|
||||
onClick={() => runExplore(nextToken)}
|
||||
>
|
||||
{t("channel.loadMore")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="px-4 sm:px-6 py-4 max-w-3xl">
|
||||
{ch ? (
|
||||
<ChannelAboutContent ch={ch} />
|
||||
) : (
|
||||
<p className="text-sm text-muted">{t("channel.noDescription")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbarSlot.Provider>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -40,6 +40,8 @@ export default function ChannelPicker({
|
||||
selected={filters.channelIds}
|
||||
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
|
||||
loading={channelsQuery.isLoading}
|
||||
error={channelsQuery.isError && !channelsQuery.data ? t("common.loadError") : undefined}
|
||||
onRetry={() => void channelsQuery.refetch()}
|
||||
emptyText={t("sidebar.noChannelMatch")}
|
||||
selectedLabels={selectedLabels}
|
||||
/>
|
||||
|
||||
@@ -14,6 +14,8 @@ import { type MyStatus } from "../lib/api";
|
||||
import { formatEta, formatNumber } from "../lib/format";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel-manager catalog stats, collapsed from a crowded inline row into a compact "Stats" chip
|
||||
// that reveals the numbers on hover (reuses the AnchoredPopover hover mode). One row per stat, each
|
||||
@@ -77,19 +79,19 @@ export default function ChannelStatsPopover({ status }: { status: MyStatus }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<BarChart3 aria-hidden="true" />}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-sm text-muted transition"
|
||||
>
|
||||
<BarChart3 className="h-4 w-4" aria-hidden="true" />
|
||||
{t("channels.stats.label")}
|
||||
</button>
|
||||
</Button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("channels.stats.label")}
|
||||
className="glass-menu w-60 rounded-xl p-2 text-xs animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-60 p-2 text-xs animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{rows.map((r) => (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { Plus, Sparkles } from "lucide-react";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { PopoverSurface, PopoverItem } from "./ui/AnchoredPopover";
|
||||
@@ -7,7 +8,11 @@ import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
||||
import { chipClass } from "./ui/chipStyles";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
@@ -25,20 +30,15 @@ interface TagChipProps {
|
||||
title?: string;
|
||||
}
|
||||
|
||||
// `block truncate` on the name, not the chip: the chip is inline-flex, where text-overflow cannot bite.
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const cls =
|
||||
"chip-solid shrink-0 text-2xs font-medium px-1.5 py-0.5 rounded-full max-w-36 truncate";
|
||||
const name = <span className="block max-w-32 truncate">{tag.name}</span>;
|
||||
return onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
className={`${cls} hover:opacity-80 transition`}
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
<Chip variant="tag" size="xs" onClick={onClick} title={title}>
|
||||
{name}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className={cls}>{tag.name}</span>
|
||||
<span className={chipClass("tag", "xs")}>{name}</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,33 +63,22 @@ function SuggestedChip({
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
title={t("channels.tags.suggestedHint", { pct })}
|
||||
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
|
||||
>
|
||||
<Sparkles className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">{suggestion.tag.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title={t("channels.tags.dismissSuggestion")}
|
||||
aria-label={t("channels.tags.dismissSuggestion")}
|
||||
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
className="max-w-36 font-medium"
|
||||
icon={<Sparkles />}
|
||||
onClick={onAccept}
|
||||
bodyTitle={t("channels.tags.suggestedHint", { pct })}
|
||||
bodyLabel={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel={t("channels.tags.dismissSuggestion")}
|
||||
>
|
||||
{suggestion.tag.name}
|
||||
</DismissibleChip>
|
||||
);
|
||||
}
|
||||
|
||||
const GHOST_PILL =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
@@ -158,7 +147,7 @@ function SuggestedChipsLane({
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={GHOST_PILL}>
|
||||
<span ref={pillRef} className={chipClass("suggest", "xs")}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -173,22 +162,22 @@ function SuggestedChipsLane({
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
<Chip
|
||||
variant="suggest"
|
||||
size="xs"
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
@@ -240,17 +229,11 @@ function TagEditorMenu({
|
||||
checked={on}
|
||||
onSelect={() => onToggle(tg.id)}
|
||||
label={tg.name}
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${
|
||||
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-lg outline-hidden transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
|
||||
on ? "bg-accent border-accent text-accent-fg" : "border-border"
|
||||
}`}
|
||||
>
|
||||
{on && <Check className="w-2.5 h-2.5" />}
|
||||
</span>
|
||||
<MenuCheck kind="many" on={on} />
|
||||
<span className="truncate">{tg.name}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
@@ -260,7 +243,9 @@ function TagEditorMenu({
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
|
||||
<input
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -276,18 +261,15 @@ function TagEditorMenu({
|
||||
}}
|
||||
maxLength={64}
|
||||
placeholder={t("channels.tags.createPlaceholder")}
|
||||
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="outline"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim()}
|
||||
title={t("channels.tags.create")}
|
||||
aria-label={t("channels.tags.create")}
|
||||
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
|
||||
label={t("channels.tags.create")}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
</FocusSafeButton>
|
||||
<Plus />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -381,8 +363,8 @@ export function ChannelTagsControl({
|
||||
placement: "bottom",
|
||||
});
|
||||
|
||||
const pillClass =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
|
||||
// The "+N" is a filter-look chip (neutral): the tags themselves are the accent.
|
||||
const pillClass = chipClass("filter", "xs");
|
||||
|
||||
return (
|
||||
// When `bounded`, a FIXED width keeps the cell's footprint constant under `table-layout:auto`: a
|
||||
@@ -396,21 +378,18 @@ export function ChannelTagsControl({
|
||||
{/* The add trigger LEADS the row — a stable left anchor that never moves as chips are added
|
||||
(they grow to its right), so its editor popover can't drift. Compact icon in the width-
|
||||
bounded manager cell; a labelled "+ Tag" pill where there's free width (the detail header). */}
|
||||
<button
|
||||
<Chip
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
ref={editPop.refs.setReference}
|
||||
type="button"
|
||||
title={t("channels.row.editTags")}
|
||||
aria-label={t("channels.row.editTags")}
|
||||
className={
|
||||
bounded
|
||||
? "shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
||||
: "shrink-0 inline-flex items-center gap-1 h-6 pl-1.5 pr-2 rounded-full border border-dashed border-border text-2xs font-medium text-muted hover:text-accent hover:border-accent transition"
|
||||
}
|
||||
icon={<Plus className="size-3" />}
|
||||
className={`font-medium ${bounded ? "w-6 justify-center px-0" : ""}`}
|
||||
{...editPop.getReferenceProps()}
|
||||
>
|
||||
<Plus className="w-3 h-3 shrink-0" />
|
||||
{!bounded && <span className="whitespace-nowrap">{t("channels.tags.add")}</span>}
|
||||
</button>
|
||||
{!bounded && t("channels.tags.add")}
|
||||
</Chip>
|
||||
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
@@ -459,15 +438,14 @@ export function ChannelTagsControl({
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
<Chip
|
||||
size="xs"
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -476,7 +454,7 @@ export function ChannelTagsControl({
|
||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} max-w-[18rem] p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip
|
||||
@@ -491,7 +469,7 @@ export function ChannelTagsControl({
|
||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||
<PopoverSurface
|
||||
popover={editPop}
|
||||
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} w-56 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<TagEditorMenu
|
||||
userTags={userTags}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -10,18 +10,24 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { Badge } from "./ui/Badge";
|
||||
import { Button } from "./ui/Button";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import DataTable from "./ui/DataTable";
|
||||
import Tabs, { type TabDef } from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||
import { pinChannel } from "./comfortPin";
|
||||
import { pinChannel, useComfortPin } from "./comfortPin";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useChannelActions } from "./useChannelActions";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import { placeByPriority } from "../lib/channelOrder";
|
||||
import { focusNavItem } from "../lib/listNav";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ui/ColumnSortControl";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
@@ -31,12 +37,18 @@ import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./ui/Pager";
|
||||
import { CHANNEL_LAYOUTS, type ChannelLayout } from "../lib/channelLayout";
|
||||
import { sortRows } from "../lib/columnSort";
|
||||
import { filterRows } from "../lib/tableColumns";
|
||||
import { filterRows, isActive } from "../lib/tableColumns";
|
||||
import { useFilterFold } from "../lib/useFilterFold";
|
||||
import { FilterFoldButton } from "./ui/FilterFold";
|
||||
import { HiddenFilterCue } from "./ui/HiddenFilterCue";
|
||||
import { useCardPager } from "../lib/useCardPager";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
@@ -45,6 +57,9 @@ import { useMe } from "../lib/useMe";
|
||||
|
||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||
|
||||
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
||||
const SURFACED_FILTERS = ["tags"];
|
||||
|
||||
// The top sync chips are a controlled view over the subscribed table's `sync` column filter (shared
|
||||
// via ChannelsProvider): each chip writes `filters.sync`, using the SAME vocab + predicate as the
|
||||
// Sync column funnel (channelColumns.tsx) so the two can't drift. "" = all (isActive treats it as
|
||||
@@ -62,8 +77,8 @@ const LAYOUT_ICON: Record<ChannelLayout, typeof Table> = {
|
||||
|
||||
export default function Channels() {
|
||||
const me = useMe();
|
||||
const panel = useTabPanel();
|
||||
const canWrite = me.can_write;
|
||||
const isAdmin = me.role === "admin";
|
||||
// "Connect YouTube" onboarding lives in WizardProvider (a notify action / a mutation-error CTA).
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// The manager's name filter, status chip, active tab, reset token, and focus-channel intent live
|
||||
@@ -93,7 +108,6 @@ export default function Channels() {
|
||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// Clicking a tag chip filters the feed by that tag and jumps there. Internalised (was an App
|
||||
// callback) now that Channels reads the feed filters + navigation straight from their providers.
|
||||
@@ -129,24 +143,10 @@ export default function Channels() {
|
||||
|
||||
const userTags = (tagsQuery.data ?? []).filter((t) => !t.system);
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (v: {
|
||||
id: string;
|
||||
body: { priority?: number; hidden?: boolean; deep_requested?: boolean };
|
||||
}) => api.updateChannel(v.id, v.body),
|
||||
meta: { failTitle: "errors.writeFailed.channelSettings" },
|
||||
// Requesting full history may have just pulled in a channel's recent uploads, so
|
||||
// refresh the per-user status and feed too — not only the channel list.
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
qc.invalidateQueries({ queryKey: qk.feed() });
|
||||
qc.invalidateQueries({ queryKey: qk.feedSummary() });
|
||||
},
|
||||
});
|
||||
// Priority is updated optimistically in place and NOT re-sorted/refetched, so the list doesn't
|
||||
// jump (the server list is priority-sorted; refetching would reorder rows and lose your scroll
|
||||
// position). The new order takes effect next time the page loads. Press-and-hold is debounced +
|
||||
// The row's stepper updates priority optimistically in place and does NOT re-sort/refetch, so the
|
||||
// list doesn't jump under a press-and-hold (the server list is priority-sorted; refetching would
|
||||
// reorder rows and lose your scroll position) — its new order shows next time the page loads. A
|
||||
// pick from the row's menu DOES re-sort (`pickPriority` below). Press-and-hold is debounced +
|
||||
// flushed on unmount by the shared hook (same flow the channel detail page uses).
|
||||
const { setPriority, bumpPriority } = useChannelPriority({
|
||||
read: (id) =>
|
||||
@@ -158,6 +158,21 @@ export default function Channels() {
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
});
|
||||
|
||||
// A priority step picked from the row's menu (E28 S3e3b, owner): the one thing a pick is FOR is
|
||||
// to move the channel up (or down) the list, so the list re-sorts at once — only that row moves
|
||||
// (`placeByPriority`, the server's order) — and the page and the focus follow it (`follow`): the
|
||||
// table/card pager turns to its page, and the effect below hands the focus to its "…" (its node
|
||||
// may have been re-inserted, which drops the focus to <body>, or be a virtualized card not yet
|
||||
// mounted). Picked again from there, the next step is one Enter away.
|
||||
const [follow, setFollow] = useState<{ key: string; seq: number } | null>(null);
|
||||
const moreRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const pickPriority = (c: ManagedChannel, next: number) => {
|
||||
setPriority(c.id, next);
|
||||
qc.setQueryData<ManagedChannel[]>(qk.channels(), (old) => old && placeByPriority(old, c.id));
|
||||
setFollow((f) => ({ key: c.id, seq: (f?.seq ?? 0) + 1 }));
|
||||
};
|
||||
|
||||
// Tagging is updated optimistically in place (no refetch of the whole channel list, which
|
||||
// made the toggle feel ~2s slow); only revert by refetching if the server call fails. The
|
||||
// attach/detach + create-and-attach flow is shared with the channel detail page.
|
||||
@@ -215,25 +230,6 @@ export default function Channels() {
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.syncFailed"), onOpenWizard),
|
||||
});
|
||||
const unsubscribe = useMutation({
|
||||
mutationFn: (v: { id: string; name: string }) => api.unsubscribeChannel(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "success", message: t("channels.notify.unsubscribed", { name: v.name }) });
|
||||
},
|
||||
onError: (e) =>
|
||||
notifyYouTubeActionError(e, t("channels.notify.unsubscribeFailed"), onOpenWizard),
|
||||
});
|
||||
const resetBackfill = useMutation({
|
||||
mutationFn: (v: { id: string; name: string }) => api.resetChannelBackfill(v.id),
|
||||
onSuccess: (_d, v) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.myStatus() });
|
||||
notify({ level: "success", message: t("channels.notify.resetDone", { name: v.name }) });
|
||||
},
|
||||
onError: (e) => notifyYouTubeActionError(e, t("channels.notify.resetFailed"), onOpenWizard),
|
||||
});
|
||||
const deepAll = useMutation({
|
||||
mutationFn: () => api.deepAll(true),
|
||||
onSuccess: (r: { updated?: number }) => {
|
||||
@@ -253,6 +249,13 @@ export default function Channels() {
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// B4 (E28 review L7): on a short window the band's filters — the tag chip lane and the status /
|
||||
// pager row — fold behind one "Filters · n"; the tabs and the TAGS editor stay. n = each picked tag,
|
||||
// plus every other active column filter (the status chips are the Sync column's).
|
||||
const fold = useFilterFold();
|
||||
const foldCount =
|
||||
tagFilter.length +
|
||||
Object.entries(filters).filter(([k, v]) => k !== "tags" && isActive(v)).length;
|
||||
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
@@ -327,24 +330,32 @@ export default function Channels() {
|
||||
|
||||
const onView = (c: ManagedChannel) =>
|
||||
onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"));
|
||||
const onUnsub = async (c: ManagedChannel) => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.unsubscribeOnYoutube"),
|
||||
message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.unsubscribeOnYoutube"),
|
||||
danger: true,
|
||||
});
|
||||
if (ok) unsubscribe.mutate({ id: c.id, name: c.title ?? c.id });
|
||||
};
|
||||
const onReset = async (c: ManagedChannel) => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.row.backfillThis"),
|
||||
message: t("channels.confirmReset", { name: c.title ?? c.id }),
|
||||
confirmLabel: t("channels.row.backfillThis"),
|
||||
});
|
||||
if (ok) resetBackfill.mutate({ id: c.id, name: c.title ?? c.id });
|
||||
};
|
||||
|
||||
// The channel's ONE action list (E28 C1) — ONE menu for the list, shared by the table and the card
|
||||
// layout: they are two paints of the same rows, so a right-click, a long-press and the row's "…"
|
||||
// must mean the same thing in both. Only mounted under the SUBSCRIBED branch below: a discovery
|
||||
// result is not a `Channel` row yet, so pinning or hiding one could only 404.
|
||||
// Keyed by id: a tag toggled in the menu, a priority picked, a poll — each rebuilds the row object
|
||||
// under the open menu, and its "…" must still know the menu is its own.
|
||||
const rowMenu = useContextMenu<ManagedChannel>({ keyOf: (c) => c.id });
|
||||
const rowPin = useComfortPin(rowMenu.open);
|
||||
const channelActions = useChannelActions({
|
||||
onView,
|
||||
onOpenWizard,
|
||||
tags: userTags,
|
||||
tagsState: tagsQuery.isPending ? "loading" : tagsQuery.isError ? "error" : "ready",
|
||||
onToggleTag: (c, tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId)),
|
||||
onPickPriority: pickPriority,
|
||||
});
|
||||
// The row as the cache holds it NOW, not as it was when the menu opened: the Tags submenu stays
|
||||
// open across toggles, and each tick (and the next toggle's attach-or-detach) reads this.
|
||||
const menuRow =
|
||||
rowMenu.target &&
|
||||
((channelsQuery.data ?? []).find((c) => c.id === rowMenu.target!.id) ?? rowMenu.target);
|
||||
const menuItems = menuRow
|
||||
? channelActions.items(menuRow, {
|
||||
pin: rowPin.item(pinChannel(menuRow.id), menuRow.title ?? t("channels.row.thisChannel")),
|
||||
})
|
||||
: [];
|
||||
// Distinct priority levels present in the catalog → the priority column's filter options.
|
||||
const prioOptions = [...new Set((channelsQuery.data ?? []).map((c) => c.priority))]
|
||||
.sort((a, b) => b - a)
|
||||
@@ -352,16 +363,25 @@ export default function Channels() {
|
||||
|
||||
const columns = buildChannelColumns({
|
||||
t,
|
||||
isAdmin,
|
||||
canWrite,
|
||||
prioOptions,
|
||||
userTags,
|
||||
bumpPriority,
|
||||
setPriority,
|
||||
onHide: (c) => patch.mutate({ id: c.id, body: { hidden: !c.hidden } }),
|
||||
onDeep: (c) => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }),
|
||||
onReset,
|
||||
onUnsub,
|
||||
more: (c) => {
|
||||
const mine = rowMenu.open && rowMenu.target?.id === c.id;
|
||||
return (
|
||||
<MoreButton
|
||||
ref={(el) => {
|
||||
if (el) moreRefs.current.set(c.id, el);
|
||||
else moreRefs.current.delete(c.id);
|
||||
}}
|
||||
label={t("channels.row.menu")}
|
||||
expanded={mine}
|
||||
menuProps={rowMenu.buttonProps(c)}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
);
|
||||
},
|
||||
onView,
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
@@ -429,11 +449,6 @@ export default function Channels() {
|
||||
/>
|
||||
);
|
||||
const hasLayout = view === "subscribed" || view === "discovery";
|
||||
// The comfort-shelf pin on a channel row (E24 S3). ONE menu for the list, shared by the table and
|
||||
// the card layout — they are two paints of the same rows, so a right-click must mean the same
|
||||
// thing in both. Only mounted under the SUBSCRIBED branch below: a discovery result is not a
|
||||
// `Channel` row yet, so pinning one could only 404.
|
||||
const pinMenu = useContextMenu<ManagedChannel>();
|
||||
// In cards there are no column headers to sort by, so a dropdown drives the SAME shared sort state
|
||||
// the table header uses (table ↔ cards stay in sync). Table mode keeps the header clicks.
|
||||
const sortControl = (
|
||||
@@ -461,7 +476,32 @@ export default function Channels() {
|
||||
paged: pagedCards,
|
||||
pagerProps,
|
||||
resetPage,
|
||||
} = useCardPager(sortedChannels, LS.channelCardSize, isCards);
|
||||
} = useCardPager(
|
||||
sortedChannels,
|
||||
LS.channelCardSize,
|
||||
isCards,
|
||||
follow && { seq: follow.seq, match: (c) => c.id === follow.key },
|
||||
);
|
||||
// The focus half of `follow` (the pagers took the page half during render). A mounted row names
|
||||
// its own place; a card the virtualized grid has not mounted yet is found by its place on the page.
|
||||
const focusFollowed = useEffectEvent((key: string) => {
|
||||
const wrap = listRef.current;
|
||||
if (!wrap) return;
|
||||
const button = () => moreRefs.current.get(key) ?? null;
|
||||
const item = button()?.closest("[data-nav-index]");
|
||||
const at = item
|
||||
? Number(item.getAttribute("data-nav-index"))
|
||||
: isCards
|
||||
? sortedChannels.findIndex((c) => c.id === key) -
|
||||
pagerProps.page * Math.max(0, pagerProps.size)
|
||||
: -1;
|
||||
// Filtered out by its new priority (a Prio column filter): nothing to follow — honest, not a
|
||||
// jump to some other row.
|
||||
if (at >= 0) focusNavItem(wrap, at, button);
|
||||
});
|
||||
useEffect(() => {
|
||||
if (follow) focusFollowed(follow.key);
|
||||
}, [follow]);
|
||||
// Changing what you're looking at starts over at the first page, the way DataTable resets its own
|
||||
// page on a filter/sort change. Deliberately NOT keyed on the row count: the qk.channels() query
|
||||
// refetches on window focus and after every sync, so a catalogue that merely gained a channel would
|
||||
@@ -483,6 +523,7 @@ export default function Channels() {
|
||||
const tabs = (
|
||||
<div className="px-4 pt-2 max-w-384 mx-auto flex items-center justify-between gap-4 flex-wrap">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
tabs={viewTabs}
|
||||
active={view}
|
||||
onChange={(id) => setView(id as ChannelsView)}
|
||||
@@ -496,19 +537,78 @@ export default function Channels() {
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more — the
|
||||
// layout, the cards' sort, the status and tag filters, the tag editor and the catalog sync. Backfill
|
||||
// stays in the "…" only: a long quota-heavy run is not a thing to start from a stray right-click.
|
||||
const syncOptions = SYNC_CHIPS.map((c) => ({ value: c.value, label: t(c.labelKey) }));
|
||||
const pageMenuItems = menuGroups(
|
||||
hasLayout
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "layout",
|
||||
label: t("channels.layoutLabel"),
|
||||
options: layoutOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: layout,
|
||||
onPick: setLayout,
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" && isCards
|
||||
? [
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("channels.sort.label"),
|
||||
options: optionsFromColumns(columns),
|
||||
sort,
|
||||
onChange: setSort,
|
||||
ascLabel: t("channels.sort.asc"),
|
||||
descLabel: t("channels.sort.desc"),
|
||||
defaultLabel: t("channels.sort.default"),
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed"
|
||||
? [
|
||||
choiceSubmenu({
|
||||
id: "status",
|
||||
label: t("channels.filters.syncLabel"),
|
||||
options: syncOptions,
|
||||
selected: syncFilter,
|
||||
onPick: (sync) => setFilters({ ...filters, sync }),
|
||||
}),
|
||||
{ id: "tags", label: t("channels.tags.edit"), onSelect: () => setTagManagerOpen(true) },
|
||||
...(tagFilter.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clearTags",
|
||||
label: t("channels.tags.clearFilter"),
|
||||
onSelect: () => setFilters({ ...filters, tags: [] }),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
view === "subscribed" ? catalogActions.filter((a) => a.id === "sync") : [],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{pageMenuItems.length > 0 && (
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
)}
|
||||
{/* The tab bar is always fixed chrome (it portals into the shell's fixed band, so it never
|
||||
scrolls and doesn't jump between tabs). On Subscriptions the stats/description/tags ride
|
||||
with it, and the status-chip + pager row lands just below (DataTable's controlsInBand). */}
|
||||
<PageToolbar>
|
||||
{tabs}
|
||||
{view === "subscribed" && (
|
||||
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||
{/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
|
||||
overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
|
||||
"…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="@container/tagrow px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||
{/* One row: the TAGS editor trigger (left) + the tag-filter chip lane (folds overflow into
|
||||
"+N") + Clear, then the catalog "…" actions pinned right, so the "…" menu always stays on the
|
||||
first line. Only the suggestions block wraps: below 40rem it takes a line of its own under
|
||||
the row (E28 S3f3, the owner's pick A) — at 375 it had squeezed the chip lane to 0 px and
|
||||
pushed the "…" off the screen. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex flex-wrap items-center gap-2 min-w-0">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
@@ -522,25 +622,30 @@ export default function Channels() {
|
||||
{t("channels.tags.label")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
<FilterFoldButton fold={fold} label={t("sidebar.filters")} count={foldCount} />
|
||||
{!fold.folded && <TagFilterChips chips={tagFilterChips} className="flex-1" />}
|
||||
{!fold.folded && tagFilter.length > 0 && (
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
className="text-xs px-2"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<div
|
||||
data-tag-suggestions
|
||||
className="shrink-0 flex items-center gap-1.5 @max-[40rem]/tagrow:order-last @max-[40rem]/tagrow:basis-full"
|
||||
>
|
||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
<Badge tone="accent">
|
||||
<Sparkles />
|
||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||
</span>
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<Button variant="outline" size="xs" onClick={acceptAllHighConfidence}>
|
||||
@@ -555,7 +660,7 @@ export default function Channels() {
|
||||
)}
|
||||
{/* In table mode the status chips + pager ride in DataTable's own in-band controls row. The card
|
||||
layout has no such row, so surface the same status chips (left) and its pager (right) here. */}
|
||||
{view === "subscribed" && layout === "cards" && (
|
||||
{view === "subscribed" && layout === "cards" && !fold.folded && (
|
||||
<div className="px-4 pb-2 max-w-384 mx-auto flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
@@ -566,97 +671,120 @@ export default function Channels() {
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
|
||||
{view === "discovery" ? (
|
||||
<ChannelDiscovery
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={blockedQuery.data ?? []}
|
||||
icon={<Ban className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.blocked.title")}
|
||||
hint={t("channels.blocked.hint")}
|
||||
emptyText={t("channels.blocked.empty")}
|
||||
actionLabel={t("channel.unblock")}
|
||||
actionIcon={<X className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => unblock.mutate(id)}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
<div {...panel.panelProps(view)}>
|
||||
{view === "discovery" ? (
|
||||
<ChannelDiscovery
|
||||
canWrite={canWrite}
|
||||
search={search}
|
||||
onOpenWizard={onOpenWizard}
|
||||
onViewChannel={onViewChannel}
|
||||
/>
|
||||
</div>
|
||||
) : view === "hidden" ? (
|
||||
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
||||
) : view === "blocked" ? (
|
||||
/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={blockedQuery.data ?? []}
|
||||
icon={<Ban className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.blocked.title")}
|
||||
hint={t("channels.blocked.hint")}
|
||||
emptyText={t("channels.blocked.empty")}
|
||||
actionLabel={t("channel.unblock")}
|
||||
actionIcon={<X className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => unblock.mutate(id)}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : view === "hidden" ? (
|
||||
/* Hidden channels — kept out of the Mine feed but still in the shared Library; managed here
|
||||
instead of dimmed in the subscribed table. Same card grid as Blocked (ExcludedChannelList). */
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={hiddenChannels}
|
||||
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.hidden.title")}
|
||||
hint={t("channels.hidden.hint")}
|
||||
emptyText={t("channels.hidden.empty")}
|
||||
actionLabel={t("channels.hidden.unhide")}
|
||||
actionIcon={<Eye className="w-3.5 h-3.5" />}
|
||||
onAction={(id) => patch.mutate({ id, body: { hidden: false } })}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||
<div className="px-4 pb-4 pt-3 max-w-384 mx-auto">
|
||||
<ExcludedChannelList
|
||||
channels={hiddenChannels}
|
||||
icon={<EyeOff className="w-4 h-4 text-muted" />}
|
||||
title={t("channels.hidden.title")}
|
||||
hint={t("channels.hidden.hint")}
|
||||
emptyText={t("channels.hidden.empty")}
|
||||
actionLabel={t("channels.hidden.unhide")}
|
||||
actionIcon={<Eye className="w-3.5 h-3.5" />}
|
||||
onAction={channelActions.unhide}
|
||||
onView={(c) => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* No top padding: the sticky header sits flush at the scroller top so it doesn't shift when
|
||||
`position: sticky` engages on the first scroll (the band's controls row above gives the
|
||||
visual gap). */
|
||||
<div className="px-4 pb-4 max-w-384 mx-auto">
|
||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||
<div ref={listRef} className="px-4 pb-4 max-w-384 mx-auto">
|
||||
{/* Channel table — only the rows scroll; the header row is sticky, and the controls row
|
||||
(status chips + pager) rides in the fixed band right above it (controlsInBand). The
|
||||
card layout swaps the table for a virtualized grid over the same rows (its pager and
|
||||
status chips ride in the fixed band above instead). */}
|
||||
{channelsQuery.isError && !channelsQuery.data ? (
|
||||
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : channelsQuery.isLoading ? (
|
||||
<LoadingState label={t("channels.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={syncedChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||
controlsPosition="both"
|
||||
controlsLeading={statusChips}
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
keyboardNav
|
||||
/>
|
||||
) : (
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
actionsKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={pinMenu.triggerProps}
|
||||
/>
|
||||
)}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinChannel(c.id)}
|
||||
title={(c) => c.title ?? t("channels.row.thisChannel")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{channelsQuery.isError && !channelsQuery.data ? (
|
||||
<StateMessage tone="error" onRetry={() => channelsQuery.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : channelsQuery.isLoading ? (
|
||||
<LoadingState shape="table" label={t("channels.loading")} />
|
||||
) : layout === "table" ? (
|
||||
<DataTable
|
||||
rows={syncedChannels}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
persistKey={accountKey(LS.channelsTable) ?? undefined}
|
||||
// Folded, the status / pager row leaves the band; the list's own end keeps it.
|
||||
controlsPosition={fold.folded ? "bottom" : "both"}
|
||||
controlsLeading={statusChips}
|
||||
controlsInBand
|
||||
controlsBandClassName="px-4 max-w-384 mx-auto"
|
||||
sort={sort}
|
||||
onSortChange={setSort}
|
||||
filters={filters}
|
||||
onFiltersChange={setFilters}
|
||||
fixedLayout
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowMenu.triggerProps}
|
||||
// `group`: the row's "…" reveals on its hover or focus (`.reveal`), and stays on touch.
|
||||
rowClassName={() => "group"}
|
||||
keyboardNav
|
||||
reveal={follow}
|
||||
surfacedFilters={SURFACED_FILTERS}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Cards have no funnels: a Priority or name filter set in the table would narrow them
|
||||
unseen. The tags have their own chip row. */}
|
||||
<HiddenFilterCue
|
||||
columns={columns}
|
||||
filters={filters}
|
||||
by="cards"
|
||||
surfaced={SURFACED_FILTERS}
|
||||
onFilter={(key, value) => setFilters({ ...filters, [key]: value })}
|
||||
/>
|
||||
<ChannelLayoutGrid
|
||||
rows={pagedCards}
|
||||
columns={columns}
|
||||
rowKey={(c) => c.id}
|
||||
leadKey="priority"
|
||||
aboutKey="about"
|
||||
moreKey="actions"
|
||||
emptyText={t("channels.empty")}
|
||||
rowProps={rowMenu.triggerProps}
|
||||
rowClassName={() => "group"}
|
||||
/>
|
||||
{/* Folded, the status / pager row leaves the band, as the table's does — and the
|
||||
list's own end keeps it here too (round 2, F2: the cards had lost their pager). */}
|
||||
{fold.folded && (
|
||||
<div className="pt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
{statusChips}
|
||||
{cardPager}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("channels.row.menu")} items={menuItems} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import ChatThread from "./ChatThread";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The Messenger-style floating chat dock: open conversations live here, bottom-right, across
|
||||
// page navigation (state persisted per user). Always mounted (for human users) so it also owns
|
||||
@@ -73,7 +76,9 @@ export default function ChatDock({ meId }: { meId: number }) {
|
||||
function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl flex flex-col overflow-hidden">
|
||||
<div
|
||||
className={`${SURFACE.panel} pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] flex flex-col overflow-hidden`}
|
||||
>
|
||||
{/* One-shot attention flash on a new message; keyed by the counter so it replays each time. */}
|
||||
{chat.flash > 0 && (
|
||||
<div
|
||||
@@ -82,9 +87,10 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
|
||||
<button
|
||||
<Pressable
|
||||
selectable
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="flex items-center gap-2 min-w-0 flex-1 text-left"
|
||||
className="flex items-center gap-2 min-w-0 flex-1"
|
||||
title={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
>
|
||||
<Avatar
|
||||
@@ -93,21 +99,16 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
|
||||
className="w-7 h-7 rounded-full shrink-0 text-xs"
|
||||
/>
|
||||
<span className="font-semibold text-sm truncate">{chat.name}</span>
|
||||
</button>
|
||||
<button
|
||||
</Pressable>
|
||||
<IconButton
|
||||
onClick={() => toggleMinimize(chat.partnerId)}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
||||
aria-label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
label={chat.minimized ? t("messages.expand") : t("messages.minimize")}
|
||||
>
|
||||
{chat.minimized ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => closeChat(chat.partnerId)}
|
||||
className="shrink-0 p-1 rounded-md text-muted hover:text-fg hover:bg-bg transition"
|
||||
aria-label={t("messages.close")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
{chat.minimized ? <ChevronUp /> : <ChevronDown />}
|
||||
</IconButton>
|
||||
<IconButton onClick={() => closeChat(chat.partnerId)} label={t("messages.close")}>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
{!chat.minimized && (
|
||||
<div className="flex flex-col h-96">
|
||||
|
||||
@@ -6,13 +6,14 @@ import { Send } from "lucide-react";
|
||||
import { api, type Message, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { partnerPub, POLL_MS, refreshPartnerPub, useKeyState } from "../lib/messaging";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { Textarea } from "./ui/Input";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
// The message list + composer for one conversation, shared by the full Messages page and the
|
||||
// floating dock window. Handles client-side decryption and encrypt-on-send; the surrounding
|
||||
@@ -193,9 +194,10 @@ export default function ChatThread({
|
||||
}`}
|
||||
>
|
||||
{text}
|
||||
<div className={`text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}>
|
||||
{relativeTime(m.created_at)}
|
||||
</div>
|
||||
<RelativeTime
|
||||
at={m.created_at}
|
||||
className={`block text-3xs mt-1 ${mine ? "text-accent-fg/70" : "text-muted"}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -210,7 +212,8 @@ export default function ChatThread({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-end gap-2 p-2 border-t border-border">
|
||||
<textarea
|
||||
<Textarea
|
||||
className="flex-1 resize-none max-h-28"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -221,18 +224,16 @@ export default function ChatThread({
|
||||
}}
|
||||
rows={1}
|
||||
placeholder={t("messages.writePlaceholder")}
|
||||
className="flex-1 resize-none max-h-28 bg-surface border border-border rounded-xl px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40"
|
||||
/>
|
||||
<Button
|
||||
<IconButton
|
||||
label={t("messages.send")}
|
||||
size="md"
|
||||
variant="primary"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={submit}
|
||||
disabled={!draft.trim() || send.isPending}
|
||||
aria-label={t("messages.send")}
|
||||
title={t("messages.send")}
|
||||
icon={<Send className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark, BookmarkX, Check, Eye, EyeOff, RotateCcw, Undo2 } from "lucide-react";
|
||||
|
||||
import { type PlexCard, type Video, type VideoStatus } from "../lib/api";
|
||||
import { isResettable } from "../lib/api/feed";
|
||||
import { pinPlex, pinVideo, useComfortPin, type PinTarget } from "./comfortPin";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { type ContextMenuController } from "./ui/useContextMenu";
|
||||
|
||||
// The menu SURFACE for the comfort-shelf pin (E24 S3). Split from `comfortPin.tsx` for the same
|
||||
// reason `ContextMenu.tsx` is split from `useContextMenu.ts`: a file that exports components and
|
||||
// nothing else keeps react-refresh working for every surface that imports it.
|
||||
|
||||
/**
|
||||
* The whole right-click menu for a surface whose only context action is the pin — the card grids and
|
||||
* rails. Render ONE per list, outside the rows (see `ui/ContextMenu`), and spread
|
||||
* `menu.triggerProps` on each row.
|
||||
*
|
||||
* `target` maps the row the user right-clicked to its pin; `title` names it in the confirmation
|
||||
* toast. Both come from the call site because only it knows what its rows are.
|
||||
*/
|
||||
export function ComfortPinMenu<T>({
|
||||
menu,
|
||||
target,
|
||||
title,
|
||||
}: {
|
||||
menu: ContextMenuController<T>;
|
||||
target: (row: T) => PinTarget | null;
|
||||
title: (row: T) => string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pin = useComfortPin(menu.open);
|
||||
const row = menu.target;
|
||||
return (
|
||||
<ContextMenu
|
||||
menu={menu}
|
||||
ariaLabel={t("home.pin.menu")}
|
||||
items={row ? pin.item(target(row), title(row)) : []}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Plex poster grids and rails — five of them, all pinning the same way. One component so the
|
||||
* show-vs-item decision and the toast's name are written once rather than five times. */
|
||||
export function PlexPinMenu({ menu }: { menu: ContextMenuController<PlexCard> }) {
|
||||
return <ComfortPinMenu menu={menu} target={pinPlex} title={(c) => c.title} />;
|
||||
}
|
||||
|
||||
/** The feed's menu (the grid and a channel page): the card's own state actions — the hover row's
|
||||
* buttons, which a keyboard or a touch user otherwise reaches only by Tabbing through every card —
|
||||
* then the pin. Playlists and downloads stay on the card: each opens its own anchored dialog. */
|
||||
export function FeedVideoMenu({
|
||||
menu,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onResetState,
|
||||
}: {
|
||||
menu: ContextMenuController<Video>;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pin = useComfortPin(menu.open);
|
||||
const v = menu.target;
|
||||
const icon = "w-4 h-4";
|
||||
const items: ActionMenuItem[] = v
|
||||
? [
|
||||
v.status === "watched"
|
||||
? {
|
||||
id: "watched",
|
||||
label: t("card.menu.unmarkWatched"),
|
||||
icon: <Undo2 className={icon} />,
|
||||
onSelect: () => onState(v.id, "new"),
|
||||
}
|
||||
: {
|
||||
id: "watched",
|
||||
label: t("card.markWatched"),
|
||||
icon: <Check className={icon} />,
|
||||
onSelect: () => onState(v.id, "watched"),
|
||||
},
|
||||
{
|
||||
id: "save",
|
||||
label: v.saved ? t("card.menu.unsave") : t("card.saveForLater"),
|
||||
icon: v.saved ? <BookmarkX className={icon} /> : <Bookmark className={icon} />,
|
||||
onSelect: () => onToggleSave(v.id, !v.saved),
|
||||
},
|
||||
{
|
||||
id: "hide",
|
||||
label: v.status === "hidden" ? t("card.unhide") : t("card.hide"),
|
||||
icon: v.status === "hidden" ? <Eye className={icon} /> : <EyeOff className={icon} />,
|
||||
onSelect: () => onState(v.id, v.status === "hidden" ? "new" : "hidden"),
|
||||
},
|
||||
...(isResettable(v)
|
||||
? [
|
||||
{
|
||||
id: "reset",
|
||||
label: t("card.resetState"),
|
||||
icon: <RotateCcw className={icon} />,
|
||||
onSelect: () => onResetState(v.id),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...pin.item(pinVideo(v.id), v.title ?? ""),
|
||||
]
|
||||
: [];
|
||||
return <ContextMenu menu={menu} ariaLabel={t("card.menu.label")} items={items} />;
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
// jsdom does no layout, so what is asserted is the class contract those rules rest on, the same
|
||||
// trade SearchBar.dom.test.tsx already makes for the narrow-header rules.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ConfigItem } from "../lib/api";
|
||||
|
||||
@@ -42,7 +42,12 @@ vi.mock("../lib/api", async (importOriginal) => {
|
||||
api: {
|
||||
...actual.api,
|
||||
adminConfig: () =>
|
||||
Promise.resolve({ groups: { g: [text("site_name")] }, secrets_manageable: true }),
|
||||
Promise.resolve({
|
||||
groups: {
|
||||
g: [text("site_name"), { ...text("signups_open"), type: "bool", value: "false" }],
|
||||
},
|
||||
secrets_manageable: true,
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -73,4 +78,21 @@ describe("ConfigPanel field row (E22 round 2)", () => {
|
||||
expect(control.className).toContain("sm:shrink-0");
|
||||
expect(control.className).not.toMatch(/(^|\s)shrink-0(\s|$)/);
|
||||
});
|
||||
|
||||
it("toggles a boolean key from its title and its hint, not only from the switch (F12)", async () => {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<ConfigPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
const sw = await screen.findByRole("switch", { name: "config.fields.signups_open.label" });
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
fireEvent.click(screen.getByText("config.fields.signups_open.label"));
|
||||
expect(sw.getAttribute("aria-checked")).toBe("true");
|
||||
fireEvent.click(screen.getByText("config.fields.signups_open.hint"));
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
// The switch itself still toggles exactly once, not once more through its label.
|
||||
fireEvent.click(sw);
|
||||
expect(sw.getAttribute("aria-checked")).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
@@ -9,6 +9,7 @@ import Tooltip from "./ui/Tooltip";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { Switch } from "./ui/form";
|
||||
@@ -17,7 +18,10 @@ import FilterSelect from "./ui/FilterSelect";
|
||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { Button } from "./ui/Button";
|
||||
import { CheckRow } from "./ui/NativeControls";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
||||
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||
@@ -27,6 +31,7 @@ import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
const panel = useTabPanel();
|
||||
const qc = useQueryClient();
|
||||
const me = useMe();
|
||||
const q = useQuery({ queryKey: qk.adminConfig(), queryFn: api.adminConfig });
|
||||
@@ -184,16 +189,22 @@ export default function ConfigPanel() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
|
||||
{/* The tab bar is the page's band (B4); the intro scrolls with the fields — in the band it
|
||||
took three lines of a short window for text read once. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
|
||||
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
|
||||
<Tabs tabs={groupTabs} active={activeGroup ?? ""} onChange={setTab} />
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
tabs={groupTabs}
|
||||
active={activeGroup ?? ""}
|
||||
onChange={setTab}
|
||||
/>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
|
||||
<div className="px-4 pb-24 pt-1 max-w-3xl w-full mx-auto">
|
||||
<p className="text-xs text-muted mb-3 leading-relaxed">{t("config.intro")}</p>
|
||||
{activeGroup && (
|
||||
<div className="glass rounded-2xl p-4 mb-4">
|
||||
<div {...panel.panelProps(activeGroup ?? "")} className={`${SURFACE.panel} p-4 mb-4`}>
|
||||
<div className="divide-y divide-border/60">
|
||||
{(data.groups[activeGroup] ?? []).map((item) => (
|
||||
<Field
|
||||
@@ -218,47 +229,51 @@ export default function ConfigPanel() {
|
||||
with no error anywhere — say so next to the fields that fix it. */}
|
||||
{!me.email_configured && <InlineNotice>{t("config.emailOff")}</InlineNotice>}
|
||||
<Tooltip hint={t("config.testEmailHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Send />}
|
||||
loading={testEmail.isPending}
|
||||
aria-disabled={dirty}
|
||||
onClick={() => testEmail.mutate()}
|
||||
disabled={testEmail.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.testEmail")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted">{t("config.testDirty")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "issues" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.issuesTestHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Plug />}
|
||||
loading={testIssues.isPending}
|
||||
aria-disabled={dirty}
|
||||
onClick={() => testIssues.mutate()}
|
||||
disabled={testIssues.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testIssues.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.issuesTest")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeGroup === "plex" && (
|
||||
<div className="mt-3 pt-3 border-t border-border/60">
|
||||
<Tooltip hint={t("config.plexTestHint")}>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
icon={<Plug />}
|
||||
loading={testPlex.isPending}
|
||||
aria-disabled={dirty}
|
||||
onClick={() => testPlex.mutate()}
|
||||
disabled={testPlex.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Plug className={`w-4 h-4 ${testPlex.isPending ? "animate-pulse" : ""}`} />
|
||||
{t("config.plexTest")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.plexTestDirty")}</p>}
|
||||
{dirty && <p className="text-2xs text-muted mt-2">{t("config.testDirty")}</p>}
|
||||
{plexResult && (
|
||||
<div className="mt-3">
|
||||
<p className="text-xs text-muted mb-2">
|
||||
@@ -280,22 +295,18 @@ export default function ConfigPanel() {
|
||||
<p className="text-2xs text-muted mb-1.5">{t("config.plexPickLibraries")}</p>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{plexResult.sections.map((s) => (
|
||||
<label
|
||||
<CheckRow
|
||||
key={s.key}
|
||||
className="inline-flex items-center gap-2 text-sm cursor-pointer"
|
||||
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
|
||||
onChange={() => togglePlexLib(s.key)}
|
||||
className="text-sm"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={plexLibs.length === 0 || plexLibs.includes(s.key)}
|
||||
onChange={() => togglePlexLib(s.key)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span>{s.title}</span>
|
||||
<span className="text-2xs text-muted">
|
||||
{s.type === "movie" ? t("config.plexMovies") : t("config.plexShows")}
|
||||
{s.count != null ? ` · ${s.count}` : ""}
|
||||
</span>
|
||||
</label>
|
||||
</CheckRow>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-2xs text-muted mt-2">{t("config.plexPickHint")}</p>
|
||||
@@ -367,22 +378,30 @@ function Field({
|
||||
// toggles a secret's reset-on-save (applied on Save, not immediately). A boolean toggle is
|
||||
// its own control — storing its value is equivalent to the default, so no reset link.
|
||||
const showReset = item.is_set && !isBool;
|
||||
const switchId = useId();
|
||||
const Text = isBool ? "label" : "div";
|
||||
|
||||
return (
|
||||
// Stacked below `sm`: the control column cannot shrink, and beside a wide control it left the
|
||||
// label and hint a sliver of a phone's width.
|
||||
<div className="flex flex-col gap-2 py-3 sm:flex-row sm:items-start sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{/* A switch's whole text column is its label, so the title or the hint toggles it (F12); a
|
||||
label may only hold phrasing content, hence the block spans. */}
|
||||
<Text
|
||||
{...(isBool && { htmlFor: switchId })}
|
||||
className={`min-w-0 flex-1 ${isBool ? "cursor-pointer select-none" : ""}`}
|
||||
>
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="block text-xs text-muted leading-relaxed mt-0.5">
|
||||
{t(`config.fields.${item.key}.hint`, "")}
|
||||
</p>
|
||||
<p className="text-2xs text-muted mt-1">{status}</p>
|
||||
</div>
|
||||
</span>
|
||||
<span className="block text-2xs text-muted mt-1">{status}</span>
|
||||
</Text>
|
||||
|
||||
<div className="flex max-w-full flex-col items-start gap-1.5 sm:shrink-0 sm:items-end">
|
||||
{isBool ? (
|
||||
<Switch
|
||||
id={switchId}
|
||||
label={label}
|
||||
checked={value === "true"}
|
||||
onChange={(v) => onChange(v ? "true" : "false")}
|
||||
@@ -390,7 +409,8 @@ function Field({
|
||||
) : item.type === "choice" ? (
|
||||
<ChoiceField item={item} label={label} value={value} onChange={onChange} />
|
||||
) : (
|
||||
<input
|
||||
<Input
|
||||
className="w-52"
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
value={value}
|
||||
disabled={secretDisabled || (item.secret && pendingSecretReset)}
|
||||
@@ -399,20 +419,18 @@ function Field({
|
||||
max={isNum && item.max != null ? item.max : undefined}
|
||||
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
||||
aria-label={label}
|
||||
className="w-52 bg-surface border border-border rounded-xl px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
)}
|
||||
{showReset && !secretDisabled && (
|
||||
<Tooltip hint={t("config.resetHint")}>
|
||||
<button
|
||||
<Button
|
||||
variant={item.secret && pendingSecretReset ? "link" : "link-muted"}
|
||||
icon={<RotateCcw className="size-3.5!" />}
|
||||
onClick={onResetField}
|
||||
className={`inline-flex items-center gap-1 text-xs transition ${
|
||||
item.secret && pendingSecretReset ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
className="text-xs gap-1"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("config.reset")}
|
||||
</button>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Modal from "./ui/Modal";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
|
||||
|
||||
@@ -35,6 +35,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
{children}
|
||||
{state && (
|
||||
<Modal
|
||||
initialFocus="action"
|
||||
title={state.opts.title ?? t("common.confirmTitle")}
|
||||
onClose={() => close(null)}
|
||||
maxWidth={choices.length > 1 ? "max-w-md" : "max-w-sm"}
|
||||
@@ -43,8 +44,8 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
{/* One action: the familiar Cancel/Confirm row. Two or more: a column, because side by
|
||||
side they wrap raggedly and a wrapped button reads as a second, lesser group — and
|
||||
these are peers. Ordered least to most destructive, top to bottom. */}
|
||||
<div
|
||||
className={multi ? "flex flex-col gap-2 mt-5" : "flex justify-end gap-2 mt-5 flex-wrap"}
|
||||
<ModalFooter
|
||||
className={multi ? "flex flex-col gap-2" : "flex flex-wrap justify-end gap-2"}
|
||||
>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -66,7 +67,7 @@ export function ConfirmProvider({ children }: { children: ReactNode }) {
|
||||
{c.label || t("common.confirm")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
)}
|
||||
</ChoiceContext.Provider>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 (owner): a native date field reports a complete, valid date on every keystroke of its year, so
|
||||
// typing 2026 applied 0002, 0020, 0202 and 2026 — four feed refetches for one year.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, cleanup, screen, fireEvent } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function mount(filters: Partial<FeedFilters>) {
|
||||
const setFilters = vi.fn();
|
||||
render(
|
||||
<DateFilterSelect
|
||||
filters={filters as FeedFilters}
|
||||
setFilters={setFilters}
|
||||
presets={[{ days: 7, key: "1week" }]}
|
||||
/>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: /sidebar\.datePresetLabel/ }));
|
||||
const [from] = document.querySelectorAll<HTMLInputElement>('input[type="date"]');
|
||||
return { setFilters, from: from! };
|
||||
}
|
||||
|
||||
describe("a custom range bound", () => {
|
||||
it("is applied once, when the typed year becomes a plausible upload year", () => {
|
||||
const { setFilters, from } = mount({ dateTo: "2026-09-01" });
|
||||
for (const day of ["0002-01-15", "0020-01-15", "0202-01-15", "2026-01-15"]) {
|
||||
fireEvent.change(from, { target: { value: day } });
|
||||
}
|
||||
expect(setFilters).toHaveBeenCalledTimes(1);
|
||||
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: "2026-01-15" });
|
||||
});
|
||||
|
||||
it("is cleared when the field is left empty, and kept when it is left half-typed", () => {
|
||||
const { setFilters, from } = mount({ dateFrom: "2026-01-15" });
|
||||
fireEvent.change(from, { target: { value: "0202-01-15" } });
|
||||
fireEvent.blur(from);
|
||||
expect(setFilters).not.toHaveBeenCalled();
|
||||
expect(from.value).toBe("2026-01-15");
|
||||
|
||||
fireEvent.change(from, { target: { value: "" } });
|
||||
expect(setFilters).not.toHaveBeenCalled();
|
||||
fireEvent.blur(from);
|
||||
expect(setFilters).toHaveBeenCalledTimes(1);
|
||||
expect(setFilters.mock.calls[0]![0]).toMatchObject({ dateFrom: undefined });
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,21 @@
|
||||
import { useLayoutEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CalendarDays, Check, ChevronDown } from "lucide-react";
|
||||
import { CalendarDays } from "lucide-react";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { type FeedFilters } from "../lib/api";
|
||||
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
|
||||
import {
|
||||
deriveDateKey,
|
||||
isCommittableDay,
|
||||
selectDateChip,
|
||||
type DatePreset,
|
||||
} from "../lib/dateFilter";
|
||||
import { formatDate } from "../lib/format";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
|
||||
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
|
||||
@@ -47,10 +58,7 @@ export default function DateFilterSelect({
|
||||
const key = deriveDateKey(filters, customOpen, presets);
|
||||
|
||||
const fmt = (s: string) => {
|
||||
const d = parseDay(s);
|
||||
return d
|
||||
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
|
||||
: s;
|
||||
return parseDay(s) ? formatDate(s, i18n.language) : s;
|
||||
};
|
||||
const rangeSummary = () => {
|
||||
const { dateFrom: f, dateTo: to } = filters;
|
||||
@@ -91,29 +99,23 @@ export default function DateFilterSelect({
|
||||
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
|
||||
};
|
||||
|
||||
const dateInputCls =
|
||||
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
<SelectTrigger
|
||||
context="form"
|
||||
open={open}
|
||||
icon={<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
|
||||
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
|
||||
open ? "border-accent" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-left text-fg">{summary}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
|
||||
</button>
|
||||
{summary}
|
||||
</SelectTrigger>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.datePresetLabel")}
|
||||
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.menu} min-w-52 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
|
||||
{options.map((o) => {
|
||||
@@ -125,67 +127,46 @@ export default function DateFilterSelect({
|
||||
checked={on}
|
||||
onSelect={() => pick(o.value)}
|
||||
label={o.label}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
|
||||
<MenuCheck kind="one" on={on} />
|
||||
<span className="flex-1 truncate">{o.label}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
|
||||
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
|
||||
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. */}
|
||||
{key === "custom" && (
|
||||
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.from")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateFrom ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateFrom: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
year: undefined,
|
||||
})
|
||||
<DayField
|
||||
value={filters.dateFrom}
|
||||
onCommit={(v) =>
|
||||
setFilters({ ...filters, dateFrom: v, maxAgeDays: undefined, year: undefined })
|
||||
}
|
||||
className={dateInputCls}
|
||||
/>
|
||||
</label>
|
||||
<label className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-muted">{t("sidebar.to")}</span>
|
||||
<input
|
||||
type="date"
|
||||
value={filters.dateTo ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
dateTo: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
year: undefined,
|
||||
})
|
||||
<DayField
|
||||
value={filters.dateTo}
|
||||
onCommit={(v) =>
|
||||
setFilters({ ...filters, dateTo: v, maxAgeDays: undefined, year: undefined })
|
||||
}
|
||||
className={dateInputCls}
|
||||
/>
|
||||
</label>
|
||||
{(filters.dateFrom || filters.dateTo) && (
|
||||
<button
|
||||
type="button"
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
|
||||
className="text-2xs text-muted hover:text-accent self-end"
|
||||
className="text-2xs self-end"
|
||||
>
|
||||
{t("sidebar.clearDates")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -193,3 +174,37 @@ export default function DateFilterSelect({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// One bound of the custom range. What the field shows is a draft while it has focus (`draft ?? value`):
|
||||
// the bound is applied only once the draft is a plausible day (`isCommittableDay` says why), so typing a
|
||||
// year no longer refetches the feed per digit; a date picked from the calendar applies at once. Leaving
|
||||
// the field settles it: emptied, the bound is cleared; half-typed or implausible, it shows the bound again.
|
||||
// It stops non-Escape keys so the date-segment arrows act on the field, not the menu's list navigation.
|
||||
function DayField({
|
||||
value,
|
||||
onCommit,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
onCommit: (day: string | undefined) => void;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<string>();
|
||||
return (
|
||||
<Input
|
||||
size="sm"
|
||||
type="date"
|
||||
value={draft ?? value ?? ""}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key !== "Escape") e.stopPropagation();
|
||||
}}
|
||||
onChange={(e) => {
|
||||
const day = e.target.value;
|
||||
setDraft(day);
|
||||
if (isCommittableDay(day) && day !== value) onCommit(day);
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draft === "" && value) onCommit(undefined);
|
||||
setDraft(undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ function DialogToggle() {
|
||||
<>
|
||||
<button onClick={() => setOpen((v) => !v)}>toggle dialog</button>
|
||||
{open && (
|
||||
<Modal title="dialog" onClose={() => setOpen(false)}>
|
||||
<Modal initialFocus="close" title="dialog" onClose={() => setOpen(false)}>
|
||||
<p>inside</p>
|
||||
</Modal>
|
||||
)}
|
||||
@@ -197,7 +197,7 @@ describe("the recorder pill", () => {
|
||||
const onClose = vi.fn();
|
||||
mount(
|
||||
{ debug_session: session() },
|
||||
<Modal title="dialog" onClose={onClose}>
|
||||
<Modal initialFocus="close" title="dialog" onClose={onClose}>
|
||||
<p>inside</p>
|
||||
</Modal>,
|
||||
);
|
||||
@@ -215,7 +215,7 @@ describe("the recorder pill", () => {
|
||||
it("Tab from the step prompt stays inside the dialog the pill sits in", () => {
|
||||
mount(
|
||||
{ debug_session: session() },
|
||||
<Modal title="dialog" onClose={() => {}}>
|
||||
<Modal initialFocus="close" title="dialog" onClose={() => {}}>
|
||||
<p>inside</p>
|
||||
</Modal>,
|
||||
);
|
||||
|
||||
@@ -16,19 +16,20 @@ import {
|
||||
useStepCount,
|
||||
useStepPrompt,
|
||||
} from "../lib/debugRecorder";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { formatDateTimeSec, formatDuration } from "../lib/format";
|
||||
import { useLayer } from "../lib/layerStack";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { dockUnderToasts } from "../lib/toastDock";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { inputCls } from "./ui/form";
|
||||
import IssueComposer from "./IssueComposer";
|
||||
import { useDebugRecording } from "./useDebugRecording";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The debug recorder (E16): Start → reproduce → Stop.
|
||||
//
|
||||
@@ -96,7 +97,7 @@ function StartDialog() {
|
||||
const minutes = me.diagnostics.session_minutes;
|
||||
const levels: readonly DebugSessionLevel[] = ["DEBUG", "TRACE"];
|
||||
return (
|
||||
<Modal title={t("recorder.start.title")} onClose={closeRecorderStart}>
|
||||
<Modal initialFocus="field" title={t("recorder.start.title")} onClose={closeRecorderStart}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted leading-relaxed">{t("recorder.start.intro")}</p>
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
@@ -112,7 +113,7 @@ function StartDialog() {
|
||||
onChange={setLevel}
|
||||
/>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-surface/60 px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed">
|
||||
<div className={`${SURFACE.card} px-4 py-3 flex flex-col gap-2 text-sm leading-relaxed`}>
|
||||
<div className="font-semibold">{t("recorder.start.whatTitle")}</div>
|
||||
<p>{t(`recorder.start.what.${level}`)}</p>
|
||||
<p className="text-muted">{t("settings.diagnostics.consent.never")}</p>
|
||||
@@ -121,7 +122,7 @@ function StartDialog() {
|
||||
{t("recorder.start.cap", { count: minutes })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" onClick={closeRecorderStart}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
@@ -133,7 +134,7 @@ function StartDialog() {
|
||||
>
|
||||
{t("recorder.start.confirm")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
@@ -152,7 +153,7 @@ function RecorderPill() {
|
||||
<div
|
||||
role="group"
|
||||
aria-label={t("recorder.pill.label")}
|
||||
className={`relative glass rounded-full h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 shadow-lg ${
|
||||
className={`relative ${SURFACE.floating} h-12 pl-3 sm:pl-4 pr-1.5 flex items-center gap-1.5 sm:gap-2 ${
|
||||
hidden ? "invisible" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -304,18 +305,20 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] glass rounded-xl p-3 flex flex-col gap-2 shadow-lg">
|
||||
<div
|
||||
className={`${SURFACE.popover} absolute bottom-full left-0 mb-2 w-80 max-w-[calc(100vw-2rem)] p-3 flex flex-col gap-2`}
|
||||
>
|
||||
<label htmlFor="debug-step-label" className="text-sm text-muted">
|
||||
{t("recorder.pill.stepPrompt", { n })}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
id="debug-step-label"
|
||||
autoFocus
|
||||
value={label}
|
||||
onChange={(e) => setStepPrompt(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
maxLength={200}
|
||||
className={`${inputCls} w-full`}
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 text-xs text-muted">
|
||||
<span>{t("recorder.pill.stepHint")}</span>
|
||||
@@ -369,7 +372,7 @@ const chipTone = (s: DebugSession): string => {
|
||||
|
||||
/** The last sessions, newest first — reopening one opens its composer, with what was written. */
|
||||
function SessionHistory() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
|
||||
const items = q.data?.items.filter((s) => s.ended_at) ?? [];
|
||||
if (q.isError && !q.data)
|
||||
@@ -391,9 +394,7 @@ function SessionHistory() {
|
||||
key={s.id}
|
||||
className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="tabular-nums">
|
||||
{new Date(s.started_at).toLocaleString(i18n.language)}
|
||||
</span>
|
||||
<span className="tabular-nums">{formatDateTimeSec(s.started_at)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-muted">
|
||||
{s.draft?.title || t(`settings.diagnostics.levels.${s.level}`)}
|
||||
</span>
|
||||
|
||||
@@ -122,9 +122,7 @@ describe("DiagnosticsSettings", () => {
|
||||
fireEvent.click(levelButton("settings.diagnostics.levels.DEBUG"));
|
||||
fireEvent.click(screen.getByTestId("diagnostics-consent-confirm"));
|
||||
await waitFor(() => expect(screen.getByTestId("diagnostics-status")).toBeTruthy());
|
||||
expect(screen.getByTestId("diagnostics-status").textContent).toContain(
|
||||
"settings.diagnostics.hoursMinutes:24h0m",
|
||||
);
|
||||
expect(screen.getByTestId("diagnostics-status").textContent).toContain("24 hr 0 min");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { api, type DiagnosticsLevel } from "../lib/api";
|
||||
import { levelInForce } from "../lib/diagnostics";
|
||||
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
|
||||
import { updateMeCache, useMe } from "../lib/useMe";
|
||||
import { Button } from "./ui/Button";
|
||||
import InlineNotice from "./ui/InlineNotice";
|
||||
@@ -11,6 +12,7 @@ import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { Section, SettingRow } from "./ui/form";
|
||||
import MyLogViewer from "./MyLogViewer";
|
||||
import { RecorderSettings } from "./DebugRecorder";
|
||||
import { formatUnit } from "../lib/format";
|
||||
|
||||
const LEVELS: readonly DiagnosticsLevel[] = ["INFO", "DEBUG", "TRACE"];
|
||||
|
||||
@@ -60,7 +62,7 @@ export default function DiagnosticsSettings() {
|
||||
const from = closedFrom.current;
|
||||
if (!from || from.isConnected) return;
|
||||
closedFrom.current = null;
|
||||
if (document.activeElement && document.activeElement !== document.body) return;
|
||||
if (!focusIsUnclaimed()) return;
|
||||
pickerRef.current?.querySelector<HTMLElement>('[aria-pressed="true"]')?.focus();
|
||||
});
|
||||
const watchFocus = () => {
|
||||
@@ -171,8 +173,8 @@ export default function DiagnosticsSettings() {
|
||||
level: levelName(current),
|
||||
time:
|
||||
remaining.h > 0
|
||||
? t("settings.diagnostics.hoursMinutes", remaining)
|
||||
: t("settings.diagnostics.minutes", remaining),
|
||||
? `${formatUnit(remaining.h, "hour")} ${formatUnit(remaining.m, "minute")}`
|
||||
: formatUnit(remaining.m, "minute"),
|
||||
})}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -8,6 +8,7 @@ import clsx from "clsx";
|
||||
// clicked, so it never ships in the feed chunk for the majority who just browse.
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
import { api, type Me } from "../lib/api";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// Self-contained download affordance for a video card / the player. Hidden for the demo account
|
||||
// (downloads spend server disk + need a real identity). Reflects the video's current state from
|
||||
@@ -15,11 +16,13 @@ import { api, type Me } from "../lib/api";
|
||||
export default function DownloadButton({
|
||||
videoId,
|
||||
title,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
}: {
|
||||
videoId: string;
|
||||
title?: string | null;
|
||||
className?: string;
|
||||
variant?: "ghost" | "outline" | "overlay";
|
||||
size?: "sm" | "md";
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
@@ -47,20 +50,21 @@ export default function DownloadButton({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<IconButton
|
||||
variant={variant}
|
||||
size={size}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(true);
|
||||
}}
|
||||
title={label}
|
||||
className={clsx(
|
||||
className ?? "p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
|
||||
(downloaded || inQueue) && "text-accent",
|
||||
)}
|
||||
label={label}
|
||||
>
|
||||
<Download className={clsx("w-4 h-4", inQueue && "animate-pulse")} />
|
||||
</button>
|
||||
{/* The state is in the glyph: downloaded or queued is the accent, a running one pulses. */}
|
||||
<Download
|
||||
className={clsx((downloaded || inQueue) && "text-accent", inQueue && "animate-pulse")}
|
||||
/>
|
||||
</IconButton>
|
||||
{open && (
|
||||
<Suspense fallback={null}>
|
||||
<DownloadDialog source={videoId} title={title} onClose={() => setOpen(false)} />
|
||||
|
||||
@@ -77,6 +77,24 @@ afterEach(() => {
|
||||
mocks.confirm.mockClear();
|
||||
});
|
||||
|
||||
/** Pick `label` from the row's menu, opened by its "…". The surface is visibility:hidden in jsdom
|
||||
* (floating-ui never positions it), so the row is matched on its text. */
|
||||
async function pick(row: string, label: string) {
|
||||
const more = screen
|
||||
.getByText(row)
|
||||
.closest<HTMLElement>("[data-nav-index]")!
|
||||
.querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
|
||||
fireEvent.click(more);
|
||||
const item = await waitFor(() => {
|
||||
const found = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
expect(found).toBeTruthy();
|
||||
return found!;
|
||||
});
|
||||
fireEvent.click(item);
|
||||
}
|
||||
|
||||
describe("DownloadCenter confirm dialogs", () => {
|
||||
it("label the confirm button with the action, not the dialog's question", async () => {
|
||||
mocks.jobs = [job(1, "running"), job(2, "done")];
|
||||
@@ -86,12 +104,15 @@ describe("DownloadCenter confirm dialogs", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle("downloads.actions.cancel"));
|
||||
// E28 C1: the destructive and rare row buttons live in the row's "…".
|
||||
await pick("Clip 1", "downloads.actions.cancel");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
// The tab's label shares its button with a count badge.
|
||||
fireEvent.click(screen.getByRole("button", { name: /downloads\.tabs\.done/ }));
|
||||
fireEvent.click(await screen.findByTitle("downloads.actions.redownload"));
|
||||
fireEvent.click(screen.getByTitle("downloads.actions.delete"));
|
||||
fireEvent.click(screen.getByRole("tab", { name: /downloads\.tabs\.done/ }));
|
||||
await screen.findByText("Clip 2");
|
||||
await pick("Clip 2", "downloads.actions.redownload");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(2));
|
||||
await pick("Clip 2", "downloads.actions.delete");
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(3));
|
||||
|
||||
expect(mocks.confirm.mock.calls.map(([o]) => [o.title, o.confirmLabel])).toEqual([
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 C1 on the Download Center: a download row keeps its primary action (play; pause / resume /
|
||||
// retry in the queue) and the save link in sight, and everything else — the editors, sharing,
|
||||
// re-download, the copies, cancel / delete / remove — is its ONE menu, opened by the row's "…" (it
|
||||
// shows on hover or focus) or a right-click on the row. A link inside the row keeps the browser's own
|
||||
// menu. i18n is key-passthrough, so labels read as keys.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
jobs: [] as unknown[],
|
||||
confirm: vi.fn(async (_o?: unknown) => true),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||
vi.mock("../lib/useLiveQuery", () => ({
|
||||
useLiveQuery: (key: readonly unknown[]) => ({
|
||||
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
||||
isError: false,
|
||||
isLoading: false,
|
||||
refetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../lib/useMe", () => ({ useMe: () => ({ role: "user" }) }));
|
||||
vi.mock("./ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
import DownloadCenter from "./DownloadCenter";
|
||||
|
||||
const job = (
|
||||
id: number,
|
||||
status: DownloadJob["status"],
|
||||
extra: Partial<DownloadJob> = {},
|
||||
): DownloadJob =>
|
||||
({
|
||||
id,
|
||||
status,
|
||||
progress: 0,
|
||||
speed_bps: null,
|
||||
eta_s: null,
|
||||
phase: null,
|
||||
error: null,
|
||||
error_code: null,
|
||||
error_detail: null,
|
||||
queue_pos: 0,
|
||||
created_at: null,
|
||||
source_kind: "youtube",
|
||||
source_ref: `v${id}`,
|
||||
source_url: null,
|
||||
poster_url: null,
|
||||
profile_id: null,
|
||||
spec: { mode: "av" },
|
||||
display_name: `Clip ${id}`,
|
||||
display_uploader: null,
|
||||
display_uploader_url: null,
|
||||
extra_links: [],
|
||||
can_download: status === "done",
|
||||
expired: false,
|
||||
job_kind: "download",
|
||||
asset: { media_kind: "video", duration_s: 60 },
|
||||
...extra,
|
||||
}) as unknown as DownloadJob;
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
mocks.confirm.mockClear();
|
||||
});
|
||||
|
||||
function mount(tab?: string) {
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<DownloadCenter />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
if (tab)
|
||||
fireEvent.click(screen.getByRole("tab", { name: new RegExp(`downloads\\.tabs\\.${tab}`) }));
|
||||
}
|
||||
|
||||
const rowOf = (text: string) => screen.getByText(text).closest<HTMLElement>("[data-nav-index]")!;
|
||||
const moreOf = (text: string) =>
|
||||
rowOf(text).querySelector<HTMLElement>('button[aria-label="downloads.rowMenu"]')!;
|
||||
const menuRows = () =>
|
||||
screen.getAllByRole("menuitem", { hidden: true }).map((b) => b.textContent ?? "");
|
||||
async function openMenu(open: () => void) {
|
||||
act(open);
|
||||
await waitFor(() =>
|
||||
expect(screen.getAllByRole("menuitem", { hidden: true }).length).toBeGreaterThan(0),
|
||||
);
|
||||
}
|
||||
const rowButtons = (text: string) =>
|
||||
[...rowOf(text).querySelectorAll<HTMLElement>("button, a[aria-label]")].map((b) =>
|
||||
b.getAttribute("aria-label"),
|
||||
);
|
||||
|
||||
describe("DownloadCenter · the queue row", () => {
|
||||
it("shows pause and its '…' only; the '…' hides until hover or focus", async () => {
|
||||
mocks.jobs = [job(1, "running")];
|
||||
mount();
|
||||
expect(rowButtons("Clip 1")).toEqual(["downloads.actions.pause", "downloads.rowMenu"]);
|
||||
expect(moreOf("Clip 1").classList.contains("reveal")).toBe(true);
|
||||
expect(rowOf("Clip 1").classList.contains("group")).toBe(true);
|
||||
});
|
||||
|
||||
it("holds pause, the copies and cancel; a failed job's first row is Retry", async () => {
|
||||
mocks.jobs = [job(1, "running", { source_url: "https://youtu.be/x" }), job(2, "error")];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.pause",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"downloads.actions.cancel",
|
||||
]);
|
||||
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("true");
|
||||
fireEvent.keyDown(document.activeElement!, { key: "Escape" });
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.retry",
|
||||
"common.copy.title",
|
||||
"downloads.actions.cancel",
|
||||
]);
|
||||
});
|
||||
|
||||
it("draws Retry as 'do it again', never the Play glyph (row and menu)", async () => {
|
||||
mocks.jobs = [job(1, "error"), job(2, "paused")];
|
||||
mount();
|
||||
const glyph = (el: Element) => el.querySelector("svg")?.getAttribute("class") ?? "";
|
||||
const retry = rowOf("Clip 1").querySelector('[aria-label="downloads.actions.retry"]')!;
|
||||
expect(glyph(retry)).toContain("lucide-refresh-cw");
|
||||
// A paused job resumes: that one keeps the transport glyph.
|
||||
const resume = rowOf("Clip 2").querySelector('[aria-label="downloads.actions.resume"]')!;
|
||||
expect(glyph(resume)).toContain("lucide-play");
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 1")));
|
||||
const row = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "downloads.actions.retry")!;
|
||||
expect(glyph(row)).toContain("lucide-refresh-cw");
|
||||
});
|
||||
|
||||
it("closes on a second press of the same '…'", async () => {
|
||||
mocks.jobs = [job(1, "running")];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
|
||||
// The press lands outside the panel, which floating-ui's dismiss closes on; the click after
|
||||
// it must not open it again.
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Clip 1"));
|
||||
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
|
||||
);
|
||||
expect(moreOf("Clip 1").getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("still closes when a poll replaced the job under the open menu", async () => {
|
||||
mocks.jobs = [job(1, "running")];
|
||||
mount();
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
|
||||
// The queue polls while a job runs: the next render lists a NEW object for the same job.
|
||||
mocks.jobs = [job(1, "running", { progress: 40 })];
|
||||
fireEvent.change(screen.getByPlaceholderText("downloads.page.addUrl"), {
|
||||
target: { value: "x" },
|
||||
});
|
||||
act(() => {
|
||||
fireEvent.pointerDown(moreOf("Clip 1"), { pointerType: "mouse" });
|
||||
fireEvent.mouseDown(moreOf("Clip 1"));
|
||||
fireEvent.click(moreOf("Clip 1"), { detail: 1 });
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0),
|
||||
);
|
||||
});
|
||||
|
||||
it("cancels only through the confirm", async () => {
|
||||
mocks.jobs = [job(1, "queued")];
|
||||
const cancel = vi.spyOn(api, "cancelDownload").mockResolvedValue({} as never);
|
||||
mount();
|
||||
const pickCancel = async () => {
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 1")));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "downloads.actions.cancel")!,
|
||||
);
|
||||
};
|
||||
mocks.confirm.mockResolvedValueOnce(false);
|
||||
await pickCancel();
|
||||
await waitFor(() => expect(mocks.confirm).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.confirm).toHaveBeenCalledWith(expect.objectContaining({ danger: true }));
|
||||
expect(cancel).not.toHaveBeenCalled();
|
||||
await pickCancel();
|
||||
await waitFor(() => expect(cancel).toHaveBeenCalledWith(1));
|
||||
});
|
||||
});
|
||||
|
||||
describe("DownloadCenter · the library row", () => {
|
||||
it("shows play, share, save and its '…'; the editors, managing shares and delete are in the menu", async () => {
|
||||
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
expect(rowButtons("Clip 2")).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.rowMenu",
|
||||
]);
|
||||
await openMenu(() => fireEvent.contextMenu(rowOf("Clip 2")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"downloads.actions.editDetails",
|
||||
"downloads.actions.redownload",
|
||||
"downloads.actions.shareManage",
|
||||
"common.copy.title",
|
||||
"common.copy.link",
|
||||
"downloads.actions.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("copies the share link from the row's own Share button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
vi.spyOn(api, "downloadLinks").mockResolvedValue([] as never);
|
||||
const create = vi
|
||||
.spyOn(api, "createDownloadLink")
|
||||
.mockResolvedValue({ id: 1, url: "/s/tok" } as never);
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
fireEvent.click(
|
||||
rowOf("Clip 2").querySelector<HTMLElement>('button[aria-label="downloads.actions.share"]')!,
|
||||
);
|
||||
await waitFor(() => expect(create).toHaveBeenCalledWith(2, {}));
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("saves the file through the same link as the row's save button", async () => {
|
||||
mocks.jobs = [job(2, "done")];
|
||||
const followed: string[] = [];
|
||||
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function (
|
||||
this: HTMLAnchorElement,
|
||||
) {
|
||||
followed.push(this.href);
|
||||
});
|
||||
mount("done");
|
||||
await screen.findByText("Clip 2");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 2")));
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "downloads.actions.download")!,
|
||||
);
|
||||
const button = rowOf("Clip 2").querySelector<HTMLAnchorElement>(
|
||||
'a[aria-label="downloads.actions.download"]',
|
||||
)!;
|
||||
expect(followed).toEqual([button.href]);
|
||||
});
|
||||
|
||||
it("leaves out what a file or a clip cannot do, and no rule is left behind", async () => {
|
||||
mocks.jobs = [job(3, "done", { job_kind: "upload", asset: { media_kind: "file" } } as never)];
|
||||
mount("done");
|
||||
await screen.findByText("Clip 3");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 3")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.share",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.shareManage",
|
||||
"common.copy.title",
|
||||
"downloads.actions.delete",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gives a link inside the row the browser's own menu", async () => {
|
||||
mocks.jobs = [job(2, "done", { source_url: "https://youtu.be/x" })];
|
||||
mount("done");
|
||||
const link = await screen.findByText("youtu.be/x");
|
||||
const ev = new MouseEvent("contextmenu", { bubbles: true, cancelable: true });
|
||||
act(() => void link.dispatchEvent(ev));
|
||||
expect(ev.defaultPrevented).toBe(false);
|
||||
expect(screen.queryAllByRole("menuitem", { hidden: true })).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DownloadCenter · the shared row", () => {
|
||||
it("holds play, save, edit, the copies and Remove from my list", async () => {
|
||||
mocks.jobs = [];
|
||||
vi.spyOn(api, "downloadsShared").mockResolvedValue([
|
||||
job(4, "done", { can_download: true }),
|
||||
] as never);
|
||||
const remove = vi.spyOn(api, "removeSharedDownload").mockResolvedValue({} as never);
|
||||
mount("shared");
|
||||
await screen.findByText("Clip 4");
|
||||
await openMenu(() => fireEvent.click(moreOf("Clip 4")));
|
||||
expect(menuRows()).toEqual([
|
||||
"downloads.actions.play",
|
||||
"downloads.actions.download",
|
||||
"downloads.actions.edit",
|
||||
"common.copy.title",
|
||||
"downloads.actions.removeShared",
|
||||
]);
|
||||
fireEvent.click(
|
||||
screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "downloads.actions.removeShared")!,
|
||||
);
|
||||
await waitFor(() => expect(remove).toHaveBeenCalledWith(4));
|
||||
});
|
||||
});
|
||||
|
||||
describe("menuGroups", () => {
|
||||
const row = (id: string) => ({ id, label: id, onSelect: () => {} });
|
||||
it("rules between non-empty groups only", () => {
|
||||
const out = menuGroups([], [row("a"), row("b")], [], [row("c")]);
|
||||
expect(out.map((r) => [r.id, !!r.separatorBefore])).toEqual([
|
||||
["a", false],
|
||||
["b", false],
|
||||
["c", true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,25 +1,21 @@
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Ban,
|
||||
ClipboardPaste,
|
||||
Download,
|
||||
Link2,
|
||||
Pause,
|
||||
Play,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Scissors,
|
||||
RefreshCw,
|
||||
Settings2,
|
||||
Share2,
|
||||
Pencil,
|
||||
Trash2,
|
||||
UploadCloud,
|
||||
} from "lucide-react";
|
||||
import Tabs from "./ui/Tabs";
|
||||
import { useTabPanel } from "./ui/tabPanel";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
|
||||
@@ -30,28 +26,32 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
import { useConfirm } from "./confirmContext";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { api, type DownloadJob } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
||||
import { libraryRowActions, sharedRowActions } from "../lib/downloadActions";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { DownloadRow, IconBtn, UsageBar } from "./downloadRow";
|
||||
import { MetaEditModal, ErrorModal } from "./downloadModals";
|
||||
import { AdminSystem } from "./downloadAdmin";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Input } from "./ui/Input";
|
||||
import { IconLink } from "./ui/LinkButton";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { useDownloadActions, type DownloadTab } from "./useDownloadActions";
|
||||
|
||||
const jobKey = (job: DownloadJob) => job.id;
|
||||
|
||||
export default function DownloadCenter() {
|
||||
const me = useMe();
|
||||
const { t } = useTranslation();
|
||||
const qc = useQueryClient();
|
||||
const confirm = useConfirm();
|
||||
// Always land on the queue tab (not a persisted last-tab) — the queue is where new work and
|
||||
// any failures are, so it's the useful default every visit.
|
||||
const [tab, setTab] = useState("queue");
|
||||
const panel = useTabPanel();
|
||||
const [addUrl, setAddUrl] = useState("");
|
||||
const [addSource, setAddSource] = useState<string | null>(null);
|
||||
const [upload, setUpload] = useState(false);
|
||||
@@ -78,56 +78,35 @@ export default function DownloadCenter() {
|
||||
const queue = jobs.filter((j) => ["queued", "running", "paused", "error"].includes(j.status));
|
||||
const library = jobs.filter((j) => j.status === "done");
|
||||
|
||||
const invalidate = () => invalidateDownloads(qc);
|
||||
const act = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
op,
|
||||
}: {
|
||||
id: number;
|
||||
op: "pause" | "resume" | "cancel" | "delete" | "redownload";
|
||||
}) =>
|
||||
op === "pause"
|
||||
? api.pauseDownload(id)
|
||||
: op === "resume"
|
||||
? api.resumeDownload(id)
|
||||
: op === "cancel"
|
||||
? api.cancelDownload(id)
|
||||
: op === "redownload"
|
||||
? api.redownloadDownload(id)
|
||||
: api.deleteDownload(id),
|
||||
onSuccess: invalidate,
|
||||
// A 400/409/422/500 opens the global error dialog with the backend's own message (and is then
|
||||
// skipped here); a job that is gone (404) or a lost connection is said here, naming the action.
|
||||
onError: (e, v) => notifyActionError(e, t(`errors.writeFailed.job.${v.op}`)),
|
||||
// --- The rows' ONE menu (E28 C1) -------------------------------------------------------------
|
||||
// Keyed by the job's id: the queue polls every 2 s while a job runs, replacing the object under an
|
||||
// open menu, and its "…" must still close it. The tab is the one on screen.
|
||||
const rowMenu = useContextMenu<DownloadJob>({ keyOf: jobKey });
|
||||
const actions = useDownloadActions({
|
||||
onPlay: setPlayJob,
|
||||
onEdit: setEditJob,
|
||||
onEditDetails: setMetaJob,
|
||||
onManageShares: setShareJob,
|
||||
});
|
||||
|
||||
// Remove a shared-with-me item from your list (only your grant; the owner's file is untouched).
|
||||
const removeShared = useMutation({
|
||||
mutationFn: (id: number) => api.removeSharedDownload(id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.downloadsShared() }),
|
||||
meta: { failTitle: "errors.writeFailed.removeShared" },
|
||||
});
|
||||
|
||||
// The confirm button names the ACTION ("Remove"), never the dialog's question: labelled with the title
|
||||
// it read "Re-download this file?", and the cancel dialog's own "Cancel" would sit beside "Cancel".
|
||||
const confirmThen = async (title: string, body: string, action: string, fn: () => void) => {
|
||||
if (await confirm({ title, message: body, confirmLabel: action, danger: true })) fn();
|
||||
};
|
||||
|
||||
// One-click share (E7 S3 · D2-A): ensure a downloadable link, copy it, toast with "open in new tab"
|
||||
// (see quickShareLink). Re-clicking reuses the existing link; a per-job in-flight guard also stops a
|
||||
// double-click from racing two POSTs into two links before the cache is primed. Advanced controls
|
||||
// (password, expiry, revoke, share-with-a-user) live in the Share dialog, via the manage-share button.
|
||||
const [sharingId, setSharingId] = useState<number | null>(null);
|
||||
const quickShare = async (job: DownloadJob) => {
|
||||
if (sharingId !== null) return;
|
||||
setSharingId(job.id);
|
||||
try {
|
||||
await quickShareLink(qc, job, t);
|
||||
} finally {
|
||||
setSharingId(null);
|
||||
}
|
||||
const menuTarget = rowMenu.target;
|
||||
const listTab: DownloadTab | null =
|
||||
tab === "queue" || tab === "done" || tab === "shared" ? tab : null;
|
||||
const menuItems = menuTarget && listTab ? actions.items(menuTarget, listTab) : [];
|
||||
// The row's menu triggers and its "…".
|
||||
const rowMenuProps = (job: DownloadJob) => {
|
||||
const mine = rowMenu.open && rowMenu.target?.id === job.id;
|
||||
return {
|
||||
rowProps: rowMenu.triggerProps(job),
|
||||
more: (
|
||||
<MoreButton
|
||||
label={t("downloads.rowMenu")}
|
||||
size="md"
|
||||
expanded={mine}
|
||||
menuProps={rowMenu.buttonProps(job)}
|
||||
className={mine ? undefined : "reveal"}
|
||||
/>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
// Pull a link off the clipboard into the add-URL box. Clipboard reads need a secure context +
|
||||
@@ -158,56 +137,57 @@ export default function DownloadCenter() {
|
||||
if (!tabs.some((tb) => tb.id === tab)) setTab("queue");
|
||||
|
||||
const saveBtn = (job: DownloadJob) => (
|
||||
<a
|
||||
href={api.downloadFileUrl(job.id)}
|
||||
title={t("downloads.actions.download")}
|
||||
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg transition"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</a>
|
||||
<IconLink href={api.downloadFileUrl(job.id)} label={t("downloads.actions.download")} size="md">
|
||||
<Download />
|
||||
</IconLink>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
|
||||
{/* The tabs are the page's band (B4); the tab content scrolls under them. No visible title: the
|
||||
nav rail and the tab title name the page, the shell's hidden h1 names it to a screen reader. */}
|
||||
<PageToolbar>
|
||||
<div className="px-4 pt-2 max-w-4xl w-full mx-auto">
|
||||
<div className="flex items-center justify-between gap-3 mb-1">
|
||||
<h1 className="text-xl font-semibold">{t("downloads.page.title")}</h1>
|
||||
<button
|
||||
onClick={() => setManage(true)}
|
||||
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-surface transition"
|
||||
>
|
||||
<Settings2 className="w-4 h-4" /> {t("downloads.page.manage")}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
|
||||
|
||||
<Tabs tabs={tabs} active={tab} onChange={setTab} />
|
||||
<div className="px-4 pt-2 pb-2 max-w-4xl w-full mx-auto flex flex-wrap items-center justify-between gap-2">
|
||||
<Tabs
|
||||
idBase={panel.idBase}
|
||||
tabs={tabs}
|
||||
active={tab}
|
||||
onChange={setTab}
|
||||
className="flex flex-wrap gap-1"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<Settings2 />}
|
||||
onClick={() => setManage(true)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{t("downloads.page.manage")}
|
||||
</Button>
|
||||
</div>
|
||||
</PageToolbar>
|
||||
|
||||
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
|
||||
<div {...panel.panelProps(tab)} className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
|
||||
{tab === "queue" && (
|
||||
<>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
{/* The field takes the whole line when the buttons would squeeze it (at 375 it had 57px). */}
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Input
|
||||
className="min-w-0 flex-[1_1_16rem]"
|
||||
value={addUrl}
|
||||
onChange={(e) => setAddUrl(e.target.value)}
|
||||
onFocus={() => pasteFromClipboard()}
|
||||
onKeyDown={(e) => e.key === "Enter" && addUrl.trim() && setAddSource(addUrl.trim())}
|
||||
placeholder={t("downloads.page.addUrl")}
|
||||
className={inputCls}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
iconOnly
|
||||
icon={<ClipboardPaste className="w-4 h-4" />}
|
||||
<IconButton
|
||||
label={t("downloads.page.paste")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => pasteFromClipboard(true)}
|
||||
className="shrink-0"
|
||||
/>
|
||||
>
|
||||
<ClipboardPaste className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
@@ -230,41 +210,38 @@ export default function DownloadCenter() {
|
||||
</div>
|
||||
<NavGrid className="space-y-2">
|
||||
{queue.map((job, i) => (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} onShowError={setErrorJob}>
|
||||
{(job.status === "queued" || job.status === "running") && (
|
||||
<DownloadRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
navIndex={i}
|
||||
onShowError={setErrorJob}
|
||||
{...rowMenuProps(job)}
|
||||
>
|
||||
{/* The queue's primary action stays in sight; cancel moved into the "…". */}
|
||||
{job.status === "queued" || job.status === "running" ? (
|
||||
<IconBtn
|
||||
onClick={() => act.mutate({ id: job.id, op: "pause" })}
|
||||
onClick={() => actions.run(job, "pause")}
|
||||
title={t("downloads.actions.pause")}
|
||||
>
|
||||
<Pause className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{(job.status === "paused" || job.status === "error") && (
|
||||
) : (
|
||||
<IconBtn
|
||||
onClick={() => act.mutate({ id: job.id, op: "resume" })}
|
||||
onClick={() => actions.run(job, "resume")}
|
||||
title={t(
|
||||
job.status === "error"
|
||||
? "downloads.actions.retry"
|
||||
: "downloads.actions.resume",
|
||||
)}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
{/* Retry is "do it again", not playback: the Play glyph promised a video. */}
|
||||
{job.status === "error" ? (
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
) : (
|
||||
<Play className="w-4 h-4" />
|
||||
)}
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.cancelTitle"),
|
||||
t("downloads.confirm.cancelBody"),
|
||||
t("downloads.confirm.cancelAction"),
|
||||
() => act.mutate({ id: job.id, op: "cancel" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.cancel")}
|
||||
danger
|
||||
>
|
||||
<Ban className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
))}
|
||||
{queue.length === 0 &&
|
||||
@@ -273,7 +250,7 @@ export default function DownloadCenter() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : jobsQ.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.queue")}
|
||||
@@ -287,82 +264,31 @@ export default function DownloadCenter() {
|
||||
<>
|
||||
<UsageBar />
|
||||
<NavGrid className="space-y-2">
|
||||
{library.map((job, i) => {
|
||||
// Row actions gated on the stored media_kind + job_kind (see downloadActions).
|
||||
const a = libraryRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||
{a.play && (
|
||||
<IconBtn
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
>
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.editDetails && (
|
||||
<IconBtn
|
||||
onClick={() => setMetaJob(job)}
|
||||
title={t("downloads.actions.editDetails")}
|
||||
>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn onClick={() => quickShare(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{library.map((job, i) => (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||
{libraryRowActions(job).play && (
|
||||
<IconBtn
|
||||
onClick={() => setShareJob(job)}
|
||||
title={t("downloads.actions.shareManage")}
|
||||
open
|
||||
onClick={() => setPlayJob(job)}
|
||||
title={t("downloads.actions.play")}
|
||||
>
|
||||
<Link2 className="w-4 h-4" />
|
||||
<PlayCircle className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{a.redownload && (
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.redownloadTitle"),
|
||||
t("downloads.confirm.redownloadBody"),
|
||||
t("downloads.confirm.redownloadAction"),
|
||||
() => act.mutate({ id: job.id, op: "redownload" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.redownload")}
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.deleteTitle"),
|
||||
t("downloads.confirm.deleteBody"),
|
||||
t("downloads.actions.delete"),
|
||||
() => act.mutate({ id: job.id, op: "delete" }),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.delete")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
)}
|
||||
{/* Sharing the link is the library's main errand: in sight, ahead of save. */}
|
||||
<IconBtn onClick={() => actions.share(job)} title={t("downloads.actions.share")}>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
{saveBtn(job)}
|
||||
</DownloadRow>
|
||||
))}
|
||||
{library.length === 0 &&
|
||||
(jobsQ.isError && !jobsQ.data ? (
|
||||
<StateMessage tone="error" onRetry={() => jobsQ.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : jobsQ.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.done")}
|
||||
@@ -378,7 +304,7 @@ export default function DownloadCenter() {
|
||||
// Same media_kind + can_download gate as the library, via the shared helper (S5b).
|
||||
const a = sharedRowActions(job);
|
||||
return (
|
||||
<DownloadRow key={job.id} job={job} navIndex={i}>
|
||||
<DownloadRow key={job.id} job={job} navIndex={i} {...rowMenuProps(job)}>
|
||||
{a.play && (
|
||||
<IconBtn
|
||||
open
|
||||
@@ -389,25 +315,6 @@ export default function DownloadCenter() {
|
||||
</IconBtn>
|
||||
)}
|
||||
{a.save && saveBtn(job)}
|
||||
{a.edit && (
|
||||
<IconBtn onClick={() => setEditJob(job)} title={t("downloads.actions.edit")}>
|
||||
<Scissors className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
)}
|
||||
<IconBtn
|
||||
onClick={() =>
|
||||
confirmThen(
|
||||
t("downloads.confirm.removeSharedTitle"),
|
||||
t("downloads.confirm.removeSharedBody"),
|
||||
t("downloads.actions.removeShared"),
|
||||
() => removeShared.mutate(job.id),
|
||||
)
|
||||
}
|
||||
title={t("downloads.actions.removeShared")}
|
||||
danger
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</IconBtn>
|
||||
</DownloadRow>
|
||||
);
|
||||
})}
|
||||
@@ -417,7 +324,7 @@ export default function DownloadCenter() {
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : sharedQ.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : (
|
||||
<div className="text-sm text-muted py-10 text-center">
|
||||
{t("downloads.empty.shared")}
|
||||
@@ -428,6 +335,8 @@ export default function DownloadCenter() {
|
||||
|
||||
{tab === "system" && me.role === "admin" && <AdminSystem />}
|
||||
|
||||
<ContextMenu menu={rowMenu} ariaLabel={t("downloads.rowMenu")} items={menuItems} />
|
||||
|
||||
<Suspense fallback={null}>
|
||||
{addSource && (
|
||||
<DownloadDialog
|
||||
@@ -447,7 +356,7 @@ export default function DownloadCenter() {
|
||||
{errorJob && (
|
||||
<ErrorModal
|
||||
job={errorJob}
|
||||
onRetry={() => act.mutate({ id: errorJob.id, op: "resume" })}
|
||||
onRetry={() => actions.run(errorJob, "resume")}
|
||||
onClose={() => setErrorJob(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import Modal from "./ui/Modal";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { api } from "../lib/api";
|
||||
import { invalidateDownloads } from "../lib/downloads";
|
||||
@@ -12,8 +12,8 @@ const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { inputCls } from "./ui/form";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import { Input } from "./ui/Input";
|
||||
|
||||
// Preset picker shown when the user hits Download on a card / in the player, or adds a URL from
|
||||
// the Download Center. Enqueues via the API and points the user at the Download Center.
|
||||
@@ -63,7 +63,7 @@ export default function DownloadDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal title={t("downloads.dialog.title")} onClose={onClose}>
|
||||
<Modal initialFocus="field" title={t("downloads.dialog.title")} onClose={onClose}>
|
||||
{title && <p className="text-sm text-muted mb-4 line-clamp-2">{title}</p>}
|
||||
|
||||
<span className="block text-sm font-medium mb-1" aria-hidden="true">
|
||||
@@ -77,30 +77,26 @@ export default function DownloadDialog({
|
||||
onChange={([v]) => v && setProfileId(Number(v))}
|
||||
options={profiles.map((p) => ({ value: String(p.id), label: p.name }))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManage(true)}
|
||||
className="text-xs text-accent hover:underline mt-1 mb-4"
|
||||
>
|
||||
<Button variant="link" onClick={() => setManage(true)} className="text-xs mt-1 mb-4">
|
||||
{t("downloads.dialog.manage")}
|
||||
</button>
|
||||
</Button>
|
||||
|
||||
<label className="block text-sm font-medium mb-1">{t("downloads.dialog.nameLabel")}</label>
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("downloads.dialog.namePlaceholder")}
|
||||
className={`${inputCls} mb-5`}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<ModalFooter>
|
||||
<Button variant="ghost" size="sm" onClick={onClose}>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submit} disabled={busy || !chosen}>
|
||||
{t("downloads.dialog.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
|
||||
{manage && (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import i18n from "../i18n";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { errorAttrs, log } from "../lib/log";
|
||||
import { reloadingForNewBuild } from "../lib/staleBuild";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
@@ -40,18 +41,15 @@ export default class ErrorBoundary extends Component<Props, State> {
|
||||
if (this.state.crashed) {
|
||||
if (reloadingForNewBuild()) return null;
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted p-6 text-center">
|
||||
<div className="min-h-dvh grid place-items-center text-muted p-6 text-center">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-fg mb-1">
|
||||
{i18n.t("errors.boundary.title")}
|
||||
</div>
|
||||
<div className="text-sm mb-3">{i18n.t("errors.boundary.subtitle")}</div>
|
||||
<button
|
||||
onClick={() => location.reload()}
|
||||
className="text-accent hover:underline text-sm font-semibold"
|
||||
>
|
||||
<Button variant="link" onClick={() => location.reload()} className="text-sm">
|
||||
{i18n.t("errors.boundary.reload")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { dismissError, getError, subscribeError } from "../lib/errorDialog";
|
||||
import Modal from "./ui/Modal";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
|
||||
// Renders the current app error (if any) as a modal the user must acknowledge.
|
||||
@@ -10,11 +10,11 @@ export default function ErrorDialog() {
|
||||
const err = useSyncExternalStore(subscribeError, getError, getError);
|
||||
if (!err) return null;
|
||||
return (
|
||||
<Modal title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
||||
<Modal initialFocus="close" title={err.title} onClose={dismissError} maxWidth="max-w-md">
|
||||
<p className="text-sm leading-relaxed text-fg/90">{err.message}</p>
|
||||
<div className="flex justify-end mt-5">
|
||||
<ModalFooter>
|
||||
<Button onClick={dismissError}>{t("errors.ok")}</Button>
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@ import { type ReactNode } from "react";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Button } from "./ui/Button";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
|
||||
// The presentation shared by the channel manager's "excluded channel" tabs — Hidden and Blocked.
|
||||
// A responsive grid of proper channel cards (avatar + name + open-on-YouTube, via ChannelLink) each
|
||||
@@ -40,14 +43,14 @@ export default function ExcludedChannelList({
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
{icon}
|
||||
<span className="text-xs uppercase tracking-wide text-muted">{title}</span>
|
||||
<SectionLabel>{title}</SectionLabel>
|
||||
</div>
|
||||
<NavGrid className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{channels.map((c, i) => (
|
||||
<div
|
||||
key={c.id}
|
||||
{...navItemAttrs(i)}
|
||||
className="glass-card flex items-center gap-2 rounded-xl px-3 py-2"
|
||||
className={`${SURFACE.card} flex items-center gap-2 px-3 py-2`}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChannelLink
|
||||
@@ -58,15 +61,9 @@ export default function ExcludedChannelList({
|
||||
onView={onView ? () => onView(c) : undefined}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onAction(c.id)}
|
||||
title={actionLabel}
|
||||
aria-label={actionLabel}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-full border border-border px-2 py-1 text-xs text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
{actionIcon}
|
||||
<Button variant="outline" size="xs" icon={actionIcon} onClick={() => onAction(c.id)}>
|
||||
{actionLabel}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</NavGrid>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// "Reset — clear watch progress/status" on a half-watched video. The optimistic layer overrode only
|
||||
// the STATUS, so the card kept its resume position — its progress bar and its Continue button — until
|
||||
// the next refetch (found in the E28 S3e1 UAT). The grid is replaced by a list that shows what Feed
|
||||
// hands it: each video's position, and a button that fires the reset.
|
||||
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" } }),
|
||||
}));
|
||||
vi.mock("./wizardContext", () => ({ useWizard: () => ({ openWizard: vi.fn() }) }));
|
||||
vi.mock("./VirtualFeed", () => ({
|
||||
default: ({
|
||||
items,
|
||||
onResetState,
|
||||
}: {
|
||||
items: { id: string; position_seconds: number }[];
|
||||
onResetState: (id: string) => void;
|
||||
}) => (
|
||||
<ol aria-label="results">
|
||||
{items.map((v) => (
|
||||
<li key={v.id}>
|
||||
{v.id}@{v.position_seconds}
|
||||
<button onClick={() => onResetState(v.id)}>reset {v.id}</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
),
|
||||
}));
|
||||
|
||||
import Feed from "./Feed";
|
||||
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Video } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
const FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
};
|
||||
|
||||
const halfWatched = {
|
||||
id: "v1",
|
||||
title: "v1",
|
||||
channel_id: "UCx",
|
||||
channel_title: "Channel",
|
||||
status: "new",
|
||||
position_seconds: 95,
|
||||
duration_seconds: 600,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
watch_url: "https://www.youtube.com/watch?v=v1",
|
||||
} as unknown as Video;
|
||||
|
||||
describe("Feed — resetting a half-watched video", () => {
|
||||
it("clears its resume position at once, not only its status", async () => {
|
||||
vi.spyOn(api, "feed").mockResolvedValue({ items: [halfWatched], next_cursor: null } as never);
|
||||
vi.spyOn(api, "feedSummary").mockResolvedValue({ count: 1, facets: {} } as never);
|
||||
// Never settles: what the card shows before the server answers is the optimistic layer alone.
|
||||
vi.spyOn(api, "clearState").mockReturnValue(new Promise(() => {}) as never);
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
qc.setQueryData(qk.me(), { id: 1, can_write: true, prefs: {} });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<Feed
|
||||
filters={FILTERS}
|
||||
setFilters={() => {}}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
canRead
|
||||
ytSearch=""
|
||||
onYtSearch={() => {}}
|
||||
onExitYtSearch={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByText("v1@95")).toBeTruthy());
|
||||
fireEvent.click(screen.getByText("reset v1"));
|
||||
await waitFor(() => expect(screen.getByText("v1@0")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -44,14 +44,19 @@ import SelectedViewChip from "./SelectedViewChip";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { PageToolbar } from "./ui/PageShell";
|
||||
import { PageMenu } from "./ui/PageMenu";
|
||||
import { menuGroups } from "./ui/menuGroups";
|
||||
import { choiceSubmenu, multiSubmenu, sortSubmenu } from "./ui/pageMenuItems";
|
||||
import { useWizard } from "./wizardContext";
|
||||
import SortControl from "./ui/SortControl";
|
||||
import FilterSelect from "./ui/FilterSelect";
|
||||
import ItemCount from "./ui/ItemCount";
|
||||
import { FilterFoldButton } from "./ui/FilterFold";
|
||||
import { useFilterFold } from "../lib/useFilterFold";
|
||||
import ViewSwitcher from "./ViewSwitcher";
|
||||
import VirtualFeed from "./VirtualFeed";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
|
||||
const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
|
||||
@@ -136,11 +141,16 @@ export default function Feed({
|
||||
const { openWizard: onOpenWizard } = useWizard();
|
||||
// Same list the panel's badge counts — see lib/useActiveFilters.
|
||||
const activeFilters = useActiveFilters(filters, setFilters);
|
||||
// B4: on a short window the filter row folds into one "Filters · n" button (ui/FilterFold).
|
||||
const fold = useFilterFold();
|
||||
const sortKeys = channelScoped
|
||||
? SORT_KEYS.filter((k) => k !== "subscribers" && k !== "priority")
|
||||
: SORT_KEYS;
|
||||
const [overrides, setOverrides] = useState<Record<string, VideoStatus>>({});
|
||||
const [savedOverrides, setSavedOverrides] = useState<Record<string, boolean>>({});
|
||||
// A reset drops the resume position too, which a status override cannot say: without this the card
|
||||
// kept its progress bar and its Continue button until the next refetch.
|
||||
const [resetIds, setResetIds] = useState<Record<string, true>>({});
|
||||
// The open player: which video and where to start (null = resume from saved position).
|
||||
const [activeVideo, setActiveVideo] = useState<{ video: Video; startAt: number | null } | null>(
|
||||
null,
|
||||
@@ -234,6 +244,7 @@ export default function Feed({
|
||||
setOverridesFor(filters);
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
setResetIds({});
|
||||
}
|
||||
// ...but NOT on infinite-scroll appends: fetchNextPage also bumps dataUpdatedAt while page 1's
|
||||
// rows are unchanged, so clearing overrides there would make a just-hidden card flash back.
|
||||
@@ -246,6 +257,7 @@ export default function Feed({
|
||||
if (!appended) {
|
||||
setOverrides({});
|
||||
setSavedOverrides({});
|
||||
setResetIds({});
|
||||
}
|
||||
}, [query.dataUpdatedAt, pagesLen]);
|
||||
|
||||
@@ -346,6 +358,7 @@ export default function Feed({
|
||||
// Reset to pristine (drop the whole VideoState row, incl. resume position) — the
|
||||
// un-watch toggle can't clear an in-progress position, this can.
|
||||
setOverrides((o) => ({ ...o, [id]: "new" }));
|
||||
setResetIds((r) => ({ ...r, [id]: true }));
|
||||
api
|
||||
.clearState(id)
|
||||
// Stale-mark only (see setState above): the optimistic override already reset the card.
|
||||
@@ -357,6 +370,11 @@ export default function Feed({
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setResetIds((r) => {
|
||||
const next = { ...r };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[qc],
|
||||
@@ -391,6 +409,7 @@ export default function Feed({
|
||||
const status = overrides[v.id]; // read once so the narrowing sticks
|
||||
return status ? { ...v, status } : v;
|
||||
})
|
||||
.map((v) => (resetIds[v.id] ? { ...v, position_seconds: 0 } : v))
|
||||
.map((v) => {
|
||||
const saved = savedOverrides[v.id];
|
||||
return saved !== undefined ? { ...v, saved } : v;
|
||||
@@ -436,7 +455,10 @@ export default function Feed({
|
||||
<div className="p-4">
|
||||
<div className="pb-3 mb-1 border-b border-border">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<button
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ArrowLeft />}
|
||||
onClick={() => {
|
||||
// Surface the just-searched videos in the feed you return to: switch the Source
|
||||
// filter to "search" (your own search finds) and rank them by relevance.
|
||||
@@ -444,11 +466,9 @@ export default function Feed({
|
||||
onExitYtSearch();
|
||||
dropFeedCaches();
|
||||
}}
|
||||
className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-accent transition shrink-0"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t("feed.yt.back")}
|
||||
</button>
|
||||
</Button>
|
||||
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<Youtube className="w-4 h-4 text-accent shrink-0" />
|
||||
<span className="text-sm font-semibold truncate">
|
||||
@@ -462,6 +482,7 @@ export default function Feed({
|
||||
<div className="ml-auto flex items-center gap-1.5 text-xs text-muted shrink-0">
|
||||
<span aria-hidden="true">{t("feed.yt.show")}</span>
|
||||
<SegmentedToggle
|
||||
size="md"
|
||||
ariaLabel={t("feed.yt.show")}
|
||||
title={t("feed.yt.showHint")}
|
||||
value={String(ytCount)}
|
||||
@@ -484,7 +505,10 @@ export default function Feed({
|
||||
<div className="flex items-center gap-2 flex-wrap mt-2 text-xs text-muted">
|
||||
<Info className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{t("feed.yt.ephemeralNote")}</span>
|
||||
<button
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
size="xs"
|
||||
icon={<Trash2 />}
|
||||
onClick={() => {
|
||||
const ids = ytItems.map((v) => v.id);
|
||||
api
|
||||
@@ -500,11 +524,9 @@ export default function Feed({
|
||||
dropFeedCaches();
|
||||
qc.removeQueries({ queryKey: qk.ytSearch() });
|
||||
}}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full border border-border text-muted hover:text-danger hover:border-danger transition"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t("feed.yt.clearNow")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -530,17 +552,17 @@ export default function Feed({
|
||||
/>
|
||||
{ytQuery.hasNextPage && (
|
||||
<div className="text-center pt-4">
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => ytQuery.fetchNextPage()}
|
||||
disabled={ytQuery.isFetchingNextPage}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-xl border border-border bg-card text-sm font-medium hover:border-accent hover:text-accent disabled:opacity-50 transition"
|
||||
loading={ytQuery.isFetchingNextPage}
|
||||
>
|
||||
{ytQuery.isFetchingNextPage
|
||||
? t("feed.loadingMore")
|
||||
: zeroQuota
|
||||
? t("feed.yt.loadMoreFree")
|
||||
: t("feed.yt.loadMore")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -561,7 +583,7 @@ export default function Feed({
|
||||
);
|
||||
}
|
||||
|
||||
if (query.isLoading) return <LoadingState label={t("feed.loading")} />;
|
||||
if (query.isLoading) return <LoadingState shape="grid" label={t("feed.loading")} />;
|
||||
if (query.isError && !query.data)
|
||||
return (
|
||||
<StateMessage tone="error" onRetry={() => query.refetch()}>
|
||||
@@ -594,12 +616,13 @@ export default function Feed({
|
||||
<Button variant="primary" size="lg" onClick={onOpenWizard}>
|
||||
{t("feed.setUp")}
|
||||
</Button>
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
className="mx-auto mt-3 flex text-sm"
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="block mx-auto mt-3 text-sm text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("feed.browseShared")}
|
||||
</button>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -617,43 +640,60 @@ export default function Feed({
|
||||
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
|
||||
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
|
||||
// same controls render on both the feed and channel-scoped rows.
|
||||
// One setter per toolbar control, shared with the page's background menu (the same choices, the
|
||||
// same effect, two doors).
|
||||
const setSort = (next: SortState) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
};
|
||||
const reshuffle = () => setFilters({ ...filters, seed: rollSeed() });
|
||||
const showOptions = SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }));
|
||||
const setShow = (show: string) => setFilters({ ...filters, show: show as FeedFilters["show"] });
|
||||
const contentOptions = CONTENT.map((c) => ({ value: c.key, label: t(c.label) }));
|
||||
const contentSelected = CONTENT.filter((c) => filters[c.key]).map((c) => c.key);
|
||||
const setContent = (next: string[]) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
};
|
||||
const sourceOptions = [
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
];
|
||||
const setSource = (source: string) =>
|
||||
setFilters({ ...filters, librarySource: source as FeedFilters["librarySource"] });
|
||||
const viewOptions = FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}));
|
||||
|
||||
const sortView = (
|
||||
<>
|
||||
<SortControl
|
||||
options={sortOptions}
|
||||
sort={{ key: sortKey, dir: sortDir }}
|
||||
onChange={(next) => {
|
||||
if (!next) return; // the feed is always sorted — no "no sort" option is offered
|
||||
setFilters({
|
||||
...filters,
|
||||
sort: buildSort(next.key as SortKey, next.dir),
|
||||
seed: next.key === "shuffle" ? rollSeed() : undefined,
|
||||
});
|
||||
}}
|
||||
onChange={setSort}
|
||||
label={t("feed.sortLabel")}
|
||||
ascLabel={t("feed.dirAsc")}
|
||||
descLabel={t("feed.dirDesc")}
|
||||
trailing={
|
||||
sortKey === "shuffle" ? (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
|
||||
title={t("sidebar.reshuffle")}
|
||||
aria-label={t("sidebar.reshuffle")}
|
||||
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</button>
|
||||
<IconButton variant="outline" onClick={reshuffle} label={t("sidebar.reshuffle")}>
|
||||
<RefreshCw />
|
||||
</IconButton>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
|
||||
<ViewSwitcher
|
||||
value={view}
|
||||
options={FEED_VIEWS.map((id) => ({
|
||||
id,
|
||||
label: t("feed.view." + id),
|
||||
icon: VIEW_ICON[id],
|
||||
}))}
|
||||
options={viewOptions}
|
||||
onChange={setView}
|
||||
label={t("feed.viewLabel")}
|
||||
// The mode's name only earns its place once the window is wide.
|
||||
@@ -668,55 +708,117 @@ export default function Feed({
|
||||
{/* The item count is the fixed leading element — a fixed-width pill so a changing number
|
||||
never shoves the controls beside it (see ItemCount). */}
|
||||
<ItemCount count={summaryQuery.data?.count} />
|
||||
{/* Watch state is single-select (always exactly one); content types are an independent
|
||||
multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={SHOW_IDS.map((id) => ({ value: id, label: t("sidebar.show." + id) }))}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => setFilters({ ...filters, show: next[0] as FeedFilters["show"] })}
|
||||
<FilterFoldButton
|
||||
fold={fold}
|
||||
label={t("sidebar.filters")}
|
||||
count={channelScoped ? 0 : activeFilters.length}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={CONTENT.map((c) => ({ value: c.key, label: t(c.label) }))}
|
||||
selected={CONTENT.filter((c) => filters[c.key]).map((c) => c.key)}
|
||||
onChange={(next) => {
|
||||
const patch = { ...filters };
|
||||
for (const c of CONTENT) patch[c.key] = next.includes(c.key);
|
||||
setFilters(patch);
|
||||
}}
|
||||
/>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog concept —
|
||||
it selects how videos ENTERED the library. On a channel-scoped view we already show all
|
||||
of this one channel's videos (librarySource is pinned to "all"), so the selector would
|
||||
only offer a near-empty, confusing slice — hide it here. */}
|
||||
{!channelScoped && (
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={[
|
||||
{ value: "organic", label: t("feed.source.organic") },
|
||||
{ value: "all", label: t("feed.source.all") },
|
||||
{ value: "search", label: t("feed.source.only") },
|
||||
]}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) =>
|
||||
setFilters({
|
||||
...filters,
|
||||
librarySource: next[0] as FeedFilters["librarySource"],
|
||||
})
|
||||
}
|
||||
/>
|
||||
{!fold.folded && (
|
||||
<>
|
||||
{/* Watch state is single-select (always exactly one); content types are an
|
||||
independent multi-select. Both compress to one labeled dropdown pill via FilterSelect. */}
|
||||
<FilterSelect
|
||||
label={t("feed.showLabel")}
|
||||
testId="feed-show"
|
||||
options={showOptions}
|
||||
selected={[filters.show]}
|
||||
onChange={(next) => next[0] && setShow(next[0])}
|
||||
/>
|
||||
<FilterSelect
|
||||
label={t("feed.contentLabel")}
|
||||
testId="feed-content"
|
||||
multiple
|
||||
options={contentOptions}
|
||||
selected={contentSelected}
|
||||
onChange={setContent}
|
||||
/>
|
||||
{/* The Source filter (organic / include-search / search-only) is a global-catalog
|
||||
concept — it selects how videos ENTERED the library. On a channel-scoped view we
|
||||
already show all of this one channel's videos (librarySource is pinned to "all"), so
|
||||
the selector would only offer a near-empty, confusing slice — hide it here. */}
|
||||
{!channelScoped && (
|
||||
<FilterSelect
|
||||
label={t("feed.source.label")}
|
||||
testId="feed-source"
|
||||
options={sourceOptions}
|
||||
selected={[filters.librarySource ?? "organic"]}
|
||||
onChange={(next) => next[0] && setSource(next[0])}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{/* Folded, the band is one row: Sort + View join it on the right. */}
|
||||
{fold.folded && (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The page's background menu (DESIGN "Background menu"): the toolbar's own controls, no more.
|
||||
const pageMenuItems = menuGroups(
|
||||
[
|
||||
sortSubmenu({
|
||||
id: "sort",
|
||||
label: t("feed.sortLabel"),
|
||||
options: sortOptions,
|
||||
sort: { key: sortKey, dir: sortDir },
|
||||
onChange: setSort,
|
||||
ascLabel: t("feed.dirAsc"),
|
||||
descLabel: t("feed.dirDesc"),
|
||||
}),
|
||||
...(sortKey === "shuffle"
|
||||
? [{ id: "reshuffle", label: t("sidebar.reshuffle"), onSelect: reshuffle }]
|
||||
: []),
|
||||
choiceSubmenu({
|
||||
id: "view",
|
||||
label: t("feed.viewLabel"),
|
||||
options: viewOptions.map((o) => ({ value: o.id, label: o.label })),
|
||||
selected: view,
|
||||
onPick: setView,
|
||||
}),
|
||||
],
|
||||
[
|
||||
choiceSubmenu({
|
||||
id: "show",
|
||||
label: t("feed.showLabel"),
|
||||
options: showOptions,
|
||||
selected: filters.show,
|
||||
onPick: setShow,
|
||||
}),
|
||||
multiSubmenu({
|
||||
id: "content",
|
||||
label: t("feed.contentLabel"),
|
||||
options: contentOptions,
|
||||
selected: contentSelected,
|
||||
onChange: setContent,
|
||||
}),
|
||||
...(channelScoped
|
||||
? []
|
||||
: [
|
||||
choiceSubmenu({
|
||||
id: "source",
|
||||
label: t("feed.source.label"),
|
||||
options: sourceOptions,
|
||||
selected: filters.librarySource ?? "organic",
|
||||
onPick: setSource,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
!channelScoped && activeFilters.length > 0
|
||||
? [
|
||||
{
|
||||
id: "clear",
|
||||
label: t("sidebar.clearAll"),
|
||||
onSelect: () => setFilters(clearedFilters(filters)),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-1"}>
|
||||
<PageMenu ariaLabel={t("common.pageMenu")} items={pageMenuItems} />
|
||||
{/* The toolbar (+ active-filter chips) is FIXED chrome: it portals into a PageToolbarSlot so
|
||||
it stays put while the cards scroll (bug 4). On the feed page that slot is the shell's top
|
||||
band; on a channel page ChannelPage overrides the slot with its own sticky sub-header, so
|
||||
@@ -732,20 +834,22 @@ export default function Feed({
|
||||
applied"), but still get the right-pinned Sort/View. */}
|
||||
{/* Wraps on a phone: the chips and Sort + View did not fit one line, and the row pushed the
|
||||
page 21px wide (prod UAT 2026-09-19). Sort + View then take a line of their own. */}
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
{!fold.folded && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
{!channelScoped && (
|
||||
<>
|
||||
{/* The active personal view (if any) leads the applied-filter row. */}
|
||||
<SelectedViewChip />
|
||||
<ActiveFilterChips
|
||||
filters={activeFilters}
|
||||
onClearAll={() => setFilters(clearedFilters(filters))}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageToolbar>
|
||||
{items.length === 0 ? (
|
||||
@@ -757,23 +861,23 @@ export default function Feed({
|
||||
{filters.channelIds.length > 0 && filters.scope === "my" && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm">{t("feed.channelScopeHint")}</p>
|
||||
<button
|
||||
<Button
|
||||
className="mt-3"
|
||||
icon={<Library />}
|
||||
onClick={() => setFilters({ ...filters, scope: "all" })}
|
||||
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Library className="w-4 h-4" />
|
||||
{t("feed.viewInLibrary")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{filters.q.trim() && !isDemo && (
|
||||
<button
|
||||
<Button
|
||||
className="mt-4"
|
||||
icon={<Youtube />}
|
||||
onClick={() => onYtSearch(filters.q.trim())}
|
||||
className="mt-4 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
|
||||
>
|
||||
<Youtube className="w-4 h-4" />
|
||||
{t("feed.yt.searchFor", { query: filters.q.trim() })}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed card's right-click menu (prod UAT 2026-09-19): it carried only the comfort-shelf pin, so
|
||||
// the card's own state actions — watched, saved, hidden, reset — were reachable only by hovering or
|
||||
// by Tabbing through every card. Each row fires the same handler the card's button does.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { Video } from "../lib/api";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../lib/api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
||||
return { ...actual, api: { ...actual.api, comfortPins: () => Promise.resolve({ items: [] }) } };
|
||||
});
|
||||
|
||||
import { FeedVideoMenu } from "./ComfortPinMenu";
|
||||
import { useContextMenu } from "./ui/useContextMenu";
|
||||
|
||||
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);
|
||||
|
||||
// The surface is visibility:hidden in jsdom (floating-ui never positions), so an item has no
|
||||
// accessible name there: matched on its text, as ContextMenu.dom.test.tsx does.
|
||||
const labels = () => screen.queryAllByRole("menuitem", { hidden: true }).map((b) => b.textContent);
|
||||
const item = (label: string) => {
|
||||
const found = screen
|
||||
.queryAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === label);
|
||||
if (!found) throw new Error(`no menuitem "${label}" in [${labels().join(", ")}]`);
|
||||
return found;
|
||||
};
|
||||
|
||||
const video = (extra: Partial<Video>): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "A video",
|
||||
status: "new",
|
||||
saved: false,
|
||||
position_seconds: 0,
|
||||
...extra,
|
||||
}) as Video;
|
||||
|
||||
type Handlers = {
|
||||
onState: (id: string, status: string) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onResetState: (id: string) => void;
|
||||
};
|
||||
|
||||
function Feed({ v, handlers }: { v: Video; handlers: Handlers }) {
|
||||
const menu = useContextMenu<Video>();
|
||||
return (
|
||||
<>
|
||||
<div {...menu.triggerProps(v)}>card</div>
|
||||
<FeedVideoMenu
|
||||
menu={menu}
|
||||
onState={handlers.onState}
|
||||
onToggleSave={handlers.onToggleSave}
|
||||
onResetState={handlers.onResetState}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function openMenu(v: Video) {
|
||||
const handlers = { onState: vi.fn(), onToggleSave: vi.fn(), onResetState: vi.fn() };
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FloatingTree>
|
||||
<Feed v={v} handlers={handlers} />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText("card"));
|
||||
return { handlers };
|
||||
}
|
||||
|
||||
describe("the feed card's menu", () => {
|
||||
it("offers the card's own actions on a fresh video, and each fires the card's handler", async () => {
|
||||
const { handlers } = openMenu(video({}));
|
||||
expect(labels()).toEqual([
|
||||
"card.markWatched",
|
||||
"card.saveForLater",
|
||||
"card.hide",
|
||||
"home.pin.add",
|
||||
]);
|
||||
fireEvent.click(item("card.markWatched"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "watched"));
|
||||
});
|
||||
|
||||
it("offers the way back on a watched, saved one — and the reset", async () => {
|
||||
const { handlers } = openMenu(video({ status: "watched", saved: true }));
|
||||
expect(labels()).toEqual([
|
||||
"card.menu.unmarkWatched",
|
||||
"card.menu.unsave",
|
||||
"card.hide",
|
||||
"card.resetState",
|
||||
"home.pin.add",
|
||||
]);
|
||||
fireEvent.click(item("card.menu.unsave"));
|
||||
await vi.waitFor(() => expect(handlers.onToggleSave).toHaveBeenLastCalledWith("v1", false));
|
||||
});
|
||||
|
||||
it("unhides a hidden one", async () => {
|
||||
const { handlers } = openMenu(video({ status: "hidden", position_seconds: 30 }));
|
||||
fireEvent.click(item("card.unhide"));
|
||||
await vi.waitFor(() => expect(handlers.onState).toHaveBeenLastCalledWith("v1", "new"));
|
||||
});
|
||||
|
||||
it("clears the progress of a half-watched one", async () => {
|
||||
const { handlers } = openMenu(video({ position_seconds: 30 }));
|
||||
fireEvent.click(item("card.resetState"));
|
||||
await vi.waitFor(() => expect(handlers.onResetState).toHaveBeenLastCalledWith("v1"));
|
||||
});
|
||||
});
|
||||
@@ -1,465 +0,0 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Overlay from "./ui/Overlay";
|
||||
import { copyText } from "../lib/clipboard";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
|
||||
/**
|
||||
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
|
||||
* writes the glass CSS custom properties (index.css `:root`) inline on the document element for
|
||||
* instant, build-free feedback, and persists to localStorage.
|
||||
*
|
||||
* Opt-in via Settings → Appearance (`theme.showTuner`, off by default) — a secondary "geek toy"
|
||||
* for dialling in the surface values. "Copy CSS" exports the values.
|
||||
*/
|
||||
const LS_KEY = "siftlode.devGlassTuner";
|
||||
|
||||
type Slider = {
|
||||
k: string;
|
||||
/** Its name under `settings.tuner.slider.*` — every user-facing word here is translated. */
|
||||
id: string;
|
||||
min: number;
|
||||
max: number;
|
||||
step: number;
|
||||
unit: string;
|
||||
def: number;
|
||||
};
|
||||
|
||||
// def = the BAKED baseline in index.css (:root). "Current" preset + the export diff both key off it.
|
||||
// def = the default rendered baseline = the "glass over image" (backdrop-on) tier from index.css.
|
||||
const SLIDERS: Slider[] = [
|
||||
{ k: "--glass-blur", id: "blur", min: 0, max: 40, step: 1, unit: "px", def: 10 },
|
||||
{ k: "--glass-saturate", id: "saturate", min: 1, max: 2.5, step: 0.05, unit: "", def: 1.6 },
|
||||
{
|
||||
k: "--glass-dark-bright",
|
||||
id: "darkBright",
|
||||
min: 1,
|
||||
max: 2,
|
||||
step: 0.05,
|
||||
unit: "",
|
||||
def: 1.2,
|
||||
},
|
||||
{
|
||||
k: "--glass-surface-alpha",
|
||||
id: "surfaceAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 50,
|
||||
},
|
||||
{
|
||||
k: "--glass-card-alpha",
|
||||
id: "cardAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 60,
|
||||
},
|
||||
{
|
||||
k: "--glass-menu-alpha",
|
||||
id: "menuAlpha",
|
||||
min: 40,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 66,
|
||||
},
|
||||
{
|
||||
k: "--glass-border-alpha",
|
||||
id: "borderAlpha",
|
||||
min: 20,
|
||||
max: 100,
|
||||
step: 1,
|
||||
unit: "%",
|
||||
def: 80,
|
||||
},
|
||||
{ k: "--glass-edge", id: "edge", min: 0, max: 60, step: 1, unit: "%", def: 25 },
|
||||
{ k: "--glass-inset", id: "inset", min: 0, max: 40, step: 1, unit: "%", def: 15 },
|
||||
{ k: "--glass-scrim", id: "scrim", min: 0, max: 85, step: 1, unit: "%", def: 30 },
|
||||
{ k: "--ambient", id: "ambient", min: 0, max: 2, step: 0.05, unit: "", def: 1.1 },
|
||||
];
|
||||
|
||||
const PALETTE: { k: string; id: string }[] = [
|
||||
{ k: "--bg", id: "bg" },
|
||||
{ k: "--surface", id: "surface" },
|
||||
{ k: "--card", id: "card" },
|
||||
{ k: "--border", id: "border" },
|
||||
{ k: "--fg", id: "fg" },
|
||||
{ k: "--muted", id: "muted" },
|
||||
{ k: "--accent", id: "accent" },
|
||||
];
|
||||
|
||||
// Each preset resets every slider to its default, then applies these overrides.
|
||||
// Named under `settings.tuner.preset.<id>`.
|
||||
const PRESETS: { id: string; over: Record<string, number> }[] = [
|
||||
{ id: "current", over: {} },
|
||||
{
|
||||
id: "solid",
|
||||
over: {
|
||||
"--glass-blur": 20,
|
||||
"--glass-dark-bright": 1.25,
|
||||
"--glass-surface-alpha": 94,
|
||||
"--glass-card-alpha": 94,
|
||||
"--glass-menu-alpha": 98,
|
||||
"--glass-edge": 10,
|
||||
"--glass-inset": 16,
|
||||
"--glass-scrim": 14,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "readable",
|
||||
over: {
|
||||
"--glass-edge": 42,
|
||||
"--glass-scrim": 45,
|
||||
"--glass-surface-alpha": 96,
|
||||
"--glass-card-alpha": 96,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const ALL = SLIDERS;
|
||||
const root = () => document.documentElement;
|
||||
const readVar = (k: string) => getComputedStyle(root()).getPropertyValue(k).trim();
|
||||
|
||||
type Saved = {
|
||||
vars?: Record<string, number>;
|
||||
palette?: Record<string, string>;
|
||||
open?: boolean;
|
||||
};
|
||||
|
||||
function loadSaved(): Saved {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(LS_KEY) || "{}") as Saved;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export default function GlassTuner() {
|
||||
// Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider.
|
||||
const enabled = usePrefs().theme.showTuner;
|
||||
const { t } = useTranslation();
|
||||
// Read once at mount. useState's lazy initializer rather than a useMemo with an empty dep list:
|
||||
// a memo is a caching hint React may discard and recompute, while "the saved values as they were
|
||||
// when this mounted" is state — and the effect below applies them exactly once.
|
||||
const [initial] = useState(loadSaved);
|
||||
const [vars, setVars] = useState<Record<string, number>>(() => {
|
||||
const base: Record<string, number> = {};
|
||||
ALL.forEach((s) => (base[s.k] = s.def));
|
||||
return { ...base, ...(initial.vars || {}) };
|
||||
});
|
||||
const [palette, setPalette] = useState<Record<string, string>>(() => initial.palette || {});
|
||||
const [open, setOpen] = useState<boolean>(() => initial.open ?? true);
|
||||
const [tab, setTab] = useState<"glass" | "palette">("glass");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [themeTick, setThemeTick] = useState(0);
|
||||
// Seed the colour pickers from the live scheme for display (without forcing them onto <html>).
|
||||
const [swatches, setSwatches] = useState<Record<string, string>>({});
|
||||
|
||||
// Apply saved var overrides (and any saved palette edits) once on mount.
|
||||
useEffect(() => {
|
||||
Object.entries(initial.vars || {}).forEach(([k, v]) => {
|
||||
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
|
||||
root().style.setProperty(k, v + unit);
|
||||
});
|
||||
Object.entries(initial.palette || {}).forEach(([k, v]) => root().style.setProperty(k, v));
|
||||
const sw: Record<string, string> = {};
|
||||
PALETTE.forEach((p) => {
|
||||
const v = (initial.palette?.[p.k] || readVar(p.k)).trim();
|
||||
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect -- the swatches are READ FROM THE DOM (readVar on the computed style) after the saved overrides have been applied to it, so they cannot exist before a commit.
|
||||
setSwatches(sw);
|
||||
}, [initial]);
|
||||
|
||||
function persist(next: Partial<Saved>) {
|
||||
const cur = loadSaved();
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({ ...cur, ...next }));
|
||||
}
|
||||
|
||||
function setVar(k: string, value: number) {
|
||||
const unit = ALL.find((s) => s.k === k)?.unit ?? "";
|
||||
root().style.setProperty(k, value + unit);
|
||||
setVars((prev) => {
|
||||
const next = { ...prev, [k]: value };
|
||||
persist({ vars: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function setColor(k: string, hex: string) {
|
||||
root().style.setProperty(k, hex);
|
||||
setSwatches((s) => ({ ...s, [k]: hex }));
|
||||
setPalette((prev) => {
|
||||
const next = { ...prev, [k]: hex };
|
||||
persist({ palette: next });
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
function applyPreset(over: Record<string, number>) {
|
||||
const next: Record<string, number> = { ...vars };
|
||||
SLIDERS.forEach((s) => {
|
||||
const v = over[s.k] ?? s.def;
|
||||
next[s.k] = v;
|
||||
root().style.setProperty(s.k, v + s.unit);
|
||||
});
|
||||
setVars(next);
|
||||
persist({ vars: next });
|
||||
}
|
||||
|
||||
function reset() {
|
||||
[...ALL, ...PALETTE].forEach((s) => root().style.removeProperty(s.k));
|
||||
const base: Record<string, number> = {};
|
||||
ALL.forEach((s) => (base[s.k] = s.def));
|
||||
setVars(base);
|
||||
setPalette({});
|
||||
localStorage.removeItem(LS_KEY);
|
||||
const sw: Record<string, string> = {};
|
||||
PALETTE.forEach((p) => {
|
||||
const v = readVar(p.k);
|
||||
sw[p.k] = /^#[0-9a-fA-F]{6}$/.test(v) ? v : "#000000";
|
||||
});
|
||||
setSwatches(sw);
|
||||
}
|
||||
|
||||
// Re-read the LIVE, theme-driven glass values into the sliders (clearing inline overrides first),
|
||||
// so the panel reflects whatever theme is active — switch to light, hit this, and the opacity
|
||||
// sliders show the light-theme values (index.css `html[data-theme="light"]`) ready to tune.
|
||||
function syncToTheme() {
|
||||
ALL.forEach((s) => root().style.removeProperty(s.k));
|
||||
const next: Record<string, number> = { ...vars };
|
||||
ALL.forEach((s) => {
|
||||
const n = parseFloat(readVar(s.k));
|
||||
if (!Number.isNaN(n)) next[s.k] = n;
|
||||
});
|
||||
setVars(next);
|
||||
persist({ vars: {} });
|
||||
setThemeTick((t) => t + 1);
|
||||
}
|
||||
|
||||
// Export the values that differ from the baseline, headered for the active theme's scope so a
|
||||
// light-theme tune pastes into the right block.
|
||||
const cssOut = useMemo(() => {
|
||||
void themeTick; // re-run when the active theme is re-synced
|
||||
const lines: string[] = [];
|
||||
ALL.forEach((s) => {
|
||||
if (vars[s.k] !== s.def) lines.push(` ${s.k}: ${vars[s.k]}${s.unit};`);
|
||||
});
|
||||
PALETTE.forEach((p) => {
|
||||
if (palette[p.k]) lines.push(` ${p.k}: ${palette[p.k]};`);
|
||||
});
|
||||
const scope = root().dataset.theme === "light" ? 'html[data-theme="light"]' : ":root";
|
||||
return lines.length ? `${scope} {\n${lines.join("\n")}\n}` : "/* all values at defaults */";
|
||||
}, [vars, palette, themeTick]);
|
||||
|
||||
async function copy() {
|
||||
if (await copyText(cssOut)) {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1200);
|
||||
}
|
||||
}
|
||||
|
||||
function persistOpen(v: boolean) {
|
||||
setOpen(v);
|
||||
persist({ open: v });
|
||||
}
|
||||
|
||||
if (!enabled) return null;
|
||||
|
||||
return (
|
||||
<Overlay>
|
||||
<div
|
||||
className="fixed right-0 top-24 z-tuner flex items-start"
|
||||
style={{ pointerEvents: "none" }}
|
||||
>
|
||||
{!open && (
|
||||
<button
|
||||
onClick={() => persistOpen(true)}
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="glass-menu glass-hover rounded-l-xl rounded-r-none px-2 py-3 text-xs font-semibold tracking-wide text-fg"
|
||||
title={t("settings.tuner.open")}
|
||||
>
|
||||
<span style={{ writingMode: "vertical-rl" }}>{t("settings.tuner.tab")}</span>
|
||||
</button>
|
||||
)}
|
||||
{open && (
|
||||
<div
|
||||
style={{ pointerEvents: "auto" }}
|
||||
className="glass-menu rounded-l-2xl rounded-r-none w-75 max-h-[82vh] flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60">
|
||||
<span className="text-sm font-semibold text-fg">{t("settings.tuner.title")}</span>
|
||||
<span className="text-3xs uppercase tracking-wider text-muted">
|
||||
{t("settings.tuner.lab")}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => persistOpen(false)}
|
||||
className="ml-auto text-muted hover:text-fg text-lg leading-none px-1"
|
||||
title={t("settings.tuner.collapse")}
|
||||
aria-label={t("settings.tuner.collapse")}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto px-3 py-3 flex flex-col gap-4 text-sm">
|
||||
{/* Treatment presets */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
|
||||
{t("settings.tuner.treatment")}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{PRESETS.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => applyPreset(p.over)}
|
||||
title={t(`settings.tuner.preset.${p.id}.hint`)}
|
||||
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-left text-xs text-fg"
|
||||
>
|
||||
<b className="block font-semibold">
|
||||
{t(`settings.tuner.preset.${p.id}.name`)}
|
||||
</b>
|
||||
<span className="text-muted text-3xs">
|
||||
{t(`settings.tuner.preset.${p.id}.hint`)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab switch */}
|
||||
<div className="flex gap-1.5">
|
||||
{(["glass", "palette"] as const).map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
aria-pressed={tab === id}
|
||||
className={`flex-1 rounded-lg px-2 py-1 text-xs ${
|
||||
tab === id
|
||||
? "bg-accent text-(--accent-fg) font-semibold"
|
||||
: "glass-card text-muted"
|
||||
}`}
|
||||
>
|
||||
{t(`settings.tuner.tabs.${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "glass" && (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{SLIDERS.map((s) => (
|
||||
<Ctl
|
||||
key={s.k}
|
||||
s={s}
|
||||
label={t(`settings.tuner.slider.${s.id}`)}
|
||||
value={vars[s.k] ?? s.def}
|
||||
onChange={(v) => setVar(s.k, v)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "palette" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-3xs text-muted leading-relaxed">
|
||||
{t("settings.tuner.paletteHint")}
|
||||
</p>
|
||||
{PALETTE.map((p) => (
|
||||
<label key={p.k} className="flex items-center gap-2 text-xs text-fg">
|
||||
<input
|
||||
type="color"
|
||||
value={swatches[p.k] || "#000000"}
|
||||
onChange={(e) => setColor(p.k, e.target.value)}
|
||||
className="w-8 h-6 rounded-sm border border-border bg-transparent cursor-pointer"
|
||||
/>
|
||||
<span className="flex-1">{t(`settings.tuner.color.${p.id}`)}</span>
|
||||
<code className="text-3xs text-muted">{swatches[p.k]}</code>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Export */}
|
||||
<div className="flex flex-col gap-1.5 pt-1 border-t border-border/50">
|
||||
<div className="text-3xs uppercase tracking-wider text-muted font-semibold">
|
||||
{t("settings.tuner.export")}
|
||||
</div>
|
||||
<button
|
||||
onClick={syncToTheme}
|
||||
title={t("settings.tuner.syncHint")}
|
||||
className="glass-card glass-hover rounded-lg px-2 py-1.5 text-xs text-fg text-left"
|
||||
>
|
||||
{t("settings.tuner.sync")}
|
||||
</button>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={copy}
|
||||
className="flex-1 rounded-lg px-2 py-1.5 text-xs font-semibold bg-accent text-(--accent-fg)"
|
||||
>
|
||||
{copied ? t("settings.tuner.copied") : t("settings.tuner.copy")}
|
||||
</button>
|
||||
<button
|
||||
onClick={reset}
|
||||
className="glass-card glass-hover rounded-lg px-3 py-1.5 text-xs text-fg"
|
||||
>
|
||||
{t("settings.tuner.reset")}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-[0.59375rem] leading-relaxed bg-(--bg) border border-border/60 rounded-lg p-2 overflow-x-auto text-muted whitespace-pre">
|
||||
{cssOut}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
function Ctl({
|
||||
s,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
s: Slider;
|
||||
label: string;
|
||||
value: number;
|
||||
onChange: (v: number) => void;
|
||||
}) {
|
||||
// The label names the number box, and the range beside it says the same thing: both controls
|
||||
// are one setting, and the label was not tied to either before (a screen reader read "spin
|
||||
// button" and "slider" with no name at all).
|
||||
const id = useId();
|
||||
return (
|
||||
<div className="grid grid-cols-[1fr_auto] items-center gap-x-2 gap-y-1">
|
||||
<label htmlFor={id} className="text-xs text-fg">
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id={id}
|
||||
type="number"
|
||||
min={s.min}
|
||||
max={s.max}
|
||||
step={s.step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
className="w-16 text-right text-2xs tabular-nums rounded-sm bg-card border border-border px-1.5 py-0.5 text-accent"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={s.min}
|
||||
max={s.max}
|
||||
step={s.step}
|
||||
value={value}
|
||||
onChange={(e) => onChange(Number(e.target.value))}
|
||||
aria-label={label}
|
||||
className="col-span-2 w-full accent-accent"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@
|
||||
// a Carousel (useHScroll → ResizeObserver), stubbed for jsdom.
|
||||
import { StrictMode } from "react";
|
||||
import { describe, it, expect, afterEach, beforeAll, beforeEach, vi } from "vitest";
|
||||
import { act, render, screen, fireEvent, cleanup, waitFor } from "@testing-library/react";
|
||||
import { act, render, screen, fireEvent, cleanup, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
@@ -700,11 +700,48 @@ describe("Home", () => {
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click(await screen.findByLabelText("plex.info.title"));
|
||||
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true });
|
||||
// Since E28 C1 the tile's ⓘ is its menu's "Media info" row, opened from the tile's "…".
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
|
||||
const info = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "plex.info.title");
|
||||
fireEvent.click(info!);
|
||||
await waitFor(() =>
|
||||
expect(setJumpTo).toHaveBeenCalledWith({ id: "m1", type: "movie", replace: true }),
|
||||
);
|
||||
expect(setPage).toHaveBeenCalledWith("plex");
|
||||
});
|
||||
|
||||
// C5: what a tile's Play would continue, its menu can start over — the one way to on touch.
|
||||
it("a started film's menu starts it over: the player opens from the start", async () => {
|
||||
const movie = {
|
||||
...ytCard("m1", "A Film"),
|
||||
source: "plex",
|
||||
kind: "movie",
|
||||
aspect: "poster",
|
||||
position_seconds: 600,
|
||||
video: null,
|
||||
plex: { id: "m1", type: "movie", title: "A Film", thumb: "", position_seconds: 600 },
|
||||
};
|
||||
discover.mockResolvedValue({
|
||||
budget: "any",
|
||||
hero: null,
|
||||
continue_watching: [movie],
|
||||
comfort: [],
|
||||
});
|
||||
mount();
|
||||
fireEvent.click((await screen.findAllByRole("button", { name: "card.menu.more" }))[0]!);
|
||||
const restart = screen
|
||||
.getAllByRole("menuitem", { hidden: true })
|
||||
.find((b) => b.textContent === "card.restartTitle");
|
||||
fireEvent.click(restart!);
|
||||
await waitFor(() =>
|
||||
expect(setJumpTo).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "m1", type: "play", fromStart: true }),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("rotates the tier on each press so the button offers a different KIND of answer", async () => {
|
||||
// Measured 2026-09-06: exclusion alone left it walking the resume rail and cycling nine cards,
|
||||
// never once reaching the shelf. The press count travels to the server as the rotation.
|
||||
@@ -778,3 +815,58 @@ describe("Home · keyboard", () => {
|
||||
expect(rails.map((rail) => rail.querySelectorAll("[data-nav-index]").length)).toEqual([2, 2]);
|
||||
});
|
||||
});
|
||||
|
||||
// E28 S3h (the owner, 2026-09-26/27): the hero stands in three quarters of a wide page with its text
|
||||
// centred beside the picture; one fact line, no repeated year, and prose only where there is some,
|
||||
// at a readable measure.
|
||||
describe("Home's hero text", () => {
|
||||
const film = {
|
||||
source: "plex",
|
||||
kind: "movie",
|
||||
id: "m1",
|
||||
title: "A Film",
|
||||
subtitle: "2003",
|
||||
thumb: null,
|
||||
aspect: "poster",
|
||||
duration_seconds: 9240,
|
||||
position_seconds: 0,
|
||||
remaining_seconds: null,
|
||||
status: "new",
|
||||
video: null,
|
||||
plex: {
|
||||
id: "m1",
|
||||
type: "movie",
|
||||
title: "A Film",
|
||||
thumb: "",
|
||||
year: 2003,
|
||||
rating: 7.8,
|
||||
summary: "What the film is about.",
|
||||
},
|
||||
};
|
||||
|
||||
it("a film: the year once, in the fact line with the length and score; its summary at the measure", async () => {
|
||||
discover.mockResolvedValue({ ...payload("v1"), hero: { tier: "surprise", card: film } });
|
||||
mount();
|
||||
const title = await screen.findByRole("heading", { name: "A Film" });
|
||||
const text = title.parentElement!;
|
||||
expect(within(text).getByText(/^2003 · \S/)).toBeTruthy();
|
||||
expect(within(text).getByText("7.8")).toBeTruthy();
|
||||
// the subtitle was the year alone: it is not printed a second time
|
||||
expect(within(text).queryByText("2003")).toBeNull();
|
||||
expect(within(text).getByText("What the film is about.").className).toContain("measure");
|
||||
expect(title.closest("section")!.className).toContain("lg:w-3/4");
|
||||
});
|
||||
|
||||
it("a video: the channel as the subtitle, views and age as the facts, and no summary line", async () => {
|
||||
discover.mockResolvedValue({
|
||||
...payload("v1"),
|
||||
hero: { tier: "surprise", card: ytCard("v9", "A video") },
|
||||
continue_watching: [],
|
||||
});
|
||||
mount();
|
||||
const text = (await screen.findByRole("heading", { name: "A video" })).parentElement!;
|
||||
expect(within(text).getByText("A channel")).toBeTruthy();
|
||||
expect(within(text).getByText(/^10 card\.views · /)).toBeTruthy();
|
||||
expect(text.querySelector("p.measure")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,7 +65,13 @@ vi.mock("../lib/api", () => ({
|
||||
discoverCardKey: (c: { source: string; id: string }) => `${c.source}:${c.id}`,
|
||||
}));
|
||||
|
||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ setPage: vi.fn() }) }));
|
||||
const openChannel = vi.fn();
|
||||
vi.mock("./navigationContext", () => ({
|
||||
useNavigationActions: () => ({
|
||||
setPage: vi.fn(),
|
||||
openChannel: (...a: unknown[]) => openChannel(...a),
|
||||
}),
|
||||
}));
|
||||
vi.mock("./plexContext", () => ({ usePlexActions: () => ({ setJumpTo: vi.fn() }) }));
|
||||
|
||||
import Home from "./Home";
|
||||
@@ -150,6 +156,7 @@ const item = (label: string) => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
openChannel.mockReset();
|
||||
// Home keeps its question on the history entry (Back restores it): each test starts a fresh one.
|
||||
window.history.replaceState(null, "");
|
||||
discover.mockReset().mockResolvedValue(payload());
|
||||
@@ -166,6 +173,8 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
fireEvent.contextMenu(await screen.findByText("Comfort B"));
|
||||
expect(labels()).toEqual([
|
||||
"home.hero.play",
|
||||
// A channel tile opens its channel (the owner, 2026-09-27: it had nothing a channel offers).
|
||||
"card.menu.openChannel",
|
||||
"home.shelf.moveFirst",
|
||||
"home.shelf.moveLeft",
|
||||
"home.shelf.moveRight",
|
||||
@@ -341,3 +350,28 @@ describe("Home — the comfort shelf's context menu", () => {
|
||||
expect(item("home.pin.add").disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// The owner, 2026-09-27: every Home card offers what the same card offers elsewhere.
|
||||
describe("Home — a card's own actions", () => {
|
||||
it("opens a pinned channel from its tile's ↗", async () => {
|
||||
mount();
|
||||
await screen.findByText("Comfort B");
|
||||
fireEvent.click(
|
||||
screen.getAllByRole("button", { name: "card.menu.openChannel" })[1] as HTMLElement,
|
||||
);
|
||||
expect(openChannel).toHaveBeenCalledWith("UCb", "Comfort B");
|
||||
});
|
||||
|
||||
it("gives the hero the card's whole menu, not the pin row alone", async () => {
|
||||
discover.mockResolvedValue({
|
||||
...payload(),
|
||||
hero: { tier: "surprise", card: base("h1", "Hero video") },
|
||||
});
|
||||
mount();
|
||||
fireEvent.contextMenu(await screen.findByRole("heading", { name: "Hero video" }));
|
||||
const rows = labels();
|
||||
expect(rows[0]).toBe("home.hero.play");
|
||||
expect(rows).toContain("card.markWatched");
|
||||
expect(rows).toContain("card.menu.openChannel");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
ChevronRight,
|
||||
ChevronsLeft,
|
||||
ChevronsRight,
|
||||
ArrowUpRight,
|
||||
PinOff,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SkipBack,
|
||||
Star,
|
||||
} from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
@@ -26,10 +29,9 @@ import {
|
||||
} from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { takeSurprise, useSurpriseRequest } from "../lib/homeIntent";
|
||||
import { formatDuration } from "../lib/format";
|
||||
import { formatDuration, formatRuntime, formatViews, relativeTime } from "../lib/format";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { useComfortPin, pinTargetForCard, shelfMoveTarget, type ShelfMove } from "./comfortPin";
|
||||
import { ComfortPinMenu } from "./ComfortPinMenu";
|
||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
||||
import { Button } from "./ui/Button";
|
||||
import { Carousel } from "./ui/Carousel";
|
||||
@@ -37,11 +39,31 @@ import { ContextMenu } from "./ui/ContextMenu";
|
||||
import { useContextMenu, useItemTriggers } from "./ui/useContextMenu";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import VideoCard from "./VideoCard";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import VideoCard, { VideoHoverActions } from "./VideoCard";
|
||||
import { useVideoActions } from "./useVideoActions";
|
||||
import { usePlexCardActions } from "./usePlexCardActions";
|
||||
import { PlexHoverBar, PlexPosterCard } from "./plexCards";
|
||||
import { usePlaylistsActions } from "./playlistsContext";
|
||||
import { useNavigationActions } from "./navigationContext";
|
||||
import { usePlexActions } from "./plexContext";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import clsx from "clsx";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { MoreButton } from "./ui/MoreButton";
|
||||
import { HOVER_REVEAL, HOVER_YIELD } from "./ui/reveal";
|
||||
import { MediaResumeButtons } from "./MediaResumeButtons";
|
||||
import { MediaCard, MediaPlayGlyph } from "./ui/MediaCard";
|
||||
import {
|
||||
MEDIA_BAR,
|
||||
MEDIA_BADGE,
|
||||
MEDIA_HOVER_LAYER,
|
||||
MEDIA_IMG,
|
||||
MEDIA_PICTURE,
|
||||
mediaPictureBox,
|
||||
mediaTitleClass,
|
||||
} from "./ui/mediaCardStyles";
|
||||
|
||||
// The Home surface (E24) — "Mit nézzek most?".
|
||||
//
|
||||
@@ -64,11 +86,12 @@ const PlayerModal = lazy(() => import("./PlayerModal"));
|
||||
// SUBSET, so a band added server-side and to `DISCOVER_BUDGETS` typechecked green while this row
|
||||
// silently kept offering four.
|
||||
|
||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card (its column bottoms out at
|
||||
// 180px), so a 200px slot is the same object the feed shows — not a Home-only variant that would
|
||||
// drift from it. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
||||
// A rail tile's slot width. `cardsSmall` is the feed's own dense card, so the slot holds the same
|
||||
// object the feed shows — not a Home-only variant that would drift from it. 232, not the 200 it was:
|
||||
// at 200 the picture was 184×103 and its centred Play overlapped the hover row docked under it by
|
||||
// 2px (the owner saw them run together, 2026-09-27); at 232 they stand ~7px apart. Px on purpose, like that column (`VirtualGrid`'s `minCol` is a layout-engine px value,
|
||||
// see ui/README): the card also takes it as its MEASURED width, which must be real pixels.
|
||||
const YT_SLOT = 200;
|
||||
const YT_SLOT = 232;
|
||||
|
||||
// How many already-offered cards the reroll remembers. Matched to the server's own cap
|
||||
// (`routes/discover.MAX_EXCLUDE`) rather than to a smaller number of our own: at 8 the window rolled
|
||||
@@ -85,6 +108,162 @@ const SHELF_MOVES: readonly [ShelfMove, string, typeof ChevronLeft][] = [
|
||||
["last", "home.shelf.moveLast", ChevronsRight],
|
||||
];
|
||||
|
||||
/** A Home card's ONE action list — a shelf tile's and the hero's alike: Play (it resolves `next`),
|
||||
* the card's own rows (a video's or a Plex title's, as the feed and the library show them; a
|
||||
* channel's or a playlist's open row), then the shelf's rows. */
|
||||
function useHomeCardMenu({
|
||||
cards,
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onMove,
|
||||
moveSaving = false,
|
||||
}: {
|
||||
cards: DiscoverCard[];
|
||||
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
|
||||
onInfo: (card: DiscoverCard) => void;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onToggleWatched: (card: PlexCard) => void;
|
||||
onMove?: (card: DiscoverCard, to: ShelfMove, rendered: DiscoverCard[]) => void;
|
||||
moveSaving?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
||||
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
|
||||
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
||||
const menu = useContextMenu<DiscoverCard>();
|
||||
const pin = useComfortPin(menu.open);
|
||||
// A video tile's own action list (E28 C1), the same one the feed's menu shows; the rail keeps its
|
||||
// Play row (it resolves `next`) and its shelf rows around it.
|
||||
const videoActions = useVideoActions({ onState, onToggleSave }, menu.open);
|
||||
// A Plex tile's, likewise (its "Media info" row goes where the tile's `onInfo` does).
|
||||
const plexActions = usePlexCardActions(
|
||||
{
|
||||
onOpen: (p) => {
|
||||
const c = cards.find((x) => x.plex === p);
|
||||
if (c) onPlay(c);
|
||||
},
|
||||
onInfo: (p) => {
|
||||
const c = cards.find((x) => x.plex === p);
|
||||
if (c) onInfo(c);
|
||||
},
|
||||
onToggleWatched,
|
||||
},
|
||||
menu.open,
|
||||
);
|
||||
const openPage = useOpenShelfPage();
|
||||
const target = menu.target;
|
||||
// What the pin row acts on: a comfort hero's pin is the shelf entry it came from — a pinned show's
|
||||
// next episode is not itself pinned, and looking the episode up offered "Pin" under "From your
|
||||
// shelf", a second pin (the owner's call, 2026-09-19). Any other card is its own.
|
||||
const pinned = target ? (target.pinned_from ?? target) : null;
|
||||
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
|
||||
const items: ActionMenuItem[] = !target
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "play",
|
||||
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
||||
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
||||
// than dropped, so the menu does not change shape from tile to tile.
|
||||
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
||||
onSelect: () => onPlay(target),
|
||||
},
|
||||
// What the Play row would continue can also start over (C5), with the card menus' words.
|
||||
...((target.next ?? target).position_seconds > 0
|
||||
? [
|
||||
{
|
||||
id: "restart",
|
||||
label: t("card.restartTitle"),
|
||||
icon: <SkipBack className="w-4 h-4" />,
|
||||
onSelect: () => onPlay(target, 0),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(target.video
|
||||
? videoActions
|
||||
.items(target.video, menu.anchor, { pin: false })
|
||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||
: target.plex
|
||||
? plexActions
|
||||
.items(target.plex, { play: false, pin: false })
|
||||
.map((it, i) => (i === 0 ? { ...it, separatorBefore: true } : it))
|
||||
: target.kind === "channel" || target.kind === "playlist"
|
||||
? [{ ...openPage.item(target), separatorBefore: true }]
|
||||
: []),
|
||||
...(onMove && target.pin_id != null
|
||||
? [
|
||||
...SHELF_MOVES.map(([to, key, Icon], i) => ({
|
||||
id: `shelf-${to}`,
|
||||
label: t(key),
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
separatorBefore: i === 0,
|
||||
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
||||
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||||
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
||||
onSelect: () => onMove(target, to, cards),
|
||||
})),
|
||||
{
|
||||
id: "shelf-unpin",
|
||||
label: t("home.pin.remove"),
|
||||
icon: <PinOff className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
// The only row here that removes a curated entry, and it sat in the same colour as
|
||||
// the four harmless reorders. `danger` matches the shared unpin row in
|
||||
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
||||
// rail below) composes for the same command — the two must not disagree.
|
||||
danger: true,
|
||||
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
||||
// other surface it can never be in the "not known yet" state.
|
||||
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
||||
},
|
||||
]
|
||||
: pin.item(pinTargetForCard(pinned ?? target), (pinned ?? target).title)),
|
||||
];
|
||||
return {
|
||||
menu,
|
||||
items,
|
||||
panels: (
|
||||
<>
|
||||
{videoActions.panel}
|
||||
{plexActions.panel}
|
||||
</>
|
||||
),
|
||||
plexAdd: plexActions.add,
|
||||
};
|
||||
}
|
||||
|
||||
/** A pinned channel's or playlist's own page: the shelf tile's ↗ and its menu's open row. */
|
||||
function useOpenShelfPage() {
|
||||
const { t } = useTranslation();
|
||||
const { openChannel, setPage } = useNavigationActions();
|
||||
const { setSelectedId } = usePlaylistsActions();
|
||||
const open = (card: DiscoverCard) => {
|
||||
if (card.kind === "channel") openChannel(card.id, card.title);
|
||||
else {
|
||||
setSelectedId(Number(card.id));
|
||||
setPage("playlists");
|
||||
}
|
||||
};
|
||||
const label = (card: DiscoverCard) =>
|
||||
card.kind === "channel" ? t("card.menu.openChannel") : t("home.shelf.openPlaylist");
|
||||
return {
|
||||
open,
|
||||
label,
|
||||
item: (card: DiscoverCard): ActionMenuItem => ({
|
||||
id: "open",
|
||||
label: label(card),
|
||||
icon: <ArrowUpRight className="w-4 h-4" />,
|
||||
onSelect: () => open(card),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function Rail({
|
||||
title,
|
||||
ariaLabel,
|
||||
@@ -114,60 +293,21 @@ function Rail({
|
||||
/** A reorder is being written: the move rows wait for it (one write at a time). */
|
||||
moveSaving?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// ONE right-click menu for the rail (see ui/ContextMenu). It is the shelf's whole editor: menu
|
||||
// rows rather than drag & drop, because dragging inside a horizontal, scrolling Carousel fights
|
||||
// the scroll, and because these rows are keyboard-reachable (Shift+F10) for free.
|
||||
const menu = useContextMenu<DiscoverCard>();
|
||||
const pin = useComfortPin(menu.open);
|
||||
const target = menu.target;
|
||||
const shelfIdx = target?.pin_id != null ? cards.findIndex((c) => c.pin_id === target.pin_id) : -1;
|
||||
const items: ActionMenuItem[] = !target
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: "play",
|
||||
label: target.position_seconds > 0 ? t("home.hero.continue") : t("home.hero.play"),
|
||||
icon: <Play className="w-4 h-4" />,
|
||||
// A pinned channel with nothing unwatched left, or an empty playlist, resolves to no
|
||||
// `next` — the same condition that stands its tile down. Kept visible and disabled rather
|
||||
// than dropped, so the menu does not change shape from tile to tile.
|
||||
disabled: !(target.next ?? target).video && !(target.next ?? target).plex,
|
||||
onSelect: () => onPlay(target),
|
||||
},
|
||||
...(onMove && target.pin_id != null
|
||||
? [
|
||||
...SHELF_MOVES.map(([to, key, Icon], i) => ({
|
||||
id: `shelf-${to}`,
|
||||
label: t(key),
|
||||
icon: <Icon className="w-4 h-4" />,
|
||||
separatorBefore: i === 0,
|
||||
// The same rule the move itself runs, so a row is never enabled onto a no-op.
|
||||
disabled: moveSaving || shelfMoveTarget(shelfIdx, to, cards.length) < 0,
|
||||
note: moveSaving && i === 0 ? t("home.shelf.moveSaving") : undefined,
|
||||
onSelect: () => onMove(target, to, cards),
|
||||
})),
|
||||
{
|
||||
id: "shelf-unpin",
|
||||
label: t("home.pin.remove"),
|
||||
icon: <PinOff className="w-4 h-4" />,
|
||||
separatorBefore: true,
|
||||
// The only row here that removes a curated entry, and it sat in the same colour as
|
||||
// the four harmless reorders. `danger` matches the shared unpin row in
|
||||
// `comfortPin.item()`, which is what every OTHER surface (including the hero and the
|
||||
// rail below) composes for the same command — the two must not disagree.
|
||||
danger: true,
|
||||
// The pin id travels on the shelf card, so this needs no lookup — and unlike every
|
||||
// other surface it can never be in the "not known yet" state.
|
||||
onSelect: () => pin.remove(target.pin_id as number, target.title),
|
||||
},
|
||||
]
|
||||
: pin.item(pinTargetForCard(target), target.title)),
|
||||
];
|
||||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||
cards,
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onMove,
|
||||
moveSaving,
|
||||
});
|
||||
|
||||
// One stable trigger per card (the menu acts on the unified card, not the `Video`/`PlexCard` the
|
||||
// card component holds) — a fresh lambda per render defeated `memo(VideoCard)` on every tile.
|
||||
const menuFor = useItemTriggers(cards, menu.triggerProps);
|
||||
const buttonFor = useItemTriggers(cards, menu.buttonProps);
|
||||
|
||||
// An empty rail says why it is empty rather than vanishing: a shelf that silently disappears when
|
||||
// you have not pinned anything teaches nothing about how to fill it.
|
||||
@@ -189,36 +329,59 @@ function Rail({
|
||||
{cards.map((c, i) => (
|
||||
<div
|
||||
key={`${c.source}:${c.kind}:${c.id}`}
|
||||
className={c.aspect === "poster" ? "w-37.5 shrink-0" : "shrink-0"}
|
||||
style={c.aspect === "poster" ? undefined : { width: YT_SLOT }}
|
||||
// Every shelf card is landscape, a Plex movie or show too (its wide art): a shelf mixes
|
||||
// YouTube and Plex, and a poster beside a 16:9 card stretched the whole row to the
|
||||
// poster's height, leaving a hole in every video card (the owner's pick A, 2026-09-26 —
|
||||
// Plex's and Netflix's own "Continue watching" rows do the same). A grid cell, so the card
|
||||
// fills the slot: every card's meta line (pinned to its frame's bottom) sits on one line.
|
||||
className="grid shrink-0 grid-cols-1"
|
||||
style={{ width: YT_SLOT }}
|
||||
>
|
||||
{c.video ? (
|
||||
<VideoCard
|
||||
video={c.video}
|
||||
view="cardsSmall"
|
||||
shelf
|
||||
width={YT_SLOT}
|
||||
navIndex={i}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onOpen={(v, startAt) => onPlay({ ...c, video: v }, startAt)}
|
||||
menuProps={menuFor[i]}
|
||||
menuButtonProps={buttonFor[i]}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
) : c.plex ? (
|
||||
<PlexPosterCard
|
||||
card={c.plex}
|
||||
wide
|
||||
shelf
|
||||
navIndex={i}
|
||||
onOpen={() => onPlay(c)}
|
||||
onInfo={() => onInfo(c)}
|
||||
onRestart={() => onPlay(c, 0)}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={plexAdd}
|
||||
next={c.next?.plex}
|
||||
onInfo={() => onInfo(c)}
|
||||
menuProps={menuFor[i]}
|
||||
menuButtonProps={buttonFor[i]}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
/>
|
||||
) : (
|
||||
<ShelfTile card={c} onPlay={onPlay} menuProps={menu.triggerProps} navIndex={i} />
|
||||
<ShelfTile
|
||||
card={c}
|
||||
onPlay={onPlay}
|
||||
menuProps={menuFor[i]?.()}
|
||||
menuButtonProps={buttonFor[i]?.()}
|
||||
menuOpen={menu.open && menu.target === c}
|
||||
navIndex={i}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</Carousel>
|
||||
<ContextMenu menu={menu} ariaLabel={ariaLabel} items={items} />
|
||||
{panels}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -230,45 +393,90 @@ function ShelfTile({
|
||||
card,
|
||||
onPlay,
|
||||
menuProps,
|
||||
menuButtonProps,
|
||||
menuOpen = false,
|
||||
navIndex,
|
||||
}: {
|
||||
card: DiscoverCard;
|
||||
onPlay: (card: DiscoverCard) => void;
|
||||
menuProps?: (c: DiscoverCard) => Record<string, unknown>;
|
||||
menuProps?: Record<string, unknown>;
|
||||
/** The rail menu's "…" for this tile. */
|
||||
menuButtonProps?: Record<string, unknown>;
|
||||
menuOpen?: boolean;
|
||||
navIndex?: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const openPage = useOpenShelfPage();
|
||||
const count = card.kind === "channel" ? card.unwatched_count : card.item_count;
|
||||
const countLabel =
|
||||
card.kind === "channel"
|
||||
? t("home.shelf.unwatchedCount", { count: count ?? 0 })
|
||||
: t("home.shelf.itemCount", { count: count ?? 0 });
|
||||
// The one media card (ui/MediaCard), like the videos and posters beside it on the shelf: the
|
||||
// picture is the play button, the count the meta line pinned to the frame's bottom.
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
{...menuProps?.(card)}
|
||||
{...(navIndex !== undefined && navItemAttrs(navIndex))}
|
||||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||||
// playable, but it IS the tile you most want to take off the shelf, and the only way to do
|
||||
// that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is narrower
|
||||
// than it looks: a real `disabled` button DOES still receive `contextmenu` and `pointerdown`,
|
||||
// so mouse and long-press would both survive it — what it loses is FOCUS, and with it
|
||||
// Shift+F10, the only keyboard route to the menu. Inert to the click (the handler guards),
|
||||
// alive to all three input paths.
|
||||
aria-disabled={!card.next}
|
||||
className="block w-full text-left rounded-xl overflow-hidden border border-border bg-surface transition-colors aria-disabled:opacity-60 [&:not([aria-disabled='true'])]:hover:border-accent"
|
||||
>
|
||||
<span className="block aspect-video bg-card overflow-hidden">
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" loading="lazy" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="block p-2">
|
||||
<span className="block text-xs font-medium truncate">{card.title}</span>
|
||||
<span className="block text-2xs text-muted">{countLabel}</span>
|
||||
</span>
|
||||
</button>
|
||||
<MediaCard
|
||||
dense
|
||||
rootProps={{
|
||||
...menuProps,
|
||||
...(navIndex !== undefined && navItemAttrs(navIndex)),
|
||||
}}
|
||||
className={!card.next ? "opacity-60" : undefined}
|
||||
picture={
|
||||
// The ratio lives on a wrapper, as in the other cards: MEDIA_PICTURE fills its parent, and
|
||||
// on the stretched frame itself that would be the whole card's height.
|
||||
<div className={mediaPictureBox("wide")}>
|
||||
<Pressable
|
||||
onClick={() => card.next && onPlay(card)}
|
||||
aria-label={card.title}
|
||||
// `aria-disabled`, not `disabled`: a pinned channel with nothing unwatched left is not
|
||||
// playable, but it IS the tile you most want to take off the shelf, and the only way to
|
||||
// do that is its context menu. MEASURED in Chromium (2026-09-06), because the reason is
|
||||
// narrower than it looks: a real `disabled` button DOES still receive `contextmenu` and
|
||||
// `pointerdown`, so mouse and long-press would both survive it — what it loses is FOCUS,
|
||||
// and with it Shift+F10, the only keyboard route to the menu. Inert to the click (the
|
||||
// handler guards), alive to all three input paths.
|
||||
aria-disabled={!card.next}
|
||||
data-nav-open=""
|
||||
className={MEDIA_PICTURE}
|
||||
>
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" loading="lazy" className={MEDIA_IMG} />
|
||||
) : null}
|
||||
{/* The Play only when there is something to play: the click plays the channel's newest
|
||||
unwatched video, the playlist's first. */}
|
||||
<div className={MEDIA_HOVER_LAYER}>{card.next && <MediaPlayGlyph small />}</div>
|
||||
</Pressable>
|
||||
{/* The rest of what a tile like this offers, as the video and Plex cards beside it do:
|
||||
its own page, and the "…" to the whole list (a sibling of the picture's button). */}
|
||||
<div className={clsx(MEDIA_BAR, "flex gap-1")}>
|
||||
<IconButton
|
||||
variant="media"
|
||||
onClick={() => openPage.open(card)}
|
||||
label={openPage.label(card)}
|
||||
>
|
||||
<ArrowUpRight />
|
||||
</IconButton>
|
||||
{menuButtonProps && (
|
||||
<MoreButton
|
||||
variant="media"
|
||||
label={t("card.menu.more")}
|
||||
expanded={menuOpen}
|
||||
menuProps={menuButtonProps}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
title={
|
||||
<span className={`min-h-6 ${mediaTitleClass(true, true)} select-text`} title={card.title}>
|
||||
{card.title}
|
||||
</span>
|
||||
}
|
||||
meta={<span className="block text-xs leading-6 text-muted">{countLabel}</span>}
|
||||
more={menuButtonProps && { props: menuButtonProps, open: menuOpen }}
|
||||
moreLabel={t("card.menu.more")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -276,19 +484,34 @@ function Hero({
|
||||
tier,
|
||||
card,
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
onReroll,
|
||||
rerolling,
|
||||
}: {
|
||||
tier: HeroTier;
|
||||
card: DiscoverCard;
|
||||
onPlay: (card: DiscoverCard) => void;
|
||||
onPlay: (card: DiscoverCard, startAt?: number | null) => void;
|
||||
onInfo: (card: DiscoverCard) => void;
|
||||
onState: (id: string, status: VideoStatus) => void;
|
||||
onToggleSave: (id: string, saved: boolean) => void;
|
||||
onToggleWatched: (card: PlexCard) => void;
|
||||
onReroll: () => void;
|
||||
rerolling: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// The hero is the likeliest thing on the page to be comfort media — it is what the surface just
|
||||
// answered with — so it answers the same right-click every card in the app does.
|
||||
const pinMenu = useContextMenu<DiscoverCard>();
|
||||
// The hero is a card like any other (the owner, 2026-09-27): the same hover row on its picture and
|
||||
// the same whole action list on its "…", right-click and long-press — it was a pin row alone.
|
||||
const { menu, items, panels, plexAdd } = useHomeCardMenu({
|
||||
cards: [card],
|
||||
onPlay,
|
||||
onInfo,
|
||||
onState,
|
||||
onToggleSave,
|
||||
onToggleWatched,
|
||||
});
|
||||
const started = card.position_seconds > 0;
|
||||
const left = card.remaining_seconds;
|
||||
// The kicker is driven by the tier the SERVER chose, never guessed from the card's shape: "where
|
||||
@@ -302,42 +525,120 @@ function Hero({
|
||||
? t("home.hero.comfort")
|
||||
: t("home.hero.surprise");
|
||||
|
||||
return (
|
||||
<section
|
||||
{...pinMenu.triggerProps(card)}
|
||||
className="mb-6 rounded-xl border border-border bg-surface p-4 grid gap-4 sm:grid-cols-[minmax(0,18rem)_1fr]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPlay(card)}
|
||||
className={`relative block w-full overflow-hidden rounded-lg border border-border bg-card ${
|
||||
card.aspect === "poster" ? "aspect-[2/3] max-w-[12rem]" : "aspect-video"
|
||||
}`}
|
||||
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
||||
>
|
||||
{card.thumb ? <img src={card.thumb} alt="" className="w-full h-full object-cover" /> : null}
|
||||
{card.duration_seconds != null && (
|
||||
<span className="absolute bottom-1.5 right-1.5 rounded-sm bg-black/80 px-1.5 py-0.5 text-2xs font-medium text-white tabular-nums">
|
||||
{formatDuration(card.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{started && card.duration_seconds ? (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
||||
<span
|
||||
className="block h-full bg-accent"
|
||||
style={{
|
||||
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
// One line of facts under the title: a film's year, length and score; a video's views and age.
|
||||
// The picture's column is exactly the picture's width (it was 18rem around a 12rem poster: a 6rem
|
||||
// hole), and the text stands centred beside it, so a video — shorter picture, fewer facts — fills
|
||||
// its hero as well as a film does (the owner's pick, 2026-09-26).
|
||||
const plex = card.plex;
|
||||
const video = card.video;
|
||||
const facts = plex
|
||||
? [plex.year, formatRuntime(card.duration_seconds)]
|
||||
: video
|
||||
? // The channel is the subtitle already.
|
||||
[
|
||||
video.view_count != null && `${formatViews(video.view_count)} ${t("card.views")}`,
|
||||
relativeTime(video.published_at),
|
||||
]
|
||||
: [];
|
||||
const factLine = facts.filter(Boolean).join(" · ");
|
||||
// A feed video carries no description, so only a film or an episode has a summary line.
|
||||
const summary = plex?.summary;
|
||||
// A film's subtitle is its year, which the fact line already says.
|
||||
const subtitle = card.subtitle !== String(plex?.year ?? "") ? card.subtitle : null;
|
||||
|
||||
<div className="flex min-w-0 flex-col">
|
||||
return (
|
||||
// Three quarters of a wide page, centred, so the one answer stands in focus (the owner, 2026-09-27);
|
||||
// a narrower window gives it the whole width back. The padding is even on every side.
|
||||
<section
|
||||
{...menu.triggerProps(card)}
|
||||
className={`${SURFACE.panel} mb-6 flex flex-col gap-5 p-5 sm:flex-row sm:items-center lg:mx-auto lg:w-3/4`}
|
||||
>
|
||||
<div
|
||||
className={`group relative shrink-0 ${
|
||||
card.aspect === "poster" ? "aspect-[2/3] w-40 sm:w-48" : "aspect-video w-full sm:w-72"
|
||||
}`}
|
||||
>
|
||||
<Pressable
|
||||
onClick={() => onPlay(card)}
|
||||
className="relative block h-full w-full cursor-pointer overflow-hidden rounded-lg border border-border bg-card"
|
||||
aria-label={`${started ? t("home.hero.continue") : t("home.hero.play")}: ${card.title}`}
|
||||
>
|
||||
{card.thumb ? (
|
||||
<img src={card.thumb} alt="" className="w-full h-full object-cover" />
|
||||
) : null}
|
||||
{card.duration_seconds != null && (
|
||||
// It steps aside while the hover row covers its corner, as a card's length does.
|
||||
<span className={`${MEDIA_BADGE} bottom-1.5 right-1.5 ${HOVER_YIELD}`}>
|
||||
{formatDuration(card.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
{started && card.duration_seconds ? (
|
||||
<span className="absolute inset-x-0 bottom-0 h-1 bg-black/60">
|
||||
<span
|
||||
className="block h-full bg-accent"
|
||||
style={{
|
||||
width: `${Math.min(100, (card.position_seconds / card.duration_seconds) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
<div className={MEDIA_HOVER_LAYER}>{!started && <MediaPlayGlyph />}</div>
|
||||
</Pressable>
|
||||
{/* A started hero continues on a click, and offers the start as a card does (C5): the pair
|
||||
is a sibling over the picture, never inside its button. */}
|
||||
{started && (
|
||||
<div
|
||||
className={clsx(
|
||||
HOVER_REVEAL,
|
||||
"pointer-events-none absolute inset-0 z-base flex items-center justify-center gap-2 *:pointer-events-auto",
|
||||
card.aspect === "poster" && "flex-col",
|
||||
)}
|
||||
>
|
||||
<MediaResumeButtons onContinue={() => onPlay(card)} onRestart={() => onPlay(card, 0)} />
|
||||
</div>
|
||||
)}
|
||||
{/* The card's own hover row, as on every card: a sibling of the picture's button. */}
|
||||
{card.video ? (
|
||||
<div className={MEDIA_BAR}>
|
||||
<VideoHoverActions
|
||||
video={card.video}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
media
|
||||
more={{ props: menu.buttonProps(card), open: menu.open }}
|
||||
/>
|
||||
</div>
|
||||
) : card.plex ? (
|
||||
<PlexHoverBar
|
||||
card={card.plex}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onAdd={plexAdd}
|
||||
moreProps={menu.buttonProps(card)}
|
||||
moreOpen={menu.open}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<p className="text-2xs font-semibold uppercase tracking-wider text-accent">{kicker}</p>
|
||||
<h2 className="mt-1 text-lg font-semibold leading-tight">{card.title}</h2>
|
||||
{card.subtitle && <p className="mt-0.5 text-xs text-muted truncate">{card.subtitle}</p>}
|
||||
<div className="mt-auto flex flex-wrap items-center gap-2 pt-3">
|
||||
{subtitle && <p className="mt-0.5 text-xs text-muted truncate">{subtitle}</p>}
|
||||
{(factLine || plex?.rating != null) && (
|
||||
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 text-xs text-muted">
|
||||
{factLine && <span>{factLine}</span>}
|
||||
{plex?.rating != null && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
{factLine && <span aria-hidden>·</span>}
|
||||
<Star className="h-3 w-3 fill-current text-rating" aria-hidden />
|
||||
{plex.rating.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{/* Only when there is one (a missing summary leaves no gap, the stack is centred), at the
|
||||
reading measure: the eye finds the next line's start again (it ran ~130 characters). */}
|
||||
{summary && <p className="mt-2 measure line-clamp-2 text-sm text-muted">{summary}</p>}
|
||||
<div className="flex flex-wrap items-center gap-2 pt-4">
|
||||
<Button icon={<Play className="h-4 w-4" />} onClick={() => onPlay(card)}>
|
||||
{started ? t("home.hero.continue") : t("home.hero.play")}
|
||||
</Button>
|
||||
@@ -349,16 +650,18 @@ function Hero({
|
||||
>
|
||||
{t("home.hero.reroll")}
|
||||
</Button>
|
||||
{/* On touch the picture's hover row is gone; the "…" stands here instead, as it sits
|
||||
beside a card's title. */}
|
||||
<MoreButton
|
||||
className="touch-only"
|
||||
label={t("card.menu.more")}
|
||||
expanded={menu.open}
|
||||
menuProps={menu.buttonProps(card)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* A comfort hero acts on the pin it came from: a pinned show's next episode is not itself
|
||||
pinned, and looking the episode up offered "Pin" under "From your shelf" — a second pin.
|
||||
The owner's call (2026-09-19): the row unpins the show. */}
|
||||
<ComfortPinMenu
|
||||
menu={pinMenu}
|
||||
target={(c) => pinTargetForCard(c.pinned_from ?? c)}
|
||||
title={(c) => (c.pinned_from ?? c).title}
|
||||
/>
|
||||
<ContextMenu menu={menu} ariaLabel={card.title} items={items} />
|
||||
{panels}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -572,6 +875,7 @@ export default function Home() {
|
||||
setJumpTo({
|
||||
id: target.plex.id,
|
||||
type: target.plex.type === "show" ? "show" : "play",
|
||||
fromStart: startAt === 0,
|
||||
replace: true,
|
||||
});
|
||||
setPage("plex");
|
||||
@@ -580,8 +884,9 @@ export default function Home() {
|
||||
[setJumpTo, setPage],
|
||||
);
|
||||
|
||||
// The ⓘ affordance on a Plex tile: the item's own page in the Plex module, exactly where the same
|
||||
// click goes from the Plex grid. A show tile's card click lands there too — a show is not playable.
|
||||
// The "Media info" row of a Plex tile's menu: the item's own page in the Plex module, exactly where
|
||||
// the same row goes from the Plex grid. A show tile's card click lands there too — a show is not
|
||||
// playable.
|
||||
const onInfo = useCallback(
|
||||
(card: DiscoverCard) => {
|
||||
if (!card.plex) return;
|
||||
@@ -700,7 +1005,7 @@ export default function Home() {
|
||||
if (surpriseRequest !== null && takeSurprise()) reroll("surprise");
|
||||
}, [surpriseRequest, reroll]);
|
||||
|
||||
if (q.isPending) return <LoadingState />;
|
||||
if (q.isPending) return <LoadingState shape="grid" />;
|
||||
// `&& !data`, the codebase-wide convention (and the one `ui/QueryState` documents): react-query
|
||||
// flips `status` to error on a FAILED REFETCH while keeping the cached payload, and every mutation
|
||||
// on this page invalidates `qk.discover()` — so without the guard, marking one card watched over a
|
||||
@@ -738,13 +1043,17 @@ export default function Home() {
|
||||
tier={hero.tier}
|
||||
card={hero.card}
|
||||
onPlay={onPlay}
|
||||
onInfo={onInfo}
|
||||
onState={onState}
|
||||
onToggleSave={onToggleSave}
|
||||
onToggleWatched={onToggleWatched}
|
||||
onReroll={() => reroll()}
|
||||
rerolling={rerolling}
|
||||
/>
|
||||
) : (
|
||||
// Honest: nothing in the library fits what was asked. Offering something that does not fit
|
||||
// would make the one control on this page meaningless.
|
||||
<section className="mb-6 rounded-xl border border-border bg-surface p-4">
|
||||
<section className={`${SURFACE.panel} mb-6 p-4`}>
|
||||
{/* Not `StateMessage`'s `onRetry` slot: that button is labelled "Retry", which promises
|
||||
a re-run of something that failed. Nothing failed — the library simply has no answer
|
||||
this short, and the honest offer is to ask a different question. */}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||
|
||||
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
|
||||
import { closeComposer } from "../lib/debugRecorder";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import {
|
||||
@@ -19,12 +19,16 @@ import {
|
||||
import { moduleDef } from "../lib/modules";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
|
||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||
import { Button } from "./ui/Button";
|
||||
import Modal from "./ui/Modal";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import Modal, { ModalFooter } from "./ui/Modal";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Textarea, Input } from "./ui/Input";
|
||||
import { Checkbox } from "./ui/NativeControls";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// The issue composer (E16 S3, D4 A): one dialog — the draft on the left (title, description, the
|
||||
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
||||
@@ -51,14 +55,19 @@ export default function IssueComposer({ id }: { id: string }) {
|
||||
useEffect(
|
||||
() => () => {
|
||||
window.setTimeout(() => {
|
||||
if (document.activeElement === document.body)
|
||||
if (focusIsUnclaimed())
|
||||
document.querySelector<HTMLElement>('nav [aria-current="page"]')?.focus();
|
||||
}, 0);
|
||||
},
|
||||
[],
|
||||
);
|
||||
return (
|
||||
<Modal title={t("recorder.composer.title")} onClose={closeComposer} maxWidth="max-w-5xl">
|
||||
<Modal
|
||||
initialFocus="field"
|
||||
title={t("recorder.composer.title")}
|
||||
onClose={closeComposer}
|
||||
maxWidth="max-w-5xl"
|
||||
>
|
||||
{failed && !(sessionQ.data && eventsQ.data) ? (
|
||||
<StateMessage
|
||||
tone="error"
|
||||
@@ -295,8 +304,8 @@ function ComposerBody({
|
||||
<div ref={root} className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
{t("recorder.composer.meta", {
|
||||
start: started.toLocaleString(i18n.language),
|
||||
minutes,
|
||||
start: formatDateTimeSec(started.toISOString()),
|
||||
minutes: formatUnit(minutes, "minute"),
|
||||
level: t(`settings.diagnostics.levels.${session.level}`),
|
||||
})}
|
||||
</p>
|
||||
@@ -407,7 +416,7 @@ function ComposerBody({
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-3">
|
||||
<ModalFooter>
|
||||
<span className="mr-auto flex flex-col text-xs text-muted">
|
||||
{target && <span>{t("recorder.composer.target", { repo: target.repo })}</span>}
|
||||
<span aria-live="polite">
|
||||
@@ -444,7 +453,7 @@ function ComposerBody({
|
||||
{t("recorder.composer.send")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</ModalFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -471,28 +480,28 @@ function DraftFields({
|
||||
<label htmlFor="issue-title" className="text-sm text-muted">
|
||||
{t("recorder.composer.fieldTitle")}
|
||||
</label>
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
id="issue-title"
|
||||
value={form.title}
|
||||
readOnly={readOnly}
|
||||
maxLength={200}
|
||||
placeholder={t("recorder.composer.titlePlaceholder")}
|
||||
onChange={(e) => edit((f) => ({ ...f, title: e.target.value }))}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label htmlFor="issue-description" className="text-sm text-muted">
|
||||
{t("recorder.composer.description")}
|
||||
</label>
|
||||
<textarea
|
||||
<Textarea
|
||||
className="w-full resize-y"
|
||||
id="issue-description"
|
||||
value={form.description}
|
||||
readOnly={readOnly}
|
||||
rows={4}
|
||||
placeholder={t("recorder.composer.descriptionPlaceholder")}
|
||||
onChange={(e) => edit((f) => ({ ...f, description: e.target.value }))}
|
||||
className={`${inputCls} resize-y`}
|
||||
/>
|
||||
</div>
|
||||
<fieldset className="flex flex-col gap-1.5 min-w-0">
|
||||
@@ -506,30 +515,31 @@ function DraftFields({
|
||||
<span className="w-6 shrink-0 text-sm font-semibold text-accent tabular-nums">
|
||||
{i + 1}.
|
||||
</span>
|
||||
<input
|
||||
<Input
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
aria-label={t("recorder.composer.stepLabel", { n: i + 1 })}
|
||||
value={step.text}
|
||||
readOnly={readOnly}
|
||||
placeholder={t("recorder.composer.stepPlaceholder")}
|
||||
onChange={(e) => setStep(i, { text: e.target.value })}
|
||||
className="min-w-0 flex-1 bg-transparent text-sm outline-none focus-visible:ring-2 focus-visible:ring-accent/60 rounded px-1 py-1"
|
||||
/>
|
||||
<span className="chip shrink-0 px-2 py-0.5 text-2xs">
|
||||
{t(`recorder.composer.stepKind.${step.kind}`)}
|
||||
{step.shot ? ` · ${t("recorder.composer.withShot")}` : ""}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
iconOnly
|
||||
icon={<X className="w-4 h-4" />}
|
||||
<IconButton
|
||||
label={t("recorder.composer.removeStep", { n: i + 1 })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
edit((f) => ({ ...f, steps: f.steps.filter((_, j) => j !== i) }));
|
||||
addStep.current?.focus();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
@@ -624,7 +634,7 @@ function Attachments({
|
||||
const logRow = (source: Source, icon: React.ReactNode) => {
|
||||
const [kept, total] = counts[source];
|
||||
return (
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2.5">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-border px-3 py-2.5">
|
||||
{icon}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="text-sm">
|
||||
@@ -654,7 +664,7 @@ function Attachments({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 min-w-0 rounded-xl bg-surface/40 p-3 md:p-4">
|
||||
<div className={`${SURFACE.card} flex flex-col gap-3 min-w-0 p-3 md:p-4`}>
|
||||
<div className="text-sm text-muted">{t("recorder.composer.attachments")}</div>
|
||||
{logRow("web", <FileText className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
||||
{logRow("server", <Server className="w-4.5 h-4.5 shrink-0 text-muted" aria-hidden />)}
|
||||
@@ -666,7 +676,7 @@ function Attachments({
|
||||
{shots.map((shot, i) => (
|
||||
<div
|
||||
key={shot.id}
|
||||
className="flex items-center gap-3 rounded-xl border border-border bg-card/60 px-3 py-2"
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
|
||||
>
|
||||
<img
|
||||
src={api.debugScreenshotUrl(session.id, shot.id)}
|
||||
@@ -678,18 +688,18 @@ function Attachments({
|
||||
<span className="text-xs text-muted tabular-nums">{formatBytes(shot.size)}</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
iconOnly
|
||||
icon={<X className="w-4 h-4" />}
|
||||
<IconButton
|
||||
label={t("recorder.composer.removeShot", { n: i + 1 })}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={remove.isPending && remove.variables === shot.id}
|
||||
onClick={() => {
|
||||
remove.mutate(shot.id);
|
||||
pick.current?.focus();
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -752,19 +762,18 @@ function ReviewList({
|
||||
{t("recorder.composer.reviewDone")}
|
||||
</Button>
|
||||
</div>
|
||||
<ul className="max-h-[60vh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
|
||||
<ul className="max-h-[60dvh] overflow-y-auto rounded-xl border border-border bg-bg/60 font-mono text-xs">
|
||||
{rows.map((row) => {
|
||||
const tag = LEVEL_TAG[row.level];
|
||||
const off = excluded.has(row.id);
|
||||
return (
|
||||
<li key={row.id} className="border-b border-border/50 last:border-b-0">
|
||||
<label
|
||||
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 hover:bg-card sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
|
||||
className={`grid cursor-pointer grid-cols-[auto_6.5em_3.5em_minmax(0,1fr)] items-baseline gap-x-2 px-3 py-1 state-layer sm:grid-cols-[auto_6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] ${
|
||||
off ? "opacity-50 line-through" : ""
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={!off}
|
||||
disabled={readOnly}
|
||||
aria-label={t("recorder.composer.keepRow", {
|
||||
@@ -810,7 +819,7 @@ function IssuePreview({
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<p className="text-xs text-muted">{t("recorder.composer.previewLocal")}</p>
|
||||
<pre className="max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
|
||||
<pre className="max-h-[60dvh] overflow-auto whitespace-pre-wrap rounded-xl border border-border bg-bg/60 p-3 font-mono text-xs">
|
||||
{text}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { AlertTriangle, Lock, ShieldCheck } from "lucide-react";
|
||||
import { api, HttpError } from "../lib/api";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import { Button } from "./ui/Button";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Secure-messaging key gate: first-run setup (choose a passphrase) or unlock on this device.
|
||||
// Self-contained — on success it updates the shared e2ee unlock state, so any observing view
|
||||
@@ -95,20 +96,20 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
|
||||
if (view === "reset")
|
||||
return (
|
||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5 text-danger shrink-0" />
|
||||
<div className="font-semibold text-sm">{t("messages.resetTitle")}</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted">{t("messages.resetWarn")}</p>
|
||||
{hasPassword && (
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={curPass}
|
||||
onChange={(e) => setCurPass(e.target.value)}
|
||||
placeholder={t("messages.resetCurrentPass")}
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{err && (
|
||||
@@ -133,7 +134,7 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`glass rounded-2xl space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className={`${SURFACE.panel} space-y-3 ${compact ? "p-3" : "p-4"}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-accent shrink-0" />
|
||||
<div className="font-semibold text-sm">
|
||||
@@ -145,21 +146,21 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
{configured ? t("messages.unlockBody") : t("messages.setupBody")}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
value={pass}
|
||||
onChange={(e) => setPass(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && configured && submit()}
|
||||
placeholder={t("messages.passphrase")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{!configured && (
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder={t("messages.passphraseConfirm")}
|
||||
className={inputCls}
|
||||
/>
|
||||
)}
|
||||
{err && (
|
||||
@@ -172,9 +173,9 @@ export default function KeyGate({ meId, compact = false }: { meId: number; compa
|
||||
{configured ? t("messages.unlock") : t("messages.setUp")}
|
||||
</Button>
|
||||
{configured && (
|
||||
<button onClick={toReset} className="block text-xs text-muted hover:text-fg">
|
||||
<Button variant="link-muted" onClick={toReset} className="text-xs">
|
||||
{t("messages.forgotPass")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{!configured && !compact && <p className="text-xs text-muted">{t("messages.setupWarn")}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useState } from "react";
|
||||
import { Check, Globe } from "lucide-react";
|
||||
import { Globe } from "lucide-react";
|
||||
import { MenuCheck } from "./ui/MenuCheck";
|
||||
import { LANGUAGES, type LangCode } from "../i18n";
|
||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { RAIL_GLYPH, RAIL_ROW } from "./ui/NavRail";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
|
||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||
// changing the language does (set it; persist server-side when signed in).
|
||||
@@ -42,33 +46,36 @@ export default function LanguageSwitcher({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
title={current.label}
|
||||
aria-label={current.label}
|
||||
layer
|
||||
className={
|
||||
rail
|
||||
? "relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
: "flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg text-sm text-muted hover:text-fg hover:bg-card transition"
|
||||
? `${RAIL_ROW} h-9 text-muted hover:text-fg`
|
||||
: "flex h-7 items-center gap-1.5 px-2.5 rounded-lg text-sm text-muted hover:text-fg transition touch-target"
|
||||
}
|
||||
>
|
||||
<Globe className={rail ? "w-5 h-5" : "w-4 h-4"} />
|
||||
{rail ? (
|
||||
/* Current-language badge (HU/EN) so the active language reads at a glance without
|
||||
opening the menu. The ring matches the page background to detach it from the icon. */
|
||||
<span className="absolute -bottom-1 -right-1 text-[0.5rem] font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
|
||||
{current.code.toUpperCase()}
|
||||
/* A rail row (F32), with the current-language badge (HU/EN) on the glyph's corner so the
|
||||
active language reads at a glance. The ring matches the page to detach it from the icon;
|
||||
its type is the rail count badge's (text-3xs), the smallest step on the scale (it was an
|
||||
8px one-off). */
|
||||
<span className="relative">
|
||||
<Globe className={RAIL_GLYPH} />
|
||||
<span className="absolute -bottom-1.5 -right-2.5 text-3xs font-bold leading-none px-1 py-px rounded-sm bg-accent text-accent-fg ring-2 ring-bg">
|
||||
{current.code.toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs font-semibold uppercase">{current.code}</span>
|
||||
<Globe className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
{rail ? null : <span className="text-xs font-semibold uppercase">{current.code}</span>}
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
className="glass-menu w-40 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<PopoverSurface popover={pop} className={`${SURFACE.menu} w-40 animate-[popIn_0.16s_ease]`}>
|
||||
{LANGUAGES.map((l) => (
|
||||
<PopoverItem
|
||||
key={l.code}
|
||||
@@ -79,10 +86,10 @@ export default function LanguageSwitcher({
|
||||
setOpen(false);
|
||||
}}
|
||||
label={l.label}
|
||||
className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
|
||||
className="w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg"
|
||||
>
|
||||
<MenuCheck kind="one" on={l.code === value} />
|
||||
<span>{l.label}</span>
|
||||
{l.code === value && <Check className="w-4 h-4 text-accent" />}
|
||||
</PopoverItem>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||
<Modal initialFocus="close" title={jobTitle(job)} onClose={onClose} maxWidth="max-w-4xl">
|
||||
{isAudio ? (
|
||||
<div className="flex flex-col items-center gap-4 py-2">
|
||||
{poster ? (
|
||||
@@ -63,7 +63,7 @@ export default function LibraryPlayer({ job, onClose }: { job: DownloadJob; onCl
|
||||
controls
|
||||
autoPlay
|
||||
playsInline
|
||||
className="w-full max-h-[70vh] rounded-lg bg-black object-contain"
|
||||
className="w-full max-h-[70dvh] rounded-lg bg-black object-contain"
|
||||
>
|
||||
{t("downloads.player.unsupported")}
|
||||
</video>
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
|
||||
import type { Level } from "../lib/log";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// One log line as both log viewers draw it — "Saját napló" (this tab's ring) and the admin
|
||||
// diagnostics console (the server's stored log): time, level, event code, message, and the line opens
|
||||
@@ -37,15 +38,16 @@ export default function LogLine({
|
||||
return (
|
||||
<div
|
||||
className="border-b border-border/50 last:border-b-0"
|
||||
{...navItemAttrs(navIndex, `${event} ${msg}`)}
|
||||
{...navItemAttrs(navIndex, `${event} ${msg}`, { inset: true })}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
<Pressable
|
||||
selectable
|
||||
layer={!open}
|
||||
data-nav-open
|
||||
aria-expanded={open}
|
||||
onClick={onToggle}
|
||||
title={title}
|
||||
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 text-left hover:bg-card ${
|
||||
className={`grid w-full grid-cols-[6.5em_3.5em_minmax(0,1fr)] items-baseline sm:grid-cols-[6.5em_3.5em_minmax(0,10em)_minmax(0,1fr)] gap-x-2 px-3 py-1 touch-target pointer-coarse:content-center ${
|
||||
open ? "bg-accent/10" : ""
|
||||
}`}
|
||||
>
|
||||
@@ -55,7 +57,7 @@ export default function LogLine({
|
||||
{/* A phone has no room for a fourth column: the message takes a line of its own under the
|
||||
code, full width — sharing the code's line left it a few characters. */}
|
||||
<span className="col-span-3 truncate sm:col-span-1">{msg}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
{open && <div className="bg-accent/5 px-3 py-2 text-muted">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { MouseEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Play, RotateCcw } from "lucide-react";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
|
||||
// The started card's hover pair (DESIGN "Players", C5: restarting lives on the card): Continue in the
|
||||
// accent, Restart dark translucent. ONE component for the YouTube card and the Plex cards, so the one
|
||||
// media card says the same two words in the same two looks. A small picture (a shelf's landscape, the
|
||||
// dense video card) takes the icon-only pair, which fits beside the hover row. Touch has no hover: a
|
||||
// tap on the picture continues, and the card's "…" carries the restart.
|
||||
export function MediaResumeButtons({
|
||||
small = false,
|
||||
onContinue,
|
||||
onRestart,
|
||||
}: {
|
||||
small?: boolean;
|
||||
onContinue: (e: MouseEvent) => void;
|
||||
onRestart: (e: MouseEvent) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return small ? (
|
||||
<>
|
||||
<IconButton
|
||||
variant="primary"
|
||||
size="md"
|
||||
round
|
||||
onClick={onContinue}
|
||||
label={t("card.continue")}
|
||||
title={t("card.continueTitle")}
|
||||
>
|
||||
<Play className="fill-current" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={onRestart}
|
||||
label={t("card.restart")}
|
||||
title={t("card.restartTitle")}
|
||||
>
|
||||
<RotateCcw />
|
||||
</IconButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
round
|
||||
onClick={onContinue}
|
||||
title={t("card.continueTitle")}
|
||||
icon={<Play className="fill-current" />}
|
||||
>
|
||||
{t("card.continue")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="media"
|
||||
size="sm"
|
||||
onClick={onRestart}
|
||||
title={t("card.restartTitle")}
|
||||
icon={<RotateCcw />}
|
||||
>
|
||||
{t("card.restart")}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -6,18 +6,22 @@ import { ArrowLeft, ShieldCheck, SquarePen, ExternalLink } from "lucide-react";
|
||||
import { api, type Conversation, type MessageUser } from "../lib/api";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import { relativeTime } from "../lib/format";
|
||||
import { partnerPub, POLL_MS, SYSTEM_ID, useKeyState } from "../lib/messaging";
|
||||
import { useHistorySubview } from "../lib/history";
|
||||
import { openChat } from "../lib/chatDock";
|
||||
import * as e2ee from "../lib/e2ee";
|
||||
import Avatar from "./ui/Avatar";
|
||||
import KeyGate from "./KeyGate";
|
||||
import { inputCls } from "./ui/form";
|
||||
import ChatThread from "./ChatThread";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { navItemAttrs } from "../lib/listNav";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { Input } from "./ui/Input";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import { SectionLabel } from "./ui/Headings";
|
||||
import { RelativeTime } from "./ui/RelativeTime";
|
||||
|
||||
type View =
|
||||
| "list"
|
||||
@@ -126,9 +130,7 @@ function ConversationList({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs uppercase tracking-wide text-muted">
|
||||
{t("messages.conversations")}
|
||||
</div>
|
||||
<SectionLabel>{t("messages.conversations")}</SectionLabel>
|
||||
{ready && (
|
||||
<Button variant="primary" size="xs" onClick={onNew}>
|
||||
<SquarePen className="w-4 h-4" />
|
||||
@@ -142,9 +144,9 @@ function ConversationList({
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading && !q.data ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : items.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">{t("messages.empty")}</div>
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>{t("messages.empty")}</div>
|
||||
) : (
|
||||
// A keyboard list (E11): `j`/`k` walk the conversations, Enter opens the one the name button
|
||||
// opens; the pop-out button beside it keeps its own Enter.
|
||||
@@ -155,12 +157,13 @@ function ConversationList({
|
||||
<div
|
||||
key={c.partner.id}
|
||||
{...navItemAttrs(i, c.partner.name)}
|
||||
className="group flex items-center gap-1 rounded-xl hover:bg-card transition"
|
||||
className="group flex items-center gap-1 rounded-xl state-layer transition"
|
||||
>
|
||||
<button
|
||||
<Pressable
|
||||
selectable
|
||||
data-nav-open=""
|
||||
onClick={() => onOpen(c)}
|
||||
className="flex items-center gap-3 p-2.5 text-left min-w-0 flex-1"
|
||||
className="flex items-center gap-3 p-2.5 min-w-0 flex-1"
|
||||
>
|
||||
<Avatar
|
||||
src={c.partner.avatar_url}
|
||||
@@ -174,9 +177,10 @@ function ConversationList({
|
||||
>
|
||||
{c.partner.name}
|
||||
</span>
|
||||
<span className="ml-auto text-2xs text-muted shrink-0">
|
||||
{relativeTime(c.last_message.created_at)}
|
||||
</span>
|
||||
<RelativeTime
|
||||
at={c.last_message.created_at}
|
||||
className="ml-auto text-2xs text-muted shrink-0"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
@@ -191,16 +195,16 @@ function ConversationList({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</Pressable>
|
||||
{!isSystem && ready && (
|
||||
<button
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => openChat(c.partner)}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="shrink-0 mr-1.5 p-1.5 rounded-lg text-muted reveal hover:text-accent hover:bg-bg transition"
|
||||
label={t("messages.popOut")}
|
||||
className="mr-1.5 reveal"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -221,40 +225,43 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
<IconButton
|
||||
label={t("channel.back")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
<div className="text-xs uppercase tracking-wide text-muted">{t("messages.newTitle")}</div>
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<SectionLabel>{t("messages.newTitle")}</SectionLabel>
|
||||
</div>
|
||||
<input
|
||||
<Input
|
||||
className="w-full"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder={t("messages.searchPeople")}
|
||||
className={inputCls}
|
||||
/>
|
||||
{q.isError && !q.data ? (
|
||||
<StateMessage tone="error" onRetry={() => q.refetch()}>
|
||||
{t("common.loadError")}
|
||||
</StateMessage>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState />
|
||||
<LoadingState shape="list" />
|
||||
) : users.length === 0 ? (
|
||||
<div className="glass rounded-2xl p-10 text-center text-muted">
|
||||
<div className={`${SURFACE.panel} p-10 text-center text-muted`}>
|
||||
{t("messages.noPeople")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1">
|
||||
{users.map((u) => (
|
||||
<button
|
||||
<Pressable
|
||||
selectable
|
||||
key={u.id}
|
||||
layer
|
||||
onClick={() => onPick(u)}
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl text-left hover:bg-card transition"
|
||||
className="flex items-center gap-3 p-2.5 rounded-xl"
|
||||
>
|
||||
<Avatar
|
||||
src={u.avatar_url}
|
||||
@@ -262,7 +269,7 @@ function Directory({ onPick, onBack }: { onPick: (u: MessageUser) => void; onBac
|
||||
className="w-9 h-9 rounded-full shrink-0 text-sm"
|
||||
/>
|
||||
<span className="truncate font-medium">{u.name}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -289,17 +296,18 @@ function PageThread({
|
||||
const ready = useKeyState() === "ready";
|
||||
const name = seedName ?? "";
|
||||
return (
|
||||
<div className="flex flex-col h-[65vh] min-h-80">
|
||||
<div className="flex flex-col h-[65dvh] min-h-80">
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<Button
|
||||
<IconButton
|
||||
label={t("channel.back")}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
// An icon-only button needs a name: both of these announced themselves as a bare "button",
|
||||
// and they are the only way out of the sub-view (E10 review round 1).
|
||||
label={t("channel.back")}
|
||||
onClick={onBack}
|
||||
icon={<ArrowLeft className="w-4 h-4" />}
|
||||
/>
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
</IconButton>
|
||||
<Avatar
|
||||
src={seedAvatar}
|
||||
fallback={name}
|
||||
@@ -314,14 +322,13 @@ function PageThread({
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{!isSystem && ready && (
|
||||
<button
|
||||
<IconButton
|
||||
size="md"
|
||||
onClick={() => openChat({ id: partnerId, name, avatar_url: seedAvatar ?? null })}
|
||||
title={t("messages.popOut")}
|
||||
aria-label={t("messages.popOut")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
||||
label={t("messages.popOut")}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</button>
|
||||
<ExternalLink />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<ChatThread meId={meId} partnerId={partnerId} isSystem={isSystem} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import clsx from "clsx";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Copy, Search } from "lucide-react";
|
||||
@@ -16,6 +17,8 @@ import LogLine from "./LogLine";
|
||||
import NavGrid from "./ui/NavGrid";
|
||||
import { Button } from "./ui/Button";
|
||||
import FilterChipGroup from "./ui/FilterChipGroup";
|
||||
import { InnerInput } from "./ui/Input";
|
||||
import { fieldClass } from "./ui/fieldStyles";
|
||||
|
||||
// Rows rendered at once. The ring holds up to 2000 events; drawing all of them on every new event
|
||||
// would make the settings page the slowest thing in the app while it is watching the app.
|
||||
@@ -123,15 +126,19 @@ export default function MyLogViewer() {
|
||||
<div data-testid="my-log" className="overflow-hidden rounded-xl border border-border bg-bg">
|
||||
<p className="px-3 pt-2 text-xs text-muted">{t("settings.myLog.hint")}</p>
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border p-2">
|
||||
<label className="flex min-w-[10rem] flex-1 items-center gap-1.5 rounded-lg border border-border bg-card px-2 py-1 text-sm focus-within:border-accent">
|
||||
<label
|
||||
className={clsx(
|
||||
fieldClass({ size: "sm", pill: true }),
|
||||
"flex min-w-[10rem] flex-1 items-center gap-1.5",
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden />
|
||||
<input
|
||||
<InnerInput
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("settings.myLog.search")}
|
||||
aria-label={t("settings.myLog.search")}
|
||||
className="min-w-0 flex-1 bg-transparent outline-none"
|
||||
/>
|
||||
</label>
|
||||
<FilterChipGroup
|
||||
|
||||
@@ -11,15 +11,30 @@ import * as e2ee from "../lib/e2ee";
|
||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
|
||||
import {
|
||||
moduleDef,
|
||||
moduleLabelKey,
|
||||
moduleOrder,
|
||||
moduleRailKey,
|
||||
SYSTEM_PAGES,
|
||||
} from "../lib/modules";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type LangCode } from "../i18n";
|
||||
import { Button } from "./ui/Button";
|
||||
import AvatarImg from "./ui/Avatar";
|
||||
import Tooltip from "./ui/Tooltip";
|
||||
import LanguageSwitcher from "./LanguageSwitcher";
|
||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { SURFACE } from "./ui/surfaces";
|
||||
import {
|
||||
NavRail,
|
||||
NavRailAction,
|
||||
NavRailDivider,
|
||||
NavRailItem,
|
||||
NavRailList,
|
||||
RAIL_ROW,
|
||||
} from "./ui/NavRail";
|
||||
import { usePrefs } from "./prefsContext";
|
||||
|
||||
// Primary app navigation: a thin icon-only left rail. The modules used to hide under the avatar
|
||||
// dropdown; they now live here, each revealing its name in a hover/focus flyout label. Account
|
||||
@@ -155,107 +170,60 @@ export default function NavSidebar({
|
||||
.filter((p) => sys.has(p))
|
||||
.map((p) => ({ page: p, ...META(p) }));
|
||||
|
||||
const rowBase =
|
||||
"selectable w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition";
|
||||
const { railLabels } = usePrefs();
|
||||
const name = displayName(me);
|
||||
// Role is surfaced as a plain title tooltip on the account row (no visual badge).
|
||||
const roleLabel = t(`nav.role.${roleKey(me)}`);
|
||||
|
||||
const renderItem = ({ page: p, icon: Icon, label, badge }: NavItem) => {
|
||||
const active = page === p;
|
||||
// On the active row the background is the accent colour, so a same-accent badge would be
|
||||
// red-on-red. Invert it there (light pill, accent-coloured number) for clean contrast.
|
||||
const badgeColor = active ? "bg-accent-fg text-accent" : "bg-accent text-accent-fg";
|
||||
const show = !!badge && badge > 0;
|
||||
return (
|
||||
// The name rides in a right-side flyout on hover (rest-to-show), so the rail stays icon-only.
|
||||
<Tooltip key={p} hint={label} side="right" always openDelay={350}>
|
||||
<button
|
||||
data-testid={`nav-${p}`}
|
||||
onClick={() => setPage(p)}
|
||||
aria-label={label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`${rowBase} justify-center px-0 relative is-current`}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Icon className="w-4.5 h-4.5" />
|
||||
{/* A numeric badge centred on a circle at the icon corner; the ring matches the row
|
||||
background so it reads cleanly whether the row is active or not. */}
|
||||
{show && (
|
||||
<span
|
||||
className={`absolute -top-2 -right-2 min-w-4 h-4 px-1 rounded-full text-3xs font-bold leading-none grid place-items-center ring-2 ${badgeColor} ${
|
||||
active ? "ring-accent" : "ring-bg"
|
||||
}`}
|
||||
>
|
||||
{badge > 9 ? "9+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
const renderItem = ({ page: p, icon, label, badge }: NavItem) => (
|
||||
<NavRailItem
|
||||
key={p}
|
||||
testId={`nav-${p}`}
|
||||
icon={icon}
|
||||
label={label}
|
||||
shortLabel={t(moduleRailKey[p])}
|
||||
showLabel={railLabels}
|
||||
active={page === p}
|
||||
badge={badge}
|
||||
onClick={() => setPage(p)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
// Slim icon rail, flush against the left viewport edge (only top/bottom margin), never grows.
|
||||
// The slot is exactly the card's width so the filter tab/bar docks flush against its right edge.
|
||||
<div className="relative shrink-0 w-14">
|
||||
<nav
|
||||
className="glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 my-3 w-14 px-2"
|
||||
aria-label={t("nav.primary")}
|
||||
>
|
||||
<div
|
||||
ref={listFade.ref}
|
||||
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
|
||||
style={listFade.style}
|
||||
>
|
||||
// Slim rail (B7), flush against the left viewport edge (only top/bottom margin), never grows —
|
||||
// labels on or off. The slot is exactly the rail's width so the filter tab/bar docks flush
|
||||
// against its right edge.
|
||||
<div className="relative shrink-0 w-15">
|
||||
<NavRail label={t("nav.primary")} className="absolute top-0 bottom-0 left-0 z-rail my-3">
|
||||
<NavRailList ref={listFade.ref} style={listFade.style}>
|
||||
{userItems.map(renderItem)}
|
||||
{systemItems.length > 0 && (
|
||||
// A short, thicker centred rule is the clearest "new section" cue on an icon-only rail.
|
||||
<div className="my-2 mx-auto w-7 border-t-2 border-border" />
|
||||
)}
|
||||
{systemItems.length > 0 && <NavRailDivider />}
|
||||
{systemItems.map(renderItem)}
|
||||
</div>
|
||||
</NavRailList>
|
||||
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-1">
|
||||
<div className="flex flex-col items-center justify-center gap-2 pb-2 mb-1 border-b border-border">
|
||||
<div className="flex flex-col gap-1 pb-2 mb-1 border-b border-border">
|
||||
<LanguageSwitcher variant="rail" value={language} onChange={onChangeLanguage} />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onOpenAbout}
|
||||
title={t("header.account.about")}
|
||||
aria-label={t("header.account.about")}
|
||||
icon={<Info className="w-5 h-5" />}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={logout}
|
||||
title={t("header.account.signOut")}
|
||||
aria-label={t("header.account.signOut")}
|
||||
icon={<LogOut className="w-5 h-5" />}
|
||||
/>
|
||||
<NavRailAction icon={Info} label={t("header.account.about")} onClick={onOpenAbout} />
|
||||
<NavRailAction icon={LogOut} label={t("header.account.signOut")} onClick={logout} />
|
||||
</div>
|
||||
<Tooltip hint={t("header.account.settings")} side="right" always openDelay={350}>
|
||||
<button
|
||||
data-testid="nav-settings"
|
||||
onClick={() => setPage("settings")}
|
||||
aria-label={t("header.account.settings")}
|
||||
aria-current={page === "settings" ? "page" : undefined}
|
||||
className={`${rowBase} justify-center px-0 is-current`}
|
||||
>
|
||||
<Settings className="w-4.5 h-4.5 shrink-0" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<NavRailItem
|
||||
testId="nav-settings"
|
||||
icon={Settings}
|
||||
label={t("header.account.settings")}
|
||||
shortLabel={t(moduleRailKey.settings)}
|
||||
showLabel={railLabels}
|
||||
active={page === "settings"}
|
||||
onClick={() => setPage("settings")}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<button
|
||||
<Pressable
|
||||
ref={acctPop.refs.setReference}
|
||||
{...acctPop.getReferenceProps()}
|
||||
title={`${name} · ${roleLabel}`}
|
||||
aria-label={`${name} · ${roleLabel}`}
|
||||
className={`${rowBase} justify-center px-0 text-muted hover:text-fg hover:bg-card`}
|
||||
className={`${RAIL_ROW} h-9 text-muted state-layer hover:text-fg`}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<AvatarImg
|
||||
@@ -264,12 +232,12 @@ export default function NavSidebar({
|
||||
className="w-5.5 h-5.5 rounded-full text-3xs"
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
<PopoverSurface
|
||||
popover={acctPop}
|
||||
ariaLabel={name}
|
||||
className="glass w-60 rounded-xl p-3 animate-[popIn_0.16s_ease]"
|
||||
className={`${SURFACE.popover} w-60 p-3 animate-[popIn_0.16s_ease]`}
|
||||
>
|
||||
<div className="flex items-center gap-3 pb-3 border-b border-border">
|
||||
<AvatarImg
|
||||
@@ -305,29 +273,29 @@ export default function NavSidebar({
|
||||
{otherAccounts.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col gap-0.5">
|
||||
{otherAccounts.map((a) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={a.id}
|
||||
onClick={() => switchTo(a.id)}
|
||||
title={t("header.account.switchTo", { name: a.display_name ?? a.email })}
|
||||
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-card transition"
|
||||
className="selectable w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg transition touch-target"
|
||||
>
|
||||
<AvatarImg
|
||||
src={a.avatar_url}
|
||||
fallback={a.display_name ?? a.email}
|
||||
className="w-6 h-6 rounded-full text-3xs shrink-0"
|
||||
/>
|
||||
<span className="min-w-0 text-left">
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[0.8125rem] text-fg truncate">
|
||||
{displayName(a)}
|
||||
</span>
|
||||
<span className="block text-2xs text-muted truncate">{a.email}</span>
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-2 pt-2 border-t border-border flex flex-col">
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => {
|
||||
// Adding an account switches THIS tab to it: drop the tab's pin so that on
|
||||
// return from Google it adopts the freshly-added account (the new default)
|
||||
@@ -335,23 +303,23 @@ export default function NavSidebar({
|
||||
pinOnNextPage(null);
|
||||
window.location.href = "/auth/login";
|
||||
}}
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||
>
|
||||
<UserPlus className="w-4 h-4" />
|
||||
{t("header.account.addAccount")}
|
||||
</button>
|
||||
<button
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onClick={logout}
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
className="selectable w-full flex items-center gap-2 text-sm px-2 py-2 rounded-lg text-muted hover:text-fg transition touch-target"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
{t("header.account.signOut")}
|
||||
</button>
|
||||
</Pressable>
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</NavRail>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||