Merge: promote dev to prod

This commit is contained in:
2026-07-19 04:59:36 +02:00
101 changed files with 4210 additions and 1382 deletions
+7
View File
@@ -16,6 +16,13 @@ node_modules/
frontend/dist/
*.log
# Playwright E2E (browsers install globally; these are per-run artifacts + the auth state)
frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
frontend/playwright/.cache/
frontend/e2e/.auth/
# Data / dumps
*.dump
*.sql.gz
+1 -1
View File
@@ -1 +1 @@
0.44.0
0.45.0
@@ -0,0 +1,34 @@
"""plex_people: a small cast/crew headshot cache for person filter-chips
Revision ID: 0056_plex_people
Revises: 0055_channel_keywords
Create Date: 2026-07-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0056_plex_people"
down_revision: Union[str, None] = "0055_channel_keywords"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"plex_people",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=255), nullable=False),
# A representative proxied headshot URL (or NULL when none is available). Captured
# opportunistically from an item's rich metadata (info-page view + the rich re-sync).
sa.Column("photo_thumb", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_plex_people_name", "plex_people", ["name"], unique=True)
def downgrade() -> None:
op.drop_index("ix_plex_people_name", table_name="plex_people")
op.drop_table("plex_people")
@@ -0,0 +1,25 @@
"""plex_items/plex_shows.people_enriched_at: full-cast backfill marker
Revision ID: 0057_plex_ppl_enriched
Revises: 0056_plex_people
Create Date: 2026-07-19
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "0057_plex_ppl_enriched"
down_revision: Union[str, None] = "0056_plex_people"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
for tbl in ("plex_items", "plex_shows"):
op.add_column(tbl, sa.Column("people_enriched_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
for tbl in ("plex_items", "plex_shows"):
op.drop_column(tbl, "people_enriched_at")
+21
View File
@@ -1027,6 +1027,8 @@ class PlexShow(Base, TimestampMixin, UpdatedAtMixin):
directors: Mapped[list | None] = mapped_column(JSONB)
cast_names: Mapped[list | None] = mapped_column(JSONB)
people_text: Mapped[str | None] = mapped_column(Text) # cast+directors, folded into search_vector
# Set once the show's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
@@ -1118,6 +1120,8 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
# Cast + director names, space-joined — folded into search_vector (weight B) so the search box
# finds titles by a person's name (and ranks them above summary-only mentions).
people_text: Mapped[str | None] = mapped_column(Text)
# Set once the movie's FULL cast has been rich-fetched by the backfill; NULL = still the listing top-3.
people_enriched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
search_vector: Mapped[object | None] = mapped_column(
TSVECTOR,
Computed(
@@ -1129,6 +1133,23 @@ class PlexItem(Base, TimestampMixin, UpdatedAtMixin):
)
class PlexPerson(Base, TimestampMixin, UpdatedAtMixin):
"""A small cast/crew headshot cache, keyed by name, so person filter-chips (the cards for the
actors currently in the filter) can show a photo + count without a live per-card metadata fetch.
Populated opportunistically when an item's RICH metadata is fetched — the info-page view and the
periodic rich re-sync — since the cheap section listing that drives the main sync only carries the
top few cast. Rows whose name no longer appears in ANY item's cast_names/directors (a library
removal orphaned them) are pruned by the maintenance sweep, mirroring how Plex drops orphaned
metadata. The filterable cast lives in plex_items/plex_shows.cast_names (self-cleaning with the
row); this table is ONLY the headshot cache."""
__tablename__ = "plex_people"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(255), unique=True, index=True)
photo_thumb: Mapped[str | None] = mapped_column(Text) # proxied /person-image URL, or None
class PlexCollection(Base, TimestampMixin, UpdatedAtMixin):
"""A mirrored Plex collection (a grouping of movies or shows — franchises like Avatar, curated
sets like MCU, or auto/community lists like IMDb Top 250). Mirrored in full by the background
+97
View File
@@ -0,0 +1,97 @@
"""Shared Plex people helpers: parse cast/crew from rich metadata, persist the full cast onto a movie
or show (cast_names + the people_text search blob + the plex_people headshot cache), and prune
orphaned people. Used by BOTH the info-page lazy enrichment (routes.plex) and the background rich
re-sync (plex.sync), so the two paths never diverge."""
from urllib.parse import quote
from sqlalchemy import text
from sqlalchemy.orm import Session
from app.models import PlexPerson
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def person_thumb_url(thumb: str | None) -> str | None:
"""Proxied /person-image URL for a Plex CDN headshot, or None when it's missing / off-CDN."""
t = str(thumb or "")
return f"/api/plex/person-image?u={quote(t, safe='')}" if t.startswith(PERSON_IMG_HOST) else None
def cast_from_meta(meta: dict, limit: int = 24) -> list[dict]:
"""[{name, role, thumb}] for the cast (Role tags) in a full Plex metadata dict — thumb is the
proxied person-image URL or None. Shared by the info-page response, the lazy enrichment, and the
re-sync so the parsing lives in one place."""
out: list[dict] = []
for role in (meta.get("Role") or [])[:limit]:
name = role.get("tag")
if not name:
continue
out.append(
{"name": name, "role": role.get("role"), "thumb": person_thumb_url(role.get("thumb"))}
)
return out
def enrich_row_people(db: Session, row, cast: list[dict]) -> bool:
"""Persist the FULLER cast onto the row's `cast_names` + `people_text`, and cache each cast
member's headshot in plex_people. The cheap section listing that drives the main sync only carries
the top ~3 cast, so this — run whenever a rich fetch happens (info-page view + the rich re-sync) —
is what makes the full displayed cast filterable. Returns True if anything was written (the caller
commits). Best-effort by contract: never the reason a read 500s."""
names = list(dict.fromkeys(c["name"] for c in cast if c.get("name")))[:24]
if not names:
return False
dirty = False
# Grow/refresh cast_names only when the rich set adds names the stored (listing top-3) set lacks —
# avoid churn and never downgrade a fuller set back to the thin listing.
if set(names) - set(row.cast_names or []):
row.cast_names = names
row.people_text = " ".join(dict.fromkeys(list(row.directors or []) + names)) or None
dirty = True
# Headshot cache: upsert name -> proxied thumb (a later fetch fills a prior NULL; names without a
# whitelisted photo still get a row so a card can show a placeholder).
existing = {p.name: p for p in db.query(PlexPerson).filter(PlexPerson.name.in_(names))}
for c in cast:
nm, th = c.get("name"), c.get("thumb")
if not nm:
continue
p = existing.get(nm)
if p is None:
p = PlexPerson(name=nm, photo_thumb=th)
db.add(p)
existing[nm] = p
dirty = True
elif th and not p.photo_thumb:
p.photo_thumb = th
dirty = True
return dirty
# The person names still referenced by ANY movie/show (cast OR directors). plex_people rows outside
# this set are orphans left by a library removal.
_REFERENCED_PEOPLE_SQL = """
SELECT jsonb_array_elements_text(cast_names) FROM plex_items WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_items WHERE jsonb_typeof(directors) = 'array'
UNION SELECT jsonb_array_elements_text(cast_names) FROM plex_shows WHERE jsonb_typeof(cast_names) = 'array'
UNION SELECT jsonb_array_elements_text(directors) FROM plex_shows WHERE jsonb_typeof(directors) = 'array'
"""
def prune_orphan_people(db: Session) -> int:
"""Delete plex_people (headshot cache) rows whose name no longer appears in any movie/show cast or
directors — orphaned by a library removal. Returns the number pruned. Commits its own work."""
# `WHERE ref IS NOT NULL` matters: a JSON-null array element surfaces as a SQL NULL in the
# referenced set, and `name NOT IN (…, NULL)` is NULL (never TRUE) for every row — the classic
# NOT IN trap that would silently prune nothing. Filtering NULLs out keeps the delete correct.
res = db.execute(
text(
"DELETE FROM plex_people WHERE name NOT IN "
f"(SELECT ref FROM ({_REFERENCED_PEOPLE_SQL}) AS refs(ref) WHERE ref IS NOT NULL)"
)
)
db.commit()
return res.rowcount or 0
+55
View File
@@ -16,6 +16,7 @@ from sqlalchemy.orm import Session
from app import sysconfig
from app.models import PlexCollection, PlexItem, PlexLibrary, PlexSeason, PlexShow
from app.plex import people
from app.plex.client import PlexClient, PlexError, PlexNotConfigured
log = logging.getLogger("siftlode.plex")
@@ -129,16 +130,70 @@ def sync(db: Session) -> dict:
_sync_shows(db, plex, lib, stats)
_sync_collections(db, plex, lib, stats)
db.commit()
# Rich-fetch a bounded batch of not-yet-enriched titles' FULL cast, so the whole library
# becomes filterable over successive runs (the cheap listing above only stored the top ~3).
try:
stats["people_enriched"] = _enrich_people_batch(db, plex)
except Exception as e: # best-effort — never fail the sync over the backfill
log.warning("Plex people enrich batch failed: %s", e)
except PlexNotConfigured as e:
return {"skipped": str(e)}
except PlexError as e:
db.rollback()
log.warning("Plex sync failed: %s", e)
return {"error": str(e)}
# Prune the headshot cache: a reconcile may have removed titles, orphaning people who no longer
# appear in ANY item's cast/directors — drop those rows, mirroring how Plex drops orphaned metadata.
try:
stats["people_pruned"] = people.prune_orphan_people(db)
except Exception as e: # best-effort housekeeping — never fail the sync over it
log.warning("Plex people prune failed: %s", e)
log.info("Plex sync done: %s", stats)
return stats
# How many titles to rich-fetch per sync run for the cast backfill — bounds the extra Plex load; the
# whole library is covered over successive runs (each fetched item is stamped people_enriched_at once).
_ENRICH_BATCH = 60
def _enrich_people_batch(db: Session, plex: PlexClient, limit: int = _ENRICH_BATCH) -> int:
"""Rich-fetch the FULL cast of up to `limit` not-yet-enriched movies/shows and persist it (via the
shared enrich_row_people). Oldest-id first, movies before shows. Stamps people_enriched_at only on
a SUCCESSFUL fetch, so a transient per-item failure retries next run rather than being lost.
Returns how many items were enriched this run."""
movies = (
db.query(PlexItem)
.filter(PlexItem.kind == "movie", PlexItem.people_enriched_at.is_(None))
.order_by(PlexItem.id)
.limit(limit)
.all()
)
remaining = limit - len(movies)
shows = (
db.query(PlexShow)
.filter(PlexShow.people_enriched_at.is_(None))
.order_by(PlexShow.id)
.limit(remaining)
.all()
if remaining > 0
else []
)
now = datetime.now(timezone.utc)
n = 0
for row in [*movies, *shows]:
try:
meta = plex.metadata(row.rating_key) or {}
except Exception as e:
log.debug("Plex people enrich fetch failed for %s: %s", row.rating_key, e)
continue # leave people_enriched_at NULL so it's retried on a later run
people.enrich_row_people(db, row, people.cast_from_meta(meta))
row.people_enriched_at = now
n += 1
db.commit()
return n
def _upsert_library(db: Session, key: str, title: str, kind: str) -> PlexLibrary:
lib = db.query(PlexLibrary).filter_by(plex_key=key).first()
if lib is None:
+79 -26
View File
@@ -17,7 +17,6 @@ from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import quote
import httpx
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request, Response
@@ -34,6 +33,7 @@ from app.models import (
PlexItem,
PlexLibrary,
PlexLink,
PlexPerson,
PlexPlaylist,
PlexPlaylistItem,
PlexSeason,
@@ -42,6 +42,7 @@ from app.models import (
User,
)
from app.plex import paths as plex_paths
from app.plex import people as plex_people
from app.plex import stream as plex_stream
from app.plex import sync as plex_sync
from app.plex import watch_sync as plex_watch
@@ -372,8 +373,10 @@ def _meta_filter_conds(model, p: dict) -> list:
conds.append(model.added_at >= cutoff)
for d in _csv(p.get("directors")): # AND
conds.append(model.directors.contains([d]))
for a in _csv(p.get("actors")): # AND
conds.append(model.cast_names.contains([a]))
alist = _csv(p.get("actors"))
if alist: # AND (all) or OR (any) — default OR, mirroring genre_mode
ac = [model.cast_names.contains([a]) for a in alist]
conds.append(and_(*ac) if p.get("actor_mode") == "all" else or_(*ac))
stds = _csv(p.get("studios"))
if stds: # OR
conds.append(model.studio.in_(stds))
@@ -404,6 +407,7 @@ class LibraryFilters:
added_within: str | None = None
directors: str | None = None
actors: str | None = None
actor_mode: str = "any" # how multiple actors combine: any (OR, default) | all (AND)
studios: str | None = None
collection: str | None = None
@@ -414,7 +418,8 @@ class LibraryFilters:
"year_max": self.year_max, "rating_min": self.rating_min,
"duration_min": self.duration_min, "duration_max": self.duration_max,
"added_within": self.added_within, "directors": self.directors,
"actors": self.actors, "studios": self.studios, "collection": self.collection,
"actors": self.actors, "actor_mode": self.actor_mode,
"studios": self.studios, "collection": self.collection,
}
@@ -1221,6 +1226,17 @@ def show_detail(
# failure (not configured, HTTP/JSON shape, _rich_meta parse error) must degrade, not 500.
pass
# Lazy enrichment: persist the show's full cast so its cast members become filterable (same as the
# movie info page — the sync only stored the listing's top ~3), and stamp people_enriched_at so the
# backfill skips it. Best-effort.
if rich.get("cast"):
plex_people.enrich_row_people(db, sh, rich["cast"])
sh.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception:
db.rollback()
return {
"show": {
"id": sh.rating_key,
@@ -1569,11 +1585,6 @@ _PROGRESS_MIN_S = 5
_FINISH_MARGIN_S = 30
# Cast/crew photos come from Plex's PUBLIC metadata CDN (no token). We proxy them (host-whitelisted)
# so the user's browser makes no third-party request (privacy + self-host CSP friendliness).
_PERSON_IMG_HOST = "https://metadata-static.plex.tv/"
def _rich_meta(meta: dict) -> dict:
"""Pull the "info page" extras out of a full Plex metadata dict: an IMDb score + link, content
rating, genres, director(s), studio, tagline, and cast (name/role/photo). Best-effort — any
@@ -1599,21 +1610,6 @@ def _rich_meta(meta: dict) -> dict:
if gid.startswith("imdb://"):
imdb_id = gid.split("imdb://", 1)[1]
break
cast = []
for role in (meta.get("Role") or [])[:24]:
name = role.get("tag")
if not name:
continue
thumb = str(role.get("thumb") or "")
cast.append(
{
"name": name,
"role": role.get("role"),
"thumb": f"/api/plex/person-image?u={quote(thumb, safe='')}"
if thumb.startswith(_PERSON_IMG_HOST)
else None,
}
)
return {
"imdb_rating": imdb_rating,
"imdb_id": imdb_id,
@@ -1623,16 +1619,61 @@ def _rich_meta(meta: dict) -> dict:
"directors": [d.get("tag") for d in (meta.get("Director") or []) if d.get("tag")][:4],
"studio": meta.get("studio"),
"tagline": meta.get("tagline"),
"cast": cast,
"cast": plex_people.cast_from_meta(meta),
}
@router.get("/people/cards")
def people_cards(
names: str,
scope: str = "both",
user: User = Depends(current_user),
db: Session = Depends(get_db),
) -> dict:
"""Cards for the actor filter-chips currently applied: each name + its cached headshot + how many
movies/shows (in the given scope) feature them. Photos come from the plex_people cache (populated
as items are rich-fetched — info-page view + the rich re-sync); counts are live against cast_names.
A name with no cached photo yet still returns a card (the UI shows a placeholder)."""
wanted = _csv(names)[:16]
if not wanted:
return {"people": []}
libs = db.query(PlexLibrary).filter_by(enabled=True).all()
movie_lib_ids = [lb.id for lb in libs if lb.kind == "movie"]
show_lib_ids = [lb.id for lb in libs if lb.kind == "show"]
want_movies = scope in ("movie", "both") and bool(movie_lib_ids)
want_shows = scope in ("show", "both") and bool(show_lib_ids)
photos = {p.name: p.photo_thumb for p in db.query(PlexPerson).filter(PlexPerson.name.in_(wanted))}
out = []
for nm in wanted:
count = 0
if want_movies:
count += (
db.query(func.count(PlexItem.id))
.filter(
PlexItem.kind == "movie",
PlexItem.library_id.in_(movie_lib_ids),
PlexItem.cast_names.contains([nm]),
)
.scalar()
or 0
)
if want_shows:
count += (
db.query(func.count(PlexShow.id))
.filter(PlexShow.library_id.in_(show_lib_ids), PlexShow.cast_names.contains([nm]))
.scalar()
or 0
)
out.append({"name": nm, "photo": photos.get(nm), "count": count})
return {"people": out}
@router.get("/person-image")
def person_image(u: str, request: Request) -> Response:
"""Proxy a cast/crew photo from Plex's public metadata CDN (host-whitelisted; no token needed).
Signed-session auth (no DB) so it, like /image, never holds a pool connection during the fetch."""
_require_session(request)
if not u.startswith(_PERSON_IMG_HOST):
if not u.startswith(plex_people.PERSON_IMG_HOST):
raise HTTPException(status_code=400, detail="Unsupported image host")
cache_key = "person_" + hashlib.sha1(u.encode()).hexdigest()
hit = _img_cache_read(cache_key)
@@ -1779,6 +1820,18 @@ def item_detail(
except Exception:
pass # metadata extras are best-effort; any live-Plex failure must degrade, not 500 — playback works without them
# Lazy enrichment: the rich fetch above carries the FULL cast (the sync only stored the listing's
# top ~3), so persist it onto this movie so every cast member shown here becomes filterable, and
# stamp people_enriched_at so the backfill skips it. Movie only — episodes aren't in the grid; a
# show's cast is enriched from /show. Best-effort.
if it.kind == "movie" and rich.get("cast"):
plex_people.enrich_row_people(db, it, rich["cast"])
it.people_enriched_at = datetime.now(timezone.utc)
try:
db.commit()
except Exception:
db.rollback()
prev_id, next_id = _episode_nav(db, it)
show = db.get(PlexShow, it.show_id) if it.kind == "episode" and it.show_id else None
return {
+51
View File
@@ -0,0 +1,51 @@
# E2E characterization (golden-master) net
Playwright tests that lock the app's five core flows — **feed** (scroll + filter), **channel**
(open → back), **player** (open/close), **settings** (dirty-save + guard), and **page switching**
so the Platform refactor can *prove* it changed nothing. They assert current, correct behavior;
they deliberately do **not** encode the known Platform bugs (those get their own specs when the
sprints that fix them land).
## Run
```sh
siftlode e2e # against the baked localdev :8080 stack (authoritative)
siftlode e2e-ui # same, Playwright UI mode
siftlode e2e-seed # (re)seed the test account only, no tests
```
The localdev stack must be up (`siftlode dev`). `global.setup.ts` reseeds the account and logs in
once, sharing the session via `e2e/.auth/state.json` (git-ignored).
**Target**`PW_BASE_URL` (default `http://127.0.0.1:8080`):
- **`:8080`** (baked SPA) is authoritative and same-origin. After a **frontend change you must
`siftlode dev` (rebuild)** for its baked SPA to carry the latest test hooks.
- **`:5173`** (Vite dev) serves source live — the fast refactor loop; it proxies `/api` to `:8080`,
so the stack must still be up. All five flows are proxy-safe (none use the WebSocket):
```sh
PW_BASE_URL=http://localhost:5173 siftlode e2e
```
## Account & fixture
Tests run as a dedicated, **non-demo admin** account `e2e@siftlode.local` (never a real account;
admin so the suite can reach the admin-only pages / render branches, matching the real primary
user), reset to a deterministic baseline every run by
[`scripts/e2e_seed.py`](../../scripts/e2e_seed.py) (run
in-container so it reuses the app's models + argon2 hasher): the top-3 organic channels subscribed,
one in-progress + one watched + one saved video, prefs pinned to `language: en`. Credentials come
from `E2E_EMAIL` / `E2E_PASSWORD` (local-only defaults). Override the container name with
`E2E_API_CONTAINER`; skip the in-container seed with `E2E_SKIP_SEED=1`.
## Selectors
The app has no `data-testid`s of its own and all copy is i18n (en/hu), so the specs key on a thin
`data-testid` layer (added with this sprint) plus the locale pinned to `en`. Stable ARIA hooks that
already existed are used directly (`role="dialog"` on the player, `aria-current="page"` on the
active nav row, `aria-pressed` on the feed toggles).
## Typecheck
`npx tsc -p e2e/tsconfig.json --noEmit` (the specs are outside the app's `src` tsconfig).
+128
View File
@@ -0,0 +1,128 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
// Locks channel-view entry + exit: opening a channel from a feed card replaces the view, and
// both the in-page Back and the browser Back return to the feed. (This orthogonal channel branch
// is exactly what the Platform refactor folds into the PageShell contract.)
test.describe("channel view", () => {
test("open from a card, then in-page Back returns to the feed", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("channel-back")).toBeVisible();
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("video-card").first()).toBeVisible();
});
test("browser Back also closes the channel page", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.goBack();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("video-card").first()).toBeVisible();
});
// Bug 1 (Platform S4): opening a channel and coming Back restores the feed's scroll position
// rather than dumping you at the top. The shell owns the scroller and remembers each view's offset.
test("Back restores the feed scroll position", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const main = page.getByRole("main");
await main.evaluate((el) => el.scrollBy(0, 3000));
// Let any page-fetch triggered by the scroll settle, so the same content is cached for the
// return trip (a fetch still in flight at navigation would leave a shorter list to restore into).
await page.waitForTimeout(600);
const saved = await main.evaluate((el) => el.scrollTop);
expect(saved).toBeGreaterThan(1500);
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("video-card").first()).toBeVisible();
// Restored to roughly where we were, not reset to the top. Tolerance absorbs the virtualizer's
// estimate-vs-measured row heights differing slightly between the two mounts.
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop), { timeout: 5000 })
.toBeGreaterThan(saved - 500);
});
// Bug 2 (Platform S5): opening a channel no longer FORCES Live/Upcoming on — the channel view
// seeds its content-type prefs (normal / shorts / live) from the global feed. Locked both ways so
// a future refactor can't silently re-hardcode it.
test("the channel view inherits the feed's content filters (no forced Live/Upcoming)", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const feedLive = page.getByTestId("feed-content-includeLive");
await expect(feedLive).toHaveAttribute("aria-pressed", "false");
// Inherit OFF: the channel toolbar's Live/Upcoming stays off, not forced on.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false");
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
// Inherit ON: flip the feed's Live/Upcoming on, reopen the channel → it inherits the on state.
await feedLive.click();
await expect(feedLive).toHaveAttribute("aria-pressed", "true");
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true");
});
// Bug 2 (Platform S5): the channel view is its OWN filter scope — a content toggle made there
// must NOT leak back into the global feed. (FeedFiltersProvider owns the feed's filters; the
// channel page keeps a separate local cell.)
test("a channel's content toggle does not leak into the feed", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false");
// Turn Live/Upcoming ON inside the channel view.
await page.getByTestId("video-card-channel").first().click();
await expect(page.getByTestId("channel-title")).toBeVisible();
await page.getByTestId("feed-content-includeLive").click();
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "true");
// Back on the feed, its Live/Upcoming is still off — the channel's change stayed local.
await page.getByTestId("channel-back").click();
await expect(page.getByTestId("channel-title")).toHaveCount(0);
await expect(page.getByTestId("feed-content-includeLive")).toHaveAttribute("aria-pressed", "false");
});
// Platform S4b: the whole channel chrome (banner, identity, tabs, toolbar) is fixed — it stays
// put while only the video grid scrolls.
test("the channel chrome stays fixed while the video grid scrolls", async ({ page }) => {
await openApp(page);
await page.getByTestId("video-card-channel").first().click();
const count = page.getByTestId("feed-result-count");
const title = page.getByTestId("channel-title");
await expect(count).toBeVisible();
await expect(page.getByTestId("video-card").first()).toBeVisible();
const titleTop = await title.evaluate((el) => el.getBoundingClientRect().top);
const countTop = await count.evaluate((el) => el.getBoundingClientRect().top);
// Keep nudging until the grid is tall enough to actually scroll (it may still be filling in).
await expect
.poll(async () => {
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 1500));
return page.getByRole("main").evaluate((el) => el.scrollTop);
})
.toBeGreaterThan(400);
// The chrome didn't move — banner title and toolbar are in exactly the same place.
expect(await title.evaluate((el) => el.getBoundingClientRect().top)).toBe(titleTop);
expect(await count.evaluate((el) => el.getBoundingClientRect().top)).toBe(countTop);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
// Locks the feed's core contract: it renders cards, a filter re-queries it and surfaces a
// removable chip, and its virtualized list windows in fresh rows as you scroll.
test.describe("feed", () => {
test("a filter re-queries the feed and surfaces a removable chip", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const count = page.getByTestId("feed-result-count");
await expect(count).toHaveText(/\d/);
const baseline = await count.innerText();
// Clean baseline: default filters, so no chips and "unwatched" is the active show.
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
// Narrow to "watched" — the seed guarantees exactly one, a real deterministic change.
await page.getByTestId("feed-show-watched").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(1);
await expect(count).not.toHaveText(baseline);
// Remove the chip → default show restored, chips gone.
await page.getByTestId("active-filter-chip").click();
await expect(page.getByTestId("active-filter-chip")).toHaveCount(0);
await expect(page.getByTestId("feed-show-unwatched")).toHaveAttribute("aria-pressed", "true");
});
test("scrolling virtualizes in fresh cards", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
const renderedIds = () =>
page
.getByTestId("video-card")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-video-id")));
const before = await renderedIds();
expect(before.length).toBeGreaterThan(0);
// The feed scrolls inside <main> (role="main"), not the window.
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 5000));
// A windowed list renders different rows once scrolled.
await expect
.poll(async () => (await renderedIds()).some((id) => id && !before.includes(id)))
.toBe(true);
});
// Bug 4 (Platform S4): the toolbar (show/content chips, count, sort, view) is fixed chrome — it
// pins in the shell's toolbar band above the scroller, so it stays put while the cards scroll.
test("the feed toolbar stays pinned while cards scroll", async ({ page }) => {
await openApp(page);
const count = page.getByTestId("feed-result-count");
await expect(count).toBeVisible();
const topBefore = await count.evaluate((el) => el.getBoundingClientRect().top);
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000));
await expect(count).toBeInViewport();
const topAfter = await count.evaluate((el) => el.getBoundingClientRect().top);
expect(Math.abs(topAfter - topBefore)).toBeLessThan(4);
});
// Bug 3 (Platform S4): changing a filter is a fresh result set, so the scroll jumps back to the
// top rather than stranding you deep in the old list's offset.
test("a new filter resets the scroll to the top", async ({ page }) => {
await openApp(page);
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByRole("main").evaluate((el) => el.scrollBy(0, 4000));
expect(await page.getByRole("main").evaluate((el) => el.scrollTop)).toBeGreaterThan(500);
// Switch the show filter → a different result set → back to the top.
await page.getByTestId("feed-show-all").click();
await expect
.poll(() => page.getByRole("main").evaluate((el) => el.scrollTop))
.toBeLessThan(50);
});
});
+70
View File
@@ -0,0 +1,70 @@
import { test as setup, expect } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
/**
* One-time setup for the characterization net (the `setup` project the tests depend on):
* 1. preflight — the localdev :8080 stack must be up (owned by `siftlode dev`);
* 2. seed — reset the dedicated E2E account to a known baseline, in-container (idempotent);
* 3. authenticate ONCE via password-login and persist the session + `en` locale to
* storageState, so the real specs never re-auth (auth endpoints are rate-limited).
*/
const STORAGE = "e2e/.auth/state.json";
const EMAIL = process.env.E2E_EMAIL || "e2e@siftlode.local";
const PASSWORD = process.env.E2E_PASSWORD || "e2e-golden-master";
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
const HERE = dirname(fileURLToPath(import.meta.url));
const SEED_SCRIPT = resolve(HERE, "../../scripts/e2e_seed.py");
// The in-container seed only makes sense when the target is the local stack.
const isLocalTarget = /\/\/(127\.0\.0\.1|localhost)[:/]/.test(BASE_URL);
setup("seed + authenticate", async ({ page }) => {
// 1. Preflight: fail fast (with a useful message) if the stack isn't running.
const health = await page.request.get("/healthz");
expect(
health.ok(),
`localdev stack not reachable at ${BASE_URL} — start it with \`siftlode dev\``,
).toBeTruthy();
// 2. Idempotent seed inside the api container (skippable / local-only).
if (!process.env.E2E_SKIP_SEED && isLocalTarget) {
try {
const out = execFileSync(
"docker",
["exec", "-i", "-e", `E2E_EMAIL=${EMAIL}`, "-e", `E2E_PASSWORD=${PASSWORD}`, CONTAINER, "python", "-"],
{ input: readFileSync(SEED_SCRIPT) },
);
console.log(`[e2e seed] ${out.toString().trim()}`);
} catch (err: unknown) {
const e = err as { stderr?: Buffer; message?: string };
throw new Error(`E2E seed failed: ${e.stderr?.toString() || e.message}`);
}
}
// 3. Authenticate. page.request shares the browser context's cookie jar, so the `session`
// cookie it receives is captured by storageState below.
const login = await page.request.post("/auth/password-login", {
data: { email: EMAIL, password: PASSWORD },
});
expect(login.ok(), "password-login failed — is the E2E account seeded?").toBeTruthy();
const me = await (await page.request.get("/api/me")).json();
expect(me.authenticated, "session not established after login").toBeTruthy();
// 4. Pin the UI locale and suppress the first-login onboarding wizard (a full-screen overlay
// that would intercept every click — the test account has no YouTube connection, so it
// auto-opens). The dismissed flag is per-account: `siftlode.onboarding.dismissed.<id>`.
await page.goto("/");
await page.evaluate((id: number) => {
localStorage.setItem("siftlode.lang", "en");
localStorage.setItem(`siftlode.onboarding.dismissed.${id}`, "1");
}, me.id);
mkdirSync(dirname(STORAGE), { recursive: true });
await page.context().storageState({ path: STORAGE });
});
+20
View File
@@ -0,0 +1,20 @@
import { expect, type Page } from "@playwright/test";
/** Open the app and wait for the authenticated shell (the nav rail) to be ready. */
export async function openApp(page: Page, path = "/"): Promise<void> {
await page.goto(path);
await expect(page.getByTestId("nav-feed")).toBeVisible();
}
/** Click a top-level module in the nav rail and confirm it becomes the active page. */
export async function gotoPage(page: Page, id: string): Promise<void> {
await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
}
/** Open the in-app player from the first feed card (clicks its thumbnail link). */
export async function openFirstVideo(page: Page): Promise<void> {
await expect(page.getByTestId("video-card").first()).toBeVisible();
await page.getByTestId("video-card").first().getByRole("link").first().click();
await expect(page.getByTestId("player-modal")).toBeVisible();
}
+32
View File
@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test";
import { openApp } from "./helpers";
// Locks top-level navigation: each rail item activates its page (aria-current), and browser
// Back steps through the history the app pushes. The render ternary + prop-drilled page state
// this exercises is precisely what the module-registry refactor replaces.
test.describe("page switching", () => {
// Core + admin modules (the E2E account is an admin, matching the real primary user), so this
// also exercises the render ternary's admin branches (scheduler / config / users / audit).
const RAIL = ["channels", "playlists", "stats", "scheduler", "config", "users", "audit", "settings", "feed"];
test("each rail item activates its page", async ({ page }) => {
await openApp(page);
for (const id of RAIL) {
await page.getByTestId(`nav-${id}`).click();
await expect(page.getByTestId(`nav-${id}`)).toHaveAttribute("aria-current", "page");
}
});
test("browser Back steps through visited pages", async ({ page }) => {
await openApp(page);
await page.getByTestId("nav-channels").click();
await expect(page.getByTestId("nav-channels")).toHaveAttribute("aria-current", "page");
await page.getByTestId("nav-playlists").click();
await expect(page.getByTestId("nav-playlists")).toHaveAttribute("aria-current", "page");
await page.goBack();
await expect(page.getByTestId("nav-channels")).toHaveAttribute("aria-current", "page");
});
});
+28
View File
@@ -0,0 +1,28 @@
import { test, expect } from "@playwright/test";
import { openApp, openFirstVideo } from "./helpers";
// Locks the in-app player's open/close contract. The player is a portaled overlay whose
// inconsistent mounting caused an E3 regression — exactly the kind of thing the refactor's
// overlay-root convention must not break. We assert the modal shell, not YouTube playback.
test.describe("player modal", () => {
test.beforeEach(async ({ page }) => {
// Hermetic: don't actually load the YouTube iframe/player.
await page.route(/youtube\.com/, (route) => route.abort());
});
test("opens from a card and closes via the close button", async ({ page }) => {
await openApp(page);
await openFirstVideo(page);
await page.getByTestId("player-close").click();
await expect(page.getByTestId("player-modal")).toHaveCount(0);
});
test("closes on Escape", async ({ page }) => {
await openApp(page);
await openFirstVideo(page);
await page.keyboard.press("Escape");
await expect(page.getByTestId("player-modal")).toHaveCount(0);
});
});
+51
View File
@@ -0,0 +1,51 @@
import { test, expect, type Page } from "@playwright/test";
import { openApp, gotoPage } from "./helpers";
// Locks the Settings AUTO-SAVE contract (the draft + Save button + leave-guard were removed): an
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
// it; leaving Settings after a change is no longer guarded.
test.describe("settings auto-save", () => {
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
const putPrefs = (page: Page) =>
page.waitForResponse(
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
);
test("changing a setting auto-saves and shows the saved indicator", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
const toggle = page.getByRole("switch", { name: TOGGLE });
const indicator = page.getByTestId("settings-save-indicator");
// Idle: nothing to save, so no indicator (and no draft Save bar).
await expect(indicator).toHaveCount(0);
// Change it → the PUT fires on its own (no Save click) and the indicator confirms it.
const [saved] = await Promise.all([putPrefs(page), toggle.click()]);
expect(saved.ok()).toBeTruthy();
await expect(indicator).toHaveAttribute("data-state", "saved");
// Restore the baseline so the shared account stays clean.
await Promise.all([putPrefs(page), toggle.click()]);
});
test("a changed setting no longer blocks leaving the page", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
const toggle = page.getByRole("switch", { name: TOGGLE });
await Promise.all([putPrefs(page), toggle.click()]);
// No unsaved-changes prompt — navigating away just lands on the feed.
await page.getByTestId("nav-feed").click();
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("video-card").first()).toBeVisible();
// Restore the baseline (back on Settings).
await gotoPage(page, "settings");
await Promise.all([putPrefs(page), page.getByRole("switch", { name: TOGGLE }).click()]);
});
});
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["node", "@playwright/test"],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"esModuleInterop": true,
"resolveJsonModule": true
},
"include": [".", "../playwright.config.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"$schema": "https://unpkg.com/knip@6/schema.json",
"playwright": {
"config": ["playwright.config.ts"],
"entry": ["e2e/**/*.@(spec|setup).ts"]
}
}
+82
View File
@@ -20,6 +20,8 @@
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
@@ -866,6 +868,22 @@
"node": ">= 8"
}
},
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
"integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.27",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
@@ -1328,6 +1346,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -2198,6 +2226,53 @@
"node": ">= 6"
}
},
"node_modules/playwright": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
"integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.61.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
"integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/postcss": {
"version": "8.5.15",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
@@ -2778,6 +2853,13 @@
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+2
View File
@@ -22,6 +22,8 @@
"react-i18next": "^14.1.2"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@types/node": "^26.1.1",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
+51
View File
@@ -0,0 +1,51 @@
import { defineConfig, devices } from "@playwright/test";
/**
* E2E characterization (golden-master) net for the Platform refactor.
*
* Target (PW_BASE_URL): defaults to the baked localdev stack on :8080 (production-faithful,
* same-origin, no HMR/WS gaps). Point it at http://localhost:5173 for the fast Vite loop — all
* five characterization flows are proxy-safe (none use the WebSocket).
*
* The stack must already be UP (owned by `siftlode dev`); there is deliberately no `webServer`.
* `e2e/global.setup.ts` seeds the dedicated test account and logs in once, sharing the resulting
* session via storageState so the real tests never re-authenticate (auth endpoints are rate-limited).
*
* Runs serial / single-worker on purpose: the specs share ONE seeded account, and a couple of
* them mutate server state (Settings save), so determinism beats parallelism here.
*/
const BASE_URL = process.env.PW_BASE_URL || "http://127.0.0.1:8080";
const STORAGE_STATE = "e2e/.auth/state.json";
export default defineConfig({
testDir: "./e2e",
fullyParallel: false,
workers: 1,
forbidOnly: !!process.env.CI,
retries: 0,
reporter: [["list"], ["html", { open: "never" }]],
timeout: 30_000,
expect: { timeout: 7_000 },
use: {
baseURL: BASE_URL,
storageState: STORAGE_STATE,
locale: "en-US",
// retain-on-failure (not on-first-retry): with retries:0 a "first retry" never happens, so a
// trace would never be captured — this keeps a full trace for any failure, discarded on success.
trace: "retain-on-failure",
screenshot: "only-on-failure",
},
projects: [
{
name: "setup",
testMatch: /global\.setup\.ts/,
use: { storageState: { cookies: [], origins: [] } },
},
{
name: "chromium",
use: { ...devices["Desktop Chrome"], storageState: STORAGE_STATE },
dependencies: ["setup"],
},
],
});
+78 -656
View File
@@ -1,25 +1,15 @@
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import {
api,
EMPTY_PLEX_FILTERS,
getActiveAccount,
setActiveAccount,
setUnauthorizedHandler,
type FeedFilters,
type PlexFilters,
} from "./lib/api";
import { setLanguage, isSupported, type LangCode } from "./i18n";
import {
applyTheme,
DEFAULT_THEME,
loadLocalTheme,
saveLocalTheme,
type ThemePrefs,
} from "./lib/theme";
import { hasFilterParams, isPage, paramsToFilters, readPage, stripUrlParams, type Page } from "./lib/urlState";
import { pageTitleKey } from "./lib/pageMeta";
import { hasFilterParams, stripUrlParams } from "./lib/urlState";
import type { Page } from "./lib/urlState";
import {
loadLayout,
normalizeLayout,
@@ -28,162 +18,54 @@ import {
type PanelId,
type PanelLayout,
} from "./lib/panelLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { notify, removeByMetaKind } from "./lib/notifications";
import {
accountKey,
getAccountRaw,
LS,
readAccount,
readJSON,
readMerged,
setAccountRaw,
useAccountPersistedState,
writeAccount,
writeJSON,
} from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
import { useNavigation, useNavigationActions } from "./components/NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider";
import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider";
import { moduleDef } from "./lib/modules";
import { PAGE_CONTENT, PAGE_RAIL } from "./components/moduleRegistry";
// Persistent shell — always mounted, so eagerly imported.
import Welcome from "./components/Welcome";
import SetupWizard from "./components/SetupWizard";
import Header from "./components/Header";
import NavSidebar from "./components/NavSidebar";
import Sidebar from "./components/Sidebar";
import PlaylistsRail from "./components/PlaylistsRail";
import BackToTop from "./components/BackToTop";
import PageShell from "./components/PageShell";
import GlassTuner from "./components/GlassTuner";
import ChatDock from "./components/ChatDock";
import Toaster from "./components/Toaster";
import ErrorDialog from "./components/ErrorDialog";
import VersionBanner from "./components/VersionBanner";
import DemoBanner from "./components/DemoBanner";
import { setNavigator } from "./lib/nav";
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
import { shouldAutoOpenOnboarding } from "./lib/onboarding";
import { CURRENT_VERSION } from "./lib/releaseNotes";
// Route-level code splitting: each module page (and the heavier modals) is its own lazy chunk,
// so the initial load — and the logged-out landing — pulls only the shell, and admin-only pages
// never reach non-admins. All are rendered inside a <Suspense> boundary below.
const Feed = lazy(() => import("./components/Feed"));
const PlexBrowse = lazy(() => import("./components/PlexBrowse"));
const PlexSidebar = lazy(() => import("./components/PlexSidebar"));
// The page modules (and their side rails) are code-split per route via the module registry
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
// two modals, which sit outside the registry-driven content column.
const ChannelPage = lazy(() => import("./components/ChannelPage"));
const Channels = lazy(() => import("./components/Channels"));
const Playlists = lazy(() => import("./components/Playlists"));
const Stats = lazy(() => import("./components/Stats"));
const Scheduler = lazy(() => import("./components/Scheduler"));
const ConfigPanel = lazy(() => import("./components/ConfigPanel"));
const AdminUsers = lazy(() => import("./components/AdminUsers"));
const AuditLog = lazy(() => import("./components/AuditLog"));
const SettingsPanel = lazy(() => import("./components/SettingsPanel"));
const NotificationsPanel = lazy(() => import("./components/NotificationsPanel"));
const Messages = lazy(() => import("./components/Messages"));
const DownloadCenter = lazy(() => import("./components/DownloadCenter"));
const OnboardingWizard = lazy(() => import("./components/OnboardingWizard"));
const About = lazy(() => import("./components/About"));
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const FILTERS_KEY = LS.filters;
const PAGE_KEY = LS.page;
const PERF_KEY = LS.perfMode;
// The preferences edited on the Settings page. They apply locally for instant preview but
// persist to the server only on an explicit Save — so App holds both the live "draft" (the
// individual states below) and the last-saved "baseline" to compute dirty / revert on discard.
type EditablePrefs = {
theme: ThemePrefs;
view: "grid" | "list";
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
};
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
// This account's own persisted filters (per-account keys — never another account's). A chosen
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
// very first load, after which that state persists like any other. (Re-apply the default view
// from the sidebar to return to it.)
const fKey = accountKey(LS.filters, id);
let hasStored = false;
try {
hasStored = !!fKey && localStorage.getItem(fKey) != null;
} catch {
/* localStorage may be unavailable */
}
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
const dvKey = accountKey(LS.defaultViewFilters, id);
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
if (def) return { ...DEFAULT_FILTERS, ...def };
return { ...DEFAULT_FILTERS };
}
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
return loadAccountFilters(getActiveAccount());
}
export default function App() {
const { t, i18n } = useTranslation();
const confirm = useConfirm();
// Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs
// state without being recreated; populated by an effect each render (below).
const prefsDirtyRef = useRef(false);
const discardRef = useRef<() => void>(() => {});
const confirmRef = useRef(confirm);
const pageRef = useRef<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
// — such filters win over the account's stored view and are persisted to the account instead.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search))
);
const [view, setView] = useState<"grid" | "list">("grid");
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
theme: loadLocalTheme(),
view: "grid",
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
}));
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const { page, channelView, ytSearch } = useNavigation();
const { setPage, enterYtSearch } = useNavigationActions();
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
const view = useFeedView();
const { setView } = useFeedViewActions();
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
@@ -191,176 +73,15 @@ export default function App() {
plex: loadLayout("plex"),
playlists: loadLayout("playlists"),
}));
const [page, setPageState] = useState<Page>(loadInitialPage);
// Client-side name filters for the Channels + Playlists lists — lifted here so the shared top
// SearchBar can drive them (the modules read/seed them via props).
const [channelsSearch, setChannelsSearch] = useState("");
const [playlistsSearch, setPlaylistsSearch] = useState("");
// Live YouTube search term (null = normal feed). Ephemeral: not persisted and not in the URL,
// so a reload returns to the normal feed (a search spends quota, so we never auto-replay it).
// The search is a feed SUB-VIEW that owns a browser-history entry (history.state._yt): the
// popstate handler below derives ytSearch from it, so Back steps player → search → feed in
// that order (a player opened over the results pops first, then the search, then the feed) —
// instead of the search vanishing because it had no history entry of its own.
const [ytSearch, setYtSearch] = useState<string | null>(() => {
// Restore a live search across a reload ONLY when it was served by the zero-quota scrape
// source (re-fetching is free) — an api-source search would re-spend ~100 units, so it's
// dropped to the normal feed as before. The scrape flag rides in history.state (survives F5).
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps
// it once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
}, []);
// The open channel page (null = no channel page). Like the YouTube-search sub-view it owns a
// browser-history entry (history.state._chan = channel id, _chanName = a display name to show
// before the detail loads), so Back closes the channel page (after any player opened over it)
// and reload returns to the normal app. Channels/videos are reached by id, so it's not in the URL.
const [channelView, setChannelView] = useState<{ id: string; name?: string } | null>(() => {
// Restore the open channel page across a reload (it rides in history.state, which survives F5),
// unlike the YouTube search which is intentionally dropped (it would re-spend quota).
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
});
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan)
window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
}, []);
const [wizardOpen, setWizardOpen] = useState(false);
// Channel status chip, persisted so a reload (F5) keeps it; clamp a stale value at render.
const [channelFilterRaw, setChannelFilter] = useAccountPersistedState(LS.channelFilter, "all");
const channelFilter: ChannelStatusFilter = (
["needs_full", "fully_synced", "hidden", "all"] as const
).includes(channelFilterRaw as ChannelStatusFilter)
? (channelFilterRaw as ChannelStatusFilter)
: "all";
const [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
// The Channel manager's active tab (subscriptions vs playlist discovery). Lifted here and
// persisted so navigation intents that target the subscriptions table — the header's
// "without full history" link, focus-channel — can force it back to "subscribed" instead
// of dumping the user on whatever tab they last left open.
const [channelsViewRaw, setChannelsView] = useAccountPersistedState(LS.channelsView, "subscribed");
const channelsView: ChannelsView =
channelsViewRaw === "discovery" ? "discovery" : "subscribed";
// Plex module filters (its own left-sidebar filter section) — per-account persisted.
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [plexScopeRaw, setPlexScope] = useAccountPersistedState(LS.plexLibrary, "both");
const plexScope = ["movie", "show", "both"].includes(plexScopeRaw) ? plexScopeRaw : "both";
const [plexShowFilter, setPlexShowFilter] = useAccountPersistedState(LS.plexShow, "all");
const [plexSort, setPlexSort] = useAccountPersistedState(LS.plexSort, "title");
const [plexPlaylistOpen, setPlexPlaylistOpen] = useState<number | null>(null); // sidebar → open a playlist
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [plexFiltersRaw, setPlexFiltersRaw] = useAccountPersistedState(LS.plexFilters, "{}");
const plexFilters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(plexFiltersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [plexFiltersRaw]);
const setPlexFilters = (f: PlexFilters) => setPlexFiltersRaw(JSON.stringify(f));
// The Plex library search term is its OWN ephemeral state — deliberately NOT the persisted feed
// `filters.q`. Sharing that box meant a Plex search leaked into the feed and, being persisted,
// greeted you with a stale query (colliding with a persisted collection filter → confusing
// "0 matches") after a reload. Kept in App so it survives page switches within a session, but
// resets on reload.
const [plexQ, setPlexQ] = useAccountPersistedState(LS.plexQ, ""); // survives F5, unlike the feed search
// Bumped to tell the channel manager to drop a stale column filter when we send the user
// there to see a specific set (the header's "without full history" link).
const [channelsFilterReset, setChannelsFilterReset] = useState(0);
// "Focus this channel in the manager": jump to the Channels page and seed its name filter
// so the channel is isolated. Cleared when leaving the page so it doesn't re-apply later.
const [focusChannelName, setFocusChannelName] = useState<string | null>(null);
const [focusChannelToken, setFocusChannelToken] = useState(0);
const focusChannel = (name: string) => {
setFocusChannelName(name);
setFocusChannelToken((n) => n + 1); // re-seed even when the same channel is re-focused
setChannelsView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
};
useEffect(() => {
if (page !== "channels") setFocusChannelName(null);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
if (page !== "feed") setYtSearch(null);
}, [page]);
function openReleaseNotes(highlight?: string) {
setNotesHighlight(highlight);
setNotesOpen(true);
}
function setFilters(next: FeedFilters) {
setFiltersState(next);
// Persist under THIS tab's active account so filters don't bleed between accounts.
const key = accountKey(FILTERS_KEY, getActiveAccount());
if (key) writeJSON(key, next);
}
function setPage(next: Page) {
// While a channel page is open it overlays the content column, so a nav click must close it
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === page && !channelView) return;
const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL).
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
};
// Guard leaving the Settings page with unsaved preference changes.
if (page === "settings" && prefsDirtyRef.current) {
void confirmRef.current({
title: t("settings.unsaved.title"),
message: t("settings.unsaved.message"),
confirmLabel: t("settings.unsaved.discard"),
danger: true,
}).then((ok) => {
if (!ok) return;
discardRef.current();
go();
});
return;
}
go();
}
// Expose the current setPage to decoupled callers (download toast "View", etc.).
useEffect(() => setNavigator(setPage));
// Reflect the current module in the browser tab title so open tabs are distinguishable and the
// history reads sensibly. Feed = brand only; an open channel page shows the channel name.
useEffect(() => {
const brand = "Siftlode";
const label = channelView
? channelView.name || t("header.channelManager")
: page === "feed"
? null
: t(pageTitleKey(page));
document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]);
function setPanelLayout(panel: PanelId, next: PanelLayout) {
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
saveLayoutLocal(panel, next);
@@ -397,36 +118,6 @@ export default function App() {
writeAccount(LS.playlistsCollapsed, next);
api.savePrefs({ playlistsCollapsed: next }).catch(() => {});
}
// The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane
// (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL).
const [selectedPlaylistId, setSelectedPlaylistIdState] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSelectedPlaylistId = (id: number | null) => {
setSelectedPlaylistIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
};
useEffect(() => applyTheme(theme), [theme]);
// Apply the draft prefs locally for instant preview (their localStorage mirrors update via
// the stores too); persistence to the server is deferred to an explicit Save.
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
// theme-appropriate image on <body> (dark or /light/ set) and switches the glass to translucent.
useEffect(() => {
const on = theme.bgImage && !perf;
document.documentElement.dataset.backdrop = on ? "on" : "off";
}, [theme.bgImage, perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
// Filters live in localStorage, not the address bar. If we arrived via a "Share view"
// link, its params have already hydrated the initial state — persist them and strip the
// query so the URL stays clean from here on.
@@ -439,52 +130,6 @@ export default function App() {
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// In-app Back/Forward: stamp the initial entry with the current page, then sync `page`
// from history.state on popstate. (stripUrlParams preserves history.state, so this stamp
// survives a later query-string strip.)
useEffect(() => {
// A reload returns to the normal feed by dropping a stale _yt — EXCEPT when the search was
// scrape-sourced (zero quota): then we keep _yt + _ytScrape so it restores and re-fetches for
// free (ytSearch above already initialised from it). An api-source search is still dropped (it
// would re-spend ~100 units). _chan/_chanName are always kept so a channel page survives F5.
const st = window.history.state || {};
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "");
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, "");
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
// entry so nothing is silently stranded, then ask; on discard, go where Back headed.
if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) {
window.history.pushState({ ...window.history.state, sfPage: "settings" }, "");
void confirmRef.current({
title: t("settings.unsaved.title"),
message: t("settings.unsaved.message"),
confirmLabel: t("settings.unsaved.discard"),
danger: true,
}).then((ok) => {
if (!ok) return;
discardRef.current();
setPageState(p);
setAccountRaw(PAGE_KEY, p);
});
return;
}
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
// clears it to match the entry we landed on (and a player popped first leaves it intact).
setYtSearch((e.state?._yt as string) ?? null);
// The channel page rides in history.state._chan the same way.
const chan = e.state?._chan as string | undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const queryClient = useQueryClient();
// First-run install gate: a fresh instance reports configured=false and runs in setup mode.
@@ -516,26 +161,6 @@ export default function App() {
if (meQuery.data && getActiveAccount() == null) setActiveAccount(meQuery.data.id);
}, [meQuery.data?.id]);
// Load THIS account's own filters when the account first resolves or changes (login / switch /
// add). Filters are per-account localStorage state, so another account's last view (incl. its
// default saved view) must never carry over. A share link's filters (captured at mount) win and
// are persisted to the account instead. Runs on id change only, so a 60s background refetch of
// the same account never clobbers the filters you're editing.
useEffect(() => {
const id = meQuery.data?.id;
if (id == null) return;
if (initialHadUrlFilters) {
const key = accountKey(FILTERS_KEY, id);
if (key) writeJSON(key, filters);
return;
}
let f = loadAccountFilters(id);
// The shared demo account has no subscriptions, so default it to the whole library.
if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
setFiltersState(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
// Once signed in, drop any leftover query string from the pre-login flow (e.g.
// ?access=requested, ?verify, ?reset). The logged-in app reads nothing from the URL, so the
// address bar stays clean. Gated on auth so the logged-out Welcome page still reads its params.
@@ -588,39 +213,12 @@ export default function App() {
stripUrlParams();
}, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after
// each consent redirect). Derived from granted scopes + storage flags so it's stable
// across the full-page OAuth round-trip; dismissible and reopenable from Settings.
useEffect(() => {
if (meQuery.data && shouldAutoOpenOnboarding(meQuery.data)) setWizardOpen(true);
}, [meQuery.data?.id, meQuery.data?.can_read, meQuery.data?.can_write]);
// On login, adopt server-stored preferences.
// On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags,
// language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider
// and the feed view by FeedViewProvider — each via its own passive ["me"] observer.
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
// Adopt the server prefs as both the saved baseline and the live draft. The runtime
// effects above apply the draft (theme/perf/hints/notifications) for display.
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setView(adopted.view);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
setSavedPrefs(adopted);
// Out-of-scope prefs stay instant (edited outside the Settings page).
const prefsRec = prefs as Record<string, unknown>;
(["feed", "plex", "playlists"] as PanelId[]).forEach((p) => {
const raw = prefsRec[PREF_KEY[p]];
@@ -677,79 +275,12 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
// Theme is part of the Settings draft: apply locally for preview, persist on Save.
function setTheme(next: ThemePrefs) {
setThemeState(next);
saveLocalTheme(next);
}
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) {
setLanguage(code);
api.savePrefs({ language: code }).catch(() => {});
}
const prefsDirty =
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify(savedPrefs);
const savePrefs = useCallback(() => {
const payload: EditablePrefs = { theme, view, performanceMode: perf, hints, notifications: notif };
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("saving");
api
.savePrefs(payload)
.then(() => {
setSavedPrefs(payload);
setPrefsSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure (connection-lost status / error dialog);
// just flag the bar so the user sees the save didn't take, then clear it.
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, [theme, view, perf, hints, notif]);
const discardPrefs = useCallback(() => {
setThemeState(savedPrefs.theme);
saveLocalTheme(savedPrefs.theme);
setView(savedPrefs.view);
setPerf(savedPrefs.performanceMode);
setHints(savedPrefs.hints);
setNotif(savedPrefs.notifications);
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("idle");
}, [savedPrefs]);
const prefsCtl: PrefsController = {
theme,
setTheme,
view,
setView,
perf,
setPerf,
hints,
setHints,
notif,
setNotif,
dirty: prefsDirty,
save: savePrefs,
discard: discardPrefs,
saveState: prefsSaveState,
};
// Keep the navigation guards (setPage / popstate) reading the latest values.
useEffect(() => {
prefsDirtyRef.current = prefsDirty;
discardRef.current = discardPrefs;
confirmRef.current = confirm;
pageRef.current = page;
});
// No beforeunload guard: a reload/close can only raise the browser's own native prompt
// (we can't show our in-app confirm there), and unsaved prefs are local-only — they
// revert to the saved server baseline on the next load — so there's nothing to lose.
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading)
return (
@@ -776,65 +307,57 @@ export default function App() {
<div className="flex-1 grid place-items-center text-muted">{t("common.loading")}</div>
);
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
const normalScrollKey =
page !== "feed"
? `page:${page}`
: ytSearch != null
? `yt:${ytSearch}`
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A
// page the account can't reach (a gated module via stale nav / a deep link) falls back to the feed,
// exactly as the old ternary's fallthrough did. A channel page overlays the content column, so no
// side rail shows behind it.
const me = meQuery.data!;
const pageGate = moduleDef(page).gate;
const activePage: Page = !pageGate || pageGate(me) ? page : "feed";
const { Component: PageContent, fadeTop } = PAGE_CONTENT[activePage];
const railDef = channelView ? undefined : PAGE_RAIL[activePage];
return (
<div className="h-screen flex text-fg">
<NavSidebar
me={meQuery.data!}
page={page}
setPage={setPage}
onOpenAbout={() => setAboutOpen(true)}
onChangeLanguage={changeLanguage}
language={i18n.language as LangCode}
collapsed={navCollapsed}
onToggleCollapse={() => setNavCollapsed(!navCollapsed)}
/>
{/* Full-height filter sidebar: feed only, and hidden while a channel page overlays the
content column. Sits beside the nav rail as its own collapsible column. */}
{page === "feed" && !channelView && (
<Sidebar
filters={filters}
setFilters={setFilters}
layout={panelLayouts.feed}
setLayout={(l) => setPanelLayout("feed", l)}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
/>
)}
{page === "plex" && meQuery.data!.plex_enabled && (
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
content. feed + plex share the filter-rail collapse flag, playlists has its own. */}
{railDef && (
<Suspense fallback={null}>
<PlexSidebar
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
setShow={setPlexShowFilter}
sort={plexSort}
setSort={setPlexSort}
filters={plexFilters}
setFilters={setPlexFilters}
onOpenPlaylist={setPlexPlaylistOpen}
layout={panelLayouts.plex}
setLayout={(l) => setPanelLayout("plex", l)}
collapsed={filterCollapsed}
onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)}
<railDef.Rail
layout={panelLayouts[railDef.panelId]}
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
collapsed={railDef.collapse === "playlists" ? playlistsCollapsed : filterCollapsed}
onToggleCollapse={() =>
railDef.collapse === "playlists"
? setPlaylistsCollapsed(!playlistsCollapsed)
: setFilterCollapsed(!filterCollapsed)
}
/>
</Suspense>
)}
{page === "playlists" && !channelView && (
<PlaylistsRail
canWrite={meQuery.data!.can_write}
search={playlistsSearch}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
layout={panelLayouts.playlists}
setLayout={(l) => setPanelLayout("playlists", l)}
collapsed={playlistsCollapsed}
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
/>
)}
<div className="relative flex-1 min-w-0 flex flex-col">
{channelView ? (
<PageShell scrollKey={`channel:${channelView.id}`}>
<Suspense fallback={pageFallback}>
<ChannelPage
key={channelView.id}
@@ -842,8 +365,7 @@ export default function App() {
initialName={channelView.name}
me={meQuery.data!}
view={view}
onBack={closeChannel}
onOpenChannel={openChannel}
setView={setView}
onShowInFeed={() => {
// Keep the rest of the feed's filters — the point is "this channel, the way I
// normally read": unwatched, my tags, my date window.
@@ -861,120 +383,23 @@ export default function App() {
}}
/>
</Suspense>
</PageShell>
) : (
<>
<Header
me={meQuery.data!}
filters={filters}
setFilters={setFilters}
plexQ={plexQ}
setPlexQ={setPlexQ}
channelsSearch={channelsSearch}
setChannelsSearch={setChannelsSearch}
playlistsSearch={playlistsSearch}
setPlaylistsSearch={setPlaylistsSearch}
page={page}
setPage={setPage}
onYtSearch={enterYtSearch}
onGoToFullHistory={() => {
setChannelFilter("needs_full");
setChannelsView("subscribed"); // the status filter applies to the subscriptions tab
setChannelsFilterReset((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}}
/>
{/* The header floats (fixed) over the content, so it no longer occupies flow space; this
wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level
floating panels beside the nav — none sit inside this content column anymore.) */}
<div
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
<main className="flex-1 min-w-0 overflow-y-auto">
<PageShell
scrollKey={normalScrollKey}
fadeTop={fadeTop ?? true}
header={<Header me={meQuery.data!} onYtSearch={enterYtSearch} />}
banners={
<>
{meQuery.data!.is_demo && <DemoBanner />}
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
</>
}
>
<Suspense fallback={pageFallback}>
{page === "channels" ? (
<Channels
canWrite={meQuery.data!.can_write}
isAdmin={meQuery.data!.role === "admin"}
search={channelsSearch}
setSearch={setChannelsSearch}
focusChannelName={focusChannelName}
focusChannelToken={focusChannelToken}
onFocusChannel={focusChannel}
statusFilter={channelFilter}
setStatusFilter={setChannelFilter}
view={channelsView}
setView={setChannelsView}
filtersResetToken={channelsFilterReset}
onOpenWizard={() => setWizardOpen(true)}
onViewChannel={(id, name) => openChannel(id, name)}
onFilterByTag={(tagId, name) => {
setFilters({ ...filters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
}}
/>
) : page === "stats" ? (
<Stats me={meQuery.data!} />
) : page === "scheduler" && meQuery.data!.role === "admin" ? (
<Scheduler />
) : page === "config" && meQuery.data!.role === "admin" ? (
<ConfigPanel />
) : page === "users" && meQuery.data!.role === "admin" ? (
<AdminUsers me={meQuery.data!} />
) : page === "audit" && meQuery.data!.role === "admin" ? (
<AuditLog />
) : page === "playlists" ? (
<Playlists
canWrite={meQuery.data!.can_write}
selectedId={selectedPlaylistId}
setSelectedId={setSelectedPlaylistId}
/>
) : page === "notifications" ? (
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
) : page === "messages" && !meQuery.data!.is_demo ? (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={meQuery.data!.id} />
</div>
) : page === "downloads" && !meQuery.data!.is_demo ? (
<DownloadCenter me={meQuery.data!} />
) : page === "settings" ? (
<SettingsPanel
me={meQuery.data!}
prefs={prefsCtl}
onOpenWizard={() => setWizardOpen(true)}
/>
) : page === "plex" && meQuery.data!.plex_enabled ? (
<PlexBrowse
q={plexQ}
scope={plexScope}
setScope={setPlexScope}
show={plexShowFilter}
sort={plexSort}
filters={plexFilters}
setFilters={setPlexFilters}
openPlaylist={plexPlaylistOpen}
onPlaylistOpened={() => setPlexPlaylistOpen(null)}
/>
) : (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
canRead={meQuery.data!.can_read}
isDemo={meQuery.data!.is_demo}
onOpenWizard={() => setWizardOpen(true)}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
onOpenChannel={openChannel}
/>
)}
<PageContent />
</Suspense>
</main>
</div>
</>
</PageShell>
)}
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
Anchored inside the content column so they clear the sidebar automatically
@@ -983,9 +408,6 @@ export default function App() {
</div>
{meQuery.data && !meQuery.data.is_demo && <ChatDock meId={meQuery.data.id} />}
<Suspense fallback={null}>
{wizardOpen && meQuery.data && !meQuery.data.is_demo && (
<OnboardingWizard me={meQuery.data} onClose={() => setWizardOpen(false)} />
)}
{aboutOpen && (
<About
onClose={() => setAboutOpen(false)}
@@ -1003,7 +425,7 @@ export default function App() {
{/* 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 enabled={theme.showTuner} />
<GlassTuner />
</div>
);
}
@@ -16,13 +16,19 @@ export default function ActiveFilterChips({
if (filters.length === 0) return null;
return (
<div className="flex flex-wrap items-center gap-1.5 pb-3" aria-label={t("feed.chips.label")}>
<div
data-testid="active-filter-chips"
className="flex flex-wrap items-center gap-1.5 pb-3"
aria-label={t("feed.chips.label")}
>
<span className="text-[11px] uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f) => (
<button
key={f.key}
data-testid="active-filter-chip"
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 })}
@@ -34,6 +40,7 @@ export default function ActiveFilterChips({
))}
{filters.length > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 text-xs text-muted hover:text-fg underline underline-offset-2 transition"
>
+4 -2
View File
@@ -5,6 +5,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, ListPlus, Plus, Youtube } from "lucide-react";
import { api, type Playlist } from "../lib/api";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
// A small popover (portaled to body, so the feed grid can't clip it) for toggling a
// video's membership in the user's playlists, with inline "new playlist" creation.
@@ -17,6 +18,7 @@ export default function AddToPlaylist({
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const fade = useScrollFade();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const [open, setOpen] = useState(false);
@@ -128,13 +130,13 @@ export default function AddToPlaylist({
<div
ref={panelRef}
style={{ position: "fixed", top: coords.top, left: coords.left, width: 240 }}
className="z-50 glass-menu rounded-xl p-2 shadow-2xl"
className="z-overlay glass-menu rounded-xl p-2 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<div className="text-xs text-muted px-1.5 pb-1.5">
{t("playlists.addToPlaylist")}
</div>
<div className="max-h-56 overflow-y-auto">
<div ref={fade.ref} style={fade.style} className="max-h-56 overflow-y-auto no-scrollbar">
{lists.length === 0 && !membership.isLoading && (
<div className="text-xs text-muted px-1.5 py-2">
{t("playlists.noneYet")}
+17 -7
View File
@@ -9,6 +9,8 @@ import Tooltip from "./Tooltip";
import { Section } from "./ui/form";
import { useConfirm } from "./ConfirmProvider";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import { useMe } from "../lib/useMe";
// 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
@@ -16,7 +18,8 @@ import Tabs, { usePersistedTab } from "./Tabs";
// (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.)
export default function AdminUsers({ me }: { me: Me }) {
export default function AdminUsers() {
const me = useMe();
const { t } = useTranslation();
const [tab, setTab] = usePersistedTab(ADMIN_USERS_TAB_KEY, "roles");
const tabs = [
@@ -26,12 +29,19 @@ export default function AdminUsers({ me }: { me: Me }) {
];
const active = tabs.some((x) => x.id === tab) ? tab : "roles";
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
<>
{/* The tab bar is fixed chrome; the tab content scrolls under it. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<Tabs tabs={tabs} active={active} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto">
{active === "roles" && <UsersRoles me={me} />}
{active === "access" && <AdminInvites />}
{active === "demo" && <AdminDemo />}
</div>
</>
);
}
+29 -15
View File
@@ -4,29 +4,43 @@ import { ArrowUp } from "lucide-react";
import { useTranslation } from "react-i18next";
// A floating "back to top" button that fades in once the current page's scroll container is
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside its
// own <main class="overflow-y-auto"> (the normal pages share App's; the channel page has its own),
// so it targets the live <main> and re-binds whenever `dep` changes (i.e. on navigation). Portaled
// to <body> so its fixed position is viewport-relative regardless of transformed ancestors.
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
// <main class="overflow-y-auto"> the normal pages share App's, the channel page renders its own
// so this follows whichever one is mounted. Portaled to <body> so its fixed position is
// viewport-relative regardless of transformed ancestors.
export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; threshold?: number }) {
const { t } = useTranslation();
const [show, setShow] = useState(false);
const scrollerRef = useRef<HTMLElement | null>(null);
// Listen on the document in the CAPTURE phase and read the scroller off the event, rather than
// looking <main> up when this binds. Scroll events don't bubble, but they do capture — and the
// lookup was a race the lazy pages lost: on the FIRST visit to the channel page its chunk is
// still loading, so the Suspense fallback is on screen, there is no <main> to find, and the
// effect gave up for good (its dep never changes again). The button then stayed dead for that
// whole visit and came back only once the chunk was cached. Nothing to find, nothing to race.
useEffect(() => {
const onScroll = (e: Event) => {
const el = e.target as HTMLElement | null;
if (!el || el.tagName !== "MAIN") return;
scrollerRef.current = el;
setShow(el.scrollTop > threshold);
};
document.addEventListener("scroll", onScroll, { passive: true, capture: true });
return () => document.removeEventListener("scroll", onScroll, { capture: true });
}, [threshold]);
// On navigation, re-read where the new page sits: usually the top (so hide), but some pages
// restore their scroll position. Missing <main> here is now harmless — a page that hasn't
// rendered hasn't scrolled either, and the listener above picks it up the moment it does.
useEffect(() => {
const el = document.querySelector("main");
scrollerRef.current = el;
if (!el) {
setShow(false);
return;
}
const onScroll = () => setShow(el.scrollTop > threshold);
onScroll(); // a freshly-bound page starts at the top → hidden
el.addEventListener("scroll", onScroll, { passive: true });
return () => el.removeEventListener("scroll", onScroll);
setShow(!!el && el.scrollTop > threshold);
}, [dep, threshold]);
const toTop = () => scrollerRef.current?.scrollTo({ top: 0, behavior: "smooth" });
const toTop = () =>
(scrollerRef.current ?? document.querySelector("main"))?.scrollTo({ top: 0, behavior: "smooth" });
// No aria-label/title attributes: ad-block "annoyance" cosmetic filters (e.g. Brave Shields)
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
@@ -34,11 +48,11 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
return createPortal(
<button
onClick={toTop}
className={`fixed bottom-5 right-5 z-30 inline-flex items-center justify-center w-11 h-11 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg shadow-lg hover:text-accent transition-all duration-200 ${
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
}`}
>
<ArrowUp className="w-5 h-5" aria-hidden="true" />
<ArrowUp className="w-7 h-7" aria-hidden="true" />
<span className="sr-only">{t("common.backToTop")}</span>
</button>,
document.body
+25 -17
View File
@@ -10,6 +10,7 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import Tooltip from "./Tooltip";
import ChannelLink from "./ChannelLink";
import DataTable, { type Column } from "./DataTable";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
// The Channel manager's "Discovery" tab: channels that turn up in the user's playlists but
@@ -154,22 +155,29 @@ export default function ChannelDiscovery({
];
return (
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted mb-4 leading-relaxed">
{t("channels.discovery.intro")}
</p>
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
<>
{/* Intro + the table controls are fixed chrome; only the table rows scroll (sticky header). */}
<PageToolbar>
<div className="px-4 pt-3 max-w-7xl mx-auto">
<p className="text-xs text-muted leading-relaxed">{t("channels.discovery.intro")}</p>
</div>
</PageToolbar>
<div className="px-4 pb-4 max-w-7xl mx-auto">
{query.isLoading ? (
<div className="text-muted py-8">{t("channels.discovery.loading")}</div>
) : (
<DataTable
rows={rows}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelDiscoveryTable) ?? undefined}
controlsPosition="top"
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
emptyText={t("channels.discovery.empty")}
/>
)}
</div>
</>
);
}
+45 -21
View File
@@ -1,13 +1,17 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigationActions } from "./NavigationProvider";
import { ArrowLeft, Ban, Check, ExternalLink, Loader2, Plus, RefreshCw, SlidersHorizontal } from "lucide-react";
import Avatar from "./Avatar";
import Feed from "./Feed";
import { PageToolbar, PageToolbarSlot } from "./PageShell";
import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
// --- About-tab metadata helpers ---
@@ -54,25 +58,36 @@ export default function ChannelPage({
initialName,
me,
view,
onBack,
onOpenChannel,
setView,
onShowInFeed,
}: {
channelId: string;
initialName?: string;
me: Me;
view: "grid" | "list";
onBack: () => void;
// The feed's view pref, passed straight through to the Feed this page renders — so its
// toolbar carries the same switcher and a change here is the same account-wide choice.
view: FeedView;
setView: (v: FeedView) => void;
// Narrow the real feed to this channel and go there. Offered only for a channel you're
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
// follow would just land you on an empty page.
onShowInFeed: () => void;
onOpenChannel: (id: string, name?: string) => void;
}) {
const { t, i18n } = useTranslation();
const { closeChannel } = useNavigationActions();
const qc = useQueryClient();
const confirm = useConfirm();
// Seed the channel view's content-type prefs from the global feed (read once at mount). The
// channel page is its own filter scope, but WHICH content types the user wants to see (normal /
// shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we
// 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");
// 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
@@ -173,9 +188,10 @@ export default function ChannelPage({
sort: "newest",
scope: "all",
librarySource: "all",
includeNormal: true,
includeShorts: false,
includeLive: true,
// Content-type prefs inherited from the global feed (seeded once) — no longer forced.
includeNormal: globalFilters.includeNormal,
includeShorts: globalFilters.includeShorts,
includeLive: globalFilters.includeLive,
show: "all",
channelIds: [channelId],
}));
@@ -202,11 +218,12 @@ export default function ChannelPage({
? "pb-2 text-sm font-medium border-b-2 border-fg text-fg"
: "pb-2 text-sm text-muted hover:text-fg border-b-2 border-transparent";
// 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.
return (
// scrollbar-gutter:stable reserves the scrollbar track on BOTH tabs, so switching between the
// tall Videos tab (scrollbar) and the short About tab (no scrollbar) no longer shifts the
// header/logo/buttons horizontally as the scrollbar appears/disappears.
<main className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]">
<>
<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 ? (
@@ -241,8 +258,9 @@ export default function ChannelPage({
/>
)}
<button
onClick={onBack}
className="absolute top-5 left-7 sm:left-9 z-10 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-sm"
data-testid="channel-back"
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-sm"
>
<ArrowLeft className="w-4 h-4" />
{t("channel.back")}
@@ -250,9 +268,9 @@ export default function ChannelPage({
</div>
<div className="px-4 sm:px-6">
{/* Identity + actions. relative+z-10 so the avatar that overlaps up into the banner
{/* Identity + actions. relative+z-base so the avatar that overlaps up into the banner
paints ABOVE it (the banner's positioned container would otherwise cover it). */}
<div className="relative z-10 flex items-end gap-4 -mt-8">
<div className="relative z-base flex items-end gap-4 -mt-8">
<Avatar
src={ch?.thumbnail_url ?? null}
fallback={name}
@@ -260,7 +278,7 @@ export default function ChannelPage({
/>
<div className="flex-1 min-w-0 pb-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-xl font-semibold truncate">{name}</h1>
<h1 data-testid="channel-title" className="text-xl font-semibold truncate">{name}</h1>
{ch?.blocked ? (
<span className="text-xs px-2 py-0.5 rounded-full bg-danger/15 text-danger">
{t("channel.blockedBadge")}
@@ -363,21 +381,26 @@ export default function ChannelPage({
</div>
</div>
</div>
{/* The channelScoped Feed portals its toolbar into this node — the bottom of the fixed chrome,
just under the tabs. */}
<div ref={setChromeSlot} />
</PageToolbar>
{/* Tab content */}
{/* 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}
onOpenWizard={() => {}}
ytSearch={null}
onYtSearch={() => {}}
onExitYtSearch={() => {}}
onOpenChannel={onOpenChannel}
channelScoped
/>
{!ch?.subscribed && nextToken && (
@@ -464,6 +487,7 @@ export default function ChannelPage({
)}
</div>
)}
</main>
</PageToolbarSlot.Provider>
</>
);
}
+121 -107
View File
@@ -19,17 +19,24 @@ import { api, type ManagedChannel, type Tag } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import { PageToolbar } from "./PageShell";
import ChannelLink from "./ChannelLink";
import ChannelDiscovery from "./ChannelDiscovery";
import TagManager from "./TagManager";
import { useConfirm } from "./ConfirmProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
import { useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useWizard } from "./WizardProvider";
import { useMe } from "../lib/useMe";
export type ChannelsView = "subscribed" | "discovery";
export type ChannelsView = "subscribed" | "discovery" | "blocked";
export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden";
@@ -40,49 +47,39 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [
{ id: "hidden", labelKey: "channels.filters.hidden" },
];
export default function Channels({
canWrite,
isAdmin,
search,
setSearch,
onViewChannel,
onFilterByTag,
onFocusChannel,
focusChannelName,
focusChannelToken,
statusFilter,
setStatusFilter,
view,
setView,
filtersResetToken,
onOpenWizard,
}: {
canWrite: boolean;
isAdmin: boolean;
// The channel-name filter, lifted to App so the shared top SearchBar drives it.
search: string;
setSearch: (q: string) => void;
onViewChannel: (id: string, name: string) => void;
onFilterByTag: (tagId: number, name: string) => void;
onFocusChannel: (name: string) => void;
focusChannelName: string | null;
// Bumped every time a focus-channel intent is (re-)issued, so re-clicking the same channel
// re-seeds the search box even when the name is unchanged.
focusChannelToken: number;
statusFilter: ChannelStatusFilter;
setStatusFilter: (f: ChannelStatusFilter) => void;
// The active tab lives in App so navigation intents that target the subscriptions table
// (the header's "without full history" link, focus-channel) can force it back here.
view: ChannelsView;
setView: (v: ChannelsView) => void;
// Bumped by the header's "without full history" intent to clear a stale column filter.
filtersResetToken: number;
onOpenWizard: () => void;
}) {
export default function Channels() {
const me = useMe();
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
// in ChannelsProvider (shared with the header search box + the cross-module "focus this channel"
// callers); this page is their primary editor.
const {
search,
statusFilter,
view,
filtersResetToken,
focusName: focusChannelName,
focusToken: focusChannelToken,
} = useChannels();
const { setSearch, setStatusFilter, setView, focusChannel: onFocusChannel } = useChannelsActions();
const { openChannel: onViewChannel, setPage } = useNavigationActions();
const feedFilters = useFeedFilters();
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.
const onFilterByTag = (tagId: number, name: string) => {
setFeedFilters({ ...feedFilters, tags: [tagId], channelIds: [] });
setPage("feed");
notify({ level: "info", message: t("channels.notify.filteredByTag", { name }) });
};
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus });
@@ -381,7 +378,7 @@ export default function Channels({
const tabs = (
<div className="px-4 pt-4 max-w-7xl mx-auto">
<div className="flex flex-wrap items-center gap-1.5">
{(["subscribed", "discovery"] as ChannelsView[]).map((v) => (
{(["subscribed", "discovery", "blocked"] as ChannelsView[]).map((v) => (
<button
key={v}
onClick={() => setView(v)}
@@ -398,24 +395,15 @@ export default function Channels({
</div>
);
if (view === "discovery") {
return (
<>
{tabs}
<ChannelDiscovery
canWrite={canWrite}
search={search}
onOpenWizard={onOpenWizard}
onViewChannel={onViewChannel}
/>
</>
);
}
return (
<>
{/* 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}
<div className="px-4 pb-4 pt-3 max-w-7xl mx-auto">
{view === "subscribed" && (
<div className="px-4 pt-3 pb-2 max-w-7xl mx-auto">
{/* Per-user sync status + catalog-wide actions on one row (search/tags filtering
now lives in the table headers). */}
<div className="flex items-start justify-between gap-4 flex-wrap mb-4">
@@ -534,57 +522,80 @@ export default function Channels({
{t("channels.tags.manage")}
</button>
</div>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
{/* Channel table */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="top"
controlsLeading={statusChips}
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
{/* Blocked channels — videos from these are hidden everywhere and dropped from live search. */}
{(blockedQuery.data?.length ?? 0) > 0 && (
<div className="mt-6 border-t border-border pt-4">
<div className="flex items-center gap-2 mb-2">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</div>
)}
</div>
)}
</PageToolbar>
{tagManagerOpen && (
<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-7xl mx-auto">
{(blockedQuery.data?.length ?? 0) === 0 ? (
<p className="text-sm text-muted py-8">{t("channels.blocked.empty")}</p>
) : (
<>
<div className="flex items-center gap-2 mb-3">
<Ban className="w-4 h-4 text-muted" />
<span className="text-xs uppercase tracking-wide text-muted">
{t("channels.blocked.title")}
</span>
</div>
<div className="flex flex-wrap gap-1.5">
{blockedQuery.data!.map((c) => (
<span
key={c.id}
className="inline-flex items-center gap-1.5 text-xs pl-2.5 pr-1 py-1 rounded-full bg-card border border-border"
>
{c.title ?? c.id}
<button
onClick={() => unblock.mutate(c.id)}
title={t("channel.unblock")}
aria-label={t("channel.unblock")}
className="rounded-full p-0.5 text-muted hover:text-danger transition"
>
<X className="w-3 h-3" />
</button>
</span>
))}
</div>
<p className="text-[11px] text-muted mt-2">{t("channels.blocked.hint")}</p>
</>
)}
</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-7xl 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). */}
{channelsQuery.isLoading ? (
<div className="text-muted py-8">{t("channels.loading")}</div>
) : (
<DataTable
rows={visibleChannels}
columns={columns}
rowKey={(c) => c.id}
persistKey={accountKey(LS.channelsTable) ?? undefined}
controlsPosition="top"
controlsLeading={statusChips}
controlsInBand
controlsBandClassName="px-4 max-w-7xl mx-auto"
rowClassName={(c) => (c.hidden ? "opacity-60" : "")}
emptyText={t("channels.empty")}
/>
)}
</div>
)}
</>
);
}
@@ -684,6 +695,7 @@ function TagsCell({
// Open the picker upward when there isn't room below (rows near the viewport bottom would
// otherwise clip it against the scroll container), as long as there's room above.
const [openUp, setOpenUp] = useState(false);
const fade = useScrollFade();
const ref = useRef<HTMLDivElement | null>(null);
const btnRef = useRef<HTMLButtonElement | null>(null);
useDismiss(open, () => setOpen(false), ref);
@@ -723,7 +735,9 @@ function TagsCell({
</button>
{open && (
<div
className={`glass-menu absolute left-0 z-30 w-44 max-h-[264px] overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
ref={fade.ref}
style={fade.style}
className={`glass-menu absolute left-0 z-chrome w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease] ${
openUp ? "bottom-full mb-1" : "top-full mt-1"
}`}
>
@@ -0,0 +1,147 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import type { ChannelStatusFilter, ChannelsView } from "./Channels";
// Owns the Channels module's shared local state — the rail-less manager's name filter, status chip,
// active tab, the "drop a stale column filter" token, and the focus-a-channel intent — lifted out of
// App so the Channels page, the header search box, and the cross-module callers (the feed filter
// sidebar and the notifications inbox both "focus this channel in the manager") read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// Nested INSIDE NavigationProvider so its actions can drive navigation: focusChannel and
// goToFullHistory both jump to the Channels page (and force the subscriptions tab). status / view are
// per-account persisted (localStorage); search / focus / the reset token are ephemeral. An account
// switch does a full reload, so init-from-storage is all the hydration the persisted ones need.
const STATUS_FILTERS: readonly ChannelStatusFilter[] = [
"needs_full",
"fully_synced",
"hidden",
"all",
];
function clampStatus(v: string | null): ChannelStatusFilter {
return STATUS_FILTERS.includes(v as ChannelStatusFilter) ? (v as ChannelStatusFilter) : "all";
}
function clampView(v: string | null): ChannelsView {
return v === "discovery" ? "discovery" : v === "blocked" ? "blocked" : "subscribed";
}
type ChannelsState = {
search: string;
statusFilter: ChannelStatusFilter;
view: ChannelsView;
// Bumped to tell the manager to drop a stale column filter when an intent sends the user to a
// specific set (the header's "without full history" link).
filtersResetToken: number;
focusName: string | null;
// Bumped every time a focus intent is (re-)issued, so re-focusing the same channel re-seeds the
// manager's name filter even when the name is unchanged.
focusToken: number;
};
interface ChannelsActions {
setSearch: (q: string) => void;
setStatusFilter: (f: ChannelStatusFilter) => void;
setView: (v: ChannelsView) => void;
// "Focus this channel in the manager": jump to Channels (subscriptions tab) and seed its name
// filter so the channel is isolated. Called from the feed sidebar and the notifications inbox.
focusChannel: (name: string) => void;
// Jump to the manager filtered to channels still missing full history (the header sync chip).
goToFullHistory: () => void;
}
const StateContext = createContext<ChannelsState>({
search: "",
statusFilter: "all",
view: "subscribed",
filtersResetToken: 0,
focusName: null,
focusToken: 0,
});
const ActionsContext = createContext<ChannelsActions>({
setSearch: () => {},
setStatusFilter: () => {},
setView: () => {},
focusChannel: () => {},
goToFullHistory: () => {},
});
export function useChannels(): ChannelsState {
return useContext(StateContext);
}
export function useChannelsActions(): ChannelsActions {
return useContext(ActionsContext);
}
export function ChannelsProvider({ children }: { children: ReactNode }) {
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const [search, setSearchState] = useState("");
const [statusFilter, setStatusFilterState] = useState<ChannelStatusFilter>(() =>
clampStatus(getAccountRaw(LS.channelFilter)),
);
const [view, setViewState] = useState<ChannelsView>(() =>
clampView(getAccountRaw(LS.channelsView)),
);
const [filtersResetToken, setFiltersResetToken] = useState(0);
const [focusName, setFocusName] = useState<string | null>(null);
const [focusToken, setFocusToken] = useState(0);
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setStatusFilter = useCallback((f: ChannelStatusFilter) => {
setStatusFilterState(f);
setAccountRaw(LS.channelFilter, f);
}, []);
const setView = useCallback((v: ChannelsView) => {
setViewState(v);
setAccountRaw(LS.channelsView, v);
}, []);
const focusChannel = useCallback(
(name: string) => {
setFocusName(name);
setFocusToken((n) => n + 1); // re-seed even when the same channel is re-focused
setView("subscribed"); // the name filter applies to the subscriptions table
setPage("channels");
},
[setView, setPage],
);
const goToFullHistory = useCallback(() => {
setStatusFilter("needs_full");
setView("subscribed"); // the status filter applies to the subscriptions tab
setFiltersResetToken((n) => n + 1); // drop any stale column filter hiding the rows
setPage("channels");
}, [setStatusFilter, setView, setPage]);
// Clear the manager focus when leaving the Channels page so it doesn't re-apply on a later visit.
useEffect(() => {
if (page !== "channels") setFocusName(null);
}, [page]);
const state = useMemo<ChannelsState>(
() => ({ search, statusFilter, view, filtersResetToken, focusName, focusToken }),
[search, statusFilter, view, filtersResetToken, focusName, focusToken],
);
const actions = useMemo<ChannelsActions>(
() => ({ setSearch, setStatusFilter, setView, focusChannel, goToFullHistory }),
[setSearch, setStatusFilter, setView, focusChannel, goToFullHistory],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+9 -6
View File
@@ -7,6 +7,7 @@ import { onMessage, onUnread } from "../lib/messagesSocket";
import * as e2ee from "../lib/e2ee";
import Avatar from "./Avatar";
import ChatThread from "./ChatThread";
import Overlay from "./Overlay";
// 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
@@ -51,11 +52,13 @@ export default function ChatDock({ meId }: { meId: number }) {
if (chats.length === 0) return null;
return (
<div className="fixed bottom-0 right-0 z-40 flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
{chats.map((c) => (
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
))}
</div>
<Overlay>
<div className="fixed bottom-0 right-0 z-rail flex flex-row-reverse items-end gap-3 p-4 pointer-events-none">
{chats.map((c) => (
<ChatWindow key={c.partnerId} chat={c} meId={meId} />
))}
</div>
</Overlay>
);
}
@@ -65,7 +68,7 @@ function ChatWindow({ chat, meId }: { chat: DockChat; meId: number }) {
<div className="pointer-events-auto relative w-80 max-w-[calc(100vw-2rem)] glass rounded-2xl shadow-2xl 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 key={chat.flash} className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-10" />
<div key={chat.flash} className="chat-flash pointer-events-none absolute inset-0 rounded-2xl z-base" />
)}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-card/50">
<button
+7 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Send } from "lucide-react";
import { api, HttpError, type Message, type MessageUser } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { useScrollFade } from "../lib/useScrollFade";
import { relativeTime } from "../lib/format";
import { notify } from "../lib/notifications";
import { partnerPub, POLL_MS, useKeyState } from "../lib/messaging";
@@ -28,6 +29,7 @@ export default function ChatThread({
const [draft, setDraft] = useState("");
const [plain, setPlain] = useState<Record<number, string>>({});
const bottomRef = useRef<HTMLDivElement>(null);
const fade = useScrollFade();
const ready = useKeyState() === "ready";
const needsKey = !isSystem && !ready;
@@ -132,7 +134,11 @@ export default function ChatThread({
return (
<>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
<div
ref={fade.ref}
style={fade.style}
className="flex-1 overflow-y-auto no-scrollbar p-3 flex flex-col gap-2"
>
{items.length === 0 ? (
<div className="m-auto text-sm text-muted">{t("messages.threadEmpty")}</div>
) : (
+11 -5
View File
@@ -6,6 +6,7 @@ import { api, type ConfigItem, type PlexTestResult } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import { Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { LS } from "../lib/storage";
@@ -151,11 +152,15 @@ export default function ConfigPanel() {
}));
return (
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
<Tabs tabs={groupTabs} active={activeGroup} onChange={setTab} />
<>
{/* Intro + tab bar are fixed chrome; the config fields scroll under them. */}
<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} />
</div>
</PageToolbar>
<div className="px-4 pb-24 pt-3 max-w-3xl w-full mx-auto">
{activeGroup && (
<div className="glass rounded-2xl p-4 mb-4">
<div className="divide-y divide-border/60">
@@ -264,6 +269,7 @@ export default function ConfigPanel() {
}}
/>
</div>
</>
);
}
+47 -7
View File
@@ -2,6 +2,8 @@ import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { ArrowDown, ArrowUp, ChevronLeft, ChevronRight, ListFilter, X } from "lucide-react";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { PageToolbar } from "./PageShell";
// A reusable, client-side data table: per-column sort + filter (in the header) + pagination,
// with its sort/filter/page state optionally persisted to localStorage so a reload (F5) keeps
@@ -9,6 +11,22 @@ import { useDismiss } from "../lib/useDismiss";
// other modules (e.g. the playlist manager) can reuse it. Below `md` it falls back to a card
// list built from the same column definitions.
// The multi-select filter's option list, capped and scrollable. Its own component (module level,
// not nested in DataTable) because it owns a hook: FilterPopover is redeclared on every DataTable
// render, so React remounts it — state parked in there would churn.
function FilterOptionList({ children }: { children: ReactNode }) {
const fade = useScrollFade();
return (
<div
ref={fade.ref}
style={fade.style}
className="flex flex-col gap-0.5 max-h-60 overflow-y-auto no-scrollbar"
>
{children}
</div>
);
}
type ColumnFilter<T> =
| { kind: "text"; get: (row: T) => string }
| { kind: "select"; options: { value: string; label: string }[]; test: (row: T, value: string) => boolean }
@@ -67,6 +85,8 @@ export default function DataTable<T>({
rowClassName,
controlsPosition = "bottom",
controlsLeading,
controlsInBand = false,
controlsBandClassName,
externalFilter,
resetFiltersToken,
}: {
@@ -80,6 +100,11 @@ export default function DataTable<T>({
rowClassName?: (row: T) => string;
controlsPosition?: "top" | "bottom" | "both";
controlsLeading?: ReactNode;
// Render the controls row (controlsLeading + pager) into the shell's fixed toolbar band instead
// of inline, so it stays put above the sticky header while the rows scroll. `controlsBandClassName`
// aligns it with the page's fixed chrome (e.g. the same max-width wrapper).
controlsInBand?: boolean;
controlsBandClassName?: string;
// Set a column's filter from outside (e.g. "focus this channel" deep-links here). Applied
// whenever its value changes; the user can still clear it from the header afterwards.
externalFilter?: { key: string; value: string } | null;
@@ -183,7 +208,7 @@ export default function DataTable<T>({
return (
<div
ref={popRef}
className="glass-menu absolute left-0 top-full mt-1 z-30 w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
className="glass-menu absolute left-0 top-full mt-1 z-chrome w-52 rounded-xl p-2 animate-[popIn_0.16s_ease]"
>
{f.kind === "text" && (
<input
@@ -218,7 +243,7 @@ export default function DataTable<T>({
</div>
)}
{f.kind === "multi" && (
<div className="flex flex-col gap-0.5 max-h-60 overflow-y-auto">
<FilterOptionList>
{f.options.length === 0 && (
<span className="text-xs text-muted px-2 py-1">{t("datatable.noOptions")}</span>
)}
@@ -249,7 +274,7 @@ export default function DataTable<T>({
</button>
);
})}
</div>
</FilterOptionList>
)}
{isActive(val) && (
<button
@@ -327,14 +352,29 @@ export default function DataTable<T>({
</div>
) : null;
const topControls =
controlsInBand && controls ? (
<PageToolbar>
<div className={controlsBandClassName}>{controls}</div>
</PageToolbar>
) : (
controls
);
return (
<div>
{(controlsPosition === "top" || controlsPosition === "both") && controls}
{(controlsPosition === "top" || controlsPosition === "both") && topControls}
{/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */}
<div className="hidden md:block">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="text-muted border-b border-border">
{/* Sticky header row: the column headers stay put at the top of the page scroller while
the rows scroll under them. `bg-bg` on each cell keeps rows from showing through; the
border rides a pseudo-element (box-shadow) since a sticky <tr>'s own border scrolls
away with the collapsed table box in some browsers. The `::after` paints a short
fade just BELOW the header so rows dissolve as they slide under it (the header itself
stays crisp — the page scroller's own top fade is off on these pages). */}
<thead className="sticky top-0 z-base after:pointer-events-none after:absolute after:inset-x-0 after:top-full after:h-4 after:bg-[linear-gradient(to_bottom,var(--bg),transparent)] after:content-['']">
<tr className="text-muted [&>th]:shadow-[inset_0_-1px_0_var(--border)]">
{columns.map((col) => {
const sorted_ = sort?.key === col.key;
const filterOn = isActive(filters[col.key]);
@@ -342,7 +382,7 @@ export default function DataTable<T>({
<th
key={col.key}
style={col.width ? { width: col.width } : undefined}
className={`relative font-medium px-2 py-2 whitespace-nowrap ${align(col.align)}`}
className={`relative font-medium px-2 py-2 whitespace-nowrap bg-bg ${align(col.align)}`}
>
<span className="inline-flex items-center gap-1">
<button
+14 -4
View File
@@ -17,6 +17,7 @@ import {
} from "lucide-react";
import clsx from "clsx";
import Tabs, { usePersistedTab } from "./Tabs";
import { PageToolbar } from "./PageShell";
import Modal from "./Modal";
// Lazy: these dialogs/editors open on demand, so they stay out of the Downloads page chunk until
// used (the video editor in particular is heavy — filmstrip + scrubber).
@@ -26,7 +27,8 @@ const VideoEditor = lazy(() => import("./VideoEditor"));
const ShareDialog = lazy(() => import("./ShareDialog"));
import { useConfirm } from "./ConfirmProvider";
import { useLiveQuery } from "../lib/useLiveQuery";
import { api, type DownloadJob, type Me } from "../lib/api";
import { useMe } from "../lib/useMe";
import { api, type DownloadJob } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { formatBytes, formatDuration, formatEta, formatSpeed } from "../lib/format";
@@ -446,7 +448,7 @@ function QuotaUserPicker({ onPick }: { onPick: (u: { id: number; email: string }
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
@@ -538,7 +540,8 @@ function AdminSystem() {
// --- Main -----------------------------------------------------------------------------------
export default function DownloadCenter({ me }: { me: Me }) {
export default function DownloadCenter() {
const me = useMe();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
@@ -600,7 +603,10 @@ export default function DownloadCenter({ me }: { me: Me }) {
);
return (
<div className="p-4 max-w-4xl w-full mx-auto">
<>
{/* Page title + subtitle + tabs are fixed chrome; the tab content scrolls under them. */}
<PageToolbar>
<div className="px-4 pt-3 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
@@ -613,7 +619,10 @@ export default function DownloadCenter({ me }: { me: Me }) {
<p className="text-sm text-muted mb-4">{t("downloads.page.subtitle")}</p>
<Tabs tabs={tabs} active={tab} onChange={setTab} />
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-4xl w-full mx-auto">
{tab === "queue" && (
<>
<div className="flex gap-2 mb-4">
@@ -739,5 +748,6 @@ export default function DownloadCenter({ me }: { me: Me }) {
{editJob && <VideoEditor job={editJob} onClose={() => setEditJob(null)} />}
</Suspense>
</div>
</>
);
}
+67 -32
View File
@@ -1,7 +1,21 @@
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowDown, ArrowUp, ArrowLeft, Info, RefreshCw, Trash2, Youtube } from "lucide-react";
import {
ArrowDown,
ArrowUp,
ArrowLeft,
Columns2,
Grid3x3,
Info,
LayoutGrid,
List,
RefreshCw,
Rows3,
Trash2,
Youtube,
type LucideIcon,
} from "lucide-react";
import { api, HttpError, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify, resolveVideo } from "../lib/notifications";
@@ -14,8 +28,12 @@ import {
SORT_KEYS,
type SortKey,
} from "../lib/feedSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
const PlayerModal = lazy(() => import("./PlayerModal"));
@@ -32,6 +50,15 @@ const CONTENT = [
] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Icon per view mode; the labels are i18n'd at render (see feed.view.*).
const VIEW_ICON: Record<FeedView, LucideIcon> = {
cards: LayoutGrid,
cardsSmall: Grid3x3,
rows: Rows3,
rowsCompact: List,
tiles: Columns2,
};
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
// "relevance" (full-text search ranking) and "shuffle" are directionless single-string sorts.
@@ -56,21 +83,23 @@ export default function Feed({
filters,
setFilters,
view,
setView,
canRead,
isDemo = false,
onOpenWizard,
ytSearch,
onYtSearch,
onExitYtSearch,
onOpenChannel,
channelScoped,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
view: "grid" | "list";
// The view mode is an account pref, not a filter: it persists on pick, so it isn't part of
// FeedFilters (no share-link param, no saved view, no active-filter chip). Provided by the caller
// (FeedViewProvider on the feed page; ChannelPage threads its own).
view: FeedView;
setView: (v: FeedView) => void;
canRead: boolean;
isDemo?: boolean;
onOpenWizard: () => void;
// Live YouTube search: the active search term (null = normal feed). Entering a search and
// leaving it both step browser history (the search is a feed sub-view with its own history
// entry), so the empty-state CTA enters via onYtSearch and "back to feed" pops via
@@ -78,14 +107,13 @@ export default function Feed({
ytSearch: string | null;
onYtSearch: (q: string) => void;
onExitYtSearch: () => void;
// Open a channel's dedicated page (from a card's channel name / avatar). Threaded down to
// the cards via VirtualFeed.
onOpenChannel?: (channelId: string, channelName?: string) => void;
// True when this feed is scoped to a single channel (the channel page). Drops sort options
// that are constant within one channel (subscribers, priority) — they'd be meaningless.
channelScoped?: boolean;
}) {
const { t } = useTranslation();
// "Connect YouTube" onboarding lives in WizardProvider (the empty-state CTA when not connected).
const { openWizard: onOpenWizard } = useWizard();
// Same list the panel's badge counts — see lib/useActiveFilters.
const activeFilters = useActiveFilters(filters, setFilters);
const sortKeys = channelScoped
@@ -279,13 +307,6 @@ export default function Feed({
[qc]
);
const handleOpenChannel = useCallback(
(channelId: string, channelName: string) => {
onOpenChannel?.(channelId, channelName);
},
[onOpenChannel]
);
const onToggleSave = useCallback(
(id: string, saved: boolean) => {
setSavedOverrides((o) => ({ ...o, [id]: saved }));
@@ -418,7 +439,6 @@ export default function Feed({
view={view}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={handleOpenChannel}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={false}
@@ -451,7 +471,6 @@ export default function Feed({
queue={ytPlayerQueue}
onClose={() => setActiveVideo(null)}
onState={onState}
onOpenChannel={onOpenChannel}
/>
</Suspense>
)}
@@ -509,6 +528,7 @@ export default function Feed({
{SHOW_IDS.map((id) => (
<button
key={id}
data-testid={`feed-show-${id}`}
onClick={() => setFilters({ ...filters, show: id })}
aria-pressed={filters.show === id}
className={`text-xs px-3 py-1.5 rounded-full border transition ${
@@ -526,6 +546,7 @@ export default function Feed({
return (
<button
key={c.key}
data-testid={`feed-content-${c.key}`}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className={`text-xs px-3 py-1.5 rounded-full border transition ${
@@ -566,7 +587,7 @@ export default function Feed({
</>
)}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
<span className="text-xs text-muted whitespace-nowrap">
<span data-testid="feed-result-count" className="text-xs text-muted whitespace-nowrap">
{countQuery.data
? t("feed.videoCount", {
count: countQuery.data.count,
@@ -621,24 +642,40 @@ export default function Feed({
<RefreshCw className="w-4 h-4" />
</button>
)}
<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] }))}
onChange={setView}
label={t("feed.viewLabel")}
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
// only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</div>
</div>
</div>
);
return (
<div className="p-4">
{toolbar}
{/* Channel pages get no chips: they have no filter panel to reveal (App hides the sidebar for
them), and their Feed starts from its OWN baseline — channel-scoped, whole-catalog,
show-all — so every one of those would render as a "filter you applied" when it's just the
page being itself. */}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
<div className={channelScoped ? "px-4 sm:px-6 pb-4" : "px-4 pb-4 pt-2"}>
{/* 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
the toolbar rides with the tabs. Channel pages get no chips: their Feed starts from its OWN
baseline (channel-scoped, whole-catalog, show-all), so each would read as a "filter you
applied" when it's just the page being itself. */}
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-4 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
@@ -658,7 +695,6 @@ export default function Feed({
view={view}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={handleOpenChannel}
onResetState={onResetState}
onOpen={openVideo}
hasNextPage={!!hasNextPage}
@@ -674,7 +710,6 @@ export default function Feed({
queue={playerQueue}
onClose={() => setActiveVideo(null)}
onState={onState}
onOpenChannel={onOpenChannel}
/>
</Suspense>
)}
@@ -0,0 +1,137 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import { hasFilterParams, paramsToFilters } from "../lib/urlState";
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
// god component so consumers read it via a hook instead of prop-drilling `filters`/`setFilters`
// through the tree. Mirrors NavigationProvider (split contexts + one-line hooks); composed in
// main.tsx inside NavigationProvider, around <App/>.
//
// TWO contexts on purpose (mirrors NavigationProvider): a STABLE actions context (setFilters never
// changes reference) and a CHANGING state context (the filters object). Context consumers re-render
// only when the context they read changes — so a component that dispatches filter changes without
// reading the current filters can subscribe to actions ONLY and skip re-rendering on every tweak.
//
// SCOPE NOTE: this owns the FEED's filters only. The channel detail page keeps its OWN isolated
// filter state (ChannelPage) — it renders a channel-scoped catalog, not the global feed — and the
// reusable <Feed> is parameterized by a `filters` prop so it can render either. Because the two live
// in structurally separate cells, opening a channel can never leak into the global feed (bug 2).
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const FILTERS_KEY = LS.filters;
// This account's own persisted filters (per-account keys — never another account's). A chosen
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
function loadAccountFilters(id: number | null): FeedFilters {
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. The
// starred default view only SEEDS a fresh account that has never stored filters: it drives the
// very first load, after which that state persists like any other. (Re-apply the default view
// from the sidebar to return to it.)
const fKey = accountKey(FILTERS_KEY, id);
let hasStored = false;
try {
hasStored = !!fKey && localStorage.getItem(fKey) != null;
} catch {
/* localStorage may be unavailable */
}
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
const dvKey = accountKey(LS.defaultViewFilters, id);
const def = dvKey ? readJSON<FeedFilters | null>(dvKey, null) : null;
if (def) return { ...DEFAULT_FILTERS, ...def };
return { ...DEFAULT_FILTERS };
}
// URL wins over localStorage so a pasted link reproduces the exact view.
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
return loadAccountFilters(getActiveAccount());
}
interface FeedFiltersActions {
setFilters: (next: FeedFilters) => void;
}
const StateContext = createContext<FeedFilters>(DEFAULT_FILTERS);
const ActionsContext = createContext<FeedFiltersActions>({ setFilters: () => {} });
export function useFeedFilters(): FeedFilters {
return useContext(StateContext);
}
export function useFeedFiltersActions(): FeedFiltersActions {
return useContext(ActionsContext);
}
export function FeedFiltersProvider({ children }: { children: ReactNode }) {
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
// — such filters win over the account's stored view and are persisted to the account instead.
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search)),
);
// Passive observer: App owns the fetching `["me"]` query (gated on setup state); enabled:false
// here reads its cache and re-renders when it lands, without triggering a second fetch (which
// could 503 in setup mode). We only need id + is_demo, which don't change on background refetch.
const meQuery = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const setFilters = useCallback((next: FeedFilters) => {
setFiltersState(next);
// Persist under THIS tab's active account so filters don't bleed between accounts.
const key = accountKey(FILTERS_KEY, getActiveAccount());
if (key) writeJSON(key, next);
}, []);
// Load THIS account's own filters when the account first resolves or changes (login / switch /
// add). Filters are per-account localStorage state, so another account's last view (incl. its
// default saved view) must never carry over. A share link's filters (captured at mount) win and
// are persisted to the account instead. Runs on id change only, so a 60s background refetch of
// the same account never clobbers the filters you're editing.
useEffect(() => {
const id = meQuery.data?.id;
if (id == null) return;
if (initialHadUrlFilters) {
const key = accountKey(FILTERS_KEY, id);
if (key) writeJSON(key, filters);
return;
}
let f = loadAccountFilters(id);
// The shared demo account has no subscriptions, so default it to the whole library.
if (meQuery.data?.is_demo) f = { ...f, scope: "all" };
setFiltersState(f);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]);
const actions = useMemo<FeedFiltersActions>(() => ({ setFilters }), [setFilters]);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={filters}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
@@ -0,0 +1,69 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { parseFeedView, type FeedView } from "../lib/feedView";
import { LS, readAccount, writeAccount } from "../lib/storage";
// Owns the feed's view mode (the card/row/tile density switcher), lifted out of App so both the feed
// page and the channel page read it via hooks instead of prop-drilling `view`/`setView` through App.
// Split State + Actions contexts, mirroring the other providers; composed in main.tsx around <App/>.
//
// The view mode is an account PREFERENCE, not a filter: it persists to the server on pick (savePrefs)
// and is adopted from server prefs on login — so unlike the ephemeral list filters, this provider
// carries the same server-sync the App god component used to. A localStorage mirror seeds the initial
// render synchronously; the passive ["me"] observer adopts the server value once the account resolves.
interface FeedViewActions {
setView: (v: FeedView) => void;
}
const StateContext = createContext<FeedView>("cards");
const ActionsContext = createContext<FeedViewActions>({ setView: () => {} });
export function useFeedView(): FeedView {
return useContext(StateContext);
}
export function useFeedViewActions(): FeedViewActions {
return useContext(ActionsContext);
}
export function FeedViewProvider({ children }: { children: ReactNode }) {
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
// A user pick persists both to the localStorage mirror (instant reload) and to the server.
const setView = useCallback((next: FeedView) => {
setViewState(next);
writeAccount(LS.feedView, next);
api.savePrefs({ view: next }).catch(() => {});
}, []);
// Adopt the server-stored view on login / account switch. Writes the mirror but does NOT savePrefs
// (that would echo the just-adopted value straight back). parseFeedView also maps the pre-0.45
// "grid" / "list" values forward.
useEffect(() => {
const prefs = me?.preferences;
if (!prefs) return;
const adopted = parseFeedView(prefs.view);
setViewState(adopted);
writeAccount(LS.feedView, adopted);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
const actions = useMemo<FeedViewActions>(() => ({ setView }), [setView]);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={view}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+8 -2
View File
@@ -1,4 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import Overlay from "./Overlay";
import { usePrefs } from "./PrefsProvider";
/**
* A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it
@@ -90,7 +92,9 @@ function loadSaved(): Saved {
}
}
export default function GlassTuner({ enabled }: { enabled: boolean }) {
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 initial = useMemo(loadSaved, []);
const [vars, setVars] = useState<Record<string, number>>(() => {
const base: Record<string, number> = {};
@@ -216,7 +220,8 @@ export default function GlassTuner({ enabled }: { enabled: boolean }) {
if (!enabled) return null;
return (
<div className="fixed right-0 top-24 z-[9999] flex items-start" style={{ pointerEvents: "none" }}>
<Overlay>
<div className="fixed right-0 top-24 z-tuner flex items-start" style={{ pointerEvents: "none" }}>
{!open && (
<button
onClick={() => persistOpen(true)}
@@ -335,6 +340,7 @@ export default function GlassTuner({ enabled }: { enabled: boolean }) {
</div>
)}
</div>
</Overlay>
);
}
+20 -27
View File
@@ -1,50 +1,43 @@
import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Youtube } from "lucide-react";
import type { FeedFilters, Me } from "../lib/api";
import type { Page } from "../lib/urlState";
import type { Me } from "../lib/api";
import { moduleLabelKey, moduleOrder, stepModule } from "../lib/modules";
import ModuleName from "./ModuleName";
import SearchBar from "./SearchBar";
import SyncStatus from "./SyncStatus";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useChannels, useChannelsActions } from "./ChannelsProvider";
// The floating top header: a row of glass pills over the content — a ModuleName pill (label +
// accent dot + back/forward arrows), the module's SearchBar where it has one, and the per-user
// sync chip at the right end.
export default function Header({
me,
filters,
setFilters,
plexQ,
setPlexQ,
channelsSearch,
setChannelsSearch,
playlistsSearch,
setPlaylistsSearch,
page,
setPage,
onYtSearch,
onGoToFullHistory,
}: {
me: Me;
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
// Plex has its own ephemeral search term (see App) — the box is shared UI, the state is not.
plexQ: string;
setPlexQ: (q: string) => void;
channelsSearch: string;
setChannelsSearch: (q: string) => void;
playlistsSearch: string;
setPlaylistsSearch: (q: string) => void;
page: Page;
setPage: (p: Page) => void;
// Trigger a live YouTube search for the current term (feed only; hidden for the demo account,
// which can't spend the shared quota).
onYtSearch: (q: string) => void;
// Jump to the channel manager filtered to channels still missing full history.
onGoToFullHistory: () => void;
}) {
const { t } = useTranslation();
const { page } = useNavigation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// Each module's search term lives in its own provider (shared with that module's rail/content):
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
// "go to full history" jump is a ChannelsProvider action.
const { search: playlistsSearch } = usePlaylists();
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
const { q: plexQ } = usePlex();
const { setQ: setPlexQ } = usePlexActions();
const { search: channelsSearch } = useChannels();
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
// The ◀/▶ arrows step cyclically through the user's available modules (derived from `me`, so
// it stays correct as modules are added/gated). Labels of every active module feed ModuleName's
// dynamic width so the pill is sized to the widest reachable title in the current language.
@@ -109,7 +102,7 @@ export default function Header({
// Absolutely positioned within the (relative) content column, so its left edge tracks the
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
// slim and/or no filter sidebar is present — no hard-coded offset.
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-30 flex items-center gap-3">
<div className="pointer-events-none absolute top-3 left-3 right-[10%] z-chrome flex items-center gap-3">
<ModuleName
label={t(moduleLabelKey[page])}
labels={moduleLabels}
+2 -2
View File
@@ -103,7 +103,7 @@ export default function LanguageSwitcher({
</button>
{open &&
createPortal(
<div style={{ position: "fixed", left: pos.left, bottom: pos.bottom, zIndex: 40 }}>
<div className="z-rail" style={{ position: "fixed", left: pos.left, bottom: pos.bottom }}>
{menu}
</div>,
document.body
@@ -127,7 +127,7 @@ export default function LanguageSwitcher({
<div
className={`glass-menu absolute ${
align === "right" ? "right-0" : "left-0"
} mt-1 w-40 rounded-xl p-1.5 z-40 animate-[popIn_0.16s_ease]`}
} mt-1 w-40 rounded-xl p-1.5 z-rail animate-[popIn_0.16s_ease]`}
>
{LANGUAGES.map((l) => (
<button
+1 -1
View File
@@ -52,7 +52,7 @@ export default function Modal({
return createPortal(
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/70 backdrop-blur-sm"
onMouseDown={(e) => {
pressedOnBackdrop.current = e.target === e.currentTarget;
}}
+15 -41
View File
@@ -2,33 +2,23 @@ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import {
Activity,
BarChart3,
Bell,
Clapperboard,
Download,
Home,
Info,
ListVideo,
LogOut,
MessageSquare,
Pin,
PinOff,
ScrollText,
Settings,
Shield,
SlidersHorizontal,
Users,
Tv,
UserPlus,
type LucideIcon,
} from "lucide-react";
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
import * as e2ee from "../lib/e2ee";
import { useLiveQuery } from "../lib/useLiveQuery";
import { getUnreadCount, subscribe } from "../lib/notifications";
import type { Page } from "../lib/urlState";
import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules";
import { useScrollFade } from "../lib/useScrollFade";
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
import { type LangCode } from "../i18n";
@@ -40,8 +30,6 @@ import LanguageSwitcher from "./LanguageSwitcher";
// a thin icon-only rail. Account actions sit at the bottom in a popover.
export default function NavSidebar({
me,
page,
setPage,
onOpenAbout,
onChangeLanguage,
language,
@@ -49,8 +37,6 @@ export default function NavSidebar({
onToggleCollapse,
}: {
me: Me;
page: Page;
setPage: (p: Page) => void;
onOpenAbout: () => void;
onChangeLanguage: (code: LangCode) => void;
language: LangCode;
@@ -60,6 +46,8 @@ export default function NavSidebar({
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
const { page } = useNavigation();
const { setPage } = useNavigationActions();
// When unpinned, the rail sits slim (icons only) and peeks open into a labelled overlay on hover
// or keyboard focus WITHOUT pushing content. Pinned keeps it expanded in-flow like before.
// `collapsed` (persisted per-account) now means "unpinned".
@@ -178,34 +166,18 @@ export default function NavSidebar({
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
const listFade = useScrollFade();
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the
// shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift
// and a new/gated module updates both at once. Badges live here (nav-only concern).
// Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the
// header's ModuleName pill always read the same name.
const ICON: Record<Page, typeof Home> = {
feed: Home,
channels: Tv,
playlists: ListVideo,
plex: Clapperboard,
notifications: Bell,
messages: MessageSquare,
downloads: Download,
stats: BarChart3,
scheduler: Activity,
config: SlidersHorizontal,
users: Users,
audit: ScrollText,
settings: Settings,
};
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
// module's icon + label — come from the shared module registry (the same source the header's
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
const BADGE: Partial<Record<Page, number>> = {
notifications: unread,
messages: msgUnread,
downloads: dlActive,
};
const META = (p: Page): Omit<NavItem, "page"> => ({
icon: ICON[p],
icon: moduleDef(p).icon,
label: t(moduleLabelKey[p]),
badge: BADGE[p],
});
@@ -236,6 +208,7 @@ export default function NavSidebar({
return (
<button
key={p}
data-testid={`nav-${p}`}
onClick={() => setPage(p)}
title={slim ? label : undefined}
aria-current={active ? "page" : undefined}
@@ -278,7 +251,7 @@ export default function NavSidebar({
// margin, and a hover zone out there would fire from the dead space next to the rail —
// leaving the side panel to the left would pop the rail open with nothing under the cursor.
{...peek.handlers}
className={`glass absolute top-0 bottom-0 left-0 z-40 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
className={`glass absolute top-0 bottom-0 left-0 z-rail rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
slim ? "w-[56px] px-2" : "w-52 px-3"
}`}
aria-label={t("nav.primary")}
@@ -350,6 +323,7 @@ export default function NavSidebar({
</button>
</div>
<button
data-testid="nav-settings"
onClick={() => setPage("settings")}
title={slim ? t("header.account.settings") : undefined}
aria-current={page === "settings" ? "page" : undefined}
@@ -389,7 +363,7 @@ export default function NavSidebar({
<div
ref={acctPanelRef}
style={{ position: "fixed", left: acctPos.left, bottom: acctPos.bottom }}
className="glass w-60 rounded-xl p-3 z-50 animate-[popIn_0.16s_ease]"
className="glass w-60 rounded-xl p-3 z-overlay animate-[popIn_0.16s_ease]"
>
<div className="flex items-center gap-3 pb-3 border-b border-border">
<AvatarImg
@@ -0,0 +1,209 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import { isPage, readPage, type Page } from "../lib/urlState";
import { pageTitleKey } from "../lib/pageMeta";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { setNavigator } from "../lib/nav";
// Owns the app's navigation + browser-history state, lifted out of the App god component so pages
// consume it via a hook instead of prop-drilling `page`/`setPage`/`onOpenChannel` through the tree.
// Mirrors ConfirmProvider (context + default value + one-line hooks); composed in main.tsx inside
// ConfirmProvider, around <App/>.
//
// TWO contexts on purpose: a STABLE actions context (setPage/openChannel/… never change reference)
// and a CHANGING state context (page/channelView/ytSearch). Context consumers re-render whenever the
// value changes, so deep memo'd leaves (VideoCard, PlayerModal) subscribe to actions ONLY and never
// re-render on a page/channel switch — preserving the memo that keeps feed appends from re-rendering
// every existing card.
const PAGE_KEY = LS.page;
export interface ChannelView {
id: string;
name?: string;
}
export interface NavigationState {
page: Page;
/** The open channel page (null = none). Overlays `page`; rides in history.state._chan. */
channelView: ChannelView | null;
/** Live YouTube search term (null = normal feed). A feed sub-view; rides in history.state._yt. */
ytSearch: string | null;
}
export interface NavigationActions {
setPage: (next: Page) => void;
openChannel: (id: string, name?: string) => void;
closeChannel: () => void;
enterYtSearch: (q: string) => void;
exitYtSearch: () => void;
}
function loadInitialPage(): Page {
const params = new URLSearchParams(window.location.search);
if (params.has("page")) return readPage();
const stored = getAccountRaw(PAGE_KEY);
return isPage(stored) ? stored : "feed";
}
const StateContext = createContext<NavigationState>({
page: "feed",
channelView: null,
ytSearch: null,
});
const ActionsContext = createContext<NavigationActions>({
setPage: () => {},
openChannel: () => {},
closeChannel: () => {},
enterYtSearch: () => {},
exitYtSearch: () => {},
});
export function useNavigation(): NavigationState {
return useContext(StateContext);
}
export function useNavigationActions(): NavigationActions {
return useContext(ActionsContext);
}
export function NavigationProvider({ children }: { children: ReactNode }) {
const { t, i18n } = useTranslation();
const [page, setPageState] = useState<Page>(loadInitialPage);
// Ephemeral: not persisted and not in the URL, so a reload returns to the normal feed (a search
// spends quota, so we never auto-replay it) — UNLESS it was zero-quota scrape-sourced, which rides
// in history.state and is free to re-fetch. The popstate handler derives it from history.state so
// Back steps player → search → feed instead of the search vanishing without a history entry.
const [ytSearch, setYtSearch] = useState<string | null>(() => {
const st = window.history.state;
return st?._yt && st?._ytScrape ? (st._yt as string) : null;
});
// The open channel page rides in history.state (_chan = id, _chanName = a name to show before the
// detail loads), so Back closes it and a reload restores it (unlike ytSearch, which is dropped).
const [channelView, setChannelView] = useState<ChannelView | null>(() => {
const c = window.history.state?._chan as string | undefined;
return c ? { id: c, name: (window.history.state?._chanName as string) ?? undefined } : null;
});
// Refs so the STABLE action callbacks can read the latest state without depending on it — a dep
// would recreate them on every navigation and re-render every actions consumer (e.g. VideoCard).
// Written during render (the sanctioned "latest value" pattern): the ref is only READ in event
// handlers after commit, and this provider never suspends or renders in a transition, so no
// discarded render can leave it stale.
const pageRef = useRef(page);
pageRef.current = page;
const channelViewRef = useRef(channelView);
channelViewRef.current = channelView;
const setPage = useCallback((next: Page) => {
// A channel page overlays the content column, so a nav click must close it even when the
// underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === pageRef.current && !channelViewRef.current) return;
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving the
// app. The URL stays clean — the page rides in history.state. A fresh { sfPage } (no carried-over
// _yt/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
}, []);
const openChannel = useCallback((id: string, name?: string) => {
setChannelView({ id, name });
const st = window.history.state || {};
if (st._chan) window.history.replaceState({ ...st, _chan: id, _chanName: name ?? null }, "");
else window.history.pushState({ ...st, _chan: id, _chanName: name ?? null }, "");
}, []);
const closeChannel = useCallback(() => {
if (window.history.state?._chan) window.history.back();
else setChannelView(null);
}, []);
const enterYtSearch = useCallback((q: string) => {
setYtSearch(q);
// Drop any prior _ytScrape marker — the new search's source isn't known yet; Feed re-stamps it
// once the results resolve, so a reload only restores a confirmed scrape-sourced search.
const { _ytScrape: _drop, ...st } = window.history.state || {};
if (st._yt) window.history.replaceState({ ...st, _yt: q }, ""); // refine current search
else window.history.pushState({ ...st, sfPage: "feed", _yt: q }, ""); // new sub-view entry
}, []);
const exitYtSearch = useCallback(() => {
// Pop our search entry (so Forward still works); fall back to a plain clear if we don't own one.
if (window.history.state?._yt) window.history.back();
else setYtSearch(null);
}, []);
// Expose setPage to decoupled callers (download toast "View", etc.) via the lib/nav singleton.
useEffect(() => setNavigator(setPage), [setPage]);
// Leaving the feed ends any live YouTube search, so coming back shows the normal feed.
useEffect(() => {
if (page !== "feed") setYtSearch(null);
}, [page]);
// Reflect the current module in the browser tab title. Feed = brand only; an open channel page
// shows the channel name.
useEffect(() => {
const brand = "Siftlode";
const label = channelView
? channelView.name || t("header.channelManager")
: page === "feed"
? null
: t(pageTitleKey(page));
document.title = label ? `${label} · ${brand}` : brand;
}, [page, channelView, i18n.language, t]);
// In-app Back/Forward: stamp the initial entry with the current page, then sync state from
// history.state on popstate. (stripUrlParams preserves history.state, so this stamp survives a
// later query-string strip in App.)
useEffect(() => {
// A reload drops a stale _yt EXCEPT when scrape-sourced (zero quota): then keep _yt + _ytScrape
// so it restores and re-fetches free (ytSearch above already initialised from it). _chan/_chanName
// are always kept so a channel page survives F5.
const st = window.history.state || {};
if (st._yt && st._ytScrape) {
window.history.replaceState({ ...st, sfPage: page }, "");
} else {
const { _yt: _staleYt, _ytScrape: _staleScrape, ...rest } = st;
window.history.replaceState({ ...rest, sfPage: page }, "");
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view and the channel page ride in history.state, so Back/Forward
// restores or clears them to match the entry we landed on.
setYtSearch((e.state?._yt as string) ?? null);
const chan = e.state?._chan as string | undefined;
setChannelView(chan ? { id: chan, name: (e.state?._chanName as string) ?? undefined } : null);
}
window.addEventListener("popstate", onPop);
return () => window.removeEventListener("popstate", onPop);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const state = useMemo<NavigationState>(
() => ({ page, channelView, ytSearch }),
[page, channelView, ytSearch],
);
const actions = useMemo<NavigationActions>(
() => ({ setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch }),
[setPage, openChannel, closeChannel, enterYtSearch, exitYtSearch],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+22 -16
View File
@@ -2,7 +2,7 @@ import { useEffect, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Activity, Bell, Check, CheckCheck, ExternalLink, Eye, RotateCcw, Search, Trash2, Tv, UserPlus, X } from "lucide-react";
import { api, type AppNotification, type FeedFilters } from "../lib/api";
import { api, type AppNotification } from "../lib/api";
import { useLiveQuery } from "../lib/useLiveQuery";
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
import {
@@ -20,8 +20,11 @@ import {
type VideoWatchedMeta,
} from "../lib/notifications";
import { channelYouTubeUrl, relativeFromMs, relativeTime } from "../lib/format";
import type { Page } from "../lib/urlState";
import { LEVEL_STYLE } from "./Toaster";
import { PageToolbar } from "./PageShell";
import { useNavigationActions } from "./NavigationProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// The single notification center. "System" = durable, server-backed notifications (inbox
// phase 1). "Activity" = the client-side transient events (action confirmations, errors, with
@@ -29,18 +32,14 @@ import { LEVEL_STYLE } from "./Toaster";
// there's one indicator and one place to look. Transient toasts still pop via the Toaster.
const POLL_MS = 8000;
export default function NotificationsPanel({
filters,
setFilters,
setPage,
onFocusChannel,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
setPage: (p: Page) => void;
onFocusChannel: (name: string) => void;
}) {
export default function NotificationsPanel() {
const { t } = useTranslation();
const { setPage } = useNavigationActions();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from a new-subscription notification) lives in
// ChannelsProvider — read it here and pass it down to the notification rows.
const { focusChannel: onFocusChannel } = useChannelsActions();
const qc = useQueryClient();
const q = useLiveQuery<{ items: AppNotification[]; total: number }>(
["notifications"],
@@ -103,8 +102,11 @@ export default function NotificationsPanel({
}
return (
<div className="p-4 max-w-3xl w-full mx-auto space-y-4">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<>
{/* The inbox header (title + mark-all / clear-all) is fixed chrome; only the list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 max-w-3xl w-full mx-auto">
<div className="glass rounded-2xl p-4 flex items-center gap-3">
<Bell className="w-5 h-5 text-accent shrink-0" />
<div className="flex-1 min-w-0">
<div className="font-semibold">{t("inbox.title")}</div>
@@ -131,7 +133,10 @@ export default function NotificationsPanel({
</div>
)}
</div>
</div>
</PageToolbar>
<div className="px-4 pb-4 pt-3 max-w-3xl w-full mx-auto space-y-4">
{q.isLoading && !q.data && clientItems.length === 0 ? (
<div className="p-8 text-muted text-center">{t("common.loading")}</div>
) : !hasAny ? (
@@ -186,7 +191,8 @@ export default function NotificationsPanel({
)}
</div>
)}
</div>
</div>
</>
);
}
+4 -1
View File
@@ -4,6 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Check, Loader2, RefreshCw, ShieldCheck, X, Youtube } from "lucide-react";
import { api, type Me } from "../lib/api";
import { beginGrant, endOnboarding } from "../lib/onboarding";
import Overlay from "./Overlay";
// A first-login wizard that walks the user through granting YouTube access one scope at a
// time, each with a plain explanation and an up-front heads-up about Google's "unverified
@@ -122,7 +123,8 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
const stepIndex = step === "read" ? 0 : step === "write" ? 1 : 2;
return (
<div className="fixed inset-0 z-50 grid place-items-center p-4">
<Overlay>
<div className="fixed inset-0 z-overlay grid place-items-center p-4">
<div className="absolute inset-0 bg-black/50 animate-[overlayIn_0.2s_ease]" onClick={close} />
<div className="glass relative w-[min(94vw,460px)] rounded-2xl p-7 text-center animate-[panelIn_0.26s_cubic-bezier(0.22,1,0.36,1)]">
<button
@@ -256,5 +258,6 @@ export default function OnboardingWizard({ me, onClose }: { me: Me; onClose: ()
</div>
</div>
</div>
</Overlay>
);
}
+14
View File
@@ -0,0 +1,14 @@
import { type ReactNode } from "react";
import { createPortal } from "react-dom";
// The app's overlay-root convention. Any full-screen or floating overlay renders THROUGH this so it
// escapes whatever ancestor stacking context it was declared in — a `mask-image`/`transform`/`filter`
// on the page scroller (or a rail's `backdrop-filter`) would otherwise trap a `fixed` child and let
// the chrome paint over it. That is the exact class of bug that buried the players in E3; the
// modal/player family already dodges it by portaling to <body>, and this makes that the one blessed
// way. Stacking among overlays is then decided by DOM order + the z-index token scale (tailwind
// `zIndex`), never by an ad-hoc magic number. Portals to <body> (the existing de-facto target).
export default function Overlay({ children }: { children: ReactNode }) {
if (typeof document === "undefined") return null;
return createPortal(children, document.body);
}
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useLayoutEffect, useRef, type ReactNode } from "react";
import { useScrollFade } from "../lib/useScrollFade";
// Per-view saved scroll offsets. Module-level so a view's position survives its scroller
// unmounting — opening a channel page swaps in a different shell/scroller — and is restored when
// the same view returns. Keyed by the shell's `scrollKey`.
const savedScroll = new Map<string, number>();
// Put the scroller back to `top`. The feed is virtualized, so on a remount the list needs a frame
// or two to render enough rows to reach its full height; an immediate `scrollTop` would otherwise
// clamp short. Re-apply over a few frames until it sticks (or the content is simply shorter now).
function restoreScroll(el: HTMLElement, top: number): () => void {
if (top <= 0) {
el.scrollTop = 0;
return () => {};
}
let raf = 0;
let frames = 0;
let cancelled = false;
const tick = () => {
if (cancelled) return;
if (el.scrollHeight - el.clientHeight >= top) el.scrollTop = top;
if (el.scrollTop >= top - 1 || frames++ > 90) return; // reached, or gave up after ~1.5s
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelled = true;
cancelAnimationFrame(raf);
};
}
// The page's scroll container — the ONE scroller in the app, owned by PageShell. The feed, the
// channel page and Plex browse all scroll inside it rather than owning a scroller of their own,
// which is why the edge-fade and the scroll restore/reset policy live here once instead of three
// times.
//
// Two invariants this element carries, both learned the hard way:
//
// - `scrollbar-gutter: stable` is load-bearing, not cosmetic. The feed derives its column count
// from this element's clientWidth and its thumbnails are aspect-ratio boxes, so when the content
// lands within a scrollbar's width of the viewport height the two feed each other: scrollbar
// appears -> 15px narrower -> shorter thumbs -> content fits -> scrollbar goes -> wider ->
// taller -> repeat, every frame. Reserving the gutter keeps clientWidth constant and breaks the
// loop. On the channel page it also stops the header/logo/buttons shifting sideways when you
// switch from the tall Videos tab to the short About tab.
// - Consequently the bar STAYS visible here: `.no-scrollbar` (scrollbar-width: none) would zero
// the gutter and re-open that loop. Unlike the rail and the side panels, this scroller wears the
// fade next to its scrollbar, not instead of it.
//
// Scroll restore/reset policy (`scrollKey` identifies the logical view):
// - returning to a view with the SAME key — feed → open a channel → Back, feed filters unchanged —
// restores where you were (bug 1). The channel page is a different scroller, so the feed's
// scroller unmounts and remounts; the saved offset survives in `savedScroll` and is re-applied.
// - a NEW key — the feed's filters changed, i.e. a fresh result set — starts at the top (bug 3).
// The offset is recorded on every scroll rather than at unmount: by the time an unmount cleanup
// runs the scroller's scrollTop has already been zeroed by the teardown, so a cleanup-time read
// saves 0 and the restore is lost. Recording live sidesteps that (and StrictMode's synthetic
// unmount, which would otherwise persist a 0 too).
//
// The fade state lives in here rather than in App so that a flip re-renders this component alone —
// `children` arrives as an already-built element, so React skips the page subtree underneath.
export default function PageScroller({
children,
scrollKey,
fadeTop = true,
}: {
children: ReactNode;
scrollKey?: string;
// Off when the scroller's top is a sticky element (a table's sticky header) — see useScrollFade.
fadeTop?: boolean;
}) {
const fade = useScrollFade(20, fadeTop);
const fadeRef = fade.ref;
const elRef = useRef<HTMLElement | null>(null);
// Stable identity: an inline ref callback changes every render, which makes React detach and
// re-attach the ref each commit — and since fade.ref is a state setter, that re-attach loops it
// into "Maximum update depth exceeded". useScrollFade's ref setter is itself stable, so binding
// once here is safe.
const setRef = useCallback(
(node: HTMLElement | null) => {
elRef.current = node;
fadeRef(node);
},
[fadeRef],
);
useLayoutEffect(() => {
const el = elRef.current;
if (!el || scrollKey == null) return;
const key = scrollKey;
const want = savedScroll.get(key) ?? 0;
const cancelRestore = restoreScroll(el, want);
// Record position continuously (not at unmount): the scroller's scrollTop is unreliable by the
// time an unmount cleanup runs, and a save-on-scroll survives whatever tears down last.
const onScroll = () => {
savedScroll.set(key, el.scrollTop);
};
el.addEventListener("scroll", onScroll, { passive: true });
return () => {
cancelRestore();
el.removeEventListener("scroll", onScroll);
};
}, [scrollKey]);
return (
<main
ref={setRef}
style={fade.style}
className="flex-1 min-w-0 overflow-y-auto [scrollbar-gutter:stable]"
>
{children}
</main>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { createContext, useContext, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
// The DOM node a module's toolbar portals into (see PageToolbar below). PageShell provides its
// fixed band as the default target; a module can override it with its own <PageToolbarSlot.Provider>
// to redirect its toolbar somewhere else — the channel page points it at a sticky sub-header so the
// toolbar rides with the tabs instead of the shell's top band.
export const PageToolbarSlot = createContext<HTMLElement | null>(null);
// A module renders its per-module fixed band (filter chips / sort / counts) through this. The
// content lands in the shell's toolbar band — ABOVE the scroller, so it stays put while the content
// scrolls and is never touched by the scroller's edge-fade mask. It's a portal, so the toolbar's
// event handlers and closures still belong to the module's React subtree (setFilters etc. work as
// if it were rendered inline). Renders nothing until the band mounts (one tick on first mount) and
// nothing when there's no shell — so a module works with or without one.
export function PageToolbar({ children }: { children: ReactNode }) {
const slot = useContext(PageToolbarSlot);
return slot ? createPortal(children, slot) : null;
}
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
// branch that rendered its OWN scroller) collapses to one contract and one scroller.
//
// Regions, top to bottom:
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
// and no fixed top band, so the module's own banner can scroll from the very top.
// - `banners` — in-flow, fixed notices (demo/version) above the scroll region.
// - toolbar band — in-flow, FIXED per-module band (filter chips / sort / counts); filled by a
// module via <PageToolbar>. Empty ⇒ zero height, so pages without one lose nothing.
// - children — the ONE scrollable region. `PageScroller` owns the scroller and its two
// invariants (`scrollbar-gutter: stable`, edge fade); see that file.
//
// The shell renders a fragment so it drops straight into App's `relative` content column — the
// floating header keeps resolving its `absolute` position against that same ancestor.
export default function PageShell({
header,
banners,
children,
scrollKey,
fadeTop = true,
}: {
header?: ReactNode;
banners?: ReactNode;
children: ReactNode;
// Identifies the logical view for the scroll restore/reset policy — see PageScroller.
scrollKey?: string;
// Off when this page's scroll region starts with a sticky header — see PageScroller.
fadeTop?: boolean;
}) {
const [toolbarSlot, setToolbarSlot] = useState<HTMLDivElement | null>(null);
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
{header}
<div className={`flex-1 min-w-0 min-h-0 flex flex-col ${header ? "pt-[var(--hdr-h)]" : ""}`}>
{banners}
<div ref={setToolbarSlot} className="shrink-0" />
<PageScroller scrollKey={scrollKey} fadeTop={fadeTop}>
{children}
</PageScroller>
</div>
</PageToolbarSlot.Provider>
);
}
+1 -1
View File
@@ -39,7 +39,7 @@ export default function PanelGroup({
ref={setNodeRef}
style={style}
className={`glass-card rounded-xl ${
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
isDragging ? "relative z-base shadow-2xl ring-1 ring-accent" : ""
} ${hidden && editing ? "opacity-50" : ""}`}
>
<div className="flex items-center gap-1.5 px-3 py-2">
+26 -13
View File
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./NavigationProvider";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, ArrowLeft, Check, CheckCheck, ChevronLeft, ChevronRight, ExternalLink, Repeat, Repeat1, Settings, Shuffle, SkipBack, SkipForward, Volume2, VolumeX, X } from "lucide-react";
import Avatar from "./Avatar";
@@ -10,6 +11,7 @@ import { api, type Video } from "../lib/api";
import { channelYouTubeUrl, formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
import { renderDescription } from "../lib/descriptionLinks";
import { useBackToClose } from "../lib/history";
import { useScrollFade } from "../lib/useScrollFade";
// Experiment (branch experiment/inline-player): play the video in-app via the
// YouTube IFrame Player API (not a bare embed) so we can read playback position
@@ -54,7 +56,6 @@ export default function PlayerModal({
startIndex,
onClose,
onState,
onOpenChannel,
}: {
video: Video;
// Where to start the opened video: a number of seconds (0 = Restart), or null/undefined
@@ -69,10 +70,11 @@ export default function PlayerModal({
onState: (id: string, status: string) => void;
// Open the active video's channel page in-app (closes the player first). The small external
// icon next to the name keeps the open-on-YouTube behaviour.
onOpenChannel?: (channelId: string, channelName?: string) => void;
}) {
const { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions();
const qc = useQueryClient();
const descFade = useScrollFade();
// Browser/mouse Back closes the player instead of leaving the page.
useBackToClose(onClose);
// Freeze the launch queue: the player steps through the SAME filtered + sorted list it was
@@ -581,10 +583,16 @@ export default function PlayerModal({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active.id]);
return (
// Portaled to <body>, like every other full-screen overlay here. A fixed, full-viewport modal
// must not render inside the page scroller: once that scroller is a stacking context (its
// edge-fade mask makes it one) this z-overlay is scoped inside it, and the nav rail (z-rail) and the
// header (z-chrome) paint over the modal — worst at browser zoom, where the centered card is smaller
// relative to the rail/panel. (Same class as PlexPlayer; this one was the miss in that sweep.)
return createPortal(
<div
ref={dialogRef}
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
data-testid="player-modal"
className="fixed inset-0 z-overlay flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm"
onClick={onClose}
role="dialog"
aria-modal="true"
@@ -634,21 +642,21 @@ export default function PlayerModal({
}}
title={t("player.shortcutsHint")}
aria-label={t("player.shortcutsHint")}
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-10 cursor-pointer ${
className={`absolute inset-x-0 top-[12%] bottom-[22%] z-base cursor-pointer ${
nativeControls ? "pointer-events-none" : ""
}`}
/>
)}
{/* Discreet badge while YouTube's own controls have taken over (overlay yielded). */}
{playerError == null && nativeControls && (
<div className="pointer-events-none absolute top-3 left-3 z-20 flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<div className="pointer-events-none absolute top-3 left-3 z-menu flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-[11px] text-white/90 shadow-lg backdrop-blur-sm">
<Settings className="h-3.5 w-3.5" />
{t("player.nativeControls")}
</div>
)}
{/* Volume level flash (auto-fades). pointer-events-none so it never blocks the video. */}
{volumeUi != null && (
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-20 flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
<div className="pointer-events-none absolute top-3 left-1/2 -translate-x-1/2 z-menu flex items-center gap-2 rounded-full bg-black/70 px-3 py-1.5 text-xs text-white shadow-lg">
{volumeUi === 0 ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
<div className="h-1.5 w-28 overflow-hidden rounded-full bg-white/25">
<div className="h-full rounded-full bg-white transition-all" style={{ width: `${volumeUi}%` }} />
@@ -760,7 +768,7 @@ export default function PlayerModal({
descRect &&
createPortal(
<div
className="fixed z-[60] bg-surface border border-border rounded-xl shadow-2xl p-4"
className="fixed z-popover bg-surface border border-border rounded-xl shadow-2xl p-4"
style={{
left: descRect.left,
bottom: descRect.bottom,
@@ -775,7 +783,11 @@ export default function PlayerModal({
{detail.isLoading ? (
<div className="text-sm text-muted">{t("player.loading")}</div>
) : detail.data?.description ? (
<div className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto leading-relaxed">
<div
ref={descFade.ref}
style={descFade.style}
className="text-sm whitespace-pre-wrap break-words max-h-64 overflow-y-auto no-scrollbar leading-relaxed"
>
{renderDescription(detail.data.description, {
currentId: currentVideoId,
onSeek: seekTo,
@@ -790,6 +802,7 @@ export default function PlayerModal({
document.body
)}
<button
data-testid="player-close"
onClick={onClose}
title={t("player.closeEsc")}
className="shrink-0 inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg text-muted hover:text-fg hover:bg-surface transition"
@@ -827,16 +840,15 @@ export default function PlayerModal({
<div className="flex items-center gap-1.5 shrink-0 min-w-0">
<button
onClick={() => {
if (!onOpenChannel) return onClose();
const cid = active.channel_id;
const cname = active.channel_title ?? undefined;
// Closing the player pops its own history entry (useBackToClose → history.back()).
// Open the channel only AFTER that pop's popstate — pushing the channel entry
// before it would let the back remove it again (the bug: clicking the name only
// closed the player). One-shot listener fires after App's popstate handler.
// closed the player). One-shot listener fires after the popstate handler.
const open = () => {
window.removeEventListener("popstate", open);
onOpenChannel(cid, cname);
openChannel(cid, cname);
};
window.addEventListener("popstate", open);
onClose();
@@ -938,6 +950,7 @@ export default function PlayerModal({
</button>
)}
</div>
</div>
</div>,
document.body
);
}
+16 -11
View File
@@ -38,7 +38,10 @@ import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { useUndoable } from "../lib/useUndoable";
const PlayerModal = lazy(() => import("./PlayerModal"));
import UndoToolbar from "./UndoToolbar";
import { PageToolbar } from "./PageShell";
import { useConfirm } from "./ConfirmProvider";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
type SortKey = "manual" | "title" | "duration" | "channel";
type SortDir = "asc" | "desc";
@@ -177,17 +180,13 @@ function Row({
);
}
export default function Playlists({
canWrite,
selectedId,
setSelectedId,
}: {
canWrite: boolean;
// The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the
export default function Playlists() {
const me = useMe();
const canWrite = me.can_write;
// The selected playlist lives in PlaylistsProvider, shared with the PlaylistsRail (which owns the
// list + its rail controls). This detail pane reacts to the selection.
selectedId: number | null;
setSelectedId: (id: number | null) => void;
}) {
const { selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
@@ -443,13 +442,17 @@ export default function Playlists({
}
return (
<div className="px-4 pb-4">
<div className="px-4 pb-4 pt-3">
{selectedId == null || !detail ? (
<div className="text-muted text-sm p-4">
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
</div>
) : (
<>
{/* Header (title, actions, sort/group) is fixed chrome it portals into the shell's
fixed band, so only the video list scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-3 items-start mb-4">
<div className="w-24 h-[54px] rounded-lg bg-card overflow-hidden shrink-0">
{items[0]?.thumbnail_url && (
@@ -624,6 +627,8 @@ export default function Playlists({
</div>
</div>
)}
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="text-sm text-muted grid place-items-center text-center py-12">
@@ -0,0 +1,69 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Playlists module's shared local state — the rail's name filter and the selected playlist
// — lifted out of the App god component so the header search box, the PlaylistsRail, and the
// Playlists detail pane read it via hooks instead of prop-drilling through App. Mirrors
// FeedFiltersProvider / NavigationProvider (split State + Actions contexts, one-line hooks; composed
// in main.tsx around <App/>).
//
// The `search` term is ephemeral (resets on reload, like the other list filters); the selected
// playlist is per-account persisted (LS.playlist) so a reload returns you to it. Neither is
// server-synced, and an account switch does a full reload, so init-from-storage is all the
// hydration these need — no per-account load effect (unlike the server-backed feed filters).
type PlaylistsState = {
search: string;
selectedId: number | null;
};
interface PlaylistsActions {
setSearch: (q: string) => void;
setSelectedId: (id: number | null) => void;
}
const StateContext = createContext<PlaylistsState>({ search: "", selectedId: null });
const ActionsContext = createContext<PlaylistsActions>({
setSearch: () => {},
setSelectedId: () => {},
});
export function usePlaylists(): PlaylistsState {
return useContext(StateContext);
}
export function usePlaylistsActions(): PlaylistsActions {
return useContext(ActionsContext);
}
export function PlaylistsProvider({ children }: { children: ReactNode }) {
const [search, setSearchState] = useState("");
const [selectedId, setSelectedIdState] = useState<number | null>(() => {
const s = getAccountRaw(LS.playlist);
return s ? Number(s) : null;
});
const setSearch = useCallback((q: string) => setSearchState(q), []);
const setSelectedId = useCallback((id: number | null) => {
setSelectedIdState(id);
if (id != null) setAccountRaw(LS.playlist, String(id));
}, []);
const state = useMemo<PlaylistsState>(() => ({ search, selectedId }), [search, selectedId]);
const actions = useMemo<PlaylistsActions>(
() => ({ setSearch, setSelectedId }),
[setSearch, setSelectedId],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+7 -8
View File
@@ -10,6 +10,8 @@ import { LS, readAccountMerged, writeAccount } from "../lib/storage";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlaylists, usePlaylistsActions } from "./PlaylistsProvider";
import { useMe } from "../lib/useMe";
type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean };
const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false };
@@ -19,24 +21,21 @@ const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
export default function PlaylistsRail({
canWrite,
search,
selectedId,
setSelectedId,
layout,
setLayout,
collapsed,
onToggleCollapse,
}: {
canWrite: boolean;
search: string;
selectedId: number | null;
setSelectedId: (id: number | null) => void;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const canWrite = useMe().can_write;
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
// box and the Playlists detail pane); this rail owns the list query + its own controls.
const { search, selectedId } = usePlaylists();
const { setSelectedId } = usePlaylistsActions();
const { t } = useTranslation();
const qc = useQueryClient();
const [plSort, setPlSort] = useState<PlSort>(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT));
+72 -30
View File
@@ -1,4 +1,5 @@
import { lazy, Suspense, useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query";
import {
@@ -14,6 +15,8 @@ import {
RotateCcw,
Star,
Tv2,
User,
X,
type LucideIcon,
} from "lucide-react";
import {
@@ -32,6 +35,8 @@ import { useHistorySubview } from "../lib/history";
import { DetailCustomizeMenu, Filterable, useArtBackdrop, useDetailPrefs } from "../lib/plexDetailUi";
import PlexPlaylistAdd, { type PlexAddTarget } from "./PlexPlaylistAdd";
import PlexCollectionEditor from "./PlexCollectionEditor";
import { PageToolbar } from "./PageShell";
import { usePlex, usePlexActions } from "./PlexProvider";
// Lazy: the rich player (pulls in hls.js) loads only when something is first played.
const PlexPlayer = lazy(() => import("./PlexPlayer"));
@@ -52,34 +57,16 @@ type Sub =
// (useHistorySubview) so browser Back returns to the grid. Playback lands in P2 — a card announces
// for now. Rendered by App for page==="plex" (its own nav module).
type Props = {
q: string;
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
sort: string;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
// The sidebar opens a playlist by setting this; PlexBrowse turns it into a history subview + clears it.
openPlaylist?: number | null;
onPlaylistOpened?: () => void;
};
const PAGE = 40;
export default function PlexBrowse({
q,
scope,
setScope,
show,
sort,
filters,
setFilters,
openPlaylist,
onPlaylistOpened,
}: Props) {
export default function PlexBrowse() {
const { t } = useTranslation();
const qc = useQueryClient();
// Scope / watch-state / sort / filters live in PlexProvider (edited mainly from PlexSidebar); the
// search term (q) comes from the shared header box; the rail hands off a playlist to open via the
// shared `playlistOpen` slot, which we consume into a history subview then clear.
const { q, scope, show, sort, filters, playlistOpen: openPlaylist } = usePlex();
const { setScope, setFilters, setPlaylistOpen } = usePlexActions();
const dq = useDebounced(q.trim(), 350);
const sub = useHistorySubview<Sub>({ kind: "grid" });
@@ -88,7 +75,7 @@ export default function PlexBrowse({
useEffect(() => {
if (openPlaylist != null) {
sub.open({ kind: "playlist", id: openPlaylist });
onPlaylistOpened?.();
setPlaylistOpen(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [openPlaylist]);
@@ -151,6 +138,17 @@ export default function PlexBrowse({
const items = browseQ.data?.pages.flatMap((p) => p.items) ?? [];
const total = browseQ.data?.pages[0]?.total ?? 0;
// Cards for the actors currently in the filter (name + headshot + in-scope count) — shown above the
// grid so you SEE who you're exploring, not just chips in the rail.
const peopleCardsQ = useQuery({
queryKey: ["plex-people-cards", scope, filters.actors],
queryFn: () => api.plexPeopleCards(filters.actors, scope),
enabled: sub.view.kind === "grid" && filters.actors.length > 0,
staleTime: 60_000,
});
const personCards = peopleCardsQ.data?.people ?? [];
const removeActor = (name: string) =>
setFilters({ ...filters, actors: filters.actors.filter((x) => x !== name) });
// Episode matches (grouped "Episodes" section) — search-only; same set on every page, take page 0.
const episodes = browseQ.data?.pages[0]?.episodes ?? [];
@@ -226,8 +224,10 @@ export default function PlexBrowse({
}
if (sub.view.kind === "player") {
// The fallback is portaled for the same reason the player itself is: it is a fixed
// full-viewport overlay, and this tree renders inside the page scroller.
return (
<Suspense fallback={<div className="fixed inset-0 z-50 bg-black" />}>
<Suspense fallback={createPortal(<div className="fixed inset-0 z-overlay bg-black" />, document.body)}>
<PlexPlayer itemId={sub.view.id} queue={sub.view.queue} onClose={sub.back} />
</Suspense>
);
@@ -283,9 +283,51 @@ export default function PlexBrowse({
return (
<div className="p-4 max-w-[1600px] mx-auto">
<p className="text-xs text-muted mb-3">
{/* The title count is fixed chrome it portals into the shell's fixed band so it stays put
while only the poster grid scrolls. */}
<PageToolbar>
<div className="px-4 pt-3 pb-1 max-w-[1600px] mx-auto">
<p className="text-xs text-muted">
{browseQ.isLoading ? " " : dq ? t("plex.searchCount", { count: total }) : t("plex.count", { count: total })}
</p>
</p>
</div>
</PageToolbar>
{personCards.length > 0 && (
<div className="mb-4 flex flex-wrap gap-3">
{personCards.map((p) => (
<div
key={p.name}
className="glass-card flex items-center gap-2.5 rounded-xl py-1.5 pl-1.5 pr-2.5"
>
{p.photo ? (
<img
src={p.photo}
alt=""
loading="lazy"
className="w-10 h-10 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-10 h-10 rounded-full grid place-items-center bg-surface text-muted shrink-0">
<User className="w-5 h-5" />
</div>
)}
<div className="min-w-0">
<div className="text-sm font-medium truncate max-w-[150px]">{p.name}</div>
<div className="text-[11px] text-muted">{t("plex.people.count", { count: p.count })}</div>
</div>
<button
onClick={() => removeActor(p.name)}
title={t("plex.people.remove")}
aria-label={t("plex.people.remove")}
className="ml-1 p-1 rounded-full text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
{browseQ.isLoading ? (
<p className="text-muted text-sm">{t("plex.loading")}</p>
@@ -914,7 +956,7 @@ function PlexShowView({
<>
{/* Hero */}
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-10">
<div className="absolute right-3 top-3 z-base">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
@@ -1111,7 +1153,7 @@ function PlexSeasonView({
) : (
<>
<div className="glass relative rounded-2xl p-4 sm:p-5 mb-8 flex gap-4 sm:gap-5">
<div className="absolute right-3 top-3 z-10">
<div className="absolute right-3 top-3 z-base">
<DetailCustomizeMenu
artBg={artBg}
onToggleArtBg={toggleArtBg}
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Plus } from "lucide-react";
import { api } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
@@ -26,6 +27,8 @@ export default function PlexCollectionEditor({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const editableFade = useScrollFade();
const othersFade = useScrollFade();
const [members, setMembers] = useState<Set<string>>(() => new Set(memberOf));
const [q, setQ] = useState("");
const [newName, setNewName] = useState("");
@@ -146,7 +149,11 @@ export default function PlexCollectionEditor({
)}
{/* Editable collections with in/out toggle */}
<div className="max-h-80 space-y-1 overflow-y-auto">
<div
ref={editableFade.ref}
style={editableFade.style}
className="max-h-80 space-y-1 overflow-y-auto no-scrollbar"
>
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : shown.length === 0 ? (
@@ -197,7 +204,11 @@ export default function PlexCollectionEditor({
<summary className="cursor-pointer text-xs text-muted hover:text-fg">
{t("plex.collEditor.others", { count: eligible.length })}
</summary>
<div className="mt-2 max-h-40 space-y-1 overflow-y-auto">
<div
ref={othersFade.ref}
style={othersFade.style}
className="mt-2 max-h-40 space-y-1 overflow-y-auto no-scrollbar"
>
{eligible.map((c) => (
<div key={c.id} className="glass-card flex items-center gap-2 rounded-lg px-2 py-1.5 text-sm">
<span className="min-w-0 flex-1 truncate">{c.title}</span>
+28 -8
View File
@@ -9,6 +9,7 @@ import {
type ReactNode,
type WheelEvent as ReactWheelEvent,
} from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Hls from "hls.js";
@@ -36,6 +37,7 @@ import { api, type PlexMarker } from "../lib/api";
import { formatDuration } from "../lib/format";
import { LS, useAccountPersistedObject } from "../lib/storage";
import { useDismiss } from "../lib/useDismiss";
import { useScrollFade } from "../lib/useScrollFade";
import { useBackToClose } from "../lib/history";
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
@@ -245,13 +247,24 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
const menuOpenRef = useRef(false);
const menuRef = useRef<HTMLDivElement>(null);
const menuTriggerRef = useRef<HTMLButtonElement>(null);
const tracksRef = useRef<HTMLDivElement>(null);
const tracksRef = useRef<HTMLDivElement | null>(null);
const tracksTriggerRef = useRef<HTMLButtonElement>(null);
audioRef.current = audioOrd;
menuOpenRef.current = menuOpen || tracksOpen; // keep the control bar up while either menu is open
// Each menu stays open until a click/Escape OUTSIDE it (so sliders can be dragged freely).
useDismiss(menuOpen, () => setMenuOpen(false), [menuRef, menuTriggerRef]);
useDismiss(tracksOpen, () => setTracksOpen(false), [tracksRef, tracksTriggerRef]);
// The tracks menu is both dismiss-tracked and edge-faded, so its two refs are merged here.
// Must be stable: an inline arrow would detach (null) and re-attach the node on every render,
// and the fade's node-state ref would re-render in response — a loop. `fade.ref` is a setState.
const tracksFade = useScrollFade();
const setTracksNode = useCallback(
(node: HTMLElement | null) => {
tracksRef.current = node as HTMLDivElement | null;
tracksFade.ref(node);
},
[tracksFade.ref]
);
// Keyboard-shortcut cheat sheet (toggled with "h") + a live wall clock for the top bar.
const [helpOpen, setHelpOpen] = useState(false);
const helpOpenRef = useRef(false);
@@ -932,10 +945,15 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
// While scrubbing the head follows the finger (preview); otherwise it tracks playback.
const headPct = scrub != null && duration > 0 ? (scrub / duration) * 100 : pct;
return (
// Portaled to <body>, like every other full-screen overlay here (Modal, PlayerModal, ChatDock,
// BackToTop). A fixed, full-viewport player must not be painted inside the page scroller: the
// moment that scroller gets a mask/filter/transform/contain it becomes a stacking context, and
// this z-overlay is then scoped inside it — the rail, the side panel and the header paint straight
// over the player. That is exactly what the scroller's edge-fade did before this moved out.
return createPortal(
<div
ref={wrapRef}
className="fixed left-0 top-0 z-50 bg-black flex items-center justify-center select-none"
className="fixed left-0 top-0 z-overlay bg-black flex items-center justify-center select-none"
onMouseMove={wake}
onClick={wake}
onWheel={onWheel}
@@ -1222,10 +1240,11 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</button>
{tracksOpen && (
<div
ref={tracksRef}
ref={setTracksNode}
style={tracksFade.style}
onClick={(e) => e.stopPropagation()}
onWheel={(e) => e.stopPropagation()}
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto rounded-xl p-2 text-sm text-white shadow-2xl"
className="glass-menu absolute bottom-full right-0 mb-2 w-56 max-h-[60vh] overflow-auto no-scrollbar rounded-xl p-2 text-sm text-white shadow-2xl"
>
{detail.audio_streams.length > 1 && (
<>
@@ -1527,7 +1546,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Keyboard-shortcut cheat sheet (toggle with "h" or the keyboard button). */}
{helpOpen && (
<div
className="absolute inset-0 z-20 grid place-items-center bg-black/60 p-4"
className="absolute inset-0 z-menu grid place-items-center bg-black/60 p-4"
onClick={(e) => {
e.stopPropagation();
setHelpOpen(false);
@@ -1590,7 +1609,7 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
{/* Rich info overlay (poster/cast/ratings) — "i" or the info button; video keeps playing. */}
{infoOpen && detail && (
<div
className="absolute inset-0 z-20 grid place-items-center overflow-y-auto bg-black/70 p-4"
className="absolute inset-0 z-menu grid place-items-center overflow-y-auto bg-black/70 p-4"
onClick={(e) => {
e.stopPropagation();
setInfoOpen(false);
@@ -1606,7 +1625,8 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
</div>
</div>
)}
</div>
</div>,
document.body
);
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Minus, Plus } from "lucide-react";
import { api, type PlexPlaylist } from "../lib/api";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
// What we're adding: a single leaf (movie/episode) or a whole group (a season or an entire show, as a
@@ -16,6 +17,7 @@ export type PlexAddTarget =
// an in/out (or partial) state and a field to create a new one seeded with the target.
export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTarget; onClose: () => void }) {
const { t } = useTranslation();
const fade = useScrollFade();
const qc = useQueryClient();
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState(false);
@@ -101,7 +103,7 @@ export default function PlexPlaylistAdd({ target, onClose }: { target: PlexAddTa
</button>
</div>
<div className="max-h-80 space-y-1 overflow-y-auto">
<div ref={fade.ref} style={fade.style} className="max-h-80 space-y-1 overflow-y-auto no-scrollbar">
{listQ.isLoading ? (
<p className="py-4 text-center text-sm text-muted">{t("plex.loading")}</p>
) : playlists.length === 0 ? (
+124
View File
@@ -0,0 +1,124 @@
import {
createContext,
useCallback,
useContext,
useMemo,
useState,
type ReactNode,
} from "react";
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../lib/api";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
// Owns the Plex module's shared local state — the left-rail library scope / watch-state / sort /
// expanded filters, the header search term, and the "open this playlist" hand-off — lifted out of
// App so the PlexSidebar (rail), PlexBrowse (content), and the header search box read it via hooks
// instead of prop-drilling through App. Mirrors FeedFiltersProvider (split State + Actions contexts,
// stable useCallback setters, one-line hooks; composed in main.tsx around <App/>).
//
// scope / show / sort / filters / q are per-account persisted (localStorage); the account switch does
// a full reload, so init-from-storage is all the hydration they need. `playlistOpen` is an ephemeral
// hand-off (the rail sets it, PlexBrowse consumes it into a history subview then clears it). The Plex
// search term is deliberately its OWN state, NOT the feed's `filters.q`, so a Plex search never leaks
// into (and is never greeted by a stale value from) the feed.
type PlexState = {
scope: string; // movie | show | both (unified cross-library scope)
show: string;
sort: string;
filters: PlexFilters;
q: string;
playlistOpen: number | null;
};
interface PlexActions {
setScope: (v: string) => void;
setShow: (v: string) => void;
setSort: (v: string) => void;
setFilters: (f: PlexFilters) => void;
setQ: (q: string) => void;
setPlaylistOpen: (id: number | null) => void;
}
const StateContext = createContext<PlexState>({
scope: "both",
show: "all",
sort: "title",
filters: EMPTY_PLEX_FILTERS,
q: "",
playlistOpen: null,
});
const ActionsContext = createContext<PlexActions>({
setScope: () => {},
setShow: () => {},
setSort: () => {},
setFilters: () => {},
setQ: () => {},
setPlaylistOpen: () => {},
});
export function usePlex(): PlexState {
return useContext(StateContext);
}
export function usePlexActions(): PlexActions {
return useContext(ActionsContext);
}
export function PlexProvider({ children }: { children: ReactNode }) {
// The former per-library picker is now a cross-library SCOPE: movie | show | both (unified library).
// (Reuses the old LS.plexLibrary key; an old stored library-id value falls back to "both".)
const [scope, setScopeState] = useState<string>(() => {
const v = getAccountRaw(LS.plexLibrary) ?? "both";
return ["movie", "show", "both"].includes(v) ? v : "both";
});
const [show, setShowState] = useState<string>(() => getAccountRaw(LS.plexShow) ?? "all");
const [sort, setSortState] = useState<string>(() => getAccountRaw(LS.plexSort) ?? "title");
// The expanded Plex filters live as one JSON blob (the string-only account store serializes it).
const [filtersRaw, setFiltersRaw] = useState<string>(() => getAccountRaw(LS.plexFilters) ?? "{}");
const filters = useMemo<PlexFilters>(() => {
try {
return { ...EMPTY_PLEX_FILTERS, ...JSON.parse(filtersRaw) };
} catch {
return EMPTY_PLEX_FILTERS;
}
}, [filtersRaw]);
// Survives F5 (persisted), unlike the feed search — see the scope note above.
const [q, setQState] = useState<string>(() => getAccountRaw(LS.plexQ) ?? "");
const [playlistOpen, setPlaylistOpen] = useState<number | null>(null);
const setScope = useCallback((v: string) => {
setScopeState(v);
setAccountRaw(LS.plexLibrary, v);
}, []);
const setShow = useCallback((v: string) => {
setShowState(v);
setAccountRaw(LS.plexShow, v);
}, []);
const setSort = useCallback((v: string) => {
setSortState(v);
setAccountRaw(LS.plexSort, v);
}, []);
const setFilters = useCallback((f: PlexFilters) => {
const s = JSON.stringify(f);
setFiltersRaw(s);
setAccountRaw(LS.plexFilters, s);
}, []);
const setQ = useCallback((v: string) => {
setQState(v);
setAccountRaw(LS.plexQ, v);
}, []);
const state = useMemo<PlexState>(
() => ({ scope, show, sort, filters, q, playlistOpen }),
[scope, show, sort, filters, q, playlistOpen],
);
const actions = useMemo<PlexActions>(
() => ({ setScope, setShow, setSort, setFilters, setQ, setPlaylistOpen }),
[setScope, setShow, setSort, setFilters, setQ],
);
return (
<ActionsContext.Provider value={actions}>
<StateContext.Provider value={state}>{children}</StateContext.Provider>
</ActionsContext.Provider>
);
}
+62 -34
View File
@@ -5,8 +5,10 @@ import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react";
import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { useScrollFade } from "../lib/useScrollFade";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import { usePlex, usePlexActions } from "./PlexProvider";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -14,15 +16,6 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// the backend so the panel only offers what the library actually contains.
type Props = {
scope: string; // movie | show | both (unified cross-library scope)
setScope: (v: string) => void;
show: string;
setShow: (v: string) => void;
sort: string;
setSort: (v: string) => void;
filters: PlexFilters;
setFilters: (f: PlexFilters) => void;
onOpenPlaylist: (id: number) => void;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
@@ -40,22 +33,56 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
{ key: "long", min: 120 * 60, max: null },
];
// The any/all combine toggle for a multi-value filter (genres, actors). Same segmented-pill look as
// the feed rail's tag MATCH toggle (rounded-full track + inset active pill), not a boxed button row.
function MatchToggle({
mode,
onChange,
}: {
mode: "any" | "all";
onChange: (m: "any" | "all") => void;
}) {
const { t } = useTranslation();
return (
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-wide text-muted">
{t("plex.filter.matchLabel")}
</span>
<div
className="flex items-center rounded-full border border-border bg-card p-0.5 text-[10px]"
role="group"
aria-label={t("plex.filter.matchLabel")}
>
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => onChange(m)}
aria-pressed={mode === m}
className={`rounded-full px-2 py-0.5 transition ${
mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
</div>
);
}
export default function PlexSidebar({
scope,
setScope,
show,
setShow,
sort,
setSort,
filters,
setFilters,
onOpenPlaylist,
layout,
setLayout,
collapsed,
onToggleCollapse,
}: Props) {
const { t } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
// the header search box); this rail is the primary editor of them. Opening a playlist is a
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, sort, filters } = usePlex();
const { setScope, setShow, setSort, setFilters, setPlaylistOpen: onOpenPlaylist } = usePlexActions();
const collFade = useScrollFade();
const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() });
const [newPlaylist, setNewPlaylist] = useState("");
const [editing, setEditing] = useState(false);
@@ -221,6 +248,14 @@ export default function PlexSidebar({
onRemove={() => patch({ directors: filters.directors.filter((x) => x !== d) })}
/>
))}
{filters.actors.length > 1 && (
<div className="w-full">
<MatchToggle
mode={filters.actorMode ?? "any"}
onChange={(m) => patch({ actorMode: m })}
/>
</div>
)}
{filters.actors.map((a) => (
<RemovableChip
key={`a:${a}`}
@@ -258,7 +293,11 @@ export default function PlexSidebar({
placeholder={t("plex.filter.collectionSearch")}
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
/>
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
<div
ref={collFade.ref}
style={collFade.style}
className="flex max-h-56 flex-col gap-0.5 overflow-y-auto no-scrollbar"
>
{collections.map((c) => (
<button
key={c.id}
@@ -301,21 +340,10 @@ export default function PlexSidebar({
body: (
<>
{filters.genres.length > 1 && (
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
{(["any", "all"] as const).map((m) => (
<button
key={m}
onClick={() => patch({ genreMode: m })}
className={`px-2 py-0.5 transition ${
(filters.genreMode ?? "any") === m
? "bg-accent text-accent-fg"
: "text-muted hover:bg-surface"
}`}
>
{t(`plex.filter.match.${m}`)}
</button>
))}
</div>
<MatchToggle
mode={filters.genreMode ?? "any"}
onChange={(m) => patch({ genreMode: m })}
/>
)}
<ChipRow>
{facets.genres.map((g) => (
+171
View File
@@ -0,0 +1,171 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import {
applyTheme,
DEFAULT_THEME,
loadLocalTheme,
saveLocalTheme,
type ThemePrefs,
} from "../lib/theme";
import { configureNotifications, getNotifSettings, type NotifSettings } from "../lib/notifications";
import { hintsEnabled, setHintsEnabled } from "../lib/hints";
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import type { PrefsController } from "./SettingsPanel";
const PERF_KEY = LS.perfMode;
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
// component) plus the last-saved "baseline" used to suppress the echo when a change came from adopting
// server prefs on login. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
type EditablePrefs = {
theme: ThemePrefs;
performanceMode: boolean;
hints: boolean;
notifications: NotifSettings;
};
const PrefsContext = createContext<PrefsController>({
theme: DEFAULT_THEME,
setTheme: () => {},
perf: false,
setPerf: () => {},
hints: true,
setHints: () => {},
notif: getNotifSettings(),
setNotif: () => {},
saveState: "idle",
});
export function usePrefs(): PrefsController {
return useContext(PrefsContext);
}
export function PrefsProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
const [hints, setHints] = useState(() => hintsEnabled());
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
// The last server-known prefs. Written on adopt + after each save so the auto-save effect can
// compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI).
const baselineRef = useRef<EditablePrefs>({
theme: loadLocalTheme(),
performanceMode: getAccountRaw(PERF_KEY) === "1",
hints: hintsEnabled(),
notifications: getNotifSettings(),
});
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(undefined);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
// stores too); persistence to the server is the auto-save effect below.
useEffect(() => applyTheme(theme), [theme]);
useEffect(() => {
document.documentElement.dataset.perf = perf ? "1" : "";
setAccountRaw(PERF_KEY, perf ? "1" : "0");
}, [perf]);
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
// theme-appropriate image on <body> and switches the glass to translucent.
useEffect(() => {
const on = theme.bgImage && !perf;
document.documentElement.dataset.backdrop = on ? "on" : "off";
}, [theme.bgImage, perf]);
useEffect(() => setHintsEnabled(hints), [hints]);
useEffect(() => configureNotifications(notif), [notif]);
// Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the
// effects above; this is the only persistence path. `baselineRef` suppresses the echo when the
// change came from adopting server prefs on login (`/me` merges, so a full payload is fine).
const editablePrefs = useMemo<EditablePrefs>(
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
[theme, perf, hints, notif],
);
useEffect(() => {
window.clearTimeout(saveTimer.current);
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
// save; clear any "Saving…" left from a change that was reverted before it flushed.
setSaveState((s) => (s === "saving" ? "idle" : s));
return;
}
setSaveState("saving");
window.clearTimeout(saveMsgTimer.current);
const payload = editablePrefs;
saveTimer.current = window.setTimeout(() => {
api
.savePrefs(payload)
.then(() => {
baselineRef.current = payload;
setSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
setSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 4000);
});
}, 500);
return () => window.clearTimeout(saveTimer.current);
}, [editablePrefs]);
// Clear pending timers on unmount so a debounced PUT or a fade timer never fires setState on a dead
// component.
useEffect(
() => () => {
window.clearTimeout(saveTimer.current);
window.clearTimeout(saveMsgTimer.current);
},
[],
);
// On login / account switch, adopt the server-stored prefs into the live states AND the auto-save
// baseline (so adopting them doesn't echo a save back).
useEffect(() => {
const prefs = me?.preferences;
if (!prefs) return;
const adopted: EditablePrefs = {
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
performanceMode:
typeof prefs.performanceMode === "boolean"
? prefs.performanceMode
: getAccountRaw(PERF_KEY) === "1",
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
notifications: prefs.notifications
? { ...getNotifSettings(), ...prefs.notifications }
: getNotifSettings(),
};
setThemeState(adopted.theme);
saveLocalTheme(adopted.theme);
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
baselineRef.current = adopted;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [me?.id]);
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
const setTheme = useCallback((next: ThemePrefs) => {
setThemeState(next);
saveLocalTheme(next);
}, []);
const value = useMemo<PrefsController>(
() => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }),
[theme, setTheme, perf, hints, notif, saveState],
);
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
}
+43 -47
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage";
@@ -9,28 +9,26 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
import { useWizard } from "./WizardProvider";
import { usePrefs } from "./PrefsProvider";
import { useMe } from "../lib/useMe";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
// The Settings page edits server-persisted preferences. Each change applies locally for instant
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
// outcome. App owns the state + the save; this controller is
// how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = SaveState;
export interface PrefsController {
theme: ThemePrefs;
setTheme: (t: ThemePrefs) => void;
view: "grid" | "list";
setView: (v: "grid" | "list") => void;
perf: boolean;
setPerf: (v: boolean) => void;
hints: boolean;
setHints: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
dirty: boolean;
save: () => void;
discard: () => void;
saveState: PrefsSaveState;
}
@@ -44,16 +42,13 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [
// Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps
// the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is
// shown by the Header; the tab rail stays as in-page sub-navigation.
export default function SettingsPanel({
me,
prefs,
onOpenWizard,
}: {
me: Me;
prefs: PrefsController;
onOpenWizard: () => void;
}) {
export default function SettingsPanel() {
const me = useMe();
const { t } = useTranslation();
// The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider.
const prefs = usePrefs();
// "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button).
const { openWizard } = useWizard();
const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance");
// Clamp at render so a stale/removed tab id falls back to Appearance.
const tab: TabId = TABS.some((tabItem) => tabItem.id === tabRaw)
@@ -71,6 +66,7 @@ export default function SettingsPanel({
return (
<button
key={tabItem.id}
data-testid={`settings-tab-${tabItem.id}`}
onClick={() => setTab(tabItem.id)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
active
@@ -91,36 +87,43 @@ export default function SettingsPanel({
<div className="flex-1 min-w-0 p-4">
{tab === "appearance" && <Appearance prefs={prefs} />}
{tab === "notifications" && <Notifications prefs={prefs} />}
{tab === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
{tab === "account" && <Account me={me} onOpenWizard={openWizard} />}
</div>
</div>
{/* Save/Discard bar spans the whole card (a change can come from any tab). Only the
Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */}
<PrefsSaveBar prefs={prefs} />
{/* Auto-save indicator a change on any tab persists immediately; this just flashes the
outcome (the Account tab's actions have their own feedback). */}
<SaveIndicator state={prefs.saveState} />
</div>
</div>
);
}
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
function SaveIndicator({ state }: { state: PrefsSaveState }) {
const { t } = useTranslation();
if (state === "idle") return null;
return (
<DraftSaveBar
variant="inline"
dirty={prefs.dirty}
state={prefs.saveState}
onSave={prefs.save}
onDiscard={prefs.discard}
labels={{
saved: t("settings.save.saved"),
failed: t("settings.save.failed"),
unsaved: t("settings.save.unsaved"),
discard: t("settings.save.discard"),
save: t("settings.save.save"),
saving: t("settings.save.saving"),
}}
/>
<div
data-testid="settings-save-indicator"
data-state={state}
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
>
{state === "saving" ? (
<span className="inline-flex items-center gap-1.5 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
{t("settings.save.saving")}
</span>
) : state === "saved" ? (
<span className="inline-flex items-center gap-1.5 text-emerald-500">
<Check className="w-4 h-4" />
{t("settings.save.saved")}
</span>
) : (
<span className="text-red-500">{t("settings.save.failed")}</span>
)}
</div>
);
}
@@ -128,7 +131,7 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
const { t } = useTranslation();
// Controlled by App's prefs draft: each change applies locally for preview but is only
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs;
const { theme, setTheme, perf, setPerf, hints, setHints } = prefs;
return (
<>
@@ -186,13 +189,6 @@ function Appearance({ prefs }: { prefs: PrefsController }) {
/>
</div>
)}
<SettingRow label={t("settings.appearance.listView")} hint={t("settings.appearance.listViewHint")}>
<Switch
label={t("settings.appearance.listView")}
checked={view === "list"}
onChange={(v) => setView(v ? "list" : "grid")}
/>
</SettingRow>
<SettingRow
label={t("settings.appearance.performanceMode")}
hint={t("settings.appearance.performanceModeHint")}
+1 -1
View File
@@ -56,7 +56,7 @@ function UserShare({ job }: { job: DownloadJob }) {
className={inputCls}
/>
{open && filtered.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
<div className="absolute z-base mt-1 w-full rounded-lg glass-menu shadow-xl overflow-hidden">
{filtered.map((u) => (
<button
key={u.id}
+2 -2
View File
@@ -60,7 +60,7 @@ export default function SidePanel({
<button
onClick={onToggleCollapse}
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
className={`glass glass-hover absolute top-3 left-0 z-[34] flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
className={`glass glass-hover absolute top-3 left-0 z-paneltab flex flex-col items-center gap-2 px-1.5 py-3 rounded-l-none rounded-r-xl transition-opacity ${
expanded ? "opacity-0" : ""
} ${count > 0 ? "ring-1 ring-accent/40" : ""}`}
>
@@ -79,7 +79,7 @@ export default function SidePanel({
</button>
{expanded && (
<aside className="glass absolute top-3 bottom-3 left-0 z-[35] w-64 rounded-2xl flex flex-col overflow-hidden">
<aside className="glass absolute top-3 bottom-3 left-0 z-panel w-64 rounded-2xl flex flex-col overflow-hidden">
{/* Row 1 identity only: pin, icon, title (flexes + truncates last), count, customize.
Module actions live on their own row below so the title always has room. */}
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
+10 -8
View File
@@ -13,6 +13,9 @@ import SavedViewsWidget from "./SavedViewsWidget";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
import ChannelPicker from "./ChannelPicker";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [
@@ -37,6 +40,8 @@ function TagChip({
const { t } = useTranslation();
return (
<button
data-testid="filter-tag-chip"
data-tag={tag.name}
onClick={onClick}
title={t("sidebar.channelCount", { count })}
className={`inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
@@ -58,25 +63,22 @@ function TagChip({
}
export default function Sidebar({
filters,
setFilters,
layout,
setLayout,
onFocusChannel,
isDemo = false,
collapsed,
onToggleCollapse,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
onFocusChannel: (name: string) => void;
isDemo?: boolean;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t } = useTranslation();
const isDemo = useMe().is_demo;
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
const { focusChannel: onFocusChannel } = useChannelsActions();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? [];
+3 -1
View File
@@ -8,6 +8,7 @@ import { notify } from "../lib/notifications";
import { LS, useAccountPersistedState } from "../lib/storage";
import Tooltip from "./Tooltip";
import { Section, SettingRow } from "./ui/form";
import { useMe } from "../lib/useMe";
const RANGES = [7, 30, 90] as const;
type StatsTab = "overview" | "system";
@@ -15,7 +16,8 @@ type StatsTab = "overview" | "system";
// Usage & stats page. "Overview" is per-user (sync status + your own API usage + actions);
// "System" is the admin-only instance-wide quota dashboard + background-sync control. The
// per-user content used to live in Settings → Sync; it moved here so Settings stays config-only.
export default function Stats({ me }: { me: Me }) {
export default function Stats() {
const me = useMe();
const { t } = useTranslation();
const isAdmin = me.role === "admin";
const [tabRaw, setTab] = useAccountPersistedState(LS.statsTab, "overview");
+1 -1
View File
@@ -134,7 +134,7 @@ export default function SyncStatus({
<div
ref={popRef}
style={{ position: "fixed", top: pos.top, right: pos.right }}
className="glass-menu w-64 rounded-xl p-3 z-50 text-xs text-muted animate-[popIn_0.16s_ease]"
className="glass-menu w-64 rounded-xl p-3 z-overlay text-xs text-muted animate-[popIn_0.16s_ease]"
>
<div
title={t("header.sync.countTooltip")}
+11 -3
View File
@@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import { useScrollFade } from "../lib/useScrollFade";
import Modal from "./Modal";
import { useConfirm } from "./ConfirmProvider";
@@ -28,6 +29,7 @@ function TagRow({
const [name, setName] = useState(tag.name);
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ left: 0, top: 0 });
const popFade = useScrollFade();
const countRef = useRef<HTMLSpanElement | null>(null);
const closeTimer = useRef<number | undefined>(undefined);
const commit = () => {
@@ -77,10 +79,11 @@ function TagRow({
{open &&
createPortal(
<div
ref={popFade.ref}
onMouseEnter={openPop}
onMouseLeave={closeSoon}
style={{ position: "fixed", left: pos.left, top: pos.top, zIndex: 60 }}
className="glass-menu w-56 max-h-72 overflow-y-auto rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
style={{ position: "fixed", left: pos.left, top: pos.top, ...popFade.style }}
className="z-popover glass-menu w-56 max-h-72 overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<div className="text-[11px] uppercase tracking-wide text-muted px-2 py-1">
{t("tagManager.onChannels")}
@@ -111,6 +114,7 @@ export default function TagManager({
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const listFade = useScrollFade();
const [newName, setNewName] = useState("");
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels });
@@ -157,7 +161,11 @@ export default function TagManager({
<p className="text-sm text-muted">{t("tagManager.empty")}</p>
) : (
// Cap at ~8 rows tall, then scroll — the list can grow long.
<div className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto pr-1">
<div
ref={listFade.ref}
style={listFade.style}
className="flex flex-col gap-2 max-h-[22rem] overflow-y-auto no-scrollbar pr-1"
>
{userTags.map((tag) => (
<TagRow
key={tag.id}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function Toaster() {
const toasts = useSyncExternalStore(subscribe, getActiveToasts, getActiveToasts);
return (
<div className="absolute bottom-4 left-4 z-50 flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
<div className="absolute bottom-4 left-4 z-overlay flex flex-col gap-2 w-80 max-w-[calc(100vw-2rem)]">
{toasts.map((toast) => {
const { icon: Icon, color, bar } = LEVEL_STYLE[toast.level];
return (
+1 -1
View File
@@ -83,7 +83,7 @@ export default function Tooltip({
top: coords.top,
transform: `translateX(-50%) translateY(${coords.placement === "top" ? "-100%" : "0"})`,
}}
className="glass pointer-events-none z-[100] w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
className="glass pointer-events-none z-tooltip w-max max-w-[240px] px-2.5 py-1.5 rounded-lg text-xs leading-snug text-fg font-normal normal-case tracking-normal animate-[fadeIn_0.12s_ease]"
>
{hint}
</div>,
+352 -80
View File
@@ -1,19 +1,25 @@
import { memo, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigationActions } from "./NavigationProvider";
import {
Bookmark,
Check,
CheckCheck,
Clock,
Eye,
EyeOff,
Play,
Radio,
RotateCcw,
// Aliased: `Video` is this app's own domain type (lib/api), and it wins the name.
Video as VideoIcon,
} from "lucide-react";
import Avatar from "./Avatar";
import AddToPlaylist from "./AddToPlaylist";
import DownloadButton from "./DownloadButton";
import clsx from "clsx";
import type { Video } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import { formatDate, formatDuration, formatViews, relativeTime } from "../lib/format";
function Actions({
@@ -21,11 +27,14 @@ function Actions({
onState,
onResetState,
onToggleSave,
dense = false,
}: {
video: Video;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
/** Tighter buttons for the small card, whose column bottoms out at 180px. */
dense?: boolean;
}) {
const { t } = useTranslation();
const act = (status: string) => (e: React.MouseEvent) => {
@@ -41,15 +50,24 @@ function Actions({
e.stopPropagation();
onToggleSave(video.id, !video.saved);
};
// One button style for the row — AddToPlaylist and DownloadButton take it as a prop, so all six
// stay the same size instead of two of them keeping their own default.
// `dense` shrinks 28px buttons to 24 and halves the gap: six of them need 188px, but the small
// card's text column bottoms out around 160 (its 180px column less the padding), so at its
// narrowest the last button used to hang outside the card. 154 at dense.
const btn = clsx(
"rounded-md hover:bg-surface text-muted hover:text-fg",
dense ? "p-1" : "p-1.5"
);
return (
<div className="flex gap-1 opacity-0 group-hover:opacity-100 transition">
// flex-wrap is the safety net, not the plan: the sizing above is what makes them fit. If a
// future button or a narrower column breaks that arithmetic, the row wraps to a second line
// rather than silently rendering a half-clipped control outside the card.
<div className={clsx("flex flex-wrap opacity-0 group-hover:opacity-100 transition", dense ? "gap-0.5" : "gap-1")}>
<button
onClick={act("watched")}
title={video.status === "watched" ? t("card.watchedUnmark") : t("card.markWatched")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "watched" && "text-accent"
)}
className={clsx(btn, video.status === "watched" && "text-accent")}
>
{video.status === "watched" ? (
<CheckCheck className="w-4 h-4" />
@@ -60,22 +78,16 @@ function Actions({
<button
onClick={toggleSave}
title={video.saved ? t("card.savedRemove") : t("card.saveForLater")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.saved && "fill-current text-accent"
)}
className={clsx(btn, video.saved && "fill-current text-accent")}
>
<Bookmark className="w-4 h-4" />
</button>
<AddToPlaylist videoId={video.id} />
<DownloadButton videoId={video.id} title={video.title} />
<AddToPlaylist videoId={video.id} className={btn} />
<DownloadButton videoId={video.id} title={video.title} className={btn} />
<button
onClick={act("hidden")}
title={video.status === "hidden" ? t("card.unhide") : t("card.hide")}
className={clsx(
"p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg",
video.status === "hidden" && "text-accent"
)}
className={clsx(btn, video.status === "hidden" && "text-accent")}
>
{video.status === "hidden" ? (
<Eye className="w-4 h-4" />
@@ -91,7 +103,7 @@ function Actions({
onResetState(video.id);
}}
title={t("card.resetState")}
className="p-1.5 rounded-md hover:bg-surface text-muted hover:text-fg"
className={btn}
>
<RotateCcw className="w-4 h-4" />
</button>
@@ -112,23 +124,95 @@ function openInApp(
onOpen(video);
}
// A non-zero saved position on an unfinished video = "in progress": offer Continue / Restart
// instead of a plain Play. Note this is TRUE even with no known duration — in which case there is
// no percentage to draw, so the two are deliberately separate predicates.
function isInProgress(video: Video): boolean {
return video.position_seconds > 0 && video.status !== "watched";
}
/** Resume position as 0-100, or 0 when there's nothing to draw. Shared by Thumb and the
* thumbnail-less row, which has no image to draw the bar on but still owes you the state. */
function resumePct(video: Video): number {
return isInProgress(video) && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
}
// Thumb overlays duration / live state / saved ON the image. The one-line row has no image, so it
// renders the same FACTS as columns of its own — deliberately not shared with Thumb's badges:
// floating chips over artwork and a table cell are different presentations, and forcing one
// component to do both would be a variant-flag knot for no reuse.
// The cost is real though: the rules below (duration, live/upcoming, was_live, saved) are ALSO in
// Thumb, so a change to them must land in both. If they ever do change, share the decision — a
// videoBadges(video) -> Badge[] — and keep only the markup separate.
//
// A fixed GRID, not a flex row: each datum has to hold its own x across rows or the column stops
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
// beside them to different places, and pulled it left again whenever the duration was absent
// (live/upcoming) — visible as a ragged edge down the page.
function RowMetaCells({ video }: { video: Video }) {
const { t } = useTranslation();
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
// in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
// because the three states must stay apart by SHAPE — in the youtube scheme --accent is red, so a
// "live" dot and a "stream" dot would be the same dot.
const status =
video.live_status === "live"
? { Icon: Radio, cls: "text-red-500", label: t("card.live") }
: video.live_status === "upcoming"
? { Icon: Clock, cls: "", label: t("card.upcoming") }
: video.live_status === "was_live"
? { Icon: VideoIcon, cls: "text-accent", label: t("card.stream") }
: null;
const StatusIcon = status?.Icon;
// 4rem + 1rem + 1rem + two 4px gaps = 104px, which the one-line row's width tally counts on —
// widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
// the text-size setting the same way its contents do.
return (
<div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
"1:52" up to 59px for an 11-hour stream 4rem holds the longest). */}
<span className="text-right tabular-nums">
{video.duration_seconds != null ? formatDuration(video.duration_seconds) : ""}
</span>
{/* role="img" + aria-label names each marker ONCE and reliably; `title` alone wouldn't (a
bare span has no role, and screen readers don't dependably surface title on one), while a
title plus an sr-only twin gets it announced twice. These are indicators, NOT the controls
in Actions the label must not tell anyone to click something that doesn't respond. */}
<span>
{StatusIcon && (
<span role="img" aria-label={status.label} title={status.label}>
<StatusIcon className={clsx("w-3.5 h-3.5", status.cls)} />
</span>
)}
</span>
<span>
{video.saved && (
<span role="img" aria-label={t("card.savedMark")} title={t("card.savedMark")}>
<Bookmark className="w-3.5 h-3.5 text-accent fill-current" />
</span>
)}
</span>
</div>
);
}
function Thumb({
video,
className,
onOpen,
small = false,
}: {
video: Video;
className?: string;
onOpen?: (v: Video, startAt?: number | null) => void;
/** A row/tile thumbnail (~160-176px): the hover controls go icon-only, since the labelled
* Continue / Restart buttons are sized for the card's full-width image and get clipped here. */
small?: boolean;
}) {
const { t } = useTranslation();
// A non-zero saved position on an unfinished video = "in progress": show a resume
// progress bar and offer Continue / Restart instead of a plain Play.
const inProgress = video.position_seconds > 0 && video.status !== "watched";
const pct =
inProgress && video.duration_seconds
? Math.min(99, Math.max(2, (video.position_seconds / video.duration_seconds) * 100))
: 0;
const inProgress = isInProgress(video);
const pct = resumePct(video);
// Open in the in-app player at an explicit position (null = resume from saved).
const open = (startAt: number | null) => (e: React.MouseEvent) => {
@@ -192,27 +276,39 @@ function Thumb({
<button
onClick={open(null)}
title={t("card.continueTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition"
aria-label={t("card.continue")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-semibold bg-accent text-accent-fg shadow-lg hover:opacity-90 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
)}
>
<Play className="w-4 h-4 fill-current" />
{t("card.continue")}
{!small && t("card.continue")}
</button>
<button
onClick={open(0)}
title={t("card.restartTitle")}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition"
aria-label={t("card.restart")}
className={clsx(
"inline-flex items-center justify-center gap-1.5 font-medium bg-white/15 text-white backdrop-blur-sm hover:bg-white/25 transition",
small ? "w-9 h-9 rounded-full" : "px-3 py-1.5 rounded-lg text-sm"
)}
>
<RotateCcw className="w-4 h-4" />
{t("card.restart")}
{!small && t("card.restart")}
</button>
</>
) : (
<button
onClick={open(null)}
title={t("card.play")}
className="inline-flex items-center justify-center w-12 h-12 rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition"
aria-label={t("card.play")}
className={clsx(
"inline-flex items-center justify-center rounded-full bg-accent text-accent-fg shadow-lg hover:scale-105 transition",
small ? "w-9 h-9" : "w-12 h-12"
)}
>
<Play className="w-5 h-5 fill-current translate-x-[1px]" />
<Play className={clsx("fill-current translate-x-[1px]", small ? "w-4 h-4" : "w-5 h-5")} />
</button>
)}
</div>
@@ -227,51 +323,112 @@ function Thumb({
);
}
// The one-line row's column widths at the DEFAULT text size, mirroring the Tailwind classes the
// boxes actually use. They have to be mirrored by hand — Tailwind can't take a runtime value, and
// the classes have to stay, because they're rem: Settings' text-size slider drives the root font
// (`html { font-size: calc(16px * var(--font-scale)) }`, 0.91.3), so the boxes must grow with
// their contents. Inline pixels froze them and a channel name at 130% wanted 152px in a 128px box.
// Keep this table and the classes in step; each number is measured against the worst case in the
// LIBRARY and in BOTH languages — see the row's own comment for where each comes from.
const ROW_COL = {
actions: 192, // w-48 — six buttons at 28 + five 4px gaps = 188
channel: 128, // w-32
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
views: 64, // w-16
published: 192, // w-48
} as const;
const ROW_GAP = 12; // gap-3
const ROW_PAD = 26; // px-3 both sides, plus a rounding allowance
const ROW_TITLE_MIN = 120; // not a column: the floor below which a title isn't worth showing
// What the row must measure before each column earns its place — cumulative, so each appears only
// once everything to its left AND a readable title already fit. Calibrated at the default text
// size; at 130% the columns grow ~30% while these don't, so they read slightly eager — the title
// gets tighter there, but it can't vanish, since the last column standing down frees ~200px.
const ROW_BASE = ROW_PAD + ROW_COL.actions + ROW_GAP + ROW_TITLE_MIN;
const ROW_FITS = {
channel: ROW_BASE + ROW_GAP + ROW_COL.channel,
meta: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta,
views: ROW_BASE + ROW_GAP + ROW_COL.channel + ROW_GAP + ROW_COL.meta + ROW_GAP + ROW_COL.views,
published:
ROW_BASE +
ROW_GAP +
ROW_COL.channel +
ROW_GAP +
ROW_COL.meta +
ROW_GAP +
ROW_COL.views +
ROW_GAP +
ROW_COL.published,
};
function VideoCard({
video,
view,
width,
onState,
onResetState,
onToggleSave,
onOpenChannel,
onOpen,
}: {
video: Video;
view: "grid" | "list";
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpenChannel?: (channelId: string, channelName: string) => void;
onOpen?: (v: Video, startAt?: number | null) => void;
}) {
const { t, i18n } = useTranslation();
const { openChannel } = useNavigationActions();
const spec = FEED_VIEW_SPEC[view];
const watched = video.status === "watched";
const meta = (
// The thumbnail-less row drops Thumb, and with it the duration / live state / saved marker that
// Thumb overlays on the image. Those are metadata, not decoration, so fold them into the meta
// line here rather than let the mode silently lose them.
const noThumb = !spec.thumb;
// Each datum on its own, so the one-line row can give each a column of its own while the stacked
// layouts still run them together as one sentence. The count comes in two shapes from one span —
// one span so the exact-count tooltip covers all of whatever is rendered: prose needs the word
// ("1.5K views · 2 min ago"), the row's column doesn't, because a table doesn't repeat its unit
// on every line and the tooltip carries the figure regardless.
const viewsCell = (withWord: boolean) =>
video.view_count != null && (
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
{formatViews(video.view_count)}
{withWord && ` ${t("card.views")}`}
</span>
);
const published = (
<>
{video.view_count != null && (
<>
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
{formatViews(video.view_count)} {t("card.views")}
</span>{" "}
·{" "}
</>
)}
{relativeTime(video.published_at)}
{video.published_at && <>{" · "}{formatDate(video.published_at, i18n.language)}</>}
</>
);
// Show the full title in a native tooltip only when it's clamped (overflows its 2 lines).
// Show the full text in a native tooltip only when it's actually cut off — vertically where the
// title clamps to 2 lines, horizontally where a fixed column truncates it. Shared by the title
// and the channel name: both truncate in some views and fit in others, and a tooltip that just
// repeats what's already legible is noise.
const titleRef = useRef<HTMLAnchorElement>(null);
const channelRef = useRef<HTMLButtonElement>(null);
const [titleClamped, setTitleClamped] = useState(false);
const [channelClamped, setChannelClamped] = useState(false);
useEffect(() => {
const el = titleRef.current;
if (!el) return;
const check = () => setTitleClamped(el.scrollHeight > el.clientHeight + 1);
const cut = (el: HTMLElement) =>
el.scrollHeight > el.clientHeight + 1 || el.scrollWidth > el.clientWidth + 1;
const els: [HTMLElement | null, (v: boolean) => void][] = [
[titleRef.current, setTitleClamped],
[channelRef.current, setChannelClamped],
];
const check = () => els.forEach(([el, set]) => el && set(cut(el)));
check();
const ro = new ResizeObserver(check);
ro.observe(el);
els.forEach(([el]) => el && ro.observe(el));
return () => ro.disconnect();
}, [video.title]);
// `view` matters as much as the text: switching views returns a different branch, so React can
// remount these nodes — without it the observer would be left watching detached ones.
}, [video.title, video.channel_title, view]);
const title = (
<a
ref={titleRef}
@@ -280,61 +437,176 @@ function VideoCard({
rel="noreferrer"
onClick={(e) => openInApp(e, video, onOpen)}
title={titleClamped ? video.title ?? undefined : undefined}
className="font-medium leading-snug line-clamp-2 hover:text-accent"
className={clsx(
"font-medium leading-snug hover:text-accent",
noThumb ? "block truncate" : "line-clamp-2"
)}
>
{video.title}
</a>
);
// Title + channel button + meta — identical in both layouts (only their wrapper / Actions placement
// differs). Safe to reuse the same element: exactly one of the two branches below renders.
const channelButton = (
<button
ref={channelRef}
data-testid="video-card-channel"
onClick={(e) => {
e.stopPropagation();
openChannel(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
title={channelClamped ? video.channel_title ?? undefined : undefined}
className="text-sm text-muted truncate block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
);
const actions = (
<Actions
video={video}
onState={onState}
onResetState={onResetState}
onToggleSave={onToggleSave}
dense={spec.dense}
/>
);
// A permanent container, not a hover-only fill. Rows used to be fully transparent at rest and
// paint an opaque bg-card on hover — so running the pointer down the list kept covering and
// re-revealing the ambient backdrop behind them, which reads as the background flickering. The
// cards never did this because glass-card always paints. No hover lift here: a 900px-wide row
// jumping as the pointer crosses it is a lot more restless than a card doing it.
const rowShell = "cv-row group glass-card glass-hover relative rounded-xl transition";
if (spec.family === "row") {
// A table without being one. Every column is a FIXED width, which is what makes the same datum
// land on the same vertical line in every row. A grid can't align ACROSS rows here — VirtualFeed
// renders each as its own subtree, so there's no shared parent to be the grid — but a grid is
// still right WITHIN a cell (see RowMetaCells): fixed slots, so they line up across rows too.
// Widths are measured, not chosen by eye — against the worst case in the LIBRARY and in BOTH
// languages, since both have caught me out here:
// actions 188 (6 x 28 + 5 x 4) · meta cells 104 (see RowMetaCells) · views 48 (the bare
// "101.7K"; language-proof now the word is gone — with it, Hungarian's "megtekintés" needed
// 139) · published 183 ("11 months ago · Feb 28, 2026"; HU is shorter) · channel 128,
// which is a deliberate cut rather than a fit: names run to 342px, the median is 87, and 12
// of 102 truncate here vs 5 at 160 — worth 32px to the title, since a clipped channel name
// still reads while a clipped view count loses the number.
// Three ways this went wrong before: sizing off a PAGE of data (the badges came from 60 videos
// whose longest was 2:00:58, then wrapped on the 11-hour one); measuring with a hand-built probe
// instead of the real element (it read "101.7K megtekintés" as 122px where the live cell renders
// 139); and checking that the COLUMNS aligned without checking the data inside them (the meta
// cell was a flex row, so its contents slid about — see RowMetaCells).
// The title takes what's left, which is constant per container width — so it aligns too.
if (noThumb) {
const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return (
<div data-testid="video-card" data-video-id={video.id} className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
they're the shortest pointer trip from it. */}
<div className="shrink-0 w-48">{actions}</div>
{/* Everything else is shrink-0, so the title is the only thing that can give which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as the row narrows, least important first: a title with no date beats a
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
filter panel are doing. Until the first measure lands (width 0) show everything: the
row is off-screen for that frame anyway, and guessing narrow would flash. */}
<div className="min-w-0 flex-1">{title}</div>
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
holds the widest bare form ("101.7K" at 48) in either language dropping the word
took this from 144 and handed the difference to the title. */}
{fits(ROW_FITS.views) && (
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
{viewsCell(false)}
</div>
)}
{fits(ROW_FITS.published) && (
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
)}
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
<div className="h-full rounded-full bg-accent" style={{ width: `${pct}%` }} />
</div>
)}
</div>
);
}
}
// Everything below is a STACKED layout — the card and the thumbnailed row. Only they run the
// data together as one sentence; the one-line row above gives each datum its own column and has
// already returned, so building this for it would be waste dressed up as shared code.
const views = viewsCell(true);
const textBlock = (
<>
{title}
<button
onClick={(e) => {
e.stopPropagation();
onOpenChannel?.(video.channel_id, video.channel_title ?? t("card.thisChannel"));
}}
className="text-sm text-muted truncate mt-0.5 block w-fit max-w-full text-left hover:text-fg"
>
{video.channel_title}
</button>
<div className="text-xs text-muted mt-0.5">{meta}</div>
<div className="mt-0.5">{channelButton}</div>
<div className="text-xs text-muted mt-0.5">
{views}
{views && " · "}
{published}
</div>
</>
);
if (view === "list") {
if (spec.family === "row") {
return (
<div
className={clsx(
"cv-row group flex gap-3 p-2 rounded-xl hover:bg-card hover:shadow-lg transition",
watched && "opacity-55"
)}
>
<Thumb video={video} className="w-44 aspect-video shrink-0" onOpen={onOpen} />
<div className="min-w-0 flex-1">{textBlock}</div>
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
<div data-testid="video-card" data-video-id={video.id} className={clsx(rowShell, "flex gap-3 p-2", watched && "opacity-55")}>
<Thumb
video={video}
className={clsx("aspect-video shrink-0", spec.actionsBelow ? "w-40" : "w-44")}
onOpen={onOpen}
small
/>
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
{/* Tiles put the actions under the meta, in vertical space the thumbnail already
reserves which frees the ~168px the docked-right block used, so the block still
works in a narrow column. mt-auto pins them to the tile's bottom edge (the flex row
already stretches this column to full height), so they land in the same place
whether the title above them ran to one line or three. */}
{spec.actionsBelow && <div className="mt-auto pt-1">{actions}</div>}
</div>
{!spec.actionsBelow && actions}
</div>
);
}
// The flex column is what puts the actions on the card's bottom edge: the grid already stretches
// every card in a row to the tallest one (align-items: stretch), so the column just needs to push
// its last child down. Otherwise the action row floats under a 1-line title on one card and a
// line lower on its 2-line neighbour — the same control landing somewhere different on each.
// NOTE: do NOT add h-full here. A percentage height against the grid's content-derived height is
// circular, and VirtualFeed measures each row with a ResizeObserver — the two together oscillate
// by a pixel or two forever, which shows up as the whole page juddering.
return (
<div
data-testid="video-card"
data-video-id={video.id}
className={clsx(
"cv-card group glass-card glass-hover rounded-2xl p-2.5 transition-all duration-150 hover:-translate-y-1",
"cv-card group glass-card glass-hover rounded-2xl transition-all duration-150 hover:-translate-y-1",
"flex flex-col",
spec.dense ? "p-2" : "p-2.5",
watched && "opacity-55"
)}
>
<Thumb video={video} className="aspect-video" onOpen={onOpen} />
<div className="flex gap-3 mt-2.5 px-0.5 pb-0.5">
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
<div className="min-w-0 flex-1">
{/* The small card's image is in the SAME size class as a row's (its column bottoms out at
180px), so it needs the icon-only controls just as much the labelled pair is 194px wide
and would spill straight out of it. */}
<Thumb video={video} className="aspect-video" onOpen={onOpen} small={spec.dense} />
<div className={clsx("flex gap-3 px-0.5 pb-0.5 flex-1", spec.dense ? "mt-2" : "mt-2.5")}>
{/* The small card drops the channel avatar: at its ~180px column the 36px avatar plus the
gap would leave the title barely half the width. The channel name is still below it. */}
{!spec.dense && (
<Avatar
src={video.channel_thumbnail}
fallback={video.channel_title ?? ""}
className="w-9 h-9 rounded-full shrink-0 mt-0.5"
/>
)}
<div className="min-w-0 flex-1 flex flex-col">
{textBlock}
<Actions video={video} onState={onState} onResetState={onResetState} onToggleSave={onToggleSave} />
<div className="mt-auto pt-1">{actions}</div>
</div>
</div>
</div>
+3 -3
View File
@@ -405,7 +405,7 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
))}
{/* draggable boundary markers */}
{boundaries.map((b, bi) => (
<div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-10" style={{ left: pct(b) }}>
<div key={bi} className="absolute inset-y-0 -ml-1.5 w-3 flex items-center justify-center z-base" style={{ left: pct(b) }}>
<div
className="w-1 h-full bg-white/90 cursor-ew-resize rounded"
onPointerDown={(e) => {
@@ -426,13 +426,13 @@ export default function VideoEditor({ job, onClose }: { job: DownloadJob; onClos
</div>
))}
{/* playhead */}
<div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-20" style={{ left: pct(current) }} />
<div className="absolute inset-y-0 w-0.5 bg-white pointer-events-none z-menu" style={{ left: pct(current) }} />
</div>
{/* hover-scrub thumbnail */}
{strip && hoverTime != null && (
<div
className="absolute bottom-full mb-2 pointer-events-none z-30 rounded-md overflow-hidden border border-border shadow-xl bg-black"
className="absolute bottom-full mb-2 pointer-events-none z-chrome rounded-md overflow-hidden border border-border shadow-xl bg-black"
style={{ left: hoverLeft, width: HOVER_W }}
>
<div style={{ width: HOVER_W, height: HOVER_W / aspect, ...tileStyle(Math.floor(hoverTime / (strip.interval_s || 1))) }} />
+158
View File
@@ -0,0 +1,158 @@
import { useEffect, useRef, useState } from "react";
import { Check, ChevronDown, type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { useDismiss } from "../lib/useDismiss";
export interface ViewOption<T extends string> {
id: T;
label: string;
icon: LucideIcon;
}
// "How should this list look" — the current mode's icon in the toolbar, the named modes in a
// menu behind it. Generic over the mode id so any module's list can adopt it (the feed's
// density modes; the managers' layouts) without re-deriving the menu behaviour.
//
// This is a real menu, so it carries the whole keyboard model the role promises: arrows/Home/End
// move a roving focus, Escape closes, and closing always hands focus back to the trigger (the
// menu unmounts under the focused item, which would otherwise strand focus on <body>).
export default function ViewSwitcher<T extends string>({
value,
options,
onChange,
label,
labelClassName = "hidden",
}: {
value: T;
options: readonly ViewOption<T>[];
onChange: (v: T) => void;
label: string;
/** Where the current mode's NAME is visible beside the icon a Tailwind display class, e.g.
* `"hidden xl:inline"`. The caller sets it because only it knows how crowded its own toolbar is:
* the feed's is packed (show chips, content chips, source, count, sort) and can't spare the room
* until xl, while an emptier one could show it always. Defaults to icon-only. */
labelClassName?: string;
}) {
const [open, setOpen] = useState(false);
// Which item holds the roving tabindex; seeded to the active one when the menu opens.
const [focusIdx, setFocusIdx] = useState(0);
const btnRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
function close({ refocus = true }: { refocus?: boolean } = {}) {
setOpen(false);
if (refocus) btnRef.current?.focus();
}
// Outside-click must NOT refocus the trigger — that would steal focus from whatever the user
// just clicked. Escape is handled on the menu itself (below), where returning focus is right.
useDismiss(open, () => close({ refocus: false }), [btnRef, menuRef]);
// A `value` outside `options` is a caller bug; degrade to the first option rather than render
// nothing. The menu then shows no checked item, which is honest — nothing IS selected.
const currentIdx = Math.max(
0,
options.findIndex((o) => o.id === value)
);
// Seeding the roving index HERE — rather than in an effect keyed on `open` — matters: an effect
// would only schedule the update, so the focus effect below would still see the previous index
// and focus the wrong item for one render before correcting.
function openMenu() {
setFocusIdx(currentIdx);
setOpen(true);
}
// Clamped at render, so a caller whose option list shrinks while the menu is open can't leave
// the roving tabindex pointing past the end (which would make every item un-tabbable).
const rovingIdx = Math.min(focusIdx, options.length - 1);
useEffect(() => {
if (open) itemRefs.current[rovingIdx]?.focus();
}, [open, rovingIdx]);
const current = options[currentIdx];
if (!current) return null;
const CurrentIcon = current.icon;
function onMenuKeyDown(e: React.KeyboardEvent) {
const last = options.length - 1;
if (e.key === "Escape") {
// Stop it reaching useDismiss's document listener, which would close without refocusing.
e.stopPropagation();
close();
} else if (e.key === "ArrowDown") {
e.preventDefault();
setFocusIdx((i) => (i >= last ? 0 : i + 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setFocusIdx((i) => (i <= 0 ? last : i - 1));
} else if (e.key === "Home") {
e.preventDefault();
setFocusIdx(0);
} else if (e.key === "End") {
e.preventDefault();
setFocusIdx(last);
} else if (e.key === "Tab") {
// Tabbing out of a menu closes it, but the focus is moving on by itself.
close({ refocus: false });
}
}
return (
<div className="relative">
<button
ref={btnRef}
onClick={() => (open ? close() : openMenu())}
aria-haspopup="menu"
aria-expanded={open}
// The aria-label carries the mode's name at every size — the visible text below is a
// nicety for wide windows, not the accessible name.
aria-label={`${label}: ${current.label}`}
title={`${label}: ${current.label}`}
className="shrink-0 inline-flex items-center gap-1 pl-2 pr-1 py-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<CurrentIcon className="w-4 h-4 shrink-0" />
<span className={clsx("text-sm whitespace-nowrap", labelClassName)}>{current.label}</span>
<ChevronDown className={`w-3 h-3 shrink-0 transition-transform ${open ? "rotate-180" : ""}`} />
</button>
{open && (
<div
ref={menuRef}
role="menu"
aria-label={label}
onKeyDown={onMenuKeyDown}
className="glass-menu absolute right-0 top-full mt-1 z-chrome min-w-44 p-1.5 rounded-xl animate-[popIn_0.16s_ease]"
>
{/* The modes are one exclusive choice, so the radio items need a group to scope their
checked-ness to otherwise they read as independent checkable items. */}
<div role="group">
{options.map((o, i) => {
const Icon = o.icon;
const on = o.id === value;
return (
<button
key={o.id}
ref={(el) => {
itemRefs.current[i] = el;
}}
role="menuitemradio"
aria-checked={on}
tabIndex={i === rovingIdx ? 0 : -1}
onClick={() => {
onChange(o.id);
close();
}}
className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left transition ${
on ? "text-accent" : "text-fg hover:bg-card"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
<span className="flex-1 truncate">{o.label}</span>
{on && <Check className="w-3.5 h-3.5 shrink-0" />}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
+64 -42
View File
@@ -1,17 +1,35 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { Video } from "../lib/api";
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
import VideoCard from "./VideoCard";
// Grid column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter).
const GRID_MIN_COL = 260;
// Column sizing mirrors the CSS the feed used before virtualization
// (grid-cols-[repeat(auto-fill,minmax(260px,1fr))] with a gap-4 / 1rem gutter). The per-view
// minimum column width lives with the rest of the view matrix in lib/feedView.ts.
const GRID_GAP = 16;
const LIST_GAP = 4;
// Estimated row heights before measurement; real heights are measured per-row so
// these only affect the very first paint and the scrollbar's initial guess.
const GRID_ROW_EST = 340;
const LIST_ROW_EST = 96;
// Estimated row heights before measurement; real heights are measured per-row so these only affect
// the very first paint and the scrollbar's initial guess. Taken from measuring each mode in the
// browser rather than guessed — a bad estimate makes the scrollbar jump as you scroll in.
// RE-MEASURE these when a mode's layout changes: they have gone stale twice already, most recently
// when the bare row became a single line and halved (82 -> 46). The card figures are the roughest
// of the set by nature — a card's height follows its column width (measured ~307 at 3 columns,
// ~301 at 4, taller still at 2), so no single constant is right for every window.
const ROW_EST: Record<FeedView, number> = {
cards: 340,
cardsSmall: 257,
rows: 115,
rowsCompact: 46,
tiles: 132,
};
/** Columns that fit `width`, or 1 for the single-column views. */
function columnsFor(view: FeedView, width: number): number {
const { minCol } = FEED_VIEW_SPEC[view];
if (minCol == null) return 1;
return Math.max(1, Math.floor((width + GRID_GAP) / (minCol + GRID_GAP)));
}
// Start fetching the next page this many rows before the end scrolls into view
// (keeps the old IntersectionObserver's generous prefetch feel).
const PREFETCH_ROWS = 4;
@@ -37,10 +55,9 @@ function getScrollParent(node: HTMLElement | null): HTMLElement {
export interface VirtualFeedProps {
items: Video[];
view: "grid" | "list";
view: FeedView;
onState: (id: string, status: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
onOpenChannel: (channelId: string, channelName: string) => void;
onResetState: (id: string) => void;
onOpen: (v: Video, startAt?: number | null) => void;
hasNextPage: boolean;
@@ -53,7 +70,6 @@ export default function VirtualFeed({
view,
onState,
onToggleSave,
onOpenChannel,
onResetState,
onOpen,
hasNextPage,
@@ -62,10 +78,18 @@ export default function VirtualFeed({
}: VirtualFeedProps) {
const listRef = useRef<HTMLDivElement>(null);
const [scrollEl, setScrollEl] = useState<HTMLElement | null>(null);
const [colCount, setColCount] = useState(view === "list" ? 1 : 4);
const { minCol, maxCol } = FEED_VIEW_SPEC[view];
const singleCol = minCol == null;
const [colCount, setColCount] = useState(singleCol ? 1 : 4);
// Distance from the scroll element's content top to where this list starts (the
// feed toolbar sits above it inside the same scroll area).
const [scrollMargin, setScrollMargin] = useState(0);
// How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it
// has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter
// panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this
// wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of
// them, which squeezed the title to nothing. This is measured here anyway, for colCount.
const [itemWidth, setItemWidth] = useState(0);
useLayoutEffect(() => {
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
@@ -83,10 +107,12 @@ export default function VirtualFeed({
scrollEl.scrollTop;
setScrollMargin(m);
}
setColCount(
view === "list"
? 1
: Math.max(1, Math.floor((node.clientWidth + GRID_GAP) / (GRID_MIN_COL + GRID_GAP)))
const w = node.clientWidth;
const cols = columnsFor(view, w);
setColCount(cols);
const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view];
setItemWidth(
min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
);
};
measure();
@@ -101,9 +127,9 @@ export default function VirtualFeed({
const virtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => scrollEl,
estimateSize: () => (view === "grid" ? GRID_ROW_EST : LIST_ROW_EST),
estimateSize: () => ROW_EST[view],
overscan: 4,
gap: view === "grid" ? GRID_GAP : LIST_GAP,
gap: singleCol ? LIST_GAP : GRID_GAP,
scrollMargin,
});
@@ -137,37 +163,33 @@ export default function VirtualFeed({
transform: `translateY(${vr.start - virtualizer.options.scrollMargin}px)`,
}}
>
{view === "grid" ? (
<div
className="grid gap-4"
style={{ gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }}
>
{row.map((v) => (
<VideoCard
key={v.id}
video={v}
view="grid"
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
))}
</div>
) : (
<div className="max-w-4xl mx-auto pb-1">
{/* One branch for both shapes: the single-column views just chunk to colCount 1 and
get their reading-width cap instead of grid columns. */}
<div
className={singleCol ? "mx-auto pb-1" : "grid gap-4"}
style={
singleCol
? { maxWidth: maxCol ?? undefined }
: { gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))` }
}
>
{row.map((v) => (
<VideoCard
video={row[0]}
view="list"
key={v.id}
video={v}
view={view}
// Only the one-line row reads its own width (it stands columns down as it
// narrows). Everything else takes a stable 0, so a resize — which changes
// itemWidth every frame — doesn't blow past VideoCard's memo for views that
// wouldn't do anything with it.
width={FEED_VIEW_SPEC[view].thumb ? 0 : itemWidth}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}
onResetState={onResetState}
onOpen={onOpen}
/>
</div>
)}
))}
</div>
</div>
);
})}
+1 -1
View File
@@ -228,7 +228,7 @@ function Lightbox({ src, label, onClose }: { src: string; label: string; onClose
}, [onClose]);
return (
<div
className="fixed inset-0 z-[60] grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
className="fixed inset-0 z-popover grid place-items-center bg-black/80 backdrop-blur-sm p-4 sm:p-8"
onClick={onClose}
role="dialog"
aria-modal="true"
@@ -0,0 +1,66 @@
import {
createContext,
lazy,
Suspense,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import { useQuery } from "@tanstack/react-query";
import { api } from "../lib/api";
import { shouldAutoOpenOnboarding } from "../lib/onboarding";
const OnboardingWizard = lazy(() => import("./OnboardingWizard"));
// Owns the onboarding wizard's open state (and the wizard render itself), lifted out of App so any
// module can trigger "connect YouTube" via a hook instead of an onOpenWizard prop threaded through
// the tree. Mirrors the other split-context providers; composed in main.tsx around <App/>.
//
// The wizard auto-opens on first login for accounts that still need to connect YouTube (derived from
// granted scopes + storage flags, so it's stable across the OAuth round-trip) and is reopenable from
// Settings / the notify "reconnect" action. It reads `me` as a passive ["me"] observer (App owns the
// fetching query), so it re-runs the auto-open check when the account resolves or its grants change.
interface WizardActions {
openWizard: () => void;
closeWizard: () => void;
}
// Only the actions are shared — the open flag stays local to the provider (it renders the wizard
// itself), so no consumer needs to subscribe to it.
const ActionsContext = createContext<WizardActions>({ openWizard: () => {}, closeWizard: () => {} });
export function useWizard(): WizardActions {
return useContext(ActionsContext);
}
export function WizardProvider({ children }: { children: ReactNode }) {
const [open, setOpen] = useState(false);
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
const { data: me } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
const openWizard = useCallback(() => setOpen(true), []);
const closeWizard = useCallback(() => setOpen(false), []);
// First-login onboarding: prompt the user to connect YouTube (and resume the flow after each
// consent redirect). Re-checked when the account or its grants change.
useEffect(() => {
if (me && shouldAutoOpenOnboarding(me)) setOpen(true);
}, [me?.id, me?.can_read, me?.can_write]);
const actions = useMemo<WizardActions>(() => ({ openWizard, closeWizard }), [openWizard, closeWizard]);
return (
<ActionsContext.Provider value={actions}>
{children}
{/* The wizard is a full-screen overlay; the demo account can't connect YouTube, so it never
opens there. Lazy its chunk loads only when first opened. */}
<Suspense fallback={null}>
{open && me && !me.is_demo && <OnboardingWizard me={me} onClose={closeWizard} />}
</Suspense>
</ActionsContext.Provider>
);
}
+5 -2
View File
@@ -80,9 +80,12 @@ export function auditColumns(
render: (r) => {
const change = changeText(r);
return (
// break-words (wrap), NOT truncate: in the auto-layout table a `truncate` (nowrap) cell
// sizes to its longest row, so one long summary/change stretched the whole table past the
// card (the row rules bled into the page). Wrapping keeps every row inside the container.
<div className="min-w-0">
{r.summary && <div className="truncate">{r.summary}</div>}
{change && <div className="text-[11px] text-muted truncate">{change}</div>}
{r.summary && <div className="break-words">{r.summary}</div>}
{change && <div className="text-[11px] text-muted break-words">{change}</div>}
</div>
);
},
+121
View File
@@ -0,0 +1,121 @@
import { lazy, type ComponentType } from "react";
import type { Page } from "../lib/urlState";
import type { PanelId, PanelLayout } from "../lib/panelLayout";
import { useMe } from "../lib/useMe";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useFeedView, useFeedViewActions } from "./FeedViewProvider";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
// `page === X` chrome conditionals.
//
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
// wiring get a thin wrapper here (FeedPage supplies the GLOBAL feed scope; the channel page threads
// its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>.
const Feed = lazy(() => import("./Feed"));
const Channels = lazy(() => import("./Channels"));
const Playlists = lazy(() => import("./Playlists"));
const PlexBrowse = lazy(() => import("./PlexBrowse"));
const PlexSidebar = lazy(() => import("./PlexSidebar"));
const NotificationsPanel = lazy(() => import("./NotificationsPanel"));
const Messages = lazy(() => import("./Messages"));
const DownloadCenter = lazy(() => import("./DownloadCenter"));
const Stats = lazy(() => import("./Stats"));
const Scheduler = lazy(() => import("./Scheduler"));
const ConfigPanel = lazy(() => import("./ConfigPanel"));
const AdminUsers = lazy(() => import("./AdminUsers"));
const AuditLog = lazy(() => import("./AuditLog"));
const SettingsPanel = lazy(() => import("./SettingsPanel"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search. (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
function FeedPage() {
const me = useMe();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const view = useFeedView();
const { setView } = useFeedViewActions();
const { ytSearch } = useNavigation();
const { enterYtSearch, exitYtSearch } = useNavigationActions();
return (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
);
}
function MessagesPage() {
const me = useMe();
return (
<div className="p-4 max-w-3xl w-full mx-auto">
<Messages meId={me.id} />
</div>
);
}
export type PageDef = {
// Prop-free by construction: reads `me` + module state from hooks/providers.
Component: ComponentType;
// Channels + Audit render a table with a sticky header at the scroller top; the shell's top fade
// would sit on it and flip on with the first scroll, so they turn it off. Defaults to on.
fadeTop?: boolean;
};
// page id → the component App renders in the content column (+ its shell chrome config).
export const PAGE_CONTENT: Record<Page, PageDef> = {
feed: { Component: FeedPage },
channels: { Component: Channels, fadeTop: false },
playlists: { Component: Playlists },
plex: { Component: PlexBrowse },
notifications: { Component: NotificationsPanel },
messages: { Component: MessagesPage },
downloads: { Component: DownloadCenter },
stats: { Component: Stats },
scheduler: { Component: Scheduler },
config: { Component: ConfigPanel },
users: { Component: AdminUsers },
audit: { Component: AuditLog, fadeTop: false },
settings: { Component: SettingsPanel },
};
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
// its provider and `me` from useMe().
type RailProps = {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
};
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists has its own).
type RailCollapseKey = "filter" | "playlists";
export type RailDef = {
Rail: ComponentType<RailProps>;
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
panelId: PanelId;
collapse: RailCollapseKey;
};
// page id → its side rail (only the three pages that have one). App renders it in the rail slot.
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
};
+15 -7
View File
@@ -1,3 +1,4 @@
import { createPortal } from "react-dom";
import { Check, RotateCcw, Save } from "lucide-react";
// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
@@ -34,13 +35,17 @@ export function DraftSaveBar({
}) {
if (!dirty && state === "idle") return null;
const saving = state === "saving";
const wrap =
variant === "floating"
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
return (
<div className={wrap}>
<span className="text-sm text-muted">
const floating = variant === "floating";
const wrap = floating
? "fixed bottom-4 left-1/2 -translate-x-1/2 z-rail glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
: "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
// The floating variant is fixed to the viewport but renders from a page inside the page
// scroller, so it goes to <body> — otherwise the scroller's stacking context (its edge-fade
// mask) scopes this z-rail and fades the bar's bottom edge. The inline variant is in normal flow
// and stays put.
const bar = (
<div data-testid="draft-save-bar" className={wrap}>
<span data-testid="draft-save-status" data-state={state} className="text-sm text-muted">
{state === "saved" ? (
<span className="text-emerald-500 inline-flex items-center gap-1.5">
<Check className="w-4 h-4" />
@@ -54,6 +59,7 @@ export function DraftSaveBar({
</span>
<div className="flex items-center gap-2">
<button
data-testid="draft-discard"
onClick={onDiscard}
disabled={saving || !dirty}
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
@@ -62,6 +68,7 @@ export function DraftSaveBar({
{labels.discard}
</button>
<button
data-testid="draft-save"
onClick={onSave}
disabled={saving || !dirty}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
@@ -72,4 +79,5 @@ export function DraftSaveBar({
</div>
</div>
);
return floating ? createPortal(bar, document.body) : bar;
}
+1
View File
@@ -4,6 +4,7 @@
"watchedUnmark": "Watched — click to unmark",
"markWatched": "Mark watched",
"savedRemove": "Saved — click to remove",
"savedMark": "Saved",
"saveForLater": "Save for later",
"unhide": "Unhide",
"hide": "Hide",
+4 -2
View File
@@ -6,7 +6,8 @@
"backfillEverythingHint": "Request full back-catalog backfill for every channel you're subscribed to. Older videos and search become complete as the shared daily quota allows — this can take a while.",
"tabs": {
"subscribed": "Subscriptions",
"discovery": "Discover from playlists"
"discovery": "Discover from playlists",
"blocked": "Blocked"
},
"discovery": {
"intro": "Channels that appear in your playlists but that you don't subscribe to. Subscribe to follow them — their new uploads will start showing in your feed (subscribing costs a little YouTube quota; existing videos aren't re-fetched).",
@@ -119,6 +120,7 @@
"searchPlaceholder": "Search channels…",
"blocked": {
"title": "Blocked channels",
"hint": "Their videos are hidden from your feed, Library, and live search."
"hint": "Their videos are hidden from your feed, Library, and live search.",
"empty": "No blocked channels. Block one from its channel page to hide all its videos without unsubscribing."
}
}
+8
View File
@@ -19,6 +19,14 @@
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"viewLabel": "View",
"view": {
"cards": "Large cards",
"cardsSmall": "Small cards",
"rows": "Rows",
"rowsCompact": "Compact rows",
"tiles": "Tiles"
},
"dirAsc": "Ascending",
"dirDesc": "Descending",
"sortKey": {
+8 -2
View File
@@ -57,7 +57,8 @@
},
"collection": "Collection",
"collectionSearch": "Search collections…",
"collectionEmpty": "No collections"
"collectionEmpty": "No collections",
"matchLabel": "Match"
},
"count": "{{count}} titles",
"searchCount": "{{count}} matches",
@@ -225,5 +226,10 @@
"addShow": "Add whole show to a playlist",
"addSeason": "Add season to a playlist",
"addEpisode": "Add episode to a playlist"
},
"people": {
"count_one": "{{count}} title",
"count_other": "{{count}} titles",
"remove": "Remove from filter"
}
}
}
+1 -11
View File
@@ -17,17 +17,9 @@
"importing": "Importing your Plex watch history…"
},
"save": {
"unsaved": "Unsaved changes",
"saving": "Saving…",
"saved": "Settings saved",
"failed": "Couldn't save",
"save": "Save",
"discard": "Discard"
},
"unsaved": {
"title": "Unsaved changes",
"message": "You have unsaved settings. Discard them and leave?",
"discard": "Discard changes"
"failed": "Couldn't save"
},
"appearance": {
"colorScheme": "Color scheme",
@@ -36,8 +28,6 @@
"backgroundImage": "Background image",
"backgroundImageHint": "Show a subtle per-scheme backdrop image behind the app so the glass surfaces have something to refract. Off = flat colour.",
"backgroundFade": "Image fade",
"listView": "List view",
"listViewHint": "Show the feed as a compact list instead of a grid of cards.",
"performanceMode": "Performance mode",
"performanceModeHint": "Turns off the translucent glass blur and soft shadows for snappier rendering on slower machines.",
"showHints": "Show hints",
+1
View File
@@ -4,6 +4,7 @@
"watchedUnmark": "Megnézve — kattints a visszavonáshoz",
"markWatched": "Megnézettnek jelöl",
"savedRemove": "Mentve — kattints az eltávolításhoz",
"savedMark": "Mentve",
"saveForLater": "Mentés későbbre",
"unhide": "Megjelenítés",
"hide": "Elrejtés",
+4 -2
View File
@@ -6,7 +6,8 @@
"backfillEverythingHint": "Teljes archívum letöltését kéri minden csatornához, amelyre feliratkoztál. A régebbi videók és a keresés a megosztott napi kvóta függvényében válik teljessé — ez eltarthat egy ideig.",
"tabs": {
"subscribed": "Feliratkozások",
"discovery": "Felfedezés a lejátszási listákból"
"discovery": "Felfedezés a lejátszási listákból",
"blocked": "Letiltott"
},
"discovery": {
"intro": "Csatornák, amelyek megjelennek a lejátszási listáidban, de még nem iratkoztál fel rájuk. Iratkozz fel, hogy kövesd őket — az új feltöltéseik megjelennek a hírfolyamodban (a feliratkozás kevés YouTube-kvótát használ; a meglévő videókat nem tölti le újra).",
@@ -119,6 +120,7 @@
"searchPlaceholder": "Csatornák keresése…",
"blocked": {
"title": "Tiltott csatornák",
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből."
"hint": "A videóik el vannak rejtve a feededből, a Library-ből és a YouTube-keresésből.",
"empty": "Nincs tiltott csatorna. Egy csatorna oldaláról tilthatsz le egyet, hogy minden videóját elrejtsd leiratkozás nélkül."
}
}
+8
View File
@@ -19,6 +19,14 @@
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"viewLabel": "Nézet",
"view": {
"cards": "Nagy kártya",
"cardsSmall": "Kis kártya",
"rows": "Sor",
"rowsCompact": "Tömör sor",
"tiles": "Csempe"
},
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"sortKey": {
+8 -2
View File
@@ -57,7 +57,8 @@
},
"collection": "Kollekció",
"collectionSearch": "Kollekciók keresése…",
"collectionEmpty": "Nincs kollekció"
"collectionEmpty": "Nincs kollekció",
"matchLabel": "Egyezés"
},
"count": "{{count}} cím",
"searchCount": "{{count}} találat",
@@ -225,5 +226,10 @@
"addShow": "Egész sorozat listához adása",
"addSeason": "Évad listához adása",
"addEpisode": "Epizód listához adása"
},
"people": {
"count_one": "{{count}} cím",
"count_other": "{{count}} cím",
"remove": "Eltávolítás a szűrőből"
}
}
}
+1 -11
View File
@@ -17,17 +17,9 @@
"importing": "Plex-előzmény importálása…"
},
"save": {
"unsaved": "Nem mentett változások",
"saving": "Mentés…",
"saved": "Beállítások elmentve",
"failed": "Nem sikerült menteni",
"save": "Mentés",
"discard": "Elvetés"
},
"unsaved": {
"title": "Nem mentett változások",
"message": "Vannak nem mentett beállításaid. Elveted őket és továbblépsz?",
"discard": "Változások elvetése"
"failed": "Nem sikerült menteni"
},
"appearance": {
"colorScheme": "Színséma",
@@ -36,8 +28,6 @@
"backgroundImage": "Háttérkép",
"backgroundImageHint": "Halvány, sémánkénti háttérkép az app mögött, hogy az üveges felületeknek legyen mit megtörniük. Kikapcsolva sima szín.",
"backgroundFade": "Kép elhalványítása",
"listView": "Listanézet",
"listViewHint": "A hírfolyam kompakt listaként jelenik meg a kártyarács helyett.",
"performanceMode": "Teljesítmény mód",
"performanceModeHint": "Kikapcsolja az áttetsző üvegelmosást és a lágy árnyékokat a gyorsabb megjelenítésért lassabb gépeken.",
"showHints": "Tippek megjelenítése",
+11 -1
View File
@@ -828,6 +828,11 @@ export interface PlexCastMember {
role?: string | null;
thumb?: string | null; // proxied person-image url, or null when Plex has no photo
}
export interface PlexPersonCard {
name: string;
photo?: string | null; // proxied person-image url, or null (placeholder)
count: number; // in-scope movies/shows featuring them
}
// The expanded Plex browse filters (movie libraries). Persisted per-account as JSON.
export interface PlexFilters {
genres: string[];
@@ -840,7 +845,8 @@ export interface PlexFilters {
durationMax?: number | null;
addedWithin?: string; // "" | "24h" | "1w" | "1m" | "6m" | "1y"
directors: string[]; // AND — titles featuring ALL selected
actors: string[]; // AND — titles featuring ALL selected
actors: string[]; // OR (any) by default, or AND (all) via actorMode
actorMode?: "any" | "all"; // how multiple actors combine (default any = OR)
studios: string[]; // OR — from any selected studio
collection?: string | null; // a single Plex collection (rating_key)
collectionTitle?: string | null; // its title, for the active-filter chip
@@ -882,6 +888,7 @@ function appendPlexFilters(u: URLSearchParams, f: PlexFilters): void {
if (f.addedWithin) u.set("added_within", f.addedWithin);
if (f.directors?.length) u.set("directors", f.directors.join(","));
if (f.actors?.length) u.set("actors", f.actors.join(","));
if (f.actorMode === "all") u.set("actor_mode", "all");
if (f.studios?.length) u.set("studios", f.studios.join(","));
if (f.collection) u.set("collection", f.collection);
}
@@ -1349,6 +1356,9 @@ export const api = {
req(`/api/plex/season/${encodeURIComponent(rk)}/state`, { method: "POST", body: JSON.stringify({ watched }) }),
plexItem: (id: string): Promise<PlexItemDetail> =>
req(`/api/plex/item/${encodeURIComponent(id)}`),
// Cards for the actor filter-chips currently applied: name + cached headshot + in-scope film/show count.
plexPeopleCards: (names: string[], scope: string): Promise<{ people: PlexPersonCard[] }> =>
req(`/api/plex/people/cards?scope=${encodeURIComponent(scope)}&names=${encodeURIComponent(names.join(","))}`),
plexSession: (id: string, start = 0, audio?: number | null, aoff = 0, multi = false): Promise<PlexPlaySession> => {
const p = new URLSearchParams({ start: String(Math.max(0, Math.floor(start))) });
if (audio != null) p.set("audio", String(audio));
+70
View File
@@ -0,0 +1,70 @@
// The feed's view vocabulary. Lifted out of Feed (like feedSort.ts) because it has three
// consumers that can't share module-private state: App owns the pref, the toolbar's
// ViewSwitcher offers it, and VirtualFeed/VideoCard render it.
export type FeedView = "cards" | "cardsSmall" | "rows" | "rowsCompact" | "tiles";
export const FEED_VIEWS: readonly FeedView[] = [
"cards",
"cardsSmall",
"rows",
"rowsCompact",
"tiles",
] as const;
const FEED_VIEW_DEFAULT: FeedView = "cards";
export interface FeedViewSpec {
/** Which shape VideoCard renders: a portrait card (thumbnail on top) or a horizontal block. */
family: "card" | "row";
/** Narrowest grid column in px, or null for ONE full-width column. VirtualFeed derives the
* column count from this and the container width, so every multi-column view reflows for free. */
minCol: number | null;
/** Single-column views only: the reading-width cap in px (null = fill). A long line is hard to
* scan, so the stacked row keeps a cap; the one-line row doesn't need as tight a one. */
maxCol: number | null;
/** Show the video thumbnail. False only for the bare row whose badges (duration, live state,
* saved, resume bar) then move into the meta line, since Thumb is what draws them. */
thumb: boolean;
/** card family: tighter padding and no channel avatar, for the small card's ~180px column. */
dense: boolean;
/** row family: actions sit under the meta rather than docked at the right edge. Frees ~168px of
* width (measured), which is what lets a row block survive in a narrow tile column. */
actionsBelow: boolean;
}
// The whole matrix in one place. Adding a view here makes TS point at every switch that needs it.
export const FEED_VIEW_SPEC: Record<FeedView, FeedViewSpec> = {
cards: { family: "card", minCol: 260, maxCol: null, thumb: true, dense: false, actionsBelow: false },
cardsSmall: { family: "card", minCol: 180, maxCol: null, thumb: true, dense: true, actionsBelow: false },
// Single column on purpose: the point of the list is that your eye runs down one edge with the
// titles stacked. Density must not cost that — `tiles` is the multi-column option instead.
// Keeps the 896px cap: its actions dock at the right edge, so a wider row would only stretch the
// dead space between them and the meta.
rows: { family: "row", minCol: null, maxCol: 896, thumb: true, dense: false, actionsBelow: false },
// Much wider (1472): everything sits on one line and the columns after the title are fixed, so
// there's no gap for the extra width to stretch — it all goes to the title. Raised from 1280 at
// the user's request once they saw how much room was left over with the rail and filters hidden.
rowsCompact: { family: "row", minCol: null, maxCol: 1472, thumb: false, dense: false, actionsBelow: false },
// 380 = thumbnail (160) + gap (12) + ~192 for the text + the block's own padding (16). The text
// floor is the action row's measured 156px plus enough for a title to be worth reading.
// Tuned by measuring the real layouts, not guessed: 3 columns need 3n+32 px, so 380 turns the
// channel page (1208px) and the unpinned feed into 3 columns while the filter-pinned feed
// (~955px) still gets 2. 470 was tried first and needed 956px for two — the common layout fell
// back to ONE column and the mode looked broken; 400 then left 596px tiles whose titles ended
// halfway, which is the very waste this mode exists to kill.
tiles: { family: "row", minCol: 380, maxCol: null, thumb: true, dense: false, actionsBelow: true },
};
function isFeedView(v: unknown): v is FeedView {
return typeof v === "string" && (FEED_VIEWS as readonly string[]).includes(v);
}
/** Coerce a stored pref to a view. Up to 0.44 it was "grid" | "list"; those map forward here so
* nobody's saved choice is lost. Anything else (a corrupt value, a future view rolled back) falls
* back to the default rather than rendering nothing. */
export function parseFeedView(raw: unknown): FeedView {
if (isFeedView(raw)) return raw;
if (raw === "grid") return "cards";
if (raw === "list") return "rows";
return FEED_VIEW_DEFAULT;
}
+88 -30
View File
@@ -1,41 +1,99 @@
import {
Activity,
BarChart3,
Bell,
Clapperboard,
Download,
Home,
ListVideo,
MessageSquare,
ScrollText,
Settings,
SlidersHorizontal,
Tv,
Users,
type LucideIcon,
} from "lucide-react";
import type { Me } from "./api";
import type { Page } from "./urlState";
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"];
// The i18n key for each module's SHORT display name — the same label the nav rail shows. Single
// source so the nav rail and the header's ModuleName pill always read identically (the longer,
// descriptive `pageTitleKey` names are kept only for the browser tab title).
export const moduleLabelKey: Record<Page, string> = {
feed: "header.account.feed",
channels: "header.account.channels",
playlists: "header.account.playlists",
plex: "plex.navLabel",
notifications: "inbox.navLabel",
messages: "messages.navLabel",
downloads: "downloads.navLabel",
stats: "header.account.stats",
scheduler: "header.account.scheduler",
config: "header.account.config",
users: "header.account.users",
audit: "header.account.audit",
settings: "header.account.settings",
// ── The module registry ───────────────────────────────────────────────────────────────────────
// One ordered source of truth for every navigable module: its nav label, icon, admin/system
// placement, whether it carries a live badge, and its availability gate. The nav rail, the header's
// ◀/▶ stepper, and (as the registry grows) the page render all read from here, so a new or newly
// gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon.
export type ModuleDef = {
id: Page;
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
labelKey: string;
icon: LucideIcon;
// Admin/system modules render in their own section (below a divider) in the nav rail.
system?: boolean;
// Carries a live unread/active count in the rail (the number itself is computed in NavSidebar,
// which owns the polling queries; this only flags which modules have one).
hasBadge?: boolean;
// Availability for THIS account, derived from language-independent account state (plex toggle,
// demo restrictions, admin role). Absent = always available. Drives both the reachable module
// set (nav + stepper) and, later, the render gate.
gate?: (me: Me) => boolean;
};
const isAdmin = (me: Me) => me.role === "admin";
const notDemo = (me: Me) => !me.is_demo;
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
const MODULES: readonly ModuleDef[] = [
{ id: "feed", labelKey: "header.account.feed", icon: Home },
{ id: "channels", labelKey: "header.account.channels", icon: Tv },
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
{ id: "messages", labelKey: "messages.navLabel", icon: MessageSquare, hasBadge: true, gate: notDemo },
{ id: "downloads", labelKey: "downloads.navLabel", icon: Download, hasBadge: true, gate: notDemo },
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
{ id: "scheduler", labelKey: "header.account.scheduler", icon: Activity, system: true, gate: isAdmin },
{ id: "config", labelKey: "header.account.config", icon: SlidersHorizontal, system: true, gate: isAdmin },
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
{ id: "audit", labelKey: "header.account.audit", icon: ScrollText, system: true, gate: isAdmin },
];
// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in
// the ordered nav list, so it lives beside the array rather than in it.
const SETTINGS_MODULE: ModuleDef = {
id: "settings",
labelKey: "header.account.settings",
icon: Settings,
};
// Registering a NEW Page means adding it to MODULES (or SETTINGS_MODULE) above — that's the single
// place. If a Page is left unregistered, moduleDef()/moduleLabelKey[] return undefined and NavSidebar
// throws on `.icon`; the coverage isn't compiler-enforced here because the ordered array can't also
// be an exhaustive Page map, so treat the array as the checklist when the Page union grows.
const BY_ID = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m]),
) as Record<Page, ModuleDef>;
// Look up a module's definition by page id (every Page is covered, incl. settings).
export function moduleDef(id: Page): ModuleDef {
return BY_ID[id];
}
// The i18n key for each module's SHORT display name. Single source so the nav rail and the header's
// ModuleName pill always read identically (the longer, descriptive `pageTitleKey` names are kept
// only for the browser tab title).
export const moduleLabelKey: Record<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
) as Record<Page, string>;
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);
// The ordered list of primary module pages available to THIS user, in nav-rail order. Single
// source of truth for both the nav rail (NavSidebar) and the header's ◀/▶ module stepper, so a
// new module (or a newly-gated one) shows up in both at once — nothing hard-codes the sequence.
// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the
// reachable set changes with the language-independent account state, not the UI.
// new module (or a newly-gated one) shows up in both at once. Availability is derived from the
// account via each module's `gate`, so the reachable set changes with the account state, not the UI.
export function moduleOrder(me: Me): Page[] {
const pages: Page[] = ["feed", "channels", "playlists"];
if (me.plex_enabled) pages.push("plex");
pages.push("notifications");
if (!me.is_demo) pages.push("messages", "downloads");
pages.push("stats");
if (me.role === "admin") pages.push(...SYSTEM_PAGES);
return pages;
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
}
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
+1 -1
View File
@@ -141,7 +141,7 @@ export function DetailCustomizeMenu({
{open && (
<div
ref={menuRef}
className="glass-menu absolute right-0 top-full z-20 mt-1 w-52 rounded-xl p-1.5 text-sm"
className="glass-menu absolute right-0 top-full z-menu mt-1 w-52 rounded-xl p-1.5 text-sm"
style={{ background: "color-mix(in srgb, var(--surface) 58%, transparent)" }}
>
<PrefToggle label={t("plex.info.prefArtBg")} on={artBg} onClick={onToggleArtBg} />
+16
View File
@@ -14,6 +14,22 @@ export interface ReleaseEntry {
}
export const RELEASE_NOTES: ReleaseEntry[] = [
{
version: "0.45.0",
date: "2026-07-19",
summary:
"Read the feed the way you like it, and explore your Plex library by cast.",
features: [
"The feed has five view modes now — large cards, small cards, rows, compact rows, and a multi-column tile view. Pick the density that suits you from the switcher next to the sort control; your choice sticks.",
"Explore your Plex library by the people in it: click any cast member on a film or show's page to see everything they're in. Pick several and, by default, you'll get titles featuring any of them — flip to \"All\" to require every one. The actors you've picked now show as cards above the grid, each with a photo and a title count, and a click to drop them.",
],
fixes: [
"Plex: clicking a lower-billed cast member no longer empties the grid. We now index a title's full cast (not just the top few), so filtering by anyone shown actually finds their films.",
],
chores: [
"A large under-the-hood cleanup of the app's internal structure (no behaviour change) — smaller, clearer building blocks that make future features quicker and safer to add.",
],
},
{
version: "0.44.0",
date: "2026-07-16",
+5
View File
@@ -17,6 +17,11 @@ export const LS = {
defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
// The feed's view mode (see lib/feedView.ts) — a cache of the server pref, so a reload of an
// established tab paints your view straight away. A brand-new tab still starts on the default
// until /api/me lands: the account isn't pinned yet (App pins it once meQuery resolves), so
// the per-account key can't be read.
feedView: "siftlode.feedView",
channelFilter: "siftlode.channelFilter",
channelsView: "siftlode.channelsView",
channelsTable: "siftlode.channelsTable",
+1 -1
View File
@@ -11,7 +11,7 @@ export interface ActiveFilter {
}
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
export const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
channelIds: [],
+13
View File
@@ -0,0 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { api, type Me } from "./api";
// The signed-in account, read from the ["me"] query cache. App owns the FETCHING query (gated on
// setup state) and renders modules only once it has resolved to a real user — so anywhere inside the
// authenticated module tree the cache is always populated. `enabled:false` makes this a passive
// observer (no second fetch, which could 503 in setup mode) that still re-renders when the cache
// updates (login, the 60s refetch, a Google-link round-trip). Lets modules read `me` from a hook
// instead of having it prop-drilled through App.
export function useMe(): Me {
const { data } = useQuery({ queryKey: ["me"], queryFn: api.me, enabled: false });
return data as Me;
}
+36 -12
View File
@@ -1,38 +1,62 @@
import { useEffect, useState, type CSSProperties } from "react";
// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`):
// Scroll-affordance for a scroll container (typically one whose native scrollbar is hidden with
// `.no-scrollbar`, but the page scroller keeps its bar and takes the fade anyway):
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
export function useScrollFade(fadePx = 20): {
ref: (node: HTMLDivElement | null) => void;
// `fadeTop` gates the top edge only. Pass false when the scroller's top is a sticky element (a
// table's sticky header): fading there would eat into the header instead of hinting at content, and
// the header would visibly change as the fade turns on with the first scroll.
export function useScrollFade(fadePx = 20, fadeTop = true): {
ref: (node: HTMLElement | null) => void;
style: CSSProperties;
} {
const [el, setEl] = useState<HTMLDivElement | null>(null);
const [el, setEl] = useState<HTMLElement | null>(null);
const [fade, setFade] = useState({ up: false, down: false });
useEffect(() => {
if (!el) return;
const update = () =>
setFade({
up: el.scrollTop > 1,
down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
});
// Bail out when neither edge changed: `scroll` fires per frame, and the page scroller's
// hook sits above the whole page tree, so a new object here would re-render on every frame
// of every scroll. The two booleans only flip at the ends of the range.
const update = () => {
const up = el.scrollTop > 1;
const down = el.scrollTop + el.clientHeight < el.scrollHeight - 1;
setFade((prev) => (prev.up === up && prev.down === down ? prev : { up, down }));
};
update();
el.addEventListener("scroll", update, { passive: true });
// Watch the scroller AND its content. Content that grows or shrinks — a lazy page loading in,
// a thread's messages decrypting, a list being filtered — changes neither the scroller's own
// box nor its scrollTop, so without the children nothing would re-evaluate the edges.
const ro = new ResizeObserver(update);
ro.observe(el);
// Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
if (el.firstElementChild) ro.observe(el.firstElementChild);
for (const child of Array.from(el.children)) ro.observe(child);
// Children get SWAPPED, and a ResizeObserver holding a detached node simply never fires
// again. Measured: the Plex page opened with no bottom fade over a 3119px list, because the
// node observed at mount was the Suspense fallback the real page then replaced. (Same
// lazy-page race that broke BackToTop.) So follow the child list: the records name exactly
// which nodes arrived and left, which keeps this O(1) per change rather than re-observing
// every child of a long list on each append.
const mo = new MutationObserver((records) => {
for (const rec of records) {
for (const node of Array.from(rec.addedNodes)) if (node instanceof Element) ro.observe(node);
for (const node of Array.from(rec.removedNodes)) if (node instanceof Element) ro.unobserve(node);
}
update();
});
mo.observe(el, { childList: true });
return () => {
el.removeEventListener("scroll", update);
ro.disconnect();
mo.disconnect();
};
}, [el]);
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up && fadeTop ? fadePx : 0}px, #000 calc(100% - ${
fade.down ? fadePx : 0
}px), transparent)`;
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };
+25 -1
View File
@@ -3,6 +3,14 @@ import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { ChannelsProvider } from "./components/ChannelsProvider";
import { FeedViewProvider } from "./components/FeedViewProvider";
import { PrefsProvider } from "./components/PrefsProvider";
import { WizardProvider } from "./components/WizardProvider";
import "./i18n";
import "./index.css";
@@ -35,7 +43,23 @@ const root =
<QueryClientProvider client={queryClient}>
<ErrorBoundary>
<ConfirmProvider>
<App />
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>
</ErrorBoundary>
</QueryClientProvider>
+25
View File
@@ -16,6 +16,31 @@ export default {
fontFamily: {
sans: ["Inter", "system-ui", "-apple-system", "Segoe UI", "Roboto", "sans-serif"],
},
// Named z-index layers — the single source of truth for stacking order. Values are
// deliberately UNCHANGED from the magic numbers they replaced (this was a rename, not a
// renumber), so the visual layering is identical; the names just make the stack legible and
// give future changes one place to edit. Low → high:
// base(10) in-content decorations: sticky table head, card badges, in-flow dropdowns
// menu(20) menus/overlays sitting just over content
// chrome(30) floating chrome: the header band, back-to-top, content filter/sort menus
// paneltab / panel (34/35) the collapsed tab vs the expanded side panel (aside over tab)
// rail(40) full-height rails + docked bars: nav rail, chat dock, floating save bar
// overlay(50) full-screen modals/players, toaster, onboarding, popovers over content
// popover(60) a popover that must sit over an overlay (player scrub, tag menu) + the login gate
// tooltip(100) tooltips, above everything interactive
// tuner(9999) the dev/lab glass tuner, deliberately on top of all
zIndex: {
base: "10",
menu: "20",
chrome: "30",
paneltab: "34",
panel: "35",
rail: "40",
overlay: "50",
popover: "60",
tooltip: "100",
tuner: "9999",
},
},
},
plugins: [],

Some files were not shown because too many files have changed in this diff Show More