Merge: promote dev to prod
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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()]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "https://unpkg.com/knip@6/schema.json",
|
||||
"playwright": {
|
||||
"config": ["playwright.config.ts"],
|
||||
"entry": ["e2e/**/*.@(spec|setup).ts"]
|
||||
}
|
||||
}
|
||||
Generated
+82
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.9–1.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>
|
||||
|
||||
@@ -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))) }} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user