Compare commits
25
Commits
6fb369ffcc
...
v0.73.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe09879edd | ||
|
|
7104767ff9 | ||
|
|
17f8885fb1 | ||
|
|
549aae67cc | ||
|
|
fbb961b5b7 | ||
|
|
edcea87ea5 | ||
|
|
2c843e549b | ||
|
|
4ee0dab68f | ||
|
|
5d7be687d3 | ||
|
|
e2a8503496 | ||
|
|
a389306885 | ||
|
|
32c05897f4 | ||
|
|
2e319be569 | ||
|
|
852b227ea2 | ||
|
|
06b85586e6 | ||
|
|
fc32188fd2 | ||
|
|
9538fb1b7a | ||
|
|
5051809940 | ||
|
|
64dde6fd36 | ||
|
|
38921fb997 | ||
|
|
2d1a7ff374 | ||
|
|
361ca7872e | ||
|
|
81b7660232 | ||
|
|
7caa2636e4 | ||
|
|
1e894d378d |
@@ -12,12 +12,12 @@ provider.
|
||||
|
||||
## The gate — never merge or ship red
|
||||
|
||||
The local gate is **ruff + typecheck (3 tsc projects) + eslint + prettier --check + knip + vitest +
|
||||
pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
The local gate is **ruff (`check` + `format --check`) + typecheck (3 tsc projects) + eslint + prettier
|
||||
--check + knip + vitest + pytest**. It runs on every `publish`, which **refuses to build on failure — there is deliberately no
|
||||
`--skip` flag.** Run it and get it green **before** any merge, release, or handing work to review.
|
||||
|
||||
- Full gate (agents with the private ops dispatcher): `siftlode check`.
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- Directly, without the dispatcher: `cd frontend && npm run typecheck && npm run lint && npm run knip && npm run test`, plus backend `ruff check backend` + `ruff format --check backend` and `pytest` (the DB-lane needs Postgres).
|
||||
- New DB-backed test? It must actually run, not silently skip — assume `SIFTLODE_TEST_REQUIRE_DB=1`.
|
||||
|
||||
## Workflow
|
||||
@@ -26,12 +26,33 @@ pytest**. It runs on every `publish`, which **refuses to build on failure — th
|
||||
keep commits logical and Conventional-Commit-styled.
|
||||
- **No AI attribution in commits.** No `Co-Authored-By` trailer, no "generated by" lines. (This file
|
||||
being AI-written is the intended, visible exception; commits are not.)
|
||||
- **Release is a deliberate, user-owned step** (`siftlode release` from the feature branch: merge to
|
||||
`main` → publish → deploy → verify `/api/version`). Do not self-ship; bring work to a green,
|
||||
committed state and stop for the user's go-ahead.
|
||||
- **Release: user-owned in timing, agent-run in execution.** The user decides *when* to ship; on their
|
||||
go-ahead *you* run `siftlode release` from the feature branch (merge to `main` → publish → deploy →
|
||||
verify `/api/version`). Never self-ship — bring work to a green, committed state and stop for the
|
||||
go-ahead first, then run it yourself.
|
||||
- **Public repo.** Keep ops/topology/secrets out — those live in a separate private repo. Nothing here
|
||||
should assume or leak them.
|
||||
|
||||
## Collaboration — how to work with the human
|
||||
|
||||
The human is the product owner: they bring ideas and requirements. You are the technical lead — you own
|
||||
how it gets built. Concretely:
|
||||
|
||||
- **Plan before you build.** For anything non-trivial, present a short plan (sprint-level for larger
|
||||
work) and get a go-ahead before writing code.
|
||||
- **Offer choices, mark the optimal — and explain the trade-off.** When several solutions are viable,
|
||||
list them and mark the recommended one. This is the *one* place to spend extra words: give the *why*
|
||||
behind each option and the weight of the decision. These decision points are where the product owner
|
||||
engages with the build and learns from it, so make them understandable — a reasoned menu, not a bare one.
|
||||
- **Mockups for design/UX decisions.** For any visual or interaction choice, show mockup options to
|
||||
choose from before building.
|
||||
- **Challenge unsound requests.** If a request is technically ill-advised and you can back it with real
|
||||
arguments, push back or propose an alternative — don't comply blindly.
|
||||
- **Verify UI in the browser before calling it done.** tsc/unit/DOM tests miss visual and interaction
|
||||
bugs; do a real visual check of any UI change.
|
||||
- **Be terse everywhere else.** Outside those decision points, lead with the answer or decision. During
|
||||
implementation and debugging especially: no restating the same point reworded, no filler.
|
||||
|
||||
## Architecture shape (don't fight it)
|
||||
|
||||
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
|
||||
@@ -79,5 +100,6 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
||||
|
||||
---
|
||||
|
||||
*A fuller written-conventions doc (`docs/development.md`) is planned; once it lands, this file slims to a
|
||||
pointer plus the gate/workflow/no-attribution rules. Until then, the table above is the source of truth.*
|
||||
*Run-it-locally and the full gate commands live in [`docs/development.md`](docs/development.md); backend
|
||||
code conventions in [`backend/app/README.md`](backend/app/README.md). This file stays the source of
|
||||
truth for the canonical-patterns table and the gate/workflow/collaboration rules above.*
|
||||
|
||||
@@ -100,6 +100,11 @@ def serialize(row) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _tag_visible(user: User):
|
||||
"""A ChannelTag row this user may see: a shared system tag (user_id NULL) or their own."""
|
||||
return or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
|
||||
|
||||
def tags_by_category(db: Session, tag_ids: list[int]) -> dict[str, list[int]]:
|
||||
"""Group tag ids by their category (language/topic/other). The single source for the category split
|
||||
shared by the feed's tag WHERE (filtered_query) and the facet/count summary (feed_summary), so the
|
||||
@@ -118,6 +123,7 @@ def filtered_query(
|
||||
*,
|
||||
tags: list[int],
|
||||
tag_modes: dict[str, str],
|
||||
untagged: bool = False,
|
||||
channel_ids: list[str],
|
||||
q: str | None,
|
||||
min_duration: int | None,
|
||||
@@ -294,7 +300,7 @@ def filtered_query(
|
||||
# selections but still honours the other categories' filters).
|
||||
if exclude_tag_category:
|
||||
by_category.pop(exclude_tag_category, None)
|
||||
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
|
||||
visible = _tag_visible(user)
|
||||
for category, ids in by_category.items():
|
||||
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
|
||||
sub = (
|
||||
@@ -307,6 +313,18 @@ def filtered_query(
|
||||
sub = select(ChannelTag.channel_id).where(ChannelTag.tag_id.in_(ids), visible)
|
||||
query = query.where(Video.channel_id.in_(sub))
|
||||
|
||||
# "No tag" filter (item B): keep only channels the USER has not personally tagged in the channel
|
||||
# manager — i.e. channels with no ChannelTag row owned by this user (user_id == me, source "user").
|
||||
# System/auto tags (user_id NULL) don't count: this is "channels I haven't tagged yet". It's a plain
|
||||
# channel-level narrowing (like channel_ids/date), NOT a tag-category selection, so feed_summary
|
||||
# leaves it applied in the base pass and needs no mirror in passes().
|
||||
if untagged:
|
||||
query = query.where(
|
||||
Video.channel_id.notin_(
|
||||
select(ChannelTag.channel_id).where(ChannelTag.user_id == user.id)
|
||||
)
|
||||
)
|
||||
|
||||
# Content type: Normal / Shorts / Live·Upcoming as a union of enabled types.
|
||||
# Explicit Watched/Saved/Hidden views show every type so nothing goes missing.
|
||||
explicit_view = show in ("watched", "hidden")
|
||||
@@ -355,8 +373,9 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
within, a category in "and" mode requires ALL its selected tags)."""
|
||||
tag_modes = params.get("tag_modes", {})
|
||||
|
||||
# ONE pass: per-channel video counts for the base filter, tags stripped so every candidate channel
|
||||
# is present to facet over.
|
||||
# ONE pass: per-channel video counts for the base filter, the positive tag filter stripped so every
|
||||
# candidate channel is present to facet over. The "No tag" (untagged) filter is a plain channel-level
|
||||
# narrowing, so it STAYS applied here (not stripped) and the facets naturally drill down within it.
|
||||
base, _rank = filtered_query(db, user, **{**params, "tags": []})
|
||||
ch_col = base.with_only_columns(Video.channel_id).subquery()
|
||||
channel_counts: dict[str, int] = dict(
|
||||
@@ -386,7 +405,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
|
||||
def passes(ch_tags: set[int], exclude_cat: str | None) -> bool:
|
||||
# The Python mirror of filtered_query's SQL tag block (AND across categories, OR within,
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there.
|
||||
# topic-AND requires all selected) — keep in lockstep; see the ⚠️ note there. (The "No tag"
|
||||
# filter is applied in the base query above, so it needs no mirror here.)
|
||||
for cat, sel in selected_by_cat.items():
|
||||
if cat == exclude_cat:
|
||||
continue
|
||||
@@ -417,6 +437,30 @@ def feed_summary(db: Session, user: User, **params) -> dict:
|
||||
return {"count": count, "facets": facets}
|
||||
|
||||
|
||||
def feed_years(db: Session, user: User, **params) -> list[dict]:
|
||||
"""Per-year video counts for the current filter context (item 8 / E8·S2): powers the year control's
|
||||
quick chips + the "more…" per-year-counts dialog. Every OTHER filter (tags, channels, content type,
|
||||
search, watch state, scope) still applies, but the publish-date narrowing itself is DROPPED —
|
||||
published_after/before and max_age_days — so the year list is the full set of years available and
|
||||
switching years stays a live drill-down (the same principle as the tag facets dropping their own
|
||||
category). One grouped query, newest year first."""
|
||||
base, _rank = filtered_query(
|
||||
db,
|
||||
user,
|
||||
**{**params, "published_after": None, "published_before": None, "max_age_days": None},
|
||||
)
|
||||
pub = base.with_only_columns(Video.published_at.label("pub")).subquery()
|
||||
# Bucket by UTC year to stay in lockstep with the year filter's boundaries (published_after/before
|
||||
# are pinned to UTC in filtered_query). published_at is timestamptz, so a bare extract('year', …)
|
||||
# would use the DB session timezone and, on a non-UTC session, bucket a video near a year boundary
|
||||
# into a different year than the filter includes — the count and the drill-down feed would disagree.
|
||||
year = func.extract("year", func.timezone("UTC", pub.c.pub))
|
||||
rows = db.execute(
|
||||
select(year.label("y"), func.count()).group_by(year).order_by(year.desc())
|
||||
).all()
|
||||
return [{"year": int(y), "count": n} for y, n in rows if y is not None]
|
||||
|
||||
|
||||
# --- Keyset (cursor) pagination ---------------------------------------------
|
||||
#
|
||||
# Each sort is described as an ordered list of key columns (expression + direction
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.feedquery import (
|
||||
decode_cursor,
|
||||
encode_cursor,
|
||||
feed_summary,
|
||||
feed_years,
|
||||
filtered_query,
|
||||
keyset_predicate,
|
||||
order_by,
|
||||
@@ -53,6 +54,8 @@ def _feed_params(
|
||||
tag_mode_language: str = "or",
|
||||
tag_mode_topic: str = "or",
|
||||
tag_mode_other: str = "or",
|
||||
# "No tag" filter: keep only channels the user has not personally tagged (no user-owned ChannelTag).
|
||||
untagged: bool = False,
|
||||
channel_ids: list[str] = Query(default=[]),
|
||||
q: str | None = None,
|
||||
min_duration: int | None = None,
|
||||
@@ -74,6 +77,7 @@ def _feed_params(
|
||||
"topic": tag_mode_topic,
|
||||
"other": tag_mode_other,
|
||||
},
|
||||
"untagged": untagged,
|
||||
"channel_ids": channel_ids,
|
||||
"q": q,
|
||||
"min_duration": min_duration,
|
||||
@@ -203,6 +207,19 @@ def get_feed_summary(
|
||||
return feed_summary(db, user, **params)
|
||||
|
||||
|
||||
@router.get("/feed/years")
|
||||
def get_feed_years(
|
||||
params: dict = Depends(_feed_params),
|
||||
user: User = Depends(current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Per-year video counts for the year control (item 8 / E8·S2): the quick recent-year chips and the
|
||||
"more…" per-year-counts dialog. Every filter EXCEPT the publish-date narrowing applies, so picking a
|
||||
year re-queries the feed but not this list. Response: {"years": [{"year": <int>, "count": <int>}]},
|
||||
newest year first."""
|
||||
return {"years": feed_years(db, user, **params)}
|
||||
|
||||
|
||||
class VideoStateIn(BaseModel):
|
||||
status: str
|
||||
|
||||
|
||||
@@ -8,10 +8,22 @@ from app.auth import current_user
|
||||
from app.db import get_db
|
||||
from app.models import ChannelTag, Tag, User
|
||||
from app.routes._common import owned_or_404
|
||||
from app.sync.tagsuggest import compute_tag_suggestions
|
||||
|
||||
router = APIRouter(prefix="/api/tags", tags=["tags"])
|
||||
|
||||
|
||||
@router.get("/suggestions")
|
||||
def tag_suggestions(
|
||||
user: User = Depends(current_user), db: Session = Depends(get_db)
|
||||
) -> dict[str, list[dict]]:
|
||||
"""Suggested user tags for this user's still-untagged subscribed channels, learned from the
|
||||
auto-tag pattern of the channels they HAVE tagged (see app.sync.tagsuggest). Read-only — the
|
||||
Channel Manager shows these as one-tap-accept chips; accepting posts to the normal attach-tag
|
||||
endpoint. Keyed by channel id; channels with no suggestion are omitted."""
|
||||
return compute_tag_suggestions(db, user)
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_tags(user: User = Depends(current_user), db: Session = Depends(get_db)) -> list[dict]:
|
||||
rows = db.execute(
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Suggest a user's own manual tags for channels they haven't tagged yet.
|
||||
|
||||
The signal is the auto-tagger's *topic* output: each channel carries ``source='auto'`` topic
|
||||
ChannelTags (see ``autotag.py``). For every user tag T the user has applied to enough channels,
|
||||
we learn which topics are *characteristic* of T (present in a high fraction of the channels the
|
||||
user tagged T), then suggest T for any still-untagged channel whose own topics cover enough of
|
||||
that profile.
|
||||
|
||||
Language auto-tags are deliberately EXCLUDED from the signal: they are near-universal within a
|
||||
language ("English" is on most channels), so they don't discriminate — including them floods the
|
||||
result (measured on real data: every untagged channel got a suggestion, most on a shared language
|
||||
alone). Topic overlap is what makes "you tagged similar channels this way" meaningful.
|
||||
|
||||
Purely read-only and deterministic — the Channel Manager surfaces the result as one-tap-accept
|
||||
"ghost" chips; nothing is written here (D4 = suggest-and-confirm, no silent auto-apply). The
|
||||
thresholds are tuning constants rather than admin knobs on purpose: they mirror how ``autotag.py``
|
||||
keeps its mappings inline, and sysconfig only stores int/str/bool (no float), so a knob would mean
|
||||
storing percentages — not worth the surface for values an operator has no reason to tune per-install.
|
||||
"""
|
||||
|
||||
from collections import Counter
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import ChannelTag, Subscription, Tag, User
|
||||
|
||||
# A user tag needs at least this many manually-tagged example channels before we trust its
|
||||
# profile — fewer is noise (one gaming-cooking channel shouldn't make "cooking" mean "gaming").
|
||||
MIN_EXAMPLES = 3
|
||||
# A topic counts as *characteristic* of a user tag when it appears in ≥60% of that tag's example
|
||||
# channels. High enough that an incidental shared topic (one channel's stray category) doesn't
|
||||
# enter the profile, low enough to survive a couple of outliers in a small example set.
|
||||
CHAR_SUPPORT = 0.6
|
||||
# Suggest tag T for channel C when C's topics cover ≥60% of T's characteristic topics. The
|
||||
# intersection-over-profile shape means a partial match scores proportionally: 1 topic of a
|
||||
# 2-topic profile = 0.5 (dropped), 2 of 3 = 0.67 (kept). Tuned against real data to surface
|
||||
# strong topical matches without flooding — 0.5 let a single shared topic carry a suggestion.
|
||||
SUGGEST_THRESHOLD = 0.6
|
||||
# A suggestion at/above this score is "high confidence" — a full-profile topic match — and is the
|
||||
# only kind the UI's "Accept all confident" action touches. Never applied silently (see docstring).
|
||||
AUTOAPPLY_THRESHOLD = 0.9
|
||||
# Cap suggestions per channel so a busy channel's chip row stays legible.
|
||||
MAX_PER_CHANNEL = 3
|
||||
|
||||
|
||||
def compute_tag_suggestions(db: Session, user: User) -> dict[str, list[dict]]:
|
||||
"""Map each of the user's still-untagged subscribed channels to suggested user tags.
|
||||
|
||||
Returns ``{channel_id: [{"tag_id": int, "confidence": float}, ...]}`` — ordered best-first,
|
||||
capped at ``MAX_PER_CHANNEL``. Channels with no suggestion are omitted (empty dict when the
|
||||
user has no tag with a usable profile). Deterministic for a fixed DB state.
|
||||
"""
|
||||
# Candidate universe: the channels this user is subscribed to. `subscribed` (incl. hidden) is the
|
||||
# SIGNAL set — a hidden channel is still a valid example / topic-feature source, since hiding only
|
||||
# affects the feed, not the tagging pattern. `hidden` is excluded as a suggestion TARGET below (the
|
||||
# Channel Manager shows ghost chips only on non-hidden rows, so suggesting for hidden channels would
|
||||
# inflate the header count past what's shown and let bulk-accept tag channels the user can't see).
|
||||
subs = db.execute(
|
||||
select(Subscription.channel_id, Subscription.hidden).where(Subscription.user_id == user.id)
|
||||
).all()
|
||||
subscribed: set[str] = {cid for cid, _ in subs}
|
||||
hidden: set[str] = {cid for cid, h in subs if h}
|
||||
if not subscribed:
|
||||
return {}
|
||||
|
||||
# The user's manual tag links: tag_id -> example channels, and each channel's user-tag set (so
|
||||
# a channel with ANY user tag is excluded from "untagged"). Intersect with `subscribed`: unsubscribe
|
||||
# deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-unsubscribed
|
||||
# channel would otherwise stay an "example" — inflating a profile's CHAR_SUPPORT denominator while
|
||||
# contributing zero features (features are scanned over `subscribed` only), dropping characteristic
|
||||
# topics. Keeping examples ⊆ subscribed makes the support fraction honest.
|
||||
examples: dict[int, set[str]] = {}
|
||||
user_tagged: set[str] = set()
|
||||
for tag_id, cid in db.execute(
|
||||
select(ChannelTag.tag_id, ChannelTag.channel_id).where(
|
||||
ChannelTag.user_id == user.id, ChannelTag.source == "user"
|
||||
)
|
||||
).all():
|
||||
if cid not in subscribed:
|
||||
continue
|
||||
examples.setdefault(tag_id, set()).add(cid)
|
||||
user_tagged.add(cid)
|
||||
|
||||
# Topic features per channel: the auto TOPIC tags on the candidate channels. Language auto-tags
|
||||
# are joined out (Tag.category) — they don't discriminate (see the module docstring).
|
||||
features: dict[str, set[int]] = {}
|
||||
for cid, tid in db.execute(
|
||||
select(ChannelTag.channel_id, ChannelTag.tag_id)
|
||||
.join(Tag, Tag.id == ChannelTag.tag_id)
|
||||
.where(
|
||||
ChannelTag.user_id.is_(None),
|
||||
ChannelTag.source == "auto",
|
||||
ChannelTag.channel_id.in_(subscribed),
|
||||
Tag.category == "topic",
|
||||
)
|
||||
).all():
|
||||
features.setdefault(cid, set()).add(tid)
|
||||
|
||||
# Characteristic-feature profile per user tag with enough examples.
|
||||
profiles: dict[int, set[int]] = {}
|
||||
for tag_id, ex in examples.items():
|
||||
if len(ex) < MIN_EXAMPLES:
|
||||
continue
|
||||
counts: Counter[int] = Counter()
|
||||
for cid in ex:
|
||||
counts.update(features.get(cid, ()))
|
||||
threshold = CHAR_SUPPORT * len(ex)
|
||||
chars = {f for f, c in counts.items() if c >= threshold}
|
||||
if chars:
|
||||
profiles[tag_id] = chars
|
||||
if not profiles:
|
||||
return {}
|
||||
|
||||
suggestions: dict[str, list[dict]] = {}
|
||||
for cid in subscribed:
|
||||
if cid in hidden or cid in user_tagged: # hidden → no chips shown; tagged → not "untagged"
|
||||
continue
|
||||
cfeat = features.get(cid)
|
||||
if not cfeat:
|
||||
continue
|
||||
scored = [(tag_id, len(cfeat & chars) / len(chars)) for tag_id, chars in profiles.items()]
|
||||
hits = [(tid, s) for tid, s in scored if s >= SUGGEST_THRESHOLD]
|
||||
if not hits:
|
||||
continue
|
||||
# Best score first; tag_id as a stable tie-break so the order is deterministic.
|
||||
hits.sort(key=lambda x: (-x[1], x[0]))
|
||||
suggestions[cid] = [
|
||||
{"tag_id": tid, "confidence": round(s, 3)} for tid, s in hits[:MAX_PER_CHANNEL]
|
||||
]
|
||||
return suggestions
|
||||
@@ -162,11 +162,154 @@ _SUMMARY_CASES = [
|
||||
"show=all&tags={O1}&tags={O2}&tag_mode_other=and",
|
||||
"show=all&tags={U1}", # user-owned tag (visibility == user.id branch)
|
||||
"show=all&tags={U1}&tags={L1}", # user tag AND a system tag, cross-category
|
||||
# "No tag" filter (item B): channels with no USER tag. It stays applied in the base pass, so the
|
||||
# facet/count lockstep must still hold with it on (alone, and combined with a positive tag filter).
|
||||
"show=all&untagged=true",
|
||||
"show=all&untagged=true&tags={L1}",
|
||||
"show=all&q=clip",
|
||||
"show=unwatched",
|
||||
]
|
||||
|
||||
|
||||
def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user, make_user):
|
||||
"""The "No tag" filter (item B): `untagged=true` keeps only channels with NO tag the USER personally
|
||||
attached (no user-owned ChannelTag). System/auto tags (user_id NULL) don't count, and another user's
|
||||
tag on the same channel doesn't either."""
|
||||
user = make_user(email="untagged@example.com")
|
||||
other = make_user(email="someone-else@example.com")
|
||||
mine = Tag(user_id=user.id, name="Fav", category="other")
|
||||
sys_topic = Tag(user_id=None, name="Tech", category="topic")
|
||||
theirs = Tag(user_id=other.id, name="TheirTag", category="other")
|
||||
db.add_all([mine, sys_topic, theirs])
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chMine", "chSys", "chNone")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# chMine: the user's own tag → tagged. chSys: only a system tag + another user's tag → still
|
||||
# "untagged" for THIS user. chNone: nothing at all → untagged.
|
||||
db.add(ChannelTag(channel_id="chMine", tag_id=mine.id, user_id=user.id, source="user"))
|
||||
db.add(ChannelTag(channel_id="chSys", tag_id=sys_topic.id, user_id=None, source="auto"))
|
||||
db.add(ChannelTag(channel_id="chSys", tag_id=theirs.id, user_id=other.id, source="user"))
|
||||
for cid in chans:
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
db.add(_video(f"{cid}v", cid, f"{cid} clip"))
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
# Off: every subscribed channel shows.
|
||||
assert _feed_ids(client, "show=all") == {"chMinev", "chSysv", "chNonev"}
|
||||
# On: only channels the user has NOT personally tagged (a system tag / another user's tag don't count).
|
||||
assert _feed_ids(client, "show=all&untagged=true") == {"chSysv", "chNonev"}
|
||||
|
||||
|
||||
def test_feed_years_counts_ignore_the_date_narrowing_but_honour_other_filters(
|
||||
db, as_user, make_user
|
||||
):
|
||||
"""The year facet (item 8 / E8·S2): `/api/feed/years` returns a per-year video count, newest first,
|
||||
with the publish-date narrowing DROPPED (so switching years stays a drill-down) but every other
|
||||
filter still applied (here: a channel pick)."""
|
||||
user = make_user(email="years@example.com")
|
||||
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chX", "chY")}
|
||||
db.add_all(chans.values())
|
||||
db.flush()
|
||||
# chX: 2 videos in 2024, 1 in 2022; chY: 1 in 2023 (should drop once we scope to chX).
|
||||
spread = {
|
||||
"chX": [(2024, 1), (2024, 2), (2022, 5)],
|
||||
"chY": [(2023, 3)],
|
||||
}
|
||||
for cid, dates in spread.items():
|
||||
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
|
||||
for i, (yr, day) in enumerate(dates):
|
||||
db.add(
|
||||
Video(
|
||||
id=f"{cid}v{i}",
|
||||
channel_id=cid,
|
||||
published_at=datetime(yr, 1, day, tzinfo=timezone.utc),
|
||||
title=f"{cid} clip {i}",
|
||||
is_short=False,
|
||||
live_status="none",
|
||||
via_search=False,
|
||||
via_explore=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
client = as_user(user)
|
||||
|
||||
# All channels: every year present, counted, newest first.
|
||||
resp = client.get("/api/feed/years?show=all")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["years"] == [
|
||||
{"year": 2024, "count": 2},
|
||||
{"year": 2023, "count": 1},
|
||||
{"year": 2022, "count": 1},
|
||||
]
|
||||
|
||||
# A channel filter narrows the year list; a date narrowing is ignored (the point of the facet — so
|
||||
# a selected year never zeroes out the others). Scoping to chX drops 2023, keeps 2024 (×2) + 2022.
|
||||
resp = client.get("/api/feed/years?show=all&channel_ids=chX&published_after=2024-01-01")
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json()["years"] == [
|
||||
{"year": 2024, "count": 2},
|
||||
{"year": 2022, "count": 1},
|
||||
]
|
||||
|
||||
|
||||
def test_feed_years_buckets_by_utc_year_under_a_non_utc_session(db, make_user):
|
||||
"""feed_years must bucket by UTC year to stay in lockstep with the year filter's UTC-pinned
|
||||
published_after/before bounds. published_at is timestamptz; a bare extract('year', …) would use the
|
||||
DB session timezone, so a video just before midnight UTC on New Year's Eve would bucket into the
|
||||
wrong (local) year on a non-UTC session and disagree with the feed the year chip drills into. Called
|
||||
directly on this session (the HTTP client uses a separate connection SET TIME ZONE wouldn't reach)."""
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.feedquery import feed_years
|
||||
|
||||
user = make_user(email="tz-years@example.com")
|
||||
db.add(Channel(id="chTZ", title="chTZ", subscriber_count=1))
|
||||
db.add(Subscription(user_id=user.id, channel_id="chTZ", priority=0, hidden=False))
|
||||
# 2023-12-31 23:30 UTC — a positive-offset session tz local-shifts this into 2024.
|
||||
db.add(
|
||||
Video(
|
||||
id="tzv0",
|
||||
channel_id="chTZ",
|
||||
published_at=datetime(2023, 12, 31, 23, 30, tzinfo=timezone.utc),
|
||||
title="NYE clip",
|
||||
is_short=False,
|
||||
live_status="none",
|
||||
via_search=False,
|
||||
via_explore=False,
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
|
||||
params: dict = {
|
||||
"tags": [],
|
||||
"tag_modes": {"language": "or", "topic": "or", "other": "or"},
|
||||
"untagged": False,
|
||||
"channel_ids": [],
|
||||
"q": None,
|
||||
"min_duration": None,
|
||||
"max_duration": None,
|
||||
"max_age_days": None,
|
||||
"published_after": None,
|
||||
"published_before": None,
|
||||
"show_normal": True,
|
||||
"include_shorts": False,
|
||||
"include_live": False,
|
||||
"show": "all",
|
||||
"scope": "my",
|
||||
"library_source": "organic",
|
||||
}
|
||||
db.execute(
|
||||
text("SET TIME ZONE 'Asia/Tokyo'")
|
||||
) # +9: pushes the video's local wall-clock into 2024
|
||||
try:
|
||||
years = feed_years(db, user, **params)
|
||||
finally:
|
||||
db.execute(text("SET TIME ZONE 'UTC'")) # SET (not SET LOCAL) survives rollback — reset it
|
||||
# UTC bucketing keeps the boundary video in 2023; a bare session-tz extract would report 2024.
|
||||
assert years == [{"year": 2023, "count": 1}]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("case", _SUMMARY_CASES)
|
||||
def test_feed_summary_matches_count_plus_facets(db, as_user, make_user, case):
|
||||
# /feed/summary must return byte-identical numbers to the old /feed/count + /facets it replaces,
|
||||
|
||||
@@ -303,6 +303,54 @@ def test_remove_item_writes_through_to_plex(db, monkeypatch):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _resync_create(db, plex, lib, rk):
|
||||
"""Create-path resync stub: the new rating_key has no local row yet, so materialize it."""
|
||||
col = PlexCollection(rating_key=rk, library_id=lib.id, title="Fresh Coll", child_count=1)
|
||||
db.add(col)
|
||||
db.flush()
|
||||
return col
|
||||
|
||||
|
||||
def test_create_writes_through_to_plex(db, monkeypatch):
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_resync_create)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 200, r.text
|
||||
assert "create_collection" in stub.calls
|
||||
body = r.json()
|
||||
assert body["id"] == "NEW" # the stub's new ratingKey
|
||||
assert body["editable"] is True # create marks the new collection editable
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_create_resync_failure_is_422(db, monkeypatch):
|
||||
# The collection was created in Plex (wrote=True) but the follow-up resync read failed → 422 with
|
||||
# the honest "reconcile on the next sync" detail, not a false "write failed".
|
||||
_lib, user = _seed_editable(db)
|
||||
stub = _CollStub()
|
||||
|
||||
def _boom(db_, plex, lib_, rk):
|
||||
raise PlexError("read failed")
|
||||
|
||||
client = _admin_client(db, user, monkeypatch, stub, resync=_boom)
|
||||
try:
|
||||
r = client.post(
|
||||
"/api/plex/collections",
|
||||
json={"library": "1", "title": "Fresh Coll", "item_rating_key": "m1"},
|
||||
)
|
||||
assert r.status_code == 422, r.text
|
||||
assert "reconcile" in r.json()["detail"].lower()
|
||||
assert "create_collection" in stub.calls
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_mutation_failure_returns_422_write_failed(db, monkeypatch):
|
||||
# The Plex mutation itself raised → the write never landed → 422 "Plex write failed", and resync
|
||||
# is never reached (stub.calls holds only the mutating call).
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""DB-backed regression (E8·S4): the manual-tag-pattern suggester.
|
||||
|
||||
compute_tag_suggestions learns which auto TOPIC tags are *characteristic* of each user tag (from the
|
||||
channels the user tagged), then proposes that tag for still-untagged subscribed channels whose own
|
||||
topics cover ≥60% of the profile. The cases below pin the behaviours that would silently ship a
|
||||
useless suggester if they broke: the min-examples floor, the intersection-over-profile fraction that
|
||||
keeps a weak partial match out, the "language is not a signal" exclusion (its inclusion once flooded
|
||||
every channel with a suggestion), and the "already tagged → excluded" scope. Deterministic — no
|
||||
network, pure DB state.
|
||||
|
||||
Uses the DB lane (`db` fixture) — skipped when no Postgres is reachable.
|
||||
"""
|
||||
|
||||
from app.models import Channel, ChannelTag, Subscription, Tag, User
|
||||
from app.sync.tagsuggest import (
|
||||
AUTOAPPLY_THRESHOLD,
|
||||
CHAR_SUPPORT,
|
||||
MAX_PER_CHANNEL,
|
||||
SUGGEST_THRESHOLD,
|
||||
compute_tag_suggestions,
|
||||
)
|
||||
|
||||
|
||||
def _auto(db, name, category="topic"):
|
||||
tag = Tag(user_id=None, name=name, category=category)
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
return tag
|
||||
|
||||
|
||||
def _subscribe(db, user, ch_id, *, auto_tag_ids=(), title=None, hidden=False):
|
||||
"""A subscribed channel carrying the given auto-tags (source='auto', user_id NULL)."""
|
||||
db.add(Channel(id=ch_id, title=title or ch_id))
|
||||
db.add(Subscription(user_id=user.id, channel_id=ch_id, hidden=hidden))
|
||||
for tid in auto_tag_ids:
|
||||
db.add(ChannelTag(channel_id=ch_id, tag_id=tid, user_id=None, source="auto"))
|
||||
|
||||
|
||||
def test_suggests_a_user_tag_for_a_channel_matching_its_topic_profile(db):
|
||||
me = User(email="me@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
edu = _auto(db, "Education")
|
||||
film = _auto(db, "Film")
|
||||
eng = _auto(db, "English", category="language") # a language auto-tag → NOT part of the signal
|
||||
docu = Tag(user_id=me.id, name="docu", category="other")
|
||||
db.add(docu)
|
||||
db.flush()
|
||||
|
||||
# Three example channels the user tagged "docu", all Education+Film (+English). The topic profile
|
||||
# is {Education, Film}; the English language tag is excluded from the profile.
|
||||
for i in range(3):
|
||||
cid = f"UC_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[edu.id, film.id, eng.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=docu.id, user_id=me.id, source="user"))
|
||||
|
||||
# An untagged subscribed channel sharing the full topic profile → full-confidence suggestion.
|
||||
_subscribe(db, me, "UC_match", auto_tag_ids=[edu.id, film.id, eng.id])
|
||||
# An untagged channel sharing ONLY the language → no topic overlap at all, so no suggestion. This
|
||||
# is the anti-flood guarantee: a shared language must never carry a suggestion on its own.
|
||||
_subscribe(db, me, "UC_english_only", auto_tag_ids=[eng.id])
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
|
||||
assert "UC_match" in out
|
||||
assert out["UC_match"][0]["tag_id"] == docu.id
|
||||
# Both profile topics present → full confidence, i.e. an "accept all confident" hit.
|
||||
assert out["UC_match"][0]["confidence"] >= AUTOAPPLY_THRESHOLD
|
||||
assert "UC_english_only" not in out
|
||||
# Example channels are already tagged → excluded from suggestions.
|
||||
assert "UC_ex0" not in out
|
||||
|
||||
|
||||
def test_a_tag_below_the_example_floor_produces_no_profile(db):
|
||||
me = User(email="me2@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
edu = _auto(db, "Education")
|
||||
niche = Tag(user_id=me.id, name="niche", category="other")
|
||||
db.add(niche)
|
||||
db.flush()
|
||||
|
||||
# Only TWO example channels for "niche" (< MIN_EXAMPLES=3) → no profile, so no suggestion even
|
||||
# for a perfectly-matching untagged channel.
|
||||
for i in range(2):
|
||||
cid = f"UC_niche{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[edu.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=niche.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_would_match", auto_tag_ids=[edu.id])
|
||||
db.commit()
|
||||
|
||||
assert compute_tag_suggestions(db, me) == {}
|
||||
|
||||
|
||||
def test_partial_topic_overlap_is_a_fraction_of_the_profile(db):
|
||||
# Guards the intersection-over-profile shape at the SUGGEST_THRESHOLD boundary: a 3-topic profile
|
||||
# matched by 1 topic scores 0.33 (dropped); matched by 2 scores 0.67 (kept). Pins the threshold
|
||||
# so a change to it can't silently widen/narrow what "a suggestion" means.
|
||||
assert SUGGEST_THRESHOLD == 0.6
|
||||
me = User(email="me3@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
a, b, c = _auto(db, "A"), _auto(db, "B"), _auto(db, "C")
|
||||
tag = Tag(user_id=me.id, name="triad", category="other")
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_triad{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[a.id, b.id, c.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_one_of_three", auto_tag_ids=[a.id]) # 1/3 = 0.33 < 0.6 → dropped
|
||||
_subscribe(db, me, "UC_two_of_three", auto_tag_ids=[a.id, b.id]) # 2/3 = 0.67 ≥ 0.6 → kept
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_one_of_three" not in out
|
||||
assert "UC_two_of_three" in out
|
||||
assert out["UC_two_of_three"][0]["tag_id"] == tag.id
|
||||
assert out["UC_two_of_three"][0]["confidence"] == round(2 / 3, 3)
|
||||
|
||||
|
||||
def test_language_alone_is_never_a_profile_even_when_it_is_the_only_shared_tag(db):
|
||||
# The anti-flood invariant WITH TEETH. Every example channel shares only a language; each carries a
|
||||
# DIFFERENT topic, so no topic reaches CHAR_SUPPORT → the topic-only profile is empty → no
|
||||
# suggestion. If language were ever admitted to the signal the profile would collapse to {language}
|
||||
# and the language-only channel would score 1.0 and be suggested — the exact flood the exclusion
|
||||
# prevents. (test_suggests_… can't catch a regression here: there the language merely enlarges an
|
||||
# already topic-driven profile, so removing the exclusion flips no assertion.)
|
||||
me = User(email="lang@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
eng = _auto(db, "English", category="language")
|
||||
topics = [_auto(db, f"Topic{i}") for i in range(3)]
|
||||
lang = Tag(user_id=me.id, name="english-stuff", category="other")
|
||||
db.add(lang)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_lang_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[eng.id, topics[i].id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=lang.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_lang_only", auto_tag_ids=[eng.id])
|
||||
db.commit()
|
||||
|
||||
# No characteristic topic → no profile → nothing suggested for anyone (language never carries it).
|
||||
assert compute_tag_suggestions(db, me) == {}
|
||||
|
||||
|
||||
def test_char_support_admits_a_two_thirds_topic_and_excludes_a_one_third_topic(db):
|
||||
# Pins the characteristic-profile threshold (CHAR_SUPPORT) at its PARTIAL-support boundary: across 3
|
||||
# example channels a topic in 2/3 (0.67 ≥ 0.6) enters the profile, one in 1/3 (0.33) does not. The
|
||||
# other fixtures give every topic full 3/3 support, so a regression demanding full support (or
|
||||
# flipping ≥ to >) would ship green — this is the only case that exercises fractional support.
|
||||
assert CHAR_SUPPORT == 0.6
|
||||
me = User(email="chr@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
p, q, r = _auto(db, "P"), _auto(db, "Q"), _auto(db, "R")
|
||||
mix = Tag(user_id=me.id, name="mix", category="other")
|
||||
db.add(mix)
|
||||
db.flush()
|
||||
# p in 3/3 (kept), q in 2/3 (kept, 0.67), r in 1/3 (excluded, 0.33) → profile = {p, q}.
|
||||
example_topics = [[p.id, q.id, r.id], [p.id, q.id], [p.id]]
|
||||
for i, topics in enumerate(example_topics):
|
||||
cid = f"UC_mix_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=topics)
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=mix.id, user_id=me.id, source="user"))
|
||||
_subscribe(db, me, "UC_pq", auto_tag_ids=[p.id, q.id]) # {p,q}/{p,q} = 1.0 → suggested
|
||||
_subscribe(db, me, "UC_only_p", auto_tag_ids=[p.id]) # 1/2 = 0.5 < 0.6 → dropped
|
||||
_subscribe(db, me, "UC_p_and_r", auto_tag_ids=[p.id, r.id]) # {p}/{p,q} = 0.5 → dropped
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_pq" in out and out["UC_pq"][0]["confidence"] == 1.0
|
||||
# q (2/3) IS in the profile: p alone covers only half of it. Would be a full 1.0 match if the
|
||||
# builder wrongly required 3/3 support (profile would collapse to {p}).
|
||||
assert "UC_only_p" not in out
|
||||
# r (1/3) is NOT in the profile: {p,r} covers only p. Would score 0.67 (suggested) if r wrongly
|
||||
# entered the profile (a downward drift of CHAR_SUPPORT).
|
||||
assert "UC_p_and_r" not in out
|
||||
|
||||
|
||||
def test_suggestions_are_capped_and_ordered_best_first(db):
|
||||
# A channel matching many user-tag profiles gets at most MAX_PER_CHANNEL suggestions, ordered best
|
||||
# score first, with the weakest dropped by the cap. No other fixture matches >1 profile, so the sort
|
||||
# and the cap are both no-ops there — dropping either ships green. Four DISTINCT confidences keep the
|
||||
# order unambiguous. (The exact-tie tag_id tie-break in the sort key is a determinism detail, not
|
||||
# asserted here: on a stable sort it isn't catchable without relying on unspecified DB row order.)
|
||||
me = User(email="cap@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
|
||||
def profile_tag(name, n_topics):
|
||||
"""A user tag whose characteristic profile is exactly n_topics (3 identical example channels)."""
|
||||
topics = [_auto(db, f"{name}_t{i}") for i in range(n_topics)]
|
||||
tag = Tag(user_id=me.id, name=name, category="other")
|
||||
db.add(tag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_{name}_ex{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[t.id for t in topics])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=tag.id, user_id=me.id, source="user"))
|
||||
return tag, topics
|
||||
|
||||
t0, top0 = profile_tag("p0", 1) # profile size 1
|
||||
t1, top1 = profile_tag("p1", 5) # profile size 5
|
||||
t2, top2 = profile_tag("p2", 10) # profile size 10
|
||||
t3, top3 = profile_tag("p3", 10) # profile size 10
|
||||
|
||||
# One untagged channel matching all four at distinct confidences:
|
||||
# t0: 1/1 = 1.00
|
||||
# t1: 4/5 = 0.80
|
||||
# t2: 7/10 = 0.70
|
||||
# t3: 6/10 = 0.60 → a valid hit (≥ SUGGEST_THRESHOLD) but the weakest, dropped by the cap of 3
|
||||
matched = (
|
||||
[top0[0].id]
|
||||
+ [t.id for t in top1[:4]]
|
||||
+ [t.id for t in top2[:7]]
|
||||
+ [t.id for t in top3[:6]]
|
||||
)
|
||||
_subscribe(db, me, "UC_many", auto_tag_ids=matched)
|
||||
db.commit()
|
||||
|
||||
hits = compute_tag_suggestions(db, me)["UC_many"]
|
||||
assert len(hits) == MAX_PER_CHANNEL # 4 matches, capped to 3
|
||||
assert [h["tag_id"] for h in hits] == [t0.id, t1.id, t2.id] # best score first
|
||||
assert [h["confidence"] for h in hits] == [1.0, 0.8, 0.7]
|
||||
assert t3.id not in [
|
||||
h["tag_id"] for h in hits
|
||||
] # the weakest match (0.60) is the one the cap drops
|
||||
|
||||
|
||||
def test_unsubscribed_example_channel_does_not_inflate_the_profile_denominator(db):
|
||||
# Unsubscribe deletes the Subscription but NOT the source='user' ChannelTag, so a tagged-then-
|
||||
# unsubscribed channel must NOT count as a profile example: it contributes no topic features (those
|
||||
# are scanned over subscribed channels only) yet would inflate CHAR_SUPPORT's denominator and drop a
|
||||
# genuinely characteristic topic. Here 'X' is characteristic at 2/3 of the SUBSCRIBED examples
|
||||
# (threshold 1.8, count 2 → kept), but if the unsubscribed 4th example counted, threshold 2.4 would
|
||||
# drop it and suppress the suggestion.
|
||||
me = User(email="unsub@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
x, y = _auto(db, "X"), _auto(db, "Y")
|
||||
wood = Tag(user_id=me.id, name="wood", category="other")
|
||||
db.add(wood)
|
||||
db.flush()
|
||||
# 3 subscribed examples: 2 carry topic X, 1 carries only Y → X support 2/3 among subscribed.
|
||||
for cid, topic in [("UC_w0", x), ("UC_w1", x), ("UC_w2", y)]:
|
||||
_subscribe(db, me, cid, auto_tag_ids=[topic.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=wood.id, user_id=me.id, source="user"))
|
||||
# A 4th channel tagged "wood" but NO LONGER subscribed (channel + user tag, no Subscription row).
|
||||
db.add(Channel(id="UC_gone", title="UC_gone"))
|
||||
db.add(ChannelTag(channel_id="UC_gone", tag_id=wood.id, user_id=me.id, source="user"))
|
||||
_subscribe(
|
||||
db, me, "UC_cand", auto_tag_ids=[x.id]
|
||||
) # untagged X match → should be suggested "wood"
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_cand" in out
|
||||
assert out["UC_cand"][0]["tag_id"] == wood.id
|
||||
|
||||
|
||||
def test_hidden_subscriptions_are_not_suggestion_targets(db):
|
||||
# The Channel Manager renders suggestion ghost chips only on non-hidden rows, so a hidden channel
|
||||
# must not be a suggestion target — otherwise the header count / bulk-accept would span channels with
|
||||
# no visible chip. (Hidden channels still count as example/feature signal; only targeting is excluded.)
|
||||
me = User(email="hid@example.com")
|
||||
db.add(me)
|
||||
db.flush()
|
||||
z = _auto(db, "Z")
|
||||
ztag = Tag(user_id=me.id, name="ztag", category="other")
|
||||
db.add(ztag)
|
||||
db.flush()
|
||||
for i in range(3):
|
||||
cid = f"UC_z{i}"
|
||||
_subscribe(db, me, cid, auto_tag_ids=[z.id])
|
||||
db.add(ChannelTag(channel_id=cid, tag_id=ztag.id, user_id=me.id, source="user"))
|
||||
# A hidden, untagged, perfectly-matching subscription → NOT a target; a visible one → suggested.
|
||||
_subscribe(db, me, "UC_hidden", auto_tag_ids=[z.id], hidden=True)
|
||||
_subscribe(db, me, "UC_visible", auto_tag_ids=[z.id])
|
||||
db.commit()
|
||||
|
||||
out = compute_tag_suggestions(db, me)
|
||||
assert "UC_visible" in out
|
||||
assert "UC_hidden" not in out
|
||||
+2
-1
@@ -29,8 +29,9 @@ A change is done when all of this is green. CI runs the same checks server-side
|
||||
**Backend**
|
||||
|
||||
```bash
|
||||
# lint
|
||||
# lint + format check
|
||||
python -m ruff check --config backend/ruff.toml backend scripts
|
||||
python -m ruff format --check --config backend/ruff.toml backend scripts
|
||||
|
||||
# tests — pure-logic + the DB-backed integration lane, against an ephemeral Postgres.
|
||||
# The backend tree is bind-mounted, so it always tests your working files.
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Filter, X } from "lucide-react";
|
||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
||||
import { decideChipLayout } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||
@@ -131,52 +132,36 @@ export default function ActiveFilterChips({
|
||||
const chipRefs = useRef<Array<HTMLSpanElement | null>>([]);
|
||||
const plusRef = useRef<HTMLSpanElement | null>(null);
|
||||
const clearRef = useRef<HTMLSpanElement | null>(null);
|
||||
// How the row is currently laid out: all chips, a fit-count + "+N", or a single count pill.
|
||||
const [layout, setLayout] = useState<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
mode: "all",
|
||||
visible: filters.length,
|
||||
});
|
||||
|
||||
const n = filters.length;
|
||||
// Re-measure whenever the chip set changes (labels or count) — captures both in one dep.
|
||||
const chipsKey = filters.map((f) => f.key + f.label).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const lane = laneRef.current;
|
||||
const labelEl = labelRef.current;
|
||||
if (!lane || !labelEl || n === 0) return;
|
||||
|
||||
const measure = () => {
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const chips = chipRefs.current.slice(0, n);
|
||||
const plusEl = plusRef.current;
|
||||
// Measure the real rendered gap off the box between the last chip and the "+N" sample, so it
|
||||
// scales with the text-size slider (gap-1.5 is rem) rather than being frozen in px.
|
||||
const lastChip = chips[n - 1];
|
||||
const gap = plusEl && lastChip ? Math.max(0, plusEl.offsetLeft - rightOf(lastChip)) : 6;
|
||||
// "all" mode also renders a Clear all button after the chips (when n > 1), so reserve its width.
|
||||
const clearW = n > 1 ? gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
|
||||
setLayout(
|
||||
decideChipLayout({
|
||||
n,
|
||||
labelRight: rightOf(labelEl),
|
||||
chipRights: chips.map((c) => rightOf(c)),
|
||||
plusW: plusEl?.offsetWidth ?? 0,
|
||||
clearW,
|
||||
avail: lane.clientWidth,
|
||||
gap,
|
||||
narrowPx: NARROW_PX,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
measure();
|
||||
const ro = new ResizeObserver(measure);
|
||||
ro.observe(lane);
|
||||
return () => ro.disconnect();
|
||||
}, [n, chipsKey]);
|
||||
// The shared measurement core hands over the chip right edges + gap + pill width + lane width; this
|
||||
// policy adds the feed row's own framing (the leading label's right edge, and the trailing "Clear
|
||||
// all" button's width, reserved only when it renders — n > 1) before deciding all/some/count.
|
||||
const layout = useChipOverflowFit<{ mode: "all" | "some" | "count"; visible: number }>({
|
||||
rowRef: laneRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef: plusRef,
|
||||
deps: `${n}|${chipsKey}`,
|
||||
initial: { mode: "all", visible: n },
|
||||
decide: (g) => {
|
||||
const labelEl = labelRef.current;
|
||||
const labelRight = labelEl ? labelEl.offsetLeft + labelEl.offsetWidth : 0;
|
||||
const clearW = n > 1 ? g.gap + (clearRef.current?.offsetWidth ?? 0) : 0;
|
||||
return decideChipLayout({
|
||||
n,
|
||||
labelRight,
|
||||
chipRights: g.chipRights,
|
||||
plusW: g.pillW,
|
||||
clearW,
|
||||
avail: g.avail,
|
||||
gap: g.gap,
|
||||
narrowPx: NARROW_PX,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (n === 0) return null;
|
||||
|
||||
|
||||
@@ -77,8 +77,10 @@ export function Carousel({
|
||||
|
||||
const label = ariaLabel ?? (typeof title === "string" ? title : undefined);
|
||||
|
||||
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
|
||||
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
|
||||
const arrowBtn =
|
||||
"absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the suggested-tag ghost chips (E8·S4): on an UNTAGGED channel, ChannelTagsControl renders
|
||||
// one dashed "ghost" chip per suggestion; clicking the chip body accepts it (onAcceptSuggestion with the
|
||||
// tag id), the trailing × dismisses it (onDismissSuggestion), and a suggestion whose tag is already
|
||||
// attached is never shown. i18n is key-passthrough; the control rides the floating-ui popover platform
|
||||
// and a ResizeObserver-driven fit measurer, so we stub the observer and wrap in a FloatingTree.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
|
||||
i18n: { language: "en" },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import type { Tag } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const suggestions: SuggestedTag[] = [
|
||||
{ tag: docu, confidence: 0.95 },
|
||||
{ tag: history, confidence: 0.6 },
|
||||
];
|
||||
|
||||
function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]> = {}) {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
render(
|
||||
<FloatingTree>
|
||||
<ChannelTagsControl
|
||||
userTags={[docu, history]}
|
||||
attachedIds={[]}
|
||||
onToggle={() => {}}
|
||||
onCreate={() => {}}
|
||||
suggestions={suggestions}
|
||||
onAcceptSuggestion={onAccept}
|
||||
onDismissSuggestion={onDismiss}
|
||||
{...props}
|
||||
/>
|
||||
</FloatingTree>,
|
||||
);
|
||||
return { onAccept, onDismiss };
|
||||
}
|
||||
|
||||
describe("ChannelTagsControl suggested ghost chips", () => {
|
||||
it("renders a ghost chip per suggestion and accepts on click", () => {
|
||||
const { onAccept } = renderControl();
|
||||
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
|
||||
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
|
||||
expect(onAccept).toHaveBeenCalledWith(docu.id);
|
||||
});
|
||||
|
||||
it("dismisses via the × without accepting", () => {
|
||||
const { onAccept, onDismiss } = renderControl();
|
||||
const dismissButtons = screen.getAllByRole("button", {
|
||||
name: "channels.tags.dismissSuggestion",
|
||||
});
|
||||
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
|
||||
// suggestions array, so the second × is the "history" suggestion.
|
||||
expect(dismissButtons).toHaveLength(2);
|
||||
fireEvent.click(dismissButtons[1]!);
|
||||
expect(onDismiss).toHaveBeenCalledWith(history.id);
|
||||
expect(onAccept).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
|
||||
renderControl({ attachedIds: [docu.id] });
|
||||
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
|
||||
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
|
||||
expect(
|
||||
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Check, Plus } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, Plus, Sparkles, X } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type Tag } from "../lib/api";
|
||||
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
|
||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||
@@ -38,6 +40,157 @@ function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// A suggested user tag (E8·S4): a dashed "ghost" chip the user hasn't applied yet, learned from
|
||||
// their own tagging pattern. The chip body accepts it (one tap → attaches the real tag); the trailing
|
||||
// × dismisses it for the session. Rendered only on untagged channels, so it never competes with the
|
||||
// measured attached-chip row. (Two sibling buttons, not a button-in-button — invalid nesting.)
|
||||
export interface SuggestedTag {
|
||||
tag: Tag;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
function SuggestedChip({
|
||||
suggestion,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestion: SuggestedTag;
|
||||
onAccept: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pct = Math.round(suggestion.confidence * 100);
|
||||
return (
|
||||
<span className="shrink-0 inline-flex items-center rounded-full border border-dashed border-accent/50 text-accent/90 text-2xs font-medium pl-1 pr-0.5 py-0.5 max-w-36">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAccept}
|
||||
title={t("channels.tags.suggestedHint", { pct })}
|
||||
aria-label={t("channels.tags.acceptSuggestion", { name: suggestion.tag.name })}
|
||||
className="inline-flex items-center gap-1 min-w-0 hover:text-accent transition"
|
||||
>
|
||||
<Sparkles className="w-2.5 h-2.5 shrink-0" />
|
||||
<span className="truncate">{suggestion.tag.name}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
title={t("channels.tags.dismissSuggestion")}
|
||||
aria-label={t("channels.tags.dismissSuggestion")}
|
||||
className="shrink-0 ml-0.5 rounded-full text-accent/60 hover:text-danger transition"
|
||||
>
|
||||
<X className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const GHOST_PILL =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
|
||||
|
||||
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
|
||||
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
|
||||
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
|
||||
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
|
||||
function SuggestedChipsLane({
|
||||
suggestions,
|
||||
onAccept,
|
||||
onDismiss,
|
||||
}: {
|
||||
suggestions: SuggestedTag[];
|
||||
onAccept: (tagId: number) => void;
|
||||
onDismiss: (tagId: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: suggestions.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
const hidden = suggestions.length - visible;
|
||||
|
||||
const [overflowOpen, setOverflowOpen] = useState(false);
|
||||
const overflowPop = useAnchoredPopover({
|
||||
open: overflowOpen,
|
||||
onOpenChange: setOverflowOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom",
|
||||
});
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
|
||||
}, [hidden, overflowOpen]);
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-1 min-w-0 flex-1">
|
||||
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{suggestions.map((s, i) => (
|
||||
<span
|
||||
key={s.tag.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={GHOST_PILL}>
|
||||
+{suggestions.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{suggestions.slice(0, visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${GHOST_PILL} hover:text-accent transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
|
||||
<PopoverSurface
|
||||
popover={overflowPop}
|
||||
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{suggestions.slice(visible).map((s) => (
|
||||
<SuggestedChip
|
||||
key={s.tag.id}
|
||||
suggestion={s}
|
||||
onAccept={() => onAccept(s.tag.id)}
|
||||
onDismiss={() => onDismiss(s.tag.id)}
|
||||
/>
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
|
||||
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
|
||||
* detail page in Part 2; internal for now.) */
|
||||
@@ -137,6 +290,9 @@ export function ChannelTagsControl({
|
||||
onToggle,
|
||||
onCreate,
|
||||
onChipClick,
|
||||
suggestions,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
bounded = true,
|
||||
}: {
|
||||
userTags: Tag[];
|
||||
@@ -145,6 +301,11 @@ export function ChannelTagsControl({
|
||||
onCreate: (name: string) => void;
|
||||
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
|
||||
onChipClick?: (tagId: number, name: string) => void;
|
||||
/** Manager only (E8·S4): suggested user tags for an UNTAGGED channel, shown as accept/dismiss ghost
|
||||
* chips after the (empty) attached row. Omit on the detail page. */
|
||||
suggestions?: SuggestedTag[];
|
||||
onAcceptSuggestion?: (tagId: number) => void;
|
||||
onDismissSuggestion?: (tagId: number) => void;
|
||||
/** Cap the chip row at a fixed 12rem so it can't widen a `table-layout:auto` column and reflow the
|
||||
* table (the manager). Set false where there is free width (the channel detail header), so tags
|
||||
* fit inline instead of needlessly collapsing into "+N". */
|
||||
@@ -152,64 +313,30 @@ export function ChannelTagsControl({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
||||
// Suggestions only surface for channels with no user tag yet, but guard anyway: never suggest a tag
|
||||
// the channel already carries (an optimistic accept adds it before the query refetches).
|
||||
const shownSuggestions = (suggestions ?? []).filter((s) => !attachedIds.includes(s.tag.id));
|
||||
|
||||
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
|
||||
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
|
||||
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
|
||||
// rem boxes, never a px guess.
|
||||
// natural widths; the shared measure core (useChipOverflowFit) reads each chip's right edge, the rem
|
||||
// gap and the pill width off real boxes, and fitChipsWithPill computes EXACTLY how many fit (reserving
|
||||
// the pill whenever any overflow). One pass — the count is right as tags are added/removed/renamed —
|
||||
// and it re-measures on width/scale change via the hook's ResizeObserver.
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||
const [visible, setVisible] = useState(attached.length);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Key on id AND name so a RENAME (which changes a chip's width) re-runs the fit, not just add/remove.
|
||||
const tagsKey = attached.map((tg) => `${tg.id}:${tg.name}`).join("|");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!row || !measure) return;
|
||||
const compute = () => {
|
||||
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||
const pill = nodes[nodes.length - 1];
|
||||
if (!pill) return;
|
||||
const pillW = pill.offsetWidth;
|
||||
const chips = nodes.slice(0, -1);
|
||||
// Measure the real rendered gap (gap-1 is rem — it scales with the font slider) off the box
|
||||
// between the last chip and the sample pill, rather than assuming a px constant.
|
||||
const lastChip = chips[chips.length - 1];
|
||||
const gap = lastChip
|
||||
? Math.max(0, pill.offsetLeft - (lastChip.offsetLeft + lastChip.offsetWidth))
|
||||
: 0;
|
||||
const avail = row.clientWidth;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and no "+N"
|
||||
// pill is needed. The reservation loop below reserves the pill's width for every chip except
|
||||
// the last and breaks at the first overflow, so on its own it never reaches the only "does the
|
||||
// whole set fit?" test — it would collapse chips that would have fitted (e.g. a short trailing
|
||||
// chip narrower than the pill), leaving "+N" beside empty space.
|
||||
const lastRight = lastChip ? lastChip.offsetLeft + lastChip.offsetWidth : 0;
|
||||
if (lastRight <= avail) {
|
||||
setVisible(chips.length);
|
||||
return;
|
||||
}
|
||||
let count = chips.length;
|
||||
for (let i = 0; i < chips.length; i++) {
|
||||
const chip = chips[i];
|
||||
if (!chip) break;
|
||||
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
|
||||
const moreAfter = i < chips.length - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
setVisible(count);
|
||||
};
|
||||
compute();
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(compute);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
}, [tagsKey]);
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: tagsKey,
|
||||
initial: attached.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = attached.length - visible;
|
||||
|
||||
@@ -249,38 +376,66 @@ export function ChannelTagsControl({
|
||||
<div
|
||||
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
|
||||
>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
||||
<div
|
||||
ref={measureRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
{attached.map((tg) => (
|
||||
<TagChip key={tg.id} tag={tg} />
|
||||
))}
|
||||
<span className={pillClass}>+{attached.length}</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
|
||||
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
|
||||
channels only, and accepting one removes the channel from the suggestion set — so a channel
|
||||
never shows both, and each lane gets the full cell width instead of clipping the other. */}
|
||||
{attached.length === 0 && shownSuggestions.length > 0 ? (
|
||||
<SuggestedChipsLane
|
||||
suggestions={shownSuggestions}
|
||||
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
|
||||
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
|
||||
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
|
||||
right edge; the pill is the sample the hook reserves room for. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{attached.map((tg, i) => (
|
||||
<span
|
||||
key={tg.id}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<TagChip tag={tg} />
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={pillClass}>
|
||||
+{attached.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
|
||||
{attached.slice(0, visible).map((tg) => (
|
||||
<TagChip
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
|
||||
title={
|
||||
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={overflowPop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={t("channels.tags.moreCount", { count: hidden })}
|
||||
className={`${pillClass} hover:text-fg transition`}
|
||||
{...overflowPop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={editPop.refs.setReference}
|
||||
|
||||
@@ -4,7 +4,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { Ban, Eye, EyeOff, History, LayoutGrid, Pencil, RefreshCw, Table, X } from "lucide-react";
|
||||
import ExcludedChannelList from "./ExcludedChannelList";
|
||||
import { api, type ManagedChannel } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { buildChannelColumns, applySyncFilter } from "./channelColumns";
|
||||
@@ -15,10 +16,12 @@ import Tabs, { type TabDef } from "./Tabs";
|
||||
import ChannelLayoutGrid from "./ChannelLayoutGrid";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import { useChannelTagActions } from "../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useChannelPriority } from "../lib/useChannelPriority";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import ColumnSortControl from "./ColumnSortControl";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
import TagFilterChips, { type FilterChip } from "./TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "./Pager";
|
||||
@@ -164,10 +167,37 @@ export default function Channels() {
|
||||
),
|
||||
),
|
||||
// Keep the channel DETAIL cache in step with the manager list (the detail page reads its tags
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side.
|
||||
onSuccess: (id) => qc.invalidateQueries({ queryKey: qk.channel(id) }),
|
||||
onError: () => qc.invalidateQueries({ queryKey: qk.channels() }),
|
||||
// from qk.channel(id)); mirrors the detail page invalidating the manager list on its side. Also
|
||||
// recompute suggestions: a channel that just gained/lost a user tag changes the untagged set and
|
||||
// the learned pattern (E8·S4).
|
||||
onSuccess: (id) => {
|
||||
qc.invalidateQueries({ queryKey: qk.channel(id) });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
},
|
||||
// A failed attach/detach reverts the optimistic change by refetching the list. Also refetch
|
||||
// suggestions: acceptSuggestion optimistically dropped the channel from that cache, and on
|
||||
// failure the tag never actually attached — so the server still suggests it and the ghost chips
|
||||
// must come back rather than leaving an empty cell (honest states). Surface the write failure too.
|
||||
onError: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.channels() });
|
||||
qc.invalidateQueries({ queryKey: qk.tagSuggestions() });
|
||||
notify({ level: "error", message: t("channels.tags.updateFailed") });
|
||||
},
|
||||
});
|
||||
// --- Suggested tags (E8·S4): learned from the user's own manual-tag pattern, shown as accept/dismiss
|
||||
// ghost chips on untagged channels. Dismissal is session-only (an accepted suggestion self-clears —
|
||||
// the channel becomes tagged and the server drops it; a dismissed one just hides until reload). The
|
||||
// /tags/suggestions query, the session dismiss set, and the optimistic accept/dismiss/bulk logic all
|
||||
// live in useTagSuggestions so they can be unit-tested apart from this component.
|
||||
const {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
} = useTagSuggestions({ tags: tagsQuery.data ?? [], toggleTag });
|
||||
|
||||
const syncSubs = useMutation({
|
||||
mutationFn: () => api.syncSubscriptions(),
|
||||
onSuccess: (r: { subscriptions?: number }) => {
|
||||
@@ -217,11 +247,34 @@ export default function Channels() {
|
||||
// funnel share ONE state and stay in sync (bidirectional). Toggling a chip rewrites `filters.tags`;
|
||||
// filterRows / DataTable then applies it. Channel-name search stays separate (the shared top box).
|
||||
const tagFilter = Array.isArray(filters.tags) ? filters.tags : [];
|
||||
// Picking a real tag drops the "No tag" sentinel (item B) — they're mutually exclusive, as in the feed.
|
||||
const toggleTagFilter = (tagId: number) => {
|
||||
const id = String(tagId);
|
||||
const next = tagFilter.includes(id) ? tagFilter.filter((x) => x !== id) : [...tagFilter, id];
|
||||
const base = tagFilter.filter((x) => x !== NO_TAG_SENTINEL);
|
||||
const next = base.includes(id) ? base.filter((x) => x !== id) : [...base, id];
|
||||
setFilters({ ...filters, tags: next });
|
||||
};
|
||||
// The "No tag" chip: filter the table to channels you haven't personally tagged yet. Exclusive with
|
||||
// real tag chips (clicking it drops them); the Tags column's test reads the sentinel (channelColumns).
|
||||
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
||||
const toggleNoTagFilter = () =>
|
||||
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
|
||||
// The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any
|
||||
// overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap.
|
||||
const tagFilterChips: FilterChip[] = [
|
||||
...userTags.map((tg) => ({
|
||||
key: `t${tg.id}`,
|
||||
label: tg.name,
|
||||
active: tagFilter.includes(String(tg.id)),
|
||||
onToggle: () => toggleTagFilter(tg.id),
|
||||
})),
|
||||
{
|
||||
key: NO_TAG_SENTINEL,
|
||||
label: t("channels.tags.noTag"),
|
||||
active: noTagOn,
|
||||
onToggle: toggleNoTagFilter,
|
||||
},
|
||||
];
|
||||
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||
useEffect(() => {
|
||||
if (focusChannelName) setSearch(focusChannelName);
|
||||
@@ -295,6 +348,9 @@ export default function Channels() {
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion: acceptSuggestion,
|
||||
onDismissSuggestion: dismissSuggestion,
|
||||
});
|
||||
|
||||
// Status chips sit in the table's controls row (next to paging) — compact and close to
|
||||
@@ -426,41 +482,52 @@ export default function Channels() {
|
||||
{tabs}
|
||||
{view === "subscribed" && (
|
||||
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
|
||||
{/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name
|
||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{userTags.map((tg) => (
|
||||
<button
|
||||
key={tg.id}
|
||||
onClick={() => toggleTagFilter(tg.id)}
|
||||
aria-pressed={tagFilter.includes(String(tg.id))}
|
||||
className={filterChipClass}
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
))}
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{actionButtons}
|
||||
{/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
|
||||
overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
|
||||
"…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
||||
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
||||
<button
|
||||
onClick={() => setTagManagerOpen(true)}
|
||||
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
{t("channels.tags.label")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||
{tagFilter.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
)}
|
||||
{/* Suggested-tags affordance (E8·S4): a sparkle count of the visible suggestions, with a
|
||||
one-click bulk-accept for the high-confidence ones. The per-row ghost chips handle the
|
||||
rest. Hidden when there's nothing to suggest. */}
|
||||
{visibleSuggestions.length > 0 && (
|
||||
<div className="shrink-0 flex items-center gap-1.5">
|
||||
<Tooltip hint={t("channels.tags.suggestionsHint")}>
|
||||
<span className="inline-flex items-center gap-1 rounded-lg border border-dashed border-accent/50 px-2 py-1 text-xs text-accent/90">
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })}
|
||||
</span>
|
||||
</Tooltip>
|
||||
{highConfidence.length > 0 && (
|
||||
<button
|
||||
onClick={acceptAllHighConfidence}
|
||||
className="text-xs px-2 py-1 rounded-lg border border-border text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
{t("channels.tags.acceptAllHigh", { count: highConfidence.length })}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="shrink-0">{actionButtons}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -151,6 +151,7 @@ export default function DateFilterSelect({
|
||||
...filters,
|
||||
dateFrom: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
year: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
@@ -169,6 +170,7 @@ export default function DateFilterSelect({
|
||||
...filters,
|
||||
dateTo: e.target.value || undefined,
|
||||
maxAgeDays: undefined,
|
||||
year: undefined,
|
||||
})
|
||||
}
|
||||
className={dateInputCls}
|
||||
|
||||
@@ -19,14 +19,6 @@ describe("loadAccountFilters — legacy tagMode migration on seed", () => {
|
||||
expect(f.q).toBe("hi"); // other fields preserved through the merge
|
||||
});
|
||||
|
||||
it("migrates a legacy tagMode:'and' from the default-view mirror when no last-session blob exists", () => {
|
||||
localStorage.setItem(
|
||||
accountKey(LS.defaultViewFilters, ID)!,
|
||||
JSON.stringify({ tagMode: "and" }),
|
||||
);
|
||||
expect(loadAccountFilters(ID).tagModes.topic).toBe("and");
|
||||
});
|
||||
|
||||
it("leaves tagModes at defaults when nothing is stored", () => {
|
||||
expect(loadAccountFilters(ID).tagModes).toEqual({ language: "or", topic: "or", other: "or" });
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
api,
|
||||
getActiveAccount,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
DEFAULT_TAG_MODES,
|
||||
type FeedFilters,
|
||||
} from "../lib/api";
|
||||
@@ -39,6 +40,7 @@ import { useNavigation } from "./NavigationProvider";
|
||||
const DEFAULT_FILTERS: FeedFilters = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
q: "",
|
||||
sort: "newest",
|
||||
@@ -51,16 +53,16 @@ const DEFAULT_FILTERS: FeedFilters = {
|
||||
|
||||
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.
|
||||
// This account's own persisted filters (per-account keys — never another account's). 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.
|
||||
export 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.)
|
||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||
const fKey = accountKey(FILTERS_KEY, id);
|
||||
let hasStored = false;
|
||||
try {
|
||||
@@ -68,18 +70,18 @@ export function loadAccountFilters(id: number | null): FeedFilters {
|
||||
} catch {
|
||||
/* localStorage may be unavailable */
|
||||
}
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes` on every seed path,
|
||||
// matching what the saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes
|
||||
// MUST run on the RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects
|
||||
// DEFAULT_TAG_MODES (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
// Migrate a persisted legacy global `tagMode` into the per-category `tagModes`, matching what the
|
||||
// saved-view (asFilters) and URL (paramsToFilters) paths already do. coerceTagModes MUST run on the
|
||||
// RAW stored blob, not the object merged over DEFAULT_FILTERS: the merge injects DEFAULT_TAG_MODES
|
||||
// (all "or"), which coerceTagModes prefers over the legacy scalar and would mask it.
|
||||
if (hasStored && fKey) {
|
||||
const raw = readJSON<Partial<FeedFilters>>(fKey, {});
|
||||
return { ...DEFAULT_FILTERS, ...raw, tagModes: coerceTagModes(raw) };
|
||||
}
|
||||
const dvKey = accountKey(LS.defaultViewFilters, id);
|
||||
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
|
||||
if (def) {
|
||||
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
|
||||
return {
|
||||
...DEFAULT_FILTERS,
|
||||
...raw,
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
};
|
||||
}
|
||||
return { ...DEFAULT_FILTERS };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The scroll-restoration contract PageScroller implements: offsets are remembered per
|
||||
// (history entry id + content key), so Back to an entry restores where you left it while a forward
|
||||
// navigation (a new entry id) or a new content key opens at the top. jsdom lays nothing out, so we
|
||||
// feed the <main> fake metrics, drive restoreScroll's rAF loop synchronously, and steer the entry id
|
||||
// through window.history.state (what ensureNavId/peekNavId read) — firing the nav bus (notifyNav)
|
||||
// stands in for a Back/Forward that doesn't re-render the scroller.
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k }) };
|
||||
});
|
||||
|
||||
import PageScroller from "./PageScroller";
|
||||
import { notifyNav } from "../lib/history";
|
||||
|
||||
beforeAll(() => {
|
||||
// useScrollFade wires a ResizeObserver (watch-the-children); jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// restoreScroll re-applies the offset over a few rAF frames (the feed needs time to reach height);
|
||||
// run rAF synchronously so a restore lands within the test tick.
|
||||
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", () => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function setEntry(id: number) {
|
||||
window.history.replaceState({ sfScroll: id }, "");
|
||||
}
|
||||
|
||||
function sizedMain(container: HTMLElement): HTMLElement {
|
||||
const main = container.querySelector("main")!;
|
||||
// Enough scrollable room for a 300px offset (restoreScroll only sets when scrollHeight-clientHeight
|
||||
// >= the target), and a writable scrollTop jsdom otherwise no-ops.
|
||||
Object.defineProperty(main, "scrollHeight", { configurable: true, value: 1000 });
|
||||
Object.defineProperty(main, "clientHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(main, "scrollTop", { configurable: true, writable: true, value: 0 });
|
||||
return main;
|
||||
}
|
||||
|
||||
describe("PageScroller scroll memory", () => {
|
||||
it("restores per history entry: a forward nav opens at the top, Back restores the offset", () => {
|
||||
setEntry(1);
|
||||
const { container } = render(<PageScroller scrollKey="e:k1">x</PageScroller>);
|
||||
const main = sizedMain(container);
|
||||
|
||||
// The user scrolls entry 1 to 200 — recorded live on scroll.
|
||||
main.scrollTop = 200;
|
||||
fireEvent.scroll(main);
|
||||
|
||||
// Forward navigation → a fresh entry id, same content key → the view opens at the top.
|
||||
setEntry(2);
|
||||
notifyNav();
|
||||
expect(main.scrollTop).toBe(0);
|
||||
|
||||
// Back to entry 1 → its saved offset is restored.
|
||||
setEntry(1);
|
||||
notifyNav();
|
||||
expect(main.scrollTop).toBe(200);
|
||||
});
|
||||
|
||||
it("restores per content key within one entry: a new key resets to top, returning restores", () => {
|
||||
setEntry(10);
|
||||
const { container, rerender } = render(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
||||
const main = sizedMain(container);
|
||||
|
||||
main.scrollTop = 300;
|
||||
fireEvent.scroll(main);
|
||||
|
||||
// A new content key (e.g. a fresh feed filter) in the SAME entry starts at the top.
|
||||
rerender(<PageScroller scrollKey="e10:b">x</PageScroller>);
|
||||
expect(main.scrollTop).toBe(0);
|
||||
|
||||
// Returning to the first key restores its offset.
|
||||
rerender(<PageScroller scrollKey="e10:a">x</PageScroller>);
|
||||
expect(main.scrollTop).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
||||
import type { YTPlayer } from "../lib/youtube";
|
||||
@@ -337,7 +337,10 @@ export default function PlayerModal({
|
||||
if (e.key === "Escape") {
|
||||
// Defer to any layer opened above the player FIRST (a Modal like the download dialog, or an
|
||||
// anchored popover): it takes the Escape and we do nothing — so we don't also step out of
|
||||
// maximise or close the player underneath it.
|
||||
// maximise or close the player underneath it. The consumed-event flag is the reliable signal
|
||||
// (UX-8): the layer's `document` listener runs before ours and its close can pop it off the
|
||||
// stack synchronously, so `anyLayerAbove` alone would miss it and close the player too.
|
||||
if (escapeHandled(e)) return;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) return;
|
||||
// In fullscreen, let the browser's Esc exit fullscreen only — don't also close the modal.
|
||||
if (fullscreenElement()) return;
|
||||
|
||||
@@ -4,7 +4,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 { notifyActionError } from "../lib/actionErrors";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import Modal from "./Modal";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
@@ -102,8 +102,9 @@ export default function PlexCollectionEditor({
|
||||
setMembers((prev) => new Set(prev).add(col.id));
|
||||
refresh();
|
||||
} catch (e) {
|
||||
// Surfaces the 409 when the name duplicates an existing collection (Plex conflates same names).
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
// The backend rejects a duplicate name with 409 (Plex conflates same names) — localize it to
|
||||
// nameTaken rather than surfacing the server's raw English detail.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S4 Play-all — buildQueue's show→episode expansion and playAll's decision branch, the honest-states
|
||||
// behaviour the deep-review flagged as untested. A leaf member queues as itself; a show member is
|
||||
// expanded (lazily, on click) into its episodes in season→episode order; a show that expands to zero
|
||||
// episodes must NOT silently no-op but surface the `playEmpty` info notice. api.plexCollection /
|
||||
// api.plexShow are stubbed so the queries resolve synchronously; notify is spied to assert the notice.
|
||||
import { describe, it, expect, vi, beforeAll, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
beforeAll(() => {
|
||||
// Poster grids / carousels wire a ResizeObserver; jsdom ships none.
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
// Spy notify without dropping the module's other exports (the render subtree uses them).
|
||||
const h = vi.hoisted(() => ({ notify: vi.fn() }));
|
||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
||||
notify: (...a: unknown[]) => h.notify(...a),
|
||||
}));
|
||||
|
||||
import PlexCollectionView from "./PlexCollectionView";
|
||||
import { api, type PlexCard, type PlexCollectionDetail, type PlexShowDetail } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
h.notify.mockClear();
|
||||
});
|
||||
|
||||
const card = (id: string, type: "movie" | "show"): PlexCard =>
|
||||
({
|
||||
id,
|
||||
type,
|
||||
title: id,
|
||||
year: 2000,
|
||||
duration_seconds: 3600,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
playable: "direct",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
}) as unknown as PlexCard;
|
||||
|
||||
const detail = (items: PlexCard[]): PlexCollectionDetail =>
|
||||
({
|
||||
id: "col1",
|
||||
title: "My Collection",
|
||||
thumb: "/api/plex/image/col1",
|
||||
year_min: null,
|
||||
year_max: null,
|
||||
content_rating: null,
|
||||
child_count: items.length,
|
||||
total_runtime_s: 0,
|
||||
items,
|
||||
can_edit: false,
|
||||
editable: false,
|
||||
}) as unknown as PlexCollectionDetail;
|
||||
|
||||
const showDetail = (episodeIds: string[][]): PlexShowDetail =>
|
||||
({
|
||||
seasons: episodeIds.map((eps) => ({ episodes: eps.map((id) => ({ id })) })),
|
||||
}) as unknown as PlexShowDetail;
|
||||
|
||||
function mount(items: PlexCard[], onPlay = vi.fn()) {
|
||||
vi.spyOn(api, "plexCollection").mockResolvedValue(detail(items));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexCollectionView
|
||||
collectionId="col1"
|
||||
onBack={() => {}}
|
||||
onPlay={onPlay}
|
||||
onOpenCard={() => {}}
|
||||
onInfoCard={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
return onPlay;
|
||||
}
|
||||
|
||||
describe("PlexCollectionView — Play-all", () => {
|
||||
it("queues a leaf member as itself", async () => {
|
||||
const onPlay = mount([card("movie1", "movie")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(onPlay).toHaveBeenCalledWith("movie1", ["movie1"]);
|
||||
expect(h.notify).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("expands a show member into its episodes in season→episode order", async () => {
|
||||
const spyShow = vi
|
||||
.spyOn(api, "plexShow")
|
||||
.mockResolvedValue(showDetail([["s1e1", "s1e2"], ["s2e1"]]));
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() => expect(onPlay).toHaveBeenCalledTimes(1));
|
||||
expect(spyShow).toHaveBeenCalledWith("show1");
|
||||
expect(onPlay).toHaveBeenCalledWith("s1e1", ["s1e1", "s1e2", "s2e1"]);
|
||||
});
|
||||
|
||||
it("surfaces the playEmpty notice (not a silent no-op) when a show expands to zero episodes", async () => {
|
||||
vi.spyOn(api, "plexShow").mockResolvedValue(showDetail([])); // no seasons → empty queue
|
||||
const onPlay = mount([card("show1", "show")]);
|
||||
fireEvent.click(await screen.findByText("plex.collection.playAll"));
|
||||
await waitFor(() =>
|
||||
expect(h.notify).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ level: "info", message: "plex.collection.playEmpty" }),
|
||||
),
|
||||
);
|
||||
expect(onPlay).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -5,11 +5,13 @@ import { Play, Shuffle, Pencil, Trash2, Plus, X, Lock, Check, Search } from "luc
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollectionDetail } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { notifyActionError, notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { BackBtn, ActionBtn, DetailHero } from "../lib/plexDetailUi";
|
||||
import { PlexPosterCard } from "./plexCards";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
|
||||
@@ -57,6 +59,9 @@ export default function PlexCollectionView({
|
||||
const [name, setName] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Building the play queue can await show → episode expansion (see buildQueue); disable Play/Shuffle
|
||||
// meanwhile so a double-tap can't fire two queues.
|
||||
const [queueBusy, setQueueBusy] = useState(false);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexCollections() });
|
||||
@@ -64,8 +69,48 @@ export default function PlexCollectionView({
|
||||
};
|
||||
|
||||
const items = d?.items ?? [];
|
||||
// The Play queue is the playable-leaf members (movies/episodes) in order — a show can't queue directly.
|
||||
const playable = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
// Movies/episodes are playable leaves that queue as themselves; a show can't queue directly, so
|
||||
// Play-all expands it into its episodes (buildQueue). Either kind means there's something to play —
|
||||
// this is what a show-collection needs: before, `playable` held only leaves, so a show-collection's
|
||||
// Play-all was empty and did nothing.
|
||||
const leafKeys = items.filter((c) => c.type !== "show").map((c) => c.id);
|
||||
const hasShows = items.some((c) => c.type === "show");
|
||||
const canPlay = leafKeys.length > 0 || hasShows;
|
||||
|
||||
// Build the ordered play queue: leaf members as themselves, each show member expanded into its
|
||||
// episodes (season then episode order). Shows are fetched lazily HERE (on Play-all/Shuffle click),
|
||||
// in parallel, but concatenated in member order so the queue follows the grid — not on page load,
|
||||
// which would fetch every show up front for a button that may never be pressed.
|
||||
async function buildQueue(): Promise<string[]> {
|
||||
const perMember = await Promise.all(
|
||||
items.map(async (c) => {
|
||||
if (c.type !== "show") return [c.id];
|
||||
const detail = await qc.fetchQuery({
|
||||
queryKey: qk.plexShow(c.id),
|
||||
queryFn: () => api.plexShow(c.id),
|
||||
});
|
||||
return showEpisodeKeys(detail);
|
||||
}),
|
||||
);
|
||||
return perMember.flat();
|
||||
}
|
||||
|
||||
async function playAll(shuffle: boolean) {
|
||||
setQueueBusy(true);
|
||||
try {
|
||||
let queue = await buildQueue();
|
||||
if (shuffle) queue = shuffled(queue);
|
||||
// canPlay gates on "has a show member", but a show can expand to zero episodes (unmatched /
|
||||
// empty). An enabled Play-all that silently no-ops is a dishonest state, so say so plainly.
|
||||
if (queue.length > 0) onPlay(queue[0]!, queue);
|
||||
else notify({ level: "info", message: t("plex.collection.playEmpty") });
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.playFailed"));
|
||||
} finally {
|
||||
setQueueBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const canEdit = isAdmin && !!d?.can_edit;
|
||||
// A plain (non-smart, non-external) collection can be admin-enabled for editing. Shares
|
||||
// isIntrinsicReadOnly with the browse tile so the lock/edit affordance can't drift between them.
|
||||
@@ -99,7 +144,8 @@ export default function PlexCollectionView({
|
||||
invalidate();
|
||||
setRenaming(false);
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
// The backend enforces per-library name uniqueness — localize its 409 to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -170,9 +216,9 @@ export default function PlexCollectionView({
|
||||
<DetailHero
|
||||
thumb={d.thumb ?? `/api/plex/image/${d.id}`}
|
||||
posterTitle={t("plex.play")}
|
||||
posterDisabled={playable.length === 0}
|
||||
showPlayOverlay={playable.length > 0}
|
||||
onPosterClick={() => playable.length > 0 && onPlay(playable[0]!, playable)}
|
||||
posterDisabled={!canPlay || queueBusy}
|
||||
showPlayOverlay={canPlay}
|
||||
onPosterClick={() => canPlay && !queueBusy && playAll(false)}
|
||||
customizeMenu={null}
|
||||
>
|
||||
{renaming ? (
|
||||
@@ -231,19 +277,18 @@ export default function PlexCollectionView({
|
||||
)}
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{playable.length > 0 && (
|
||||
{canPlay && (
|
||||
<>
|
||||
<ActionBtn
|
||||
primary
|
||||
onClick={() => onPlay(playable[0]!, playable)}
|
||||
onClick={() => playAll(false)}
|
||||
disabled={queueBusy}
|
||||
icon={Play}
|
||||
label={t("plex.collection.playAll")}
|
||||
/>
|
||||
<ActionBtn
|
||||
onClick={() => {
|
||||
const order = shuffled(playable);
|
||||
onPlay(order[0]!, order);
|
||||
}}
|
||||
onClick={() => playAll(true)}
|
||||
disabled={queueBusy}
|
||||
icon={Shuffle}
|
||||
label={t("plex.collection.shuffle")}
|
||||
/>
|
||||
@@ -342,14 +387,7 @@ function AddItemPanel({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [term, setTerm] = useState("");
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const [added, setAdded] = useState<Set<string>>(new Set());
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-add", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function add(card: PlexCard) {
|
||||
try {
|
||||
@@ -381,43 +419,24 @@ function AddItemPanel({
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{debounced.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>
|
||||
) : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
<PlexLibraryPicker
|
||||
scope={scope}
|
||||
term={term}
|
||||
emptyHint={<p className="text-muted text-sm">{t("plex.collection.addHint")}</p>}
|
||||
renderAction={(c) => {
|
||||
const inColl = existing.has(c.id) || added.has(c.id);
|
||||
return (
|
||||
<button
|
||||
onClick={() => add(c)}
|
||||
disabled={inColl}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
{inColl ? <Check className="h-3.5 w-3.5" /> : <Plus className="h-3.5 w-3.5" />}
|
||||
{inColl ? t("plex.collection.added") : t("plex.collection.add")}
|
||||
</button>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the shared library search-and-pick list (F6): it owns the debounced query and the
|
||||
// full loading/error/empty/no-results/list state machine so the two callers (AddItemPanel in
|
||||
// PlexCollectionView, NewCollectionPanel in plexBrowseLists) can't drift. The two things a caller
|
||||
// steers — the `enabled` gate (NewCollectionPanel holds it off until the collection name is valid)
|
||||
// and the `emptyHint` shown before a term is typed — are the pieces with no test before this file, so
|
||||
// they're covered here. i18n is key-passthrough and api/debounce are mocked so the test drives state
|
||||
// directly.
|
||||
import { type ComponentProps } from "react";
|
||||
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
|
||||
import { render, screen, cleanup, waitFor } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
// Identity debounce so a passed term is reflected immediately.
|
||||
vi.mock("../lib/useDebounced", () => ({ useDebounced: (v: unknown) => v }));
|
||||
|
||||
// api.plexLibrary stub — returns whatever the current test queued.
|
||||
const plexLibrary = vi.fn();
|
||||
vi.mock("../lib/api", () => ({ api: { plexLibrary: (...a: unknown[]) => plexLibrary(...a) } }));
|
||||
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
const card = (id: string, title: string, year = 2000) => ({
|
||||
id,
|
||||
type: "movie" as const,
|
||||
title,
|
||||
year,
|
||||
thumb: `/api/plex/image/${id}`,
|
||||
});
|
||||
const result = (items: ReturnType<typeof card>[]) => ({
|
||||
scope: "both",
|
||||
total: items.length,
|
||||
offset: 0,
|
||||
limit: 12,
|
||||
items,
|
||||
episodes: [],
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
plexLibrary.mockReset();
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const renderPicker = (props: Partial<ComponentProps<typeof PlexLibraryPicker>> = {}) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term=""
|
||||
renderAction={(c) => <button>act:{c.id}</button>}
|
||||
{...props}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe("PlexLibraryPicker", () => {
|
||||
it("renders nothing and fires no query while disabled, even with a term", () => {
|
||||
const { container } = renderPicker({ enabled: false, term: "matrix" });
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the emptyHint (and fires no query) before a term is typed", () => {
|
||||
renderPicker({ term: "", emptyHint: <p>EMPTY_HINT</p> });
|
||||
expect(screen.getByText("EMPTY_HINT")).toBeTruthy();
|
||||
expect(plexLibrary).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("queries the library once enabled + a term are both present and renders the rows via renderAction", async () => {
|
||||
plexLibrary.mockResolvedValue(result([card("m1", "The Matrix"), card("m2", "Reloaded")]));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("act:m1")).toBeTruthy());
|
||||
expect(screen.getByText("act:m2")).toBeTruthy();
|
||||
expect(screen.getByText("The Matrix")).toBeTruthy();
|
||||
expect(plexLibrary).toHaveBeenCalledWith({ scope: "both", q: "matrix", limit: 12 });
|
||||
});
|
||||
|
||||
it("renders the honest error state (not an empty/no-results state) when the query fails", async () => {
|
||||
plexLibrary.mockRejectedValue(new Error("boom"));
|
||||
renderPicker({ term: "matrix" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("common.loadError")).toBeTruthy());
|
||||
expect(screen.queryByText("plex.collection.addNoResults")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the no-results message when the query returns an empty list", async () => {
|
||||
plexLibrary.mockResolvedValue(result([]));
|
||||
renderPicker({ term: "zzzz" });
|
||||
|
||||
await waitFor(() => expect(screen.getByText("plex.collection.addNoResults")).toBeTruthy());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type PlexCard } from "../lib/api";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState } from "./QueryState";
|
||||
|
||||
// A debounced library search-and-pick list, shared by the "Add title" panel (PlexCollectionView) and
|
||||
// the "New collection" first-title panel (plexBrowseLists) — both search the library the same way and
|
||||
// render the same result rows, differing only in the per-row action. Callers keep their own chrome
|
||||
// (search input, name field, close button) and supply `term` + `renderAction`; this owns the query,
|
||||
// the loading/error/empty states, and the row markup so those can't drift between the two surfaces.
|
||||
export default function PlexLibraryPicker({
|
||||
scope,
|
||||
term,
|
||||
enabled = true,
|
||||
emptyHint = null,
|
||||
renderAction,
|
||||
}: {
|
||||
scope: string;
|
||||
/** The raw search term; debounced internally. */
|
||||
term: string;
|
||||
/** Gate the query off (e.g. until a collection name is valid). When false, renders nothing. */
|
||||
enabled?: boolean;
|
||||
/** Shown while the (debounced) term is empty — a hint, or nothing. */
|
||||
emptyHint?: ReactNode;
|
||||
/** The trailing control for each result row (Add / Create-with / …). */
|
||||
renderAction: (card: PlexCard) => ReactNode;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-pick", scope, debounced),
|
||||
queryFn: () => api.plexLibrary({ scope, q: debounced, limit: 12 }),
|
||||
enabled: enabled && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
if (!enabled) return null;
|
||||
if (debounced.length === 0) return <>{emptyHint}</>;
|
||||
if (q.isLoading) return <LoadingState label={t("plex.loading")} />;
|
||||
if (q.isError) return <p className="text-muted text-sm">{t("common.loadError")}</p>;
|
||||
if (results.length === 0)
|
||||
return <p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>;
|
||||
|
||||
return (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
{renderAction(c)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -53,7 +53,7 @@ import { LS, useAccountPersistedObject } from "../lib/storage";
|
||||
import { useDismiss } from "../lib/useDismiss";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { useBackToClose } from "../lib/history";
|
||||
import { pushLayer, removeLayer, anyLayerAbove } from "../lib/layerStack";
|
||||
import { pushLayer, removeLayer, anyLayerAbove, escapeHandled } from "../lib/layerStack";
|
||||
|
||||
// The rich info overlay (poster/cast/ratings) reuses the same component as the card's info page.
|
||||
const PlexInfo = lazy(() => import("./PlexInfo"));
|
||||
@@ -602,7 +602,10 @@ export default function PlexPlayer({ itemId, onClose, queue }: Props) {
|
||||
case "Escape":
|
||||
// Defer to any layer opened above the player (Modal or anchored popover) — let it take the
|
||||
// Escape, not us. A player MENU registers as a layer too, so it closes first and the next
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug).
|
||||
// Escape reaches the auto-skip-cancel below (the old blanket-stopPropagation bug). Check the
|
||||
// consumed-event flag first (UX-8): a layer's `document` listener runs before this `window`
|
||||
// one and its close can pop it off the stack synchronously, so `anyLayerAbove` alone misses it.
|
||||
if (escapeHandled(e)) break;
|
||||
if (layerIdRef.current != null && anyLayerAbove(layerIdRef.current)) break;
|
||||
if (skipProgressRef.current != null) cancelAutoSkipRef.current();
|
||||
else if (menuOpenRef.current) {
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// S2 — the single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
|
||||
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
|
||||
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
|
||||
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
|
||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
||||
// api.savedViews is stubbed and driven through a real QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
page: "feed" as "feed" | "plex",
|
||||
setFeedFilters: vi.fn(),
|
||||
setPlexFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("./NavigationProvider", () => ({ useNavigation: () => ({ page: h.page }) }));
|
||||
vi.mock("./FeedFiltersProvider", () => ({
|
||||
useFeedFilters: () => ({
|
||||
tags: [],
|
||||
channelIds: [],
|
||||
tagModes: { language: "or", topic: "or", other: "or" },
|
||||
q: "",
|
||||
sort: "newest",
|
||||
scope: "my",
|
||||
includeNormal: true,
|
||||
includeShorts: false,
|
||||
includeLive: false,
|
||||
show: "unwatched",
|
||||
}),
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
||||
}));
|
||||
vi.mock("./PlexProvider", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("./ConfirmProvider", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider, useSavedViews } from "./SavedViewsProvider";
|
||||
import { api, setActiveAccount, type SavedView } from "../lib/api";
|
||||
import { LS, accountKey } from "../lib/storage";
|
||||
import { ACTIVE_ACCOUNT_KEY } from "../lib/activeAccount";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
id: 1,
|
||||
name: "v",
|
||||
filters: {},
|
||||
position: 0,
|
||||
is_default: false,
|
||||
...over,
|
||||
});
|
||||
|
||||
function Probe() {
|
||||
const { loading, views } = useSavedViews();
|
||||
return (
|
||||
<div data-testid="loading" data-count={views.length}>
|
||||
{String(loading)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function mountResolved(views: SavedView[]) {
|
||||
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
|
||||
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
|
||||
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
// Wait for the query to resolve so the first-resolve effect has run.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
return qc;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.setItem(ACTIVE_ACCOUNT_KEY, String(ID));
|
||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
||||
// case would suppress seeding in the others.
|
||||
window.history.replaceState({}, "", "/");
|
||||
h.page = "feed";
|
||||
h.setFeedFilters.mockClear();
|
||||
h.setPlexFilters.mockClear();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
||||
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: on a fresh tab the query resolves before the account pins — defers, then seeds on the pin", async () => {
|
||||
// The provider mounts above <App>, which pins the tab-account only after /api/me; the ungated
|
||||
// views query resolves first. Seeding must WAIT for the account (not seed nothing and burn its
|
||||
// one-shot — the pre-fix regression that left a fresh tab on DEFAULT_FILTERS until a reload).
|
||||
sessionStorage.removeItem(ACTIVE_ACCOUNT_KEY);
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
// App pins the account; the reactive id re-fires the effect and the deferred seed lands.
|
||||
act(() => setActiveAccount(ID));
|
||||
await waitFor(() => expect(h.setFeedFilters).toHaveBeenCalledTimes(1));
|
||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
||||
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ scope: "my" }));
|
||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when no view is starred as default", async () => {
|
||||
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
|
||||
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
|
||||
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
|
||||
window.history.replaceState({}, "", "/?sort=oldest");
|
||||
await mountResolved([
|
||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||
]);
|
||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
|
||||
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
|
||||
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
|
||||
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
|
||||
// deduped by structural sharing and never reaches the effect, so the changed list is the real test.)
|
||||
const def = view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } });
|
||||
let calls = 0;
|
||||
vi.spyOn(api, "savedViews").mockImplementation(async () => {
|
||||
calls += 1;
|
||||
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
|
||||
});
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<SavedViewsProvider>
|
||||
<Probe />
|
||||
</SavedViewsProvider>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
await act(async () => {
|
||||
await qc.refetchQueries({ queryKey: qk.savedViews("feed") });
|
||||
});
|
||||
// Wait until the changed list has actually reached the component, so the seed effect has had its
|
||||
// chance to (wrongly) re-fire — otherwise the assertion could win by racing ahead of the re-render.
|
||||
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
|
||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
|
||||
h.page = "plex";
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||
});
|
||||
|
||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
||||
h.page = "plex";
|
||||
localStorage.setItem(accountKey(LS.plexFilters, ID)!, JSON.stringify({ genres: ["Comedy"] }));
|
||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,12 @@
|
||||
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
@@ -6,16 +14,18 @@ import {
|
||||
api,
|
||||
coercePlexFilters,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
getActiveAccount,
|
||||
plexFiltersSignature,
|
||||
type FeedFilters,
|
||||
type PlexFilters,
|
||||
type SavedView,
|
||||
} from "../lib/api";
|
||||
import { filtersToParams } from "../lib/urlState";
|
||||
import { preferSticky } from "../lib/savedViews";
|
||||
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { accountKey, LS, writeJSON } from "../lib/storage";
|
||||
import { accountKey, LS } from "../lib/storage";
|
||||
import { useActiveAccountId } from "../lib/activeAccount";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import { useNavigation } from "./NavigationProvider";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
@@ -33,6 +43,7 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({
|
||||
tags: raw.tags ?? [],
|
||||
channelIds: raw.channelIds ?? [],
|
||||
tagModes: coerceTagModes(raw),
|
||||
untagged: coerceUntagged(raw),
|
||||
});
|
||||
|
||||
type ViewSource = "feed" | "plex";
|
||||
@@ -46,20 +57,35 @@ interface ViewConfig {
|
||||
keyOf: (f: Record<string, unknown>) => string;
|
||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||
/** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */
|
||||
syncDefaultMirror?: (views: SavedView[]) => void;
|
||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||
canSeedDefault: () => boolean;
|
||||
}
|
||||
|
||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||
|
||||
// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load).
|
||||
function syncFeedDefaultMirror(views: SavedView[]): void {
|
||||
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
||||
if (!key) return;
|
||||
const def = views.find((v) => v.is_default);
|
||||
writeJSON(key, def ? def.filters : null);
|
||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||
function storedFiltersPresent(lsKey: string): boolean {
|
||||
const key = accountKey(lsKey, getActiveAccount());
|
||||
if (!key) return true;
|
||||
try {
|
||||
return localStorage.getItem(key) != null;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
||||
function feedCanSeedDefault(): boolean {
|
||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
||||
return !storedFiltersPresent(LS.filters);
|
||||
}
|
||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||
function plexCanSeedDefault(): boolean {
|
||||
return !storedFiltersPresent(LS.plexFilters);
|
||||
}
|
||||
|
||||
export interface SavedViewsContext {
|
||||
@@ -105,6 +131,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
keyOf: plexKeyOf,
|
||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
||||
canSeedDefault: plexCanSeedDefault,
|
||||
};
|
||||
}
|
||||
return {
|
||||
@@ -119,7 +146,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
const { seed: _seed, ...rest } = f;
|
||||
return rest;
|
||||
},
|
||||
syncDefaultMirror: syncFeedDefaultMirror,
|
||||
canSeedDefault: feedCanSeedDefault,
|
||||
};
|
||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
||||
|
||||
@@ -140,13 +167,35 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
||||
setModalOpen(false);
|
||||
}, [source]);
|
||||
|
||||
// Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the
|
||||
// mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes
|
||||
// on every live filter edit, which would fire this synchronous localStorage write far beyond its intent.
|
||||
const syncMirror = cfg.syncDefaultMirror;
|
||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
||||
// 60s background refetch never re-seeds over the filters you're editing.
|
||||
//
|
||||
// WAIT FOR THE ACCOUNT PIN. This provider mounts above <App>, which pins the tab-account only after
|
||||
// /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null.
|
||||
// canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored")
|
||||
// with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never
|
||||
// re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the
|
||||
// pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real.
|
||||
const accountId = useActiveAccountId();
|
||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
||||
useEffect(() => {
|
||||
if (viewsQuery.data) syncMirror?.(viewsQuery.data);
|
||||
}, [viewsQuery.data, syncMirror]);
|
||||
const data = viewsQuery.data;
|
||||
if (!data || accountId == null || seededRef.current.has(source)) return;
|
||||
seededRef.current.add(source);
|
||||
const def = pickDefaultToSeed(data, cfg.canSeedDefault());
|
||||
if (!def) return;
|
||||
cfg.setFilters(cfg.asFilters(def.filters));
|
||||
setStickyId(def.id);
|
||||
// cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg
|
||||
// (which re-memoizes on every filter edit) is deliberately kept out of the deps.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [viewsQuery.data, source, accountId]);
|
||||
|
||||
const currentKey = cfg.keyOf(cfg.filters);
|
||||
const activeView = useMemo(
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
@@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups";
|
||||
import { type MediaSourceKind } from "../lib/mediaSource";
|
||||
import ChannelPicker from "./ChannelPicker";
|
||||
import DateFilterSelect from "./DateFilterSelect";
|
||||
import YearFilter from "./YearFilter";
|
||||
import Combobox, { type ComboboxOption } from "./Combobox";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
|
||||
import { useChannelsActions } from "./ChannelsProvider";
|
||||
@@ -104,12 +105,48 @@ export default function Sidebar({
|
||||
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
|
||||
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
|
||||
|
||||
// The "Your tags" combobox carries the "No tag" sentinel (item B) at the top: selecting it filters to
|
||||
// channels you HAVEN'T personally tagged in the channel manager (`filters.untagged`). It's mutually
|
||||
// exclusive with picking real user tags — choosing it clears your tag picks, and picking a tag clears
|
||||
// it. Language/Topic are auto/system tags, so "No tag" is meaningless there and only lives here.
|
||||
function userTagsCombo(): ReactNode {
|
||||
const noneOn = !!filters.untagged;
|
||||
const inList = (id: number) => userTags.some((tg) => tg.id === id);
|
||||
const onChange = (next: string[]) => {
|
||||
if (next.includes(NO_TAG_SENTINEL) && !noneOn) {
|
||||
setFilters({ ...filters, tags: filters.tags.filter((id) => !inList(id)), untagged: true });
|
||||
} else {
|
||||
const reals = next.filter((v) => v !== NO_TAG_SENTINEL).map(Number);
|
||||
setFilters({
|
||||
...filters,
|
||||
tags: [...filters.tags.filter((id) => !inList(id)), ...reals],
|
||||
untagged: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={[
|
||||
{ value: NO_TAG_SENTINEL, label: t("sidebar.noTagOption") },
|
||||
...visibleChips(userTags).map(tagOption),
|
||||
]}
|
||||
selected={noneOn ? [NO_TAG_SENTINEL] : selectedIn(userTags)}
|
||||
onChange={onChange}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// The badge counts exactly what the chip row above the feed shows — same list, so they can't
|
||||
// disagree about what's applied.
|
||||
const activeCount = useActiveFilters(filters, setFilters).length;
|
||||
|
||||
const available: Record<string, boolean> = {
|
||||
date: true,
|
||||
year: true,
|
||||
channel: true,
|
||||
language: languages.length > 0,
|
||||
topic: topics.length > 0,
|
||||
@@ -124,7 +161,9 @@ export default function Sidebar({
|
||||
return (
|
||||
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
|
||||
);
|
||||
case "language": {
|
||||
case "year":
|
||||
return <YearFilter filters={filters} setFilters={setFilters} />;
|
||||
case "language":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.language")}
|
||||
@@ -136,8 +175,7 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("language", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "topic": {
|
||||
case "topic":
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.topic")}
|
||||
@@ -149,22 +187,10 @@ export default function Sidebar({
|
||||
onMatchModeChange={(m) => setTagMode("topic", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tags": {
|
||||
case "tags":
|
||||
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
|
||||
// body is just the picker.
|
||||
return (
|
||||
<Combobox
|
||||
label={t("sidebar.widget.tags")}
|
||||
options={visibleChips(userTags).map(tagOption)}
|
||||
selected={selectedIn(userTags)}
|
||||
onChange={(next) => setTagSelection(userTags, next)}
|
||||
emptyText={t("sidebar.noMatchingTags")}
|
||||
matchMode={filters.tagModes.other}
|
||||
onMatchModeChange={(m) => setTagMode("other", m)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// body is just the picker — user tags plus the "No tag" sentinel.
|
||||
return userTagsCombo();
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
||||
import { useChipOverflowFit } from "../lib/useChipOverflowFit";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
||||
|
||||
// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose
|
||||
// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses
|
||||
// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by
|
||||
// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions
|
||||
// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…"
|
||||
// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N".
|
||||
|
||||
export interface FilterChip {
|
||||
key: string;
|
||||
label: string;
|
||||
active: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={chip.onToggle}
|
||||
aria-pressed={chip.active}
|
||||
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagFilterChips({
|
||||
chips,
|
||||
className,
|
||||
}: {
|
||||
chips: FilterChip[];
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
// Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's
|
||||
// width via the `.pill` recipe, so an aria-pressed flip must re-measure too).
|
||||
const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|");
|
||||
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps: key,
|
||||
initial: chips.length,
|
||||
decide: (g) =>
|
||||
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
|
||||
});
|
||||
|
||||
const hidden = chips.length - visible;
|
||||
const [open, setOpen] = useState(false);
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-end",
|
||||
});
|
||||
// If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't
|
||||
// linger against a gone reference.
|
||||
useEffect(() => {
|
||||
if (hidden === 0 && open) setOpen(false);
|
||||
}, [hidden, open]);
|
||||
|
||||
const moreLabel = t("channels.tags.moreCount", { count: hidden });
|
||||
|
||||
return (
|
||||
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
|
||||
{/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can
|
||||
read each chip's right edge, the rem gap and the pill width off real boxes. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
|
||||
>
|
||||
{chips.map((c, i) => (
|
||||
<span
|
||||
key={c.key}
|
||||
ref={(el) => {
|
||||
chipRefs.current[i] = el;
|
||||
}}
|
||||
className="inline-flex"
|
||||
>
|
||||
<span className={filterChipClass}>{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
<span ref={pillRef} className={filterChipClass}>
|
||||
+{chips.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
|
||||
{chips.slice(0, visible).map((c) => (
|
||||
<Chip key={c.key} chip={c} extra="shrink-0" />
|
||||
))}
|
||||
{hidden > 0 && (
|
||||
<button
|
||||
ref={pop.refs.setReference}
|
||||
type="button"
|
||||
aria-label={moreLabel}
|
||||
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
|
||||
{...pop.getReferenceProps()}
|
||||
>
|
||||
+{hidden}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={moreLabel}
|
||||
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
{chips.slice(visible).map((c) => (
|
||||
<Chip key={c.key} chip={c} />
|
||||
))}
|
||||
</PopoverSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import Avatar from "./Avatar";
|
||||
import AddToPlaylist from "./AddToPlaylist";
|
||||
import DownloadButton from "./DownloadButton";
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import clsx from "clsx";
|
||||
import type { Video, VideoStatus } from "../lib/api";
|
||||
import { FEED_VIEW_SPEC, type FeedView } from "../lib/feedView";
|
||||
@@ -105,6 +106,17 @@ function Actions({
|
||||
);
|
||||
}
|
||||
|
||||
// The hover-infobox trigger skins (E8·S3). The ⓘ is hover-revealed and dropped on touch (`reveal-hover`
|
||||
// + the group opacity toggle), and lives OUTSIDE the six-button action budget — as a thumbnail-corner
|
||||
// overlay for the thumbnailed views, and inline beside the actions for the one-line row.
|
||||
const INFO_REVEAL =
|
||||
"reveal-hover opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 transition-opacity";
|
||||
const INFO_OVERLAY = clsx(
|
||||
INFO_REVEAL,
|
||||
"absolute bottom-1.5 left-1.5 z-10 rounded-md bg-black/70 p-1 text-white hover:bg-black/85",
|
||||
);
|
||||
const INFO_INLINE = clsx(INFO_REVEAL, "rounded-md p-1.5 text-muted hover:bg-surface hover:text-fg");
|
||||
|
||||
// Plain left-click opens the in-app player (the experiment); Ctrl/Cmd/middle-click
|
||||
// keep the native "open youtube.com in a new tab" behavior via the underlying href.
|
||||
function openInApp(
|
||||
@@ -262,9 +274,12 @@ function Thumb({
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
</span>
|
||||
)}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. */}
|
||||
{/* Hover overlay: Play everywhere; Continue + Restart once the video is in progress. Mouse-only:
|
||||
reveal on hover AND on keyboard focus-within (else a Tab-focused button is invisible), and
|
||||
`reveal-hover` drops it entirely on touch — the card tap already plays/resumes there, so a
|
||||
permanent dark scrim over every mobile thumbnail (and an invisible tap target) is not wanted. */}
|
||||
{onOpen && (
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 bg-black/45 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity reveal-hover">
|
||||
{inProgress ? (
|
||||
<>
|
||||
<button
|
||||
@@ -309,6 +324,10 @@ function Thumb({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* Hover infobox trigger (E8·S3), docked bottom-left so it clears the centered play overlay, the
|
||||
corner badges (saved / stream, top corners) and the duration (bottom-right). Revealed with the
|
||||
rest of the hover chrome, dropped on touch. */}
|
||||
<VideoInfoCard video={video} className={INFO_OVERLAY} />
|
||||
{/* Resume progress bar (sits at the very bottom, under the duration badge). */}
|
||||
{pct > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 h-1 bg-black/40">
|
||||
@@ -327,7 +346,7 @@ function Thumb({
|
||||
// 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
|
||||
actions: 224, // w-48 (six buttons: 6×28 + 5×4 = 188) + gap-1 + the 28px inline info trigger
|
||||
channel: 128, // w-32
|
||||
meta: 104, // RowMetaCells' own grid: 4rem + 1rem + 1rem + two 4px gaps
|
||||
views: 64, // w-16
|
||||
@@ -505,8 +524,13 @@ function VideoCard({
|
||||
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>
|
||||
they're the shortest pointer trip from it. This row has no thumbnail to host the info
|
||||
trigger, so it sits inline right after the actions (still outside their six-button
|
||||
budget). ROW_COL.actions already counts its width. */}
|
||||
<div className="shrink-0 flex items-center gap-1">
|
||||
<div className="w-48">{actions}</div>
|
||||
<VideoInfoCard video={video} className={INFO_INLINE} />
|
||||
</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
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Contract of the feed-card hover infobox (E8·S3): the trigger stays closed until opened; on open the
|
||||
// header (full title + channel) renders IMMEDIATELY from the Video we already hold, while the
|
||||
// description (videoDetail) and the channel's tags (channelDetail.tag_ids resolved against the tag
|
||||
// list) fill in from their on-demand queries; an empty description falls back to the "no description"
|
||||
// line. i18n is key-passthrough and the AnchoredPopover platform is floating-ui (hover mode → open via
|
||||
// focus), so we stub the observers autoUpdate wires and wrap in a FloatingTree + QueryClient.
|
||||
import { describe, it, expect, afterEach, beforeAll, vi } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
|
||||
const videoDetail = vi.fn();
|
||||
const channelDetail = vi.fn();
|
||||
const tags = vi.fn();
|
||||
vi.mock("../lib/api", () => ({
|
||||
api: {
|
||||
videoDetail: (...a: unknown[]) => videoDetail(...a),
|
||||
channelDetail: (...a: unknown[]) => channelDetail(...a),
|
||||
tags: (...a: unknown[]) => tags(...a),
|
||||
},
|
||||
}));
|
||||
|
||||
import VideoInfoCard from "./VideoInfoCard";
|
||||
import type { Video } from "../lib/api";
|
||||
|
||||
beforeAll(() => {
|
||||
class Stub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
|
||||
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const video = (over: Partial<Video> = {}): Video =>
|
||||
({
|
||||
id: "v1",
|
||||
title: "The full untruncated title",
|
||||
channel_id: "c1",
|
||||
channel_title: "Test Channel",
|
||||
channel_thumbnail: null,
|
||||
channel_url: "https://youtube.com/c1",
|
||||
published_at: "2026-02-24T00:00:00Z",
|
||||
thumbnail_url: null,
|
||||
duration_seconds: 754,
|
||||
view_count: 1512,
|
||||
is_short: false,
|
||||
live_status: "none",
|
||||
status: "new",
|
||||
position_seconds: 0,
|
||||
saved: false,
|
||||
watch_url: "https://youtu.be/v1",
|
||||
...over,
|
||||
}) as Video;
|
||||
|
||||
const open = (v: Video = video()) => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<VideoInfoCard video={v} className="info-trigger" />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
fireEvent.focus(screen.getByRole("button", { name: "card.info" }));
|
||||
};
|
||||
|
||||
describe("VideoInfoCard", () => {
|
||||
it("renders a trigger, panel closed until opened (no fetch at rest)", () => {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<VideoInfoCard video={video()} className="info-trigger" />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(screen.getByRole("button", { name: "card.info" })).toBeTruthy();
|
||||
expect(screen.queryByText("The full untruncated title")).toBeNull();
|
||||
// `enabled: open` gates the queries — nothing fired while closed.
|
||||
expect(videoDetail).not.toHaveBeenCalled();
|
||||
expect(channelDetail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows the header immediately from the Video, then fills in description + tags on load", async () => {
|
||||
videoDetail.mockResolvedValue({ id: "v1", description: "A longer description snippet." });
|
||||
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [1, 3] });
|
||||
tags.mockResolvedValue([
|
||||
{ id: 1, name: "English", category: "language" },
|
||||
{ id: 2, name: "Unused", category: "topic" },
|
||||
{ id: 3, name: "Astronomy", category: "topic" },
|
||||
]);
|
||||
open();
|
||||
// Header is from the Video prop — present without waiting on any query.
|
||||
expect(screen.getByText("The full untruncated title")).toBeTruthy();
|
||||
expect(screen.getByText("Test Channel")).toBeTruthy();
|
||||
// Description + only the channel's own tags (1 + 3, not 2) once the queries resolve.
|
||||
expect(await screen.findByText("A longer description snippet.")).toBeTruthy();
|
||||
expect(await screen.findByText("English")).toBeTruthy();
|
||||
expect(await screen.findByText("Astronomy")).toBeTruthy();
|
||||
expect(screen.queryByText("Unused")).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the no-description line when the video has none", async () => {
|
||||
videoDetail.mockResolvedValue({ id: "v1", description: null });
|
||||
channelDetail.mockResolvedValue({ id: "c1", tag_ids: [] });
|
||||
tags.mockResolvedValue([]);
|
||||
open();
|
||||
expect(await screen.findByText("card.noDescription")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Info } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type Video } from "../lib/api";
|
||||
import { formatDate, formatDuration, formatViews } from "../lib/format";
|
||||
import { linkify } from "../lib/linkify";
|
||||
import Avatar from "./Avatar";
|
||||
import { PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
|
||||
// The feed card's "more than the card face" hover infobox (E8·S3, decision D3): full (un-clamped)
|
||||
// title, channel, views·duration·date, a description snippet, and the channel's tags. It hangs off a
|
||||
// small ⓘ button — a thumbnail-corner overlay in the four thumbnailed views, an inline button beside
|
||||
// the actions in the one-line row (see the caller). It reveals only with the other hover controls on a
|
||||
// mouse and is dropped entirely on touch (`.reveal-hover`), where a tap already plays/resumes and a
|
||||
// hover card would only be an invisible trap. Hover mode also opens it on keyboard focus, so it stays
|
||||
// reachable by Tab.
|
||||
//
|
||||
// The card face already carries title/channel/date/duration/views, so the box's real added value is
|
||||
// the FULL title (the card clamps to 2 lines) plus the description + tags, which aren't in the feed
|
||||
// list payload. Both are fetched ON DEMAND on open (like AboutCell): the description via
|
||||
// `videoDetail`, the tags by resolving the channel's `tag_ids` (from `channelDetail`, shared with the
|
||||
// channel page's cache) against the tag list. Until they land the header still shows from the Video
|
||||
// we already have, so the box is never empty.
|
||||
//
|
||||
// The caller owns the trigger's placement + reveal via `className`: a thumbnail-corner overlay for the
|
||||
// four thumbnailed views, an inline button beside the actions for the one-line row. The action ROW
|
||||
// itself is budgeted to exactly six buttons (a seventh wraps in every view — see VideoCard's ROW_COL),
|
||||
// so the trigger deliberately lives OUTSIDE it.
|
||||
export default function VideoInfoCard({
|
||||
video,
|
||||
className,
|
||||
}: {
|
||||
video: Video;
|
||||
/** Trigger-button classes (placement + hover-reveal + skin). The reveal/touch-suppression rules
|
||||
* live here so each host can match its surface (overlay vs inline). */
|
||||
className?: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "hover",
|
||||
placement: "bottom-end",
|
||||
offset: 6,
|
||||
// A touch of open intent (so brushing past the button while reaching another control doesn't
|
||||
// pop it), a longer close grace so the cursor can travel into the interactive card.
|
||||
hoverDelay: { open: 140, close: 160 },
|
||||
fitHeight: true,
|
||||
});
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: qk.videoDetail(video.id),
|
||||
queryFn: () => api.videoDetail(video.id),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: channel } = useQuery({
|
||||
queryKey: qk.channel(video.channel_id),
|
||||
queryFn: () => api.channelDetail(video.channel_id),
|
||||
enabled: open && !!video.channel_id,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const { data: tags } = useQuery({
|
||||
queryKey: qk.tags(),
|
||||
queryFn: () => api.tags(),
|
||||
enabled: open,
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
// The channel's tags (`tag_ids`), in the channel's own id order, resolved to names via the tag list.
|
||||
// These are the channel-tags the channel actually carries; an unknown id (a just-deleted tag) drops.
|
||||
const tagNames =
|
||||
channel && tags
|
||||
? channel.tag_ids
|
||||
.map((id) => tags.find((tg) => tg.id === id))
|
||||
.filter((tg): tg is NonNullable<typeof tg> => !!tg)
|
||||
: [];
|
||||
|
||||
const description = detail?.description?.trim();
|
||||
const metaBits = [
|
||||
video.view_count != null ? `${formatViews(video.view_count)} ${t("card.views")}` : null,
|
||||
video.duration_seconds != null ? formatDuration(video.duration_seconds) : null,
|
||||
video.published_at ? formatDate(video.published_at, i18n.language) : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
// The overlay trigger sits inside the thumbnail's <a> (like the play buttons), so a click must
|
||||
// both preventDefault (no navigation) and stopPropagation (no card open); harmless inline too.
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
aria-label={t("card.info")}
|
||||
title={t("card.info")}
|
||||
className={className}
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
</button>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("card.info")}
|
||||
className="glass w-[340px] max-w-[92vw] overflow-y-auto rounded-lg px-3.5 py-3 animate-[fadeIn_0.12s_ease]"
|
||||
style={{ maxHeight: "min(26rem, var(--fit-max-h, 26rem))" }}
|
||||
>
|
||||
<p className="text-sm font-medium leading-snug wrap-break-word">{video.title}</p>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Avatar
|
||||
src={video.channel_thumbnail}
|
||||
fallback={video.channel_title ?? ""}
|
||||
className="w-6 h-6 rounded-full shrink-0"
|
||||
/>
|
||||
<span className="text-xs text-muted truncate">{video.channel_title}</span>
|
||||
</div>
|
||||
{metaBits.length > 0 && <p className="mt-1.5 text-xs text-muted">{metaBits.join(" · ")}</p>}
|
||||
{description ? (
|
||||
<p className="mt-2.5 text-xs leading-relaxed text-muted line-clamp-5 wrap-break-word">
|
||||
{linkify(description)}
|
||||
</p>
|
||||
) : detail ? (
|
||||
<p className="mt-2.5 text-xs italic text-muted">{t("card.noDescription")}</p>
|
||||
) : null}
|
||||
{tagNames.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
{tagNames.map((tg) => (
|
||||
<span key={tg.id} className="glass-card px-2 py-0.5 rounded-full text-2xs">
|
||||
{tg.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { api, type FeedFilters } from "../lib/api";
|
||||
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
|
||||
import { filterChipClass } from "./FilterChipGroup";
|
||||
import CountBadge from "./CountBadge";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
|
||||
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
|
||||
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
|
||||
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
|
||||
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
|
||||
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
|
||||
//
|
||||
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
|
||||
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
|
||||
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
|
||||
// own category.
|
||||
|
||||
const QUICK = 6; // recent-year chips shown before the "More…" overflow
|
||||
|
||||
export default function YearFilter({
|
||||
filters,
|
||||
setFilters,
|
||||
}: {
|
||||
filters: FeedFilters;
|
||||
setFilters: (f: FeedFilters) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [sortByCount, setSortByCount] = useState(false);
|
||||
|
||||
// Debounce the search term (like the sibling summary facet in Sidebar) so typing doesn't refire the
|
||||
// per-year count query on every keystroke — each distinct q is a fresh cache key, so without this
|
||||
// an N-char query would issue N /feed/years requests. The year/date narrowings are dropped: this
|
||||
// list is a live drill-down that must not change when a year (or upload-date) is picked.
|
||||
const facetFilters: FeedFilters = {
|
||||
...filters,
|
||||
q: useDebounced(filters.q, 300),
|
||||
year: undefined,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
};
|
||||
const yearsQuery = useQuery({
|
||||
queryKey: qk.feedYears(facetFilters),
|
||||
queryFn: () => api.feedYears(facetFilters),
|
||||
staleTime: 30_000,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
const years = yearsQuery.data?.years ?? [];
|
||||
const selected = filters.year;
|
||||
|
||||
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
|
||||
const pick = (year?: number) =>
|
||||
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
|
||||
|
||||
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
|
||||
// active pick is always visible), re-sorted newest-first.
|
||||
const quick = useMemo(() => {
|
||||
const top = years.slice(0, QUICK);
|
||||
if (selected != null && !top.some((y) => y.year === selected)) {
|
||||
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
|
||||
return [...top, sel].sort((a, b) => b.year - a.year);
|
||||
}
|
||||
return top;
|
||||
}, [years, selected]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() =>
|
||||
[...years].sort((a, b) =>
|
||||
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
|
||||
),
|
||||
[years, sortByCount],
|
||||
);
|
||||
|
||||
const pop = useAnchoredPopover({
|
||||
open,
|
||||
onOpenChange: setOpen,
|
||||
mode: "menu",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
|
||||
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
|
||||
if (years.length === 0) {
|
||||
if (yearsQuery.isLoading) {
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
|
||||
{t("common.loading")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (yearsQuery.isError) {
|
||||
return (
|
||||
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
|
||||
{t("common.loadError")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{quick.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<button
|
||||
key={y.year}
|
||||
type="button"
|
||||
aria-pressed={on}
|
||||
onClick={() => pick(on ? undefined : y.year)}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<span className="tabular-nums">{y.year}</span>
|
||||
{y.count > 0 && <CountBadge count={y.count} active={on} />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{years.length > quick.length && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
className={filterChipClass}
|
||||
>
|
||||
<span>{t("sidebar.year.more")}</span>
|
||||
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={t("sidebar.year.moreLabel")}
|
||||
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
|
||||
>
|
||||
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
|
||||
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
{(
|
||||
[
|
||||
["sortYear", false],
|
||||
["sortCount", true],
|
||||
] as const
|
||||
).map(([key, v]) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-pressed={sortByCount === v}
|
||||
onClick={() => setSortByCount(v)}
|
||||
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
|
||||
sortByCount === v
|
||||
? "border-accent bg-accent/15 text-accent"
|
||||
: "border-border text-muted hover:border-accent/50 hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{t("sidebar.year." + key)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="max-h-64 overflow-y-auto no-scrollbar"
|
||||
role="group"
|
||||
aria-label={t("sidebar.year.moreLabel")}
|
||||
>
|
||||
{sorted.map((y) => {
|
||||
const on = selected === y.year;
|
||||
return (
|
||||
<PopoverItem
|
||||
key={y.year}
|
||||
role="menuitemradio"
|
||||
checked={on}
|
||||
onSelect={() => {
|
||||
pick(on ? undefined : y.year);
|
||||
setOpen(false);
|
||||
}}
|
||||
label={String(y.year)}
|
||||
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
|
||||
on ? "text-fg" : "text-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`h-1.5 w-1.5 shrink-0 rounded-full ${on ? "bg-accent" : "bg-transparent"}`}
|
||||
/>
|
||||
<span className="flex-1 tabular-nums">{y.year}</span>
|
||||
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
|
||||
</PopoverItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</PopoverSurface>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,8 +5,8 @@
|
||||
// "fully" | "needsFull" | "" vocab + predicate — asserted here so a regression can't silently change
|
||||
// which channels the top chips show.
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { applySyncFilter } from "./channelColumns";
|
||||
import { type ManagedChannel } from "../lib/api";
|
||||
import { applySyncFilter, tagsColumnTest } from "./channelColumns";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
|
||||
const ch = (over: Partial<ManagedChannel>): ManagedChannel => ({
|
||||
id: "c",
|
||||
@@ -51,3 +51,23 @@ describe("applySyncFilter", () => {
|
||||
expect(applySyncFilter(rows, "needsFull").map((r) => r.id)).toEqual(["recentOnly", "none"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tagsColumnTest (item B — 'No tag')", () => {
|
||||
const tagged = ch({ id: "tagged", tag_ids: [1, 2] });
|
||||
const untagged = ch({ id: "untagged", tag_ids: [] });
|
||||
|
||||
it("the No-tag sentinel keeps only channels with no user tag", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, [NO_TAG_SENTINEL])).toBe(true);
|
||||
});
|
||||
|
||||
it("real tag values match ANY selected id (sentinel absent)", () => {
|
||||
expect(tagsColumnTest(tagged, ["2"])).toBe(true);
|
||||
expect(tagsColumnTest(tagged, ["3"])).toBe(false);
|
||||
expect(tagsColumnTest(untagged, ["1"])).toBe(false);
|
||||
});
|
||||
|
||||
it("the sentinel wins if it somehow co-exists with real ids", () => {
|
||||
expect(tagsColumnTest(tagged, [NO_TAG_SENTINEL, "1"])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,14 +2,14 @@ import type { ComponentProps } from "react";
|
||||
import type { TFunction } from "i18next";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Check, Eye, EyeOff, History, UserMinus } from "lucide-react";
|
||||
import type { ManagedChannel } from "../lib/api";
|
||||
import { NO_TAG_SENTINEL, type ManagedChannel } from "../lib/api";
|
||||
import { formatCountOrDash, formatTotalHours, relativeTime } from "../lib/format";
|
||||
import type { Column } from "./DataTable";
|
||||
import Tooltip from "./Tooltip";
|
||||
import { PriorityStepper } from "./PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl } from "./ChannelTags";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
|
||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||
@@ -42,6 +42,14 @@ export function applySyncFilter(rows: ManagedChannel[], value: unknown): Managed
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The Tags column filter predicate (item B): the "No tag" sentinel keeps channels the user hasn't
|
||||
* personally tagged (empty `tag_ids`); it's exclusive with real tag picks (the chip row enforces that),
|
||||
* so when present it wins. Otherwise a channel passes if it carries ANY selected tag id. */
|
||||
export function tagsColumnTest(c: ManagedChannel, values: string[]): boolean {
|
||||
if (values.includes(NO_TAG_SENTINEL)) return c.tag_ids.length === 0;
|
||||
return values.some((v) => c.tag_ids.includes(Number(v)));
|
||||
}
|
||||
|
||||
// The Stored count, with a "fully synced" ✓ charm to the left of the number when recent uploads and
|
||||
// full history are both in — folding the retired Sync column's at-a-glance status into the value it
|
||||
// describes. Full detail stays in the hover tooltip.
|
||||
@@ -158,6 +166,10 @@ export interface ChannelColumnDeps {
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
createAndAttachTag: (channelId: string, name: string) => void;
|
||||
onFilterByTag: (tagId: number, name: string) => void;
|
||||
// E8·S4 — suggested user tags per channel (resolved to Tag + confidence), plus accept/dismiss.
|
||||
suggestionsFor: (channelId: string) => SuggestedTag[];
|
||||
onAcceptSuggestion: (channelId: string, tagId: number) => void;
|
||||
onDismissSuggestion: (channelId: string, tagId: number) => void;
|
||||
}
|
||||
|
||||
export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChannel>[] {
|
||||
@@ -177,6 +189,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
toggleTag,
|
||||
createAndAttachTag,
|
||||
onFilterByTag,
|
||||
suggestionsFor,
|
||||
onAcceptSuggestion,
|
||||
onDismissSuggestion,
|
||||
} = deps;
|
||||
return [
|
||||
{
|
||||
@@ -289,7 +304,7 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
filter: {
|
||||
kind: "multi",
|
||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
|
||||
test: tagsColumnTest,
|
||||
},
|
||||
render: (c) => (
|
||||
<ChannelTagsControl
|
||||
@@ -298,6 +313,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column<ManagedChan
|
||||
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
|
||||
onCreate={(name) => createAndAttachTag(c.id, name)}
|
||||
onChipClick={onFilterByTag}
|
||||
suggestions={suggestionsFor(c.id)}
|
||||
onAcceptSuggestion={(tagId) => onAcceptSuggestion(c.id, tagId)}
|
||||
onDismissSuggestion={(tagId) => onDismissSuggestion(c.id, tagId)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -4,19 +4,13 @@ import type { TFunction } from "i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, ListMusic, Lock, Plus, Search, X } from "lucide-react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import {
|
||||
api,
|
||||
HttpError,
|
||||
isIntrinsicReadOnly,
|
||||
type PlexCard,
|
||||
type PlexCollection,
|
||||
} from "../lib/api";
|
||||
import { api, isIntrinsicReadOnly, type PlexCard, type PlexCollection } from "../lib/api";
|
||||
import { formatRuntime } from "../lib/format";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { notifyCollectionEditError } from "../lib/actionErrors";
|
||||
import { useDebounced } from "../lib/useDebounced";
|
||||
import { LoadingState, StateMessage } from "./QueryState";
|
||||
import PosterTile from "./PosterTile";
|
||||
import PlexLibraryPicker from "./PlexLibraryPicker";
|
||||
|
||||
// A collection tile's sub-line: item count · total runtime (each dropped when unknown, e.g. show-only
|
||||
// collections have no movie runtime).
|
||||
@@ -86,7 +80,6 @@ function NewCollectionPanel({
|
||||
const [name, setName] = useState("");
|
||||
const [term, setTerm] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const debounced = useDebounced(term.trim(), 300);
|
||||
const trimmed = name.trim();
|
||||
// Advisory only: `existingNames` is the search-scoped, all-library collection union, whereas the
|
||||
// backend enforces uniqueness PER library (Plex only conflates same-named collections within one
|
||||
@@ -94,12 +87,6 @@ function NewCollectionPanel({
|
||||
// same-name collection in another library would be un-creatable. The backend's 409 is authoritative.
|
||||
const dupHint = trimmed.length > 0 && existingNames.has(trimmed.toLowerCase());
|
||||
const nameOk = trimmed.length > 0;
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexLibrary("collection-new", debounced),
|
||||
queryFn: () => api.plexLibrary({ scope: "both", q: debounced, limit: 12 }),
|
||||
enabled: nameOk && debounced.length > 0,
|
||||
});
|
||||
const results = q.data?.items ?? [];
|
||||
|
||||
async function create(card: PlexCard) {
|
||||
if (!nameOk || busy) return;
|
||||
@@ -108,13 +95,8 @@ function NewCollectionPanel({
|
||||
const col = await api.plexCreateCollection("", trimmed, card.id); // backend derives the library
|
||||
onCreated(col.id);
|
||||
} catch (e) {
|
||||
// The backend is authoritative on per-library name uniqueness — localize its 409 to nameTaken
|
||||
// (notifyActionError would otherwise surface the server's raw English detail).
|
||||
if (e instanceof HttpError && e.status === 409) {
|
||||
notify({ level: "error", message: t("plex.collection.nameTaken") });
|
||||
} else {
|
||||
notifyActionError(e, t("plex.collection.editFailed"));
|
||||
}
|
||||
// The backend is authoritative on per-library name uniqueness — its 409 is localized to nameTaken.
|
||||
notifyCollectionEditError(e, t("plex.collection.editFailed"), t("plex.collection.nameTaken"));
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
@@ -155,38 +137,21 @@ function NewCollectionPanel({
|
||||
className="glass-card flex-1 rounded-lg px-3 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40 disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
{!nameOk || debounced.length === 0 ? null : q.isLoading ? (
|
||||
<LoadingState label={t("plex.loading")} />
|
||||
) : q.isError ? (
|
||||
<p className="text-muted text-sm">{t("common.loadError")}</p>
|
||||
) : results.length === 0 ? (
|
||||
<p className="text-muted text-sm">{t("plex.collection.addNoResults")}</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{results.map((c) => (
|
||||
<li key={c.id} className="flex items-center gap-3">
|
||||
<img
|
||||
src={c.thumb}
|
||||
alt=""
|
||||
className="h-12 w-8 shrink-0 rounded object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate text-sm">
|
||||
{c.title}
|
||||
{c.year != null && <span className="text-muted"> · {c.year}</span>}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<PlexLibraryPicker
|
||||
scope="both"
|
||||
term={term}
|
||||
enabled={nameOk}
|
||||
renderAction={(c) => (
|
||||
<button
|
||||
onClick={() => create(c)}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 rounded-lg px-2.5 py-1 text-xs disabled:opacity-50"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
{t("plex.collection.createWith")}
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { plexLang } from "../i18n";
|
||||
import { pickRatingBadge } from "../lib/plexRating";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { toggleWatchState } from "../lib/plexWatch";
|
||||
import { showEpisodeKeys } from "../lib/plexQueue";
|
||||
import {
|
||||
ActionBtn,
|
||||
BackBtn,
|
||||
@@ -306,7 +307,7 @@ export function PlexShowView({
|
||||
|
||||
const show = d?.show;
|
||||
const showLib = show?.library ?? undefined;
|
||||
const allKeys = (d?.seasons ?? []).flatMap((se) => se.episodes.map((e) => e.id));
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = show?.status === "watched";
|
||||
const {
|
||||
artBg,
|
||||
@@ -568,7 +569,7 @@ export function PlexSeasonView({
|
||||
// Play-through queue spans the WHOLE show (all seasons, in order) so a season's last episode rolls
|
||||
// straight into the next season's first — the same cross-season order the show page already uses.
|
||||
// seasonKeys stays only for the season-scoped "add to playlist" action.
|
||||
const allKeys = (d?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
const allKeys = showEpisodeKeys(d);
|
||||
const watched = se?.status === "watched";
|
||||
const { artBg, showCast, crossfade, toggleArtBg, toggleCast, toggleCrossfade } = useDetailPrefs();
|
||||
useCrossfadeBackdrop(
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Container-logic contract of the tag-suggestion hook (E8·S4), the wiring the presentational
|
||||
// ChannelTagsControl test can't reach: suggestionsFor resolves tag ids to Tags and DROPS a suggestion
|
||||
// whose tag was deleted since the last compute; accept optimistically removes the WHOLE channel from
|
||||
// the /tags/suggestions cache and toggles the tag on; dismiss is session-only and scoped to one
|
||||
// (channel, tag); visible/high-confidence derive correctly. Runs against a real QueryClient so the
|
||||
// optimistic cache write is the actual code path (not a re-implementation).
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { ReactNode } from "react";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
// The confirm dialog's answer is controllable per test (the bulk-accept path gates on it).
|
||||
const confirmState = vi.hoisted(() => ({ result: true }));
|
||||
vi.mock("./ConfirmProvider", () => ({
|
||||
useConfirm: () => () => Promise.resolve(confirmState.result),
|
||||
}));
|
||||
|
||||
import { api, type Tag } from "../lib/api";
|
||||
import type { TagSuggestions } from "../lib/api/tags";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
|
||||
const tag = (id: number, name: string): Tag => ({
|
||||
id,
|
||||
name,
|
||||
color: null,
|
||||
category: "other",
|
||||
system: false,
|
||||
channel_count: 0,
|
||||
});
|
||||
const docu = tag(1, "docu");
|
||||
const history = tag(2, "history");
|
||||
const tags = [docu, history];
|
||||
|
||||
// UC_a: two suggestions (one high-confidence). UC_b: one. UC_ghost: a suggestion for a tag id that no
|
||||
// longer exists in the catalog (deleted since compute) → must be filtered out everywhere.
|
||||
const seed = (): TagSuggestions => ({
|
||||
UC_a: [
|
||||
{ tag_id: docu.id, confidence: 0.95 },
|
||||
{ tag_id: history.id, confidence: 0.6 },
|
||||
],
|
||||
UC_b: [{ tag_id: history.id, confidence: 0.7 }],
|
||||
UC_ghost: [{ tag_id: 999, confidence: 0.99 }],
|
||||
});
|
||||
|
||||
function setup() {
|
||||
const qc = new QueryClient({
|
||||
// Freeze the seeded cache so no refetch clobbers the optimistic writes under test.
|
||||
defaultOptions: { queries: { retry: false, staleTime: Infinity, refetchOnMount: false } },
|
||||
});
|
||||
qc.setQueryData(qk.tagSuggestions(), seed());
|
||||
vi.spyOn(api, "tagSuggestions").mockResolvedValue(seed());
|
||||
const toggleTag = vi.fn();
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
|
||||
);
|
||||
const { result } = renderHook(() => useTagSuggestions({ tags, toggleTag }), { wrapper });
|
||||
return { qc, toggleTag, result };
|
||||
}
|
||||
|
||||
describe("useTagSuggestions", () => {
|
||||
it("resolves tag ids to Tags and drops suggestions whose tag was deleted", () => {
|
||||
const { result } = setup();
|
||||
const forA = result.current.suggestionsFor("UC_a");
|
||||
expect(forA.map((s) => s.tag.id)).toEqual([docu.id, history.id]);
|
||||
// The suggestion for the missing tag id 999 is dropped, not rendered as a broken chip.
|
||||
expect(result.current.suggestionsFor("UC_ghost")).toEqual([]);
|
||||
});
|
||||
|
||||
it("derives visible and high-confidence suggestions, excluding the deleted-tag one", () => {
|
||||
const { result } = setup();
|
||||
// UC_a(docu 0.95) + UC_a(history 0.6) + UC_b(history 0.7) = 3; UC_ghost excluded.
|
||||
expect(result.current.visibleSuggestions).toHaveLength(3);
|
||||
// Only the ≥0.9 one (UC_a/docu) is high-confidence.
|
||||
expect(result.current.highConfidence).toEqual([
|
||||
{ cid: "UC_a", tagId: docu.id, confidence: 0.95 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("accept optimistically removes the whole channel from the cache and toggles the tag on", () => {
|
||||
const { qc, toggleTag, result } = setup();
|
||||
act(() => result.current.acceptSuggestion("UC_a", docu.id));
|
||||
// The real tag is attached (hasTag=false → attach).
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
// The whole channel is dropped from the suggestions cache (attached lane XOR ghost lane), so its
|
||||
// OTHER ghost chip (history) goes too — not just the accepted one.
|
||||
const cache = qc.getQueryData<TagSuggestions>(qk.tagSuggestions());
|
||||
expect(cache?.UC_a).toBeUndefined();
|
||||
expect(cache?.UC_b).toBeDefined(); // other channels untouched
|
||||
});
|
||||
|
||||
it("dismiss hides only that one (channel, tag) for the session", () => {
|
||||
const { result } = setup();
|
||||
act(() => result.current.dismissSuggestion("UC_a", history.id));
|
||||
// UC_a loses only the history suggestion; docu stays.
|
||||
expect(result.current.suggestionsFor("UC_a").map((s) => s.tag.id)).toEqual([docu.id]);
|
||||
// The same tag on a DIFFERENT channel is not dismissed (dismiss is scoped to the pair).
|
||||
expect(result.current.suggestionsFor("UC_b").map((s) => s.tag.id)).toEqual([history.id]);
|
||||
});
|
||||
|
||||
it("bulk-accept applies ONLY the high-confidence suggestions after a confirm", async () => {
|
||||
confirmState.result = true;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
// Only UC_a/docu (0.95 ≥ 0.9) is accepted — not history (0.6) or UC_b/history (0.7).
|
||||
expect(toggleTag).toHaveBeenCalledTimes(1);
|
||||
expect(toggleTag).toHaveBeenCalledWith("UC_a", docu.id, false);
|
||||
});
|
||||
|
||||
it("bulk-accept does nothing when the confirm is cancelled", async () => {
|
||||
confirmState.result = false;
|
||||
const { toggleTag, result } = setup();
|
||||
await act(async () => {
|
||||
await result.current.acceptAllHighConfidence();
|
||||
});
|
||||
expect(toggleTag).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useConfirm } from "./ConfirmProvider";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
// lockstep by intent: the two aren't imported across the stack, so a change must touch both).
|
||||
const HIGH_CONFIDENCE = 0.9;
|
||||
|
||||
type VisibleSuggestion = { cid: string; tagId: number; confidence: number };
|
||||
|
||||
/** Suggested-tag state for the Channel Manager (E8·S4): the /tags/suggestions query, a session-only
|
||||
* dismiss set, and the accept / dismiss / bulk-accept actions. Suggestions are learned from the
|
||||
* user's own manual-tag pattern and shown as ghost chips on UNTAGGED channels. Extracted as a hook
|
||||
* (like useChannelTagActions) so the optimistic-cache and dismiss logic is unit-testable apart from
|
||||
* the large Channels component. */
|
||||
export function useTagSuggestions({
|
||||
tags,
|
||||
toggleTag,
|
||||
}: {
|
||||
/** The full tag catalog, to resolve a suggestion's tag_id → Tag (and drop tags deleted since compute). */
|
||||
tags: Tag[];
|
||||
/** The shared attach/detach toggle. Its onError must also refetch suggestions (see Channels) so a
|
||||
* failed accept restores the ghost chips this hook optimistically removed. */
|
||||
toggleTag: (channelId: string, tagId: number, hasTag: boolean) => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const suggestionsQuery = useQuery({ queryKey: qk.tagSuggestions(), queryFn: api.tagSuggestions });
|
||||
const suggestions: TagSuggestions = suggestionsQuery.data ?? {};
|
||||
const tagById = useMemo(() => new Map(tags.map((tg) => [tg.id, tg])), [tags]);
|
||||
const [dismissed, setDismissed] = useState<Set<string>>(() => new Set());
|
||||
const sKey = (cid: string, tagId: number) => `${cid}:${tagId}`;
|
||||
|
||||
// Resolve a channel's raw {tag_id, confidence} list to renderable {tag, confidence}, dropping any
|
||||
// that were dismissed this session or whose tag no longer exists (deleted since the last compute).
|
||||
const suggestionsFor = (cid: string): SuggestedTag[] =>
|
||||
(suggestions[cid] ?? []).flatMap((s) => {
|
||||
const tag = tagById.get(s.tag_id);
|
||||
return tag && !dismissed.has(sKey(cid, s.tag_id)) ? [{ tag, confidence: s.confidence }] : [];
|
||||
});
|
||||
|
||||
const dismissSuggestion = (cid: string, tagId: number) =>
|
||||
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
|
||||
|
||||
const acceptSuggestion = (cid: string, tagId: number) => {
|
||||
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
|
||||
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
|
||||
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
|
||||
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; on success
|
||||
// its onSuccess re-invalidates suggestions, and on failure its onError refetches them — so a
|
||||
// failed accept restores these ghost chips rather than leaving an empty cell (see Channels' onError).
|
||||
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
|
||||
if (!old?.[cid]) return old;
|
||||
const next = { ...old };
|
||||
delete next[cid];
|
||||
return next;
|
||||
});
|
||||
toggleTag(cid, tagId, false);
|
||||
};
|
||||
|
||||
// Every currently-visible suggestion (all channels, minus dismissed) and the high-confidence subset
|
||||
// the bulk action accepts.
|
||||
const visibleSuggestions: VisibleSuggestion[] = Object.keys(suggestions).flatMap((cid) =>
|
||||
suggestionsFor(cid).map((s) => ({ cid, tagId: s.tag.id, confidence: s.confidence })),
|
||||
);
|
||||
const highConfidence = visibleSuggestions.filter((s) => s.confidence >= HIGH_CONFIDENCE);
|
||||
const acceptAllHighConfidence = async () => {
|
||||
const ok = await confirm({
|
||||
title: t("channels.tags.acceptAllTitle"),
|
||||
message: t("channels.tags.acceptAllConfirm", { count: highConfidence.length }),
|
||||
confirmLabel: t("channels.tags.acceptAllConfirmCta"),
|
||||
});
|
||||
if (!ok) return;
|
||||
highConfidence.forEach(({ cid, tagId }) => acceptSuggestion(cid, tagId));
|
||||
};
|
||||
|
||||
return {
|
||||
suggestionsFor,
|
||||
dismissSuggestion,
|
||||
acceptSuggestion,
|
||||
visibleSuggestions,
|
||||
highConfidence,
|
||||
acceptAllHighConfidence,
|
||||
};
|
||||
}
|
||||
@@ -17,5 +17,7 @@
|
||||
"restart": "Restart",
|
||||
"play": "Play",
|
||||
"stream": "stream",
|
||||
"views": "views"
|
||||
"views": "views",
|
||||
"info": "Details",
|
||||
"noDescription": "No description"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,23 @@
|
||||
"noneYet": "No tags yet — create one below.",
|
||||
"create": "Create tag",
|
||||
"createPlaceholder": "New tag…",
|
||||
"createFailed": "Could not create the tag."
|
||||
"createFailed": "Could not create the tag.",
|
||||
"updateFailed": "Could not update the tag.",
|
||||
"noTag": "No tag",
|
||||
"moreCount_one": "{{count}} more tag",
|
||||
"moreCount_other": "{{count}} more tags",
|
||||
"suggestedHint": "Suggested from your tagging pattern ({{pct}}% match) — click to add",
|
||||
"acceptSuggestion": "Add suggested tag {{name}}",
|
||||
"dismissSuggestion": "Dismiss suggestion",
|
||||
"suggestionsHint": "Tags suggested for your untagged channels, learned from how you've tagged similar ones. Click a ghost chip to add it, or × to dismiss.",
|
||||
"suggestionsCount_one": "{{count}} suggestion",
|
||||
"suggestionsCount_other": "{{count}} suggestions",
|
||||
"acceptAllHigh_one": "Accept {{count}} confident",
|
||||
"acceptAllHigh_other": "Accept {{count}} confident",
|
||||
"acceptAllTitle": "Accept suggested tags",
|
||||
"acceptAllConfirm_one": "Add the {{count}} high-confidence suggested tag to its channel?",
|
||||
"acceptAllConfirm_other": "Add the {{count}} high-confidence suggested tags to their channels?",
|
||||
"acceptAllConfirmCta": "Accept all"
|
||||
},
|
||||
"loading": "Loading channels…",
|
||||
"empty": "No channels.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"label": "Filtered by",
|
||||
"remove": "Remove filter: {{name}}",
|
||||
"contentNone": "No content types",
|
||||
"untagged": "Untagged",
|
||||
"count_one": "{{count}} filter",
|
||||
"count_other": "{{count}} filters",
|
||||
"moreCount_one": "{{count}} more filter",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"added": "Added",
|
||||
"deleteConfirm": "Delete the collection \"{{title}}\"? This removes it from Plex too.",
|
||||
"editFailed": "Collection edit failed",
|
||||
"playFailed": "Couldn't build the play queue — try again.",
|
||||
"playEmpty": "Nothing to play in this collection yet.",
|
||||
"newCollection": "New collection",
|
||||
"namePlaceholder": "Collection name",
|
||||
"nameTaken": "A collection with this name already exists.",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"clearDates": "clear dates",
|
||||
"reshuffle": "Reshuffle",
|
||||
"noMatchingTags": "No matching tags here",
|
||||
"noTagOption": "No tag",
|
||||
"manageTags": "Manage",
|
||||
"widget": {
|
||||
"savedviews": "Saved views",
|
||||
@@ -37,7 +38,16 @@
|
||||
"language": "Language",
|
||||
"topic": "Topic",
|
||||
"tags": "Your tags",
|
||||
"channel": "Channel"
|
||||
"channel": "Channel",
|
||||
"year": "Year"
|
||||
},
|
||||
"year": {
|
||||
"more": "More…",
|
||||
"moreLabel": "All years",
|
||||
"none": "No years yet",
|
||||
"sortBy": "Sort",
|
||||
"sortYear": "Year",
|
||||
"sortCount": "Count"
|
||||
},
|
||||
"show": {
|
||||
"unwatched": "Unwatched",
|
||||
|
||||
@@ -17,5 +17,7 @@
|
||||
"restart": "Elölről",
|
||||
"play": "Lejátszás",
|
||||
"stream": "stream",
|
||||
"views": "megtekintés"
|
||||
"views": "megtekintés",
|
||||
"info": "Részletek",
|
||||
"noDescription": "Nincs leírás"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,23 @@
|
||||
"noneYet": "Még nincs címke — hozz létre egyet lent.",
|
||||
"create": "Címke létrehozása",
|
||||
"createPlaceholder": "Új címke…",
|
||||
"createFailed": "A címke létrehozása nem sikerült."
|
||||
"createFailed": "A címke létrehozása nem sikerült.",
|
||||
"updateFailed": "A címke frissítése nem sikerült.",
|
||||
"noTag": "Nincs címke",
|
||||
"moreCount_one": "még {{count}} címke",
|
||||
"moreCount_other": "még {{count}} címke",
|
||||
"suggestedHint": "A címkézési mintád alapján javasolt ({{pct}}% egyezés) — kattints a hozzáadáshoz",
|
||||
"acceptSuggestion": "{{name}} javasolt címke hozzáadása",
|
||||
"dismissSuggestion": "Javaslat elvetése",
|
||||
"suggestionsHint": "A címkézetlen csatornáidhoz javasolt címkék, abból tanulva, hogyan címkézted a hasonlókat. Kattints egy szellem-chipre a hozzáadáshoz, vagy ×-re az elvetéshez.",
|
||||
"suggestionsCount_one": "{{count}} javaslat",
|
||||
"suggestionsCount_other": "{{count}} javaslat",
|
||||
"acceptAllHigh_one": "{{count}} biztos elfogadása",
|
||||
"acceptAllHigh_other": "{{count}} biztos elfogadása",
|
||||
"acceptAllTitle": "Javasolt címkék elfogadása",
|
||||
"acceptAllConfirm_one": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornájához?",
|
||||
"acceptAllConfirm_other": "Hozzáadod a(z) {{count}} nagy magabiztosságú javasolt címkét a csatornáikhoz?",
|
||||
"acceptAllConfirmCta": "Mind elfogadása"
|
||||
},
|
||||
"loading": "Csatornák betöltése…",
|
||||
"empty": "Nincsenek csatornák.",
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"label": "Szűrve",
|
||||
"remove": "Szűrő eltávolítása: {{name}}",
|
||||
"contentNone": "Nincs tartalomtípus",
|
||||
"untagged": "Címkézetlen",
|
||||
"count_one": "{{count}} szűrő",
|
||||
"count_other": "{{count}} szűrő",
|
||||
"moreCount_one": "még {{count}} szűrő",
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
"added": "Hozzáadva",
|
||||
"deleteConfirm": "Törlöd a(z) „{{title}}” kollekciót? Ez a Plexből is eltávolítja.",
|
||||
"editFailed": "A kollekció szerkesztése sikertelen",
|
||||
"playFailed": "Nem sikerült összeállítani a lejátszási sort — próbáld újra.",
|
||||
"playEmpty": "Ebben a kollekcióban még nincs mit lejátszani.",
|
||||
"newCollection": "Új kollekció",
|
||||
"namePlaceholder": "Kollekció neve",
|
||||
"nameTaken": "Már létezik ilyen nevű kollekció.",
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"clearDates": "dátumok törlése",
|
||||
"reshuffle": "Újrakeverés",
|
||||
"noMatchingTags": "Nincs ide illő címke",
|
||||
"noTagOption": "Nincs címke",
|
||||
"manageTags": "Kezelés",
|
||||
"widget": {
|
||||
"savedviews": "Mentett nézetek",
|
||||
@@ -37,7 +38,16 @@
|
||||
"language": "Nyelv",
|
||||
"topic": "Téma",
|
||||
"tags": "Saját címkék",
|
||||
"channel": "Csatorna"
|
||||
"channel": "Csatorna",
|
||||
"year": "Év"
|
||||
},
|
||||
"year": {
|
||||
"more": "Több…",
|
||||
"moreLabel": "Összes év",
|
||||
"none": "Még nincs év",
|
||||
"sortBy": "Rendezés",
|
||||
"sortYear": "Év",
|
||||
"sortCount": "Darab"
|
||||
},
|
||||
"show": {
|
||||
"unwatched": "Nem nézett",
|
||||
|
||||
@@ -581,6 +581,16 @@ body {
|
||||
.reveal-idle {
|
||||
display: none;
|
||||
}
|
||||
/* Sibling of `.reveal` for a MOUSE-ONLY hover-reveal: unlike `.reveal` (shown at rest on touch),
|
||||
a `.reveal-hover` control is removed entirely on a device that can't hover, so it is never an
|
||||
invisible tap/Tab target. Pair it with the site's own `opacity-0 group-hover:… group-focus-within:…`
|
||||
(which still governs the hover + keyboard case). For a full-card/edge affordance whose action is
|
||||
already reachable on touch another way — VideoCard's play overlay (tapping the card plays/resumes)
|
||||
and the Carousel arrows (swiping scrolls the strip) — where a permanent scrim/edge button is not
|
||||
wanted. Group-agnostic (encodes only the touch removal), so it works under any `.group`/`group\/…`. */
|
||||
.reveal-hover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode: the ambient accent pools are faint over a near-black bg, so the docked frosted
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// jsdom (not node): importing ../api pulls in i18n, which reads navigator.language at module load.
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Spy on the toast sink so we can assert exactly what each error maps to.
|
||||
vi.mock("./notifications", () => ({ notify: vi.fn(() => 0) }));
|
||||
|
||||
import { HttpError } from "./api";
|
||||
import { notify } from "./notifications";
|
||||
import { notifyCollectionEditError } from "./actionErrors";
|
||||
|
||||
const notifyMock = vi.mocked(notify);
|
||||
beforeEach(() => notifyMock.mockClear());
|
||||
|
||||
describe("notifyCollectionEditError", () => {
|
||||
it("maps a backend 409 to the friendly nameTaken message (not the raw server detail)", () => {
|
||||
notifyCollectionEditError(new HttpError(409, "already exists"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "NAME_TAKEN" });
|
||||
});
|
||||
|
||||
it("falls back to the server detail for a non-409 HttpError", () => {
|
||||
notifyCollectionEditError(new HttpError(500, "server boom"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "server boom" });
|
||||
});
|
||||
|
||||
it("falls back to the generic edit-failed message for a non-HttpError", () => {
|
||||
notifyCollectionEditError(new Error("network"), "EDIT_FAILED", "NAME_TAKEN");
|
||||
expect(notifyMock).toHaveBeenCalledWith({ level: "error", message: "EDIT_FAILED" });
|
||||
});
|
||||
});
|
||||
@@ -12,3 +12,22 @@ export function notifyActionError(err: unknown, fallbackMessage: string): void {
|
||||
const detail = err instanceof HttpError ? err.detail : null;
|
||||
notify({ level: "error", message: detail || fallbackMessage });
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection write errors. The backend enforces per-library name uniqueness and returns 409 on a
|
||||
* duplicate; localize that specific case to the friendly `nameTaken` copy (notifyActionError would
|
||||
* otherwise surface the server's raw English detail). Everything else falls back to the generic
|
||||
* edit-failed handling (which still prefers the server's reason when present). Both args are already
|
||||
* translated so this stays i18n-agnostic and unit-testable.
|
||||
*/
|
||||
export function notifyCollectionEditError(
|
||||
err: unknown,
|
||||
editFailedMessage: string,
|
||||
nameTakenMessage: string,
|
||||
): void {
|
||||
if (err instanceof HttpError && err.status === 409) {
|
||||
notify({ level: "error", message: nameTakenMessage });
|
||||
return;
|
||||
}
|
||||
notifyActionError(err, editFailedMessage);
|
||||
}
|
||||
|
||||
@@ -32,7 +32,13 @@ export type * from "./api/setup";
|
||||
import { feedApi } from "./api/feed";
|
||||
export type * from "./api/feed";
|
||||
export type * from "./api/tagModes";
|
||||
export { DEFAULT_TAG_MODES, TAG_CATEGORIES, coerceTagModes } from "./api/tagModes";
|
||||
export {
|
||||
DEFAULT_TAG_MODES,
|
||||
TAG_CATEGORIES,
|
||||
NO_TAG_SENTINEL,
|
||||
coerceTagModes,
|
||||
coerceUntagged,
|
||||
} from "./api/tagModes";
|
||||
import { channelsApi } from "./api/channels";
|
||||
export type * from "./api/channels";
|
||||
import { syncApi } from "./api/sync";
|
||||
|
||||
@@ -71,6 +71,9 @@ export interface FeedFilters {
|
||||
// Library (scope "all") only — provenance filter: "organic" (default) hides search-
|
||||
// discovered videos, "all" mixes them in, "search" shows only them. Ignored for "my".
|
||||
librarySource?: "organic" | "all" | "search" | "plex";
|
||||
/** "No tag" filter (item B): keep only channels the user has NOT personally tagged in the channel
|
||||
* manager (no user-owned ChannelTag). Auto/system tags don't count. */
|
||||
untagged?: boolean;
|
||||
/** OR-ed together: a video from ANY of these passes, and every other filter still applies. */
|
||||
channelIds: string[];
|
||||
maxAgeDays?: number;
|
||||
@@ -78,6 +81,16 @@ export interface FeedFilters {
|
||||
maxDuration?: number;
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
/** Filter-by-year (item 8): a single calendar year, expressed on the wire as a whole-year
|
||||
* published_after/before range. Mutually exclusive with maxAgeDays/dateFrom/dateTo (all
|
||||
* publish-date narrowings) — the setters clear the others, and `filterParams` lets year win. */
|
||||
year?: number;
|
||||
}
|
||||
|
||||
/** One year's video count for the year control's chips + "more…" dialog (item 8 / E8·S2). */
|
||||
export interface YearCount {
|
||||
year: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
// The filter half of the query (everything except paging/sort), shared by the feed and
|
||||
@@ -89,6 +102,7 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
||||
for (const cat of TAG_CATEGORIES) {
|
||||
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
|
||||
}
|
||||
if (f.untagged) p.set("untagged", "true");
|
||||
if (f.q) p.set("q", f.q);
|
||||
p.set("scope", f.scope);
|
||||
p.set("show_normal", String(f.includeNormal));
|
||||
@@ -96,9 +110,16 @@ function filterParams(f: FeedFilters): URLSearchParams {
|
||||
p.set("include_live", String(f.includeLive));
|
||||
p.set("show", f.show);
|
||||
f.channelIds.forEach((id) => p.append("channel_ids", id));
|
||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||
// Year (item 8) is a whole-year publish-date range and wins over the maxAge/custom-range controls
|
||||
// (they're mutually exclusive publish-date narrowings — the setters enforce it, this guards the wire).
|
||||
if (f.year) {
|
||||
p.set("published_after", `${f.year}-01-01`);
|
||||
p.set("published_before", `${f.year}-12-31`);
|
||||
} else {
|
||||
if (f.maxAgeDays) p.set("max_age_days", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("published_after", f.dateFrom);
|
||||
if (f.dateTo) p.set("published_before", f.dateTo);
|
||||
}
|
||||
if (f.minDuration != null) p.set("min_duration", String(f.minDuration));
|
||||
if (f.maxDuration != null) p.set("max_duration", String(f.maxDuration));
|
||||
// Only relevant in Library scope; the backend ignores it for "my". Default = hide search.
|
||||
@@ -144,6 +165,10 @@ export const feedApi = {
|
||||
// the separate feedCount + facets the feed and sidebar used to fire on every filter change (PERF-13).
|
||||
feedSummary: (f: FeedFilters): Promise<{ count: number; facets: Record<string, number> }> =>
|
||||
req(`/api/feed/summary?${filterParams(f).toString()}`),
|
||||
// Per-year video counts for the year control (item 8 / E8·S2). The backend drops the publish-date
|
||||
// narrowing, so callers key this on the date-stripped filters (picking a year mustn't refetch it).
|
||||
feedYears: (f: FeedFilters): Promise<{ years: YearCount[] }> =>
|
||||
req(`/api/feed/years?${filterParams(f).toString()}`),
|
||||
// idempotent: setting a state / saving a progress checkpoint is safe to replay, so these
|
||||
// recover from a transient gateway/keepalive 502 instead of surfacing it (see req()).
|
||||
setState: (id: string, status: string) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { coerceTagModes, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
import { coerceTagModes, coerceUntagged, DEFAULT_TAG_MODES } from "./tagModes";
|
||||
|
||||
// coerceTagModes runs on every persisted saved-view blob (SavedViewsProvider.asFilters), on the URL
|
||||
// parse, and on the localStorage seed — the single migration point for the legacy global `tagMode`.
|
||||
@@ -40,3 +40,15 @@ describe("coerceTagModes", () => {
|
||||
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
|
||||
});
|
||||
});
|
||||
|
||||
// coerceUntagged guards the "No tag" (item B) flag on the same persisted/URL/saved-view paths.
|
||||
describe("coerceUntagged", () => {
|
||||
it("is true only for an explicit untagged:true", () => {
|
||||
expect(coerceUntagged({ untagged: true })).toBe(true);
|
||||
expect(coerceUntagged(undefined)).toBe(false);
|
||||
expect(coerceUntagged({})).toBe(false);
|
||||
expect(coerceUntagged(null)).toBe(false);
|
||||
expect(coerceUntagged({ untagged: "true" })).toBe(false);
|
||||
expect(coerceUntagged({ untagged: ["other"] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,11 @@ export type TagModes = Record<TagCategory, TagMode>;
|
||||
export const TAG_CATEGORIES: readonly TagCategory[] = ["language", "topic", "other"];
|
||||
export const DEFAULT_TAG_MODES: TagModes = { language: "or", topic: "or", other: "or" };
|
||||
|
||||
/** UI sentinel for the "No tag" (item B) option — the feed's user-tags combobox and the channel
|
||||
* manager's tag chip row both use it to mean "channels I haven't personally tagged". Not a real tag
|
||||
* id (those are numeric strings), so it can never collide with one. */
|
||||
export const NO_TAG_SENTINEL = "__none__";
|
||||
|
||||
/** Normalise a persisted/shared filter's tag match into the per-category shape, migrating the legacy
|
||||
* global `tagMode` (which only ever affected topics) into the topic slot. */
|
||||
export function coerceTagModes(raw: unknown): TagModes {
|
||||
@@ -22,3 +27,8 @@ export function coerceTagModes(raw: unknown): TagModes {
|
||||
};
|
||||
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
|
||||
}
|
||||
|
||||
/** Normalise a persisted/shared filter's "No tag" (item B) flag to a boolean. */
|
||||
export function coerceUntagged(raw: unknown): boolean {
|
||||
return (raw as { untagged?: unknown } | null)?.untagged === true;
|
||||
}
|
||||
|
||||
@@ -10,8 +10,17 @@ export interface Tag {
|
||||
channel_count: number;
|
||||
}
|
||||
|
||||
// One suggested user-tag for a channel, learned from the user's own manual-tag pattern (E8·S4).
|
||||
export interface TagSuggestion {
|
||||
tag_id: number;
|
||||
confidence: number;
|
||||
}
|
||||
// Keyed by channel id; channels with no suggestion are omitted by the server.
|
||||
export type TagSuggestions = Record<string, TagSuggestion[]>;
|
||||
|
||||
export const tagsApi = {
|
||||
tags: (): Promise<Tag[]> => req("/api/tags"),
|
||||
tagSuggestions: (): Promise<TagSuggestions> => req("/api/tags/suggestions"),
|
||||
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
|
||||
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
|
||||
updateTag: (id: number, patch: { name?: string; color?: string }) =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decideChipLayout, type ChipFitInput } from "./chipOverflow";
|
||||
import { decideChipLayout, fitChipsWithPill, type ChipFitInput } from "./chipOverflow";
|
||||
|
||||
// Build measurer-style right edges for `n` chips of a fixed width, starting after a label, with a gap.
|
||||
function rights(labelRight: number, widths: number[], gap: number): number[] {
|
||||
@@ -91,3 +91,29 @@ describe("decideChipLayout", () => {
|
||||
expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("fitChipsWithPill", () => {
|
||||
// Three chips of width 50, gap 6, starting at 0 -> right edges 50, 106, 162.
|
||||
const chipRights = rights(0, [50, 50, 50], 6);
|
||||
|
||||
it("shows all chips (no pill) when the last chip's right edge fits — the whole-set fast path", () => {
|
||||
expect(fitChipsWithPill({ chipRights, pillW: 40, gap: 6, avail: 200 })).toBe(3);
|
||||
});
|
||||
|
||||
it("returns 0 for an empty chip set", () => {
|
||||
expect(fitChipsWithPill({ chipRights: [], pillW: 40, gap: 6, avail: 200 })).toBe(0);
|
||||
});
|
||||
|
||||
it("reserves the pill width for all but the last chip when overflowing", () => {
|
||||
// avail 120: whole set (162) doesn't fit. chip0 right 50 <= 120-(40+6)=74 ok; chip1 right 106 > 74
|
||||
// -> dropped at i=1 -> visible 1.
|
||||
expect(fitChipsWithPill({ chipRights, pillW: 40, gap: 6, avail: 120 })).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps the whole set via the fast path even when a reserve-for-all loop would drop chips", () => {
|
||||
// Right edges 56, 112, 118; avail 120: the whole set fits (118 <= 120). A naive loop that reserves
|
||||
// pill+gap (46) for every non-last chip would drop at chip1 (112 > 120-46=74) and show "+2" beside
|
||||
// empty space — the fast path returns all 3 instead.
|
||||
expect(fitChipsWithPill({ chipRights: [56, 112, 118], pillW: 40, gap: 6, avail: 120 })).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Pure fit decision for a single-line chip lane that must share its width with fixed trailing
|
||||
// controls (the feed's applied-filter row + Sort/View). Lifted out of ActiveFilterChips so the
|
||||
// all/some/count math is unit-testable without a layout engine (jsdom reports 0-width boxes), the
|
||||
// same split as lib/dateFilter and lib/columnSort. The caller measures real widths off a hidden
|
||||
// mirror (including the live rem gap, so nothing is frozen in px) and hands them here.
|
||||
//
|
||||
// A sibling one-line-fit measurer lives in ChannelTags (ChannelTagsControl); if a third appears,
|
||||
// promote the DOM-measurement half to a shared hook and keep this as its policy core.
|
||||
// Pure fit decisions for a single-line chip lane that collapses its overflow into a "+N" pill.
|
||||
// Lifted out of the components so the math is unit-testable without a layout engine (jsdom reports
|
||||
// 0-width boxes), the same split as lib/dateFilter and lib/columnSort. The shared DOM-measurement
|
||||
// half — the layout-effect + ResizeObserver that reads real rem boxes off a hidden mirror — lives in
|
||||
// lib/useChipOverflowFit; it hands the measured geometry to one of these policies:
|
||||
// - decideChipLayout: the feed's applied-filter row, which also shares its width with fixed trailing
|
||||
// controls (Sort/View), reserves a leading label + a trailing "Clear all", and collapses to a
|
||||
// single count pill on a narrow lane.
|
||||
// - fitChipsWithPill: the chips-only channel tag cell (ChannelTagsControl) — just "how many fit
|
||||
// before the +N pill".
|
||||
|
||||
export interface ChipFitInput {
|
||||
/** Number of chips. */
|
||||
@@ -51,3 +53,37 @@ export function decideChipLayout(p: ChipFitInput): ChipFit {
|
||||
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
|
||||
return { mode: "some", visible: k };
|
||||
}
|
||||
|
||||
// The chips-only policy (ChannelTagsControl): how many chips fit on the line, reserving the "+N" pill's
|
||||
// width whenever any overflow. Returns the visible count.
|
||||
export interface ChipsWithPillInput {
|
||||
/** Right edge (offsetLeft + offsetWidth) of each chip, index-aligned. */
|
||||
chipRights: number[];
|
||||
/** Width of the "+N" pill. */
|
||||
pillW: number;
|
||||
/** Available lane width. */
|
||||
avail: number;
|
||||
/** The real (rem-scaled) inter-item gap. */
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export function fitChipsWithPill(p: ChipsWithPillInput): number {
|
||||
const { chipRights, pillW, avail, gap } = p;
|
||||
const n = chipRights.length;
|
||||
if (n === 0) return 0;
|
||||
// Whole-set fast path: if the LAST chip's right edge already fits, every chip fits and NO pill is
|
||||
// needed. The reservation loop below reserves the pill for every chip except the last and breaks at
|
||||
// the first overflow, so on its own it would collapse a short trailing chip (narrower than the pill)
|
||||
// that would actually have fitted, leaving "+N" beside empty space.
|
||||
if ((chipRights[n - 1] ?? 0) <= avail) return n;
|
||||
let count = n;
|
||||
for (let i = 0; i < n; i++) {
|
||||
const right = chipRights[i] ?? 0;
|
||||
const moreAfter = i < n - 1;
|
||||
if (right > avail - (moreAfter ? pillW + gap : 0)) {
|
||||
count = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
@@ -87,4 +87,11 @@ describe("selectDateChip", () => {
|
||||
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
|
||||
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
|
||||
});
|
||||
|
||||
it("clears a selected year on any date pick (they're mutually exclusive)", () => {
|
||||
// preset, custom, and 'any' all drop the year — it's the same publish-date narrowing.
|
||||
expect(selectDateChip("30", base({ year: 2024 })).filters.year).toBeUndefined();
|
||||
expect(selectDateChip("custom", base({ year: 2024 })).filters.year).toBeUndefined();
|
||||
expect(selectDateChip("any", base({ year: 2024 })).filters.year).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,18 +32,32 @@ export function selectDateChip(
|
||||
key: string,
|
||||
filters: FeedFilters,
|
||||
): { filters: FeedFilters; customOpen: boolean } {
|
||||
// A date pick and the year control are mutually exclusive publish-date narrowings, so any date
|
||||
// selection clears the year (and vice versa, in YearFilter).
|
||||
if (key === "custom") {
|
||||
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
|
||||
return { filters: { ...filters, maxAgeDays: undefined, year: undefined }, customOpen: true };
|
||||
}
|
||||
if (key === "any" || key === "") {
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: undefined,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
const days = Number(key);
|
||||
return {
|
||||
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
|
||||
filters: {
|
||||
...filters,
|
||||
maxAgeDays: days,
|
||||
dateFrom: undefined,
|
||||
dateTo: undefined,
|
||||
year: undefined,
|
||||
},
|
||||
customOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Guards the UX-8 fix: one Escape must close only the TOPMOST layer, never both an open popover AND
|
||||
// the player beneath it. The bug was that the popover's Escape listener (on `document`) fires before
|
||||
// the player's (on `window`), and closing the popover flushes its layer off the stack synchronously —
|
||||
// so by the time the player's `anyLayerAbove()` lookup runs, the stack already reads "nothing above"
|
||||
// and the player closes too. The fix stamps the shared KeyboardEvent when a layer acts; the player
|
||||
// reads the stamp instead of the (now-mutated) stack. This test reproduces that exact ordering with
|
||||
// the stack primitives — the layer removes itself between the two listeners, mimicking the flush.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import {
|
||||
pushLayer,
|
||||
removeLayer,
|
||||
anyLayerAbove,
|
||||
isTopmost,
|
||||
markEscapeHandled,
|
||||
escapeHandled,
|
||||
} from "./layerStack";
|
||||
|
||||
// Keep the module-level stack clean between cases (ids never repeat, but membership must not leak).
|
||||
const opened: number[] = [];
|
||||
function open(): number {
|
||||
const id = pushLayer();
|
||||
opened.push(id);
|
||||
return id;
|
||||
}
|
||||
afterEach(() => {
|
||||
while (opened.length) removeLayer(opened.pop()!);
|
||||
});
|
||||
|
||||
describe("layerStack Escape coordination (UX-8)", () => {
|
||||
it("a player defers even when the acting layer is popped mid-dispatch", () => {
|
||||
const playerId = open();
|
||||
const popoverId = open();
|
||||
const playerClose = vi.fn();
|
||||
|
||||
// Popover: the `document` listener from useLayer — topmost acts, stamps the event, and (the
|
||||
// regression) its close synchronously removes the layer before the window listener runs.
|
||||
const onDocumentKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(popoverId)) {
|
||||
markEscapeHandled(e);
|
||||
removeLayer(popoverId); // the synchronous React flush the real close triggers
|
||||
}
|
||||
};
|
||||
// Player: the `window` listener — fires AFTER the document one, reading an already-mutated stack.
|
||||
const onWindowKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (escapeHandled(e)) return; // the fix
|
||||
if (anyLayerAbove(playerId)) return; // the old, now-insufficient guard
|
||||
playerClose();
|
||||
};
|
||||
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
onDocumentKey(e);
|
||||
onWindowKey(e);
|
||||
|
||||
// The stamp holds even though the stack no longer shows a layer above the player...
|
||||
expect(escapeHandled(e)).toBe(true);
|
||||
expect(anyLayerAbove(playerId)).toBe(false); // ...which is exactly why anyLayerAbove alone failed
|
||||
expect(playerClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a plain Escape with no layer above still closes the player", () => {
|
||||
const playerId = open();
|
||||
const playerClose = vi.fn();
|
||||
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
if (!escapeHandled(e) && !anyLayerAbove(playerId)) playerClose();
|
||||
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stamps are per-event: a fresh Escape is not treated as already-handled", () => {
|
||||
const first = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
markEscapeHandled(first);
|
||||
const second = new KeyboardEvent("keydown", { key: "Escape" });
|
||||
expect(escapeHandled(second)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Integration guard for the UX-8 single-Escape fix — the sibling of layerStack.dom.test.ts, but wired
|
||||
// through the REAL `useLayer` hook and a REAL dispatched keydown instead of hand-rolled listeners. It
|
||||
// mounts the two production listener shapes as React components:
|
||||
// - the POPOVER exactly as AnchoredPopover uses it: `useLayer(open, onEscape)` installs the real
|
||||
// `document` keydown listener that stamps the event and closes on topmost;
|
||||
// - the PLAYER exactly as PlayerModal reads it: a `window` keydown listener that bails on
|
||||
// `escapeHandled(e)` first, then on `anyAbove()`.
|
||||
// The load-bearing subtlety the primitives test can only fake: the popover's close is wrapped in
|
||||
// `flushSync`, so React tears the popover down (and `useLayer` cleanup pops its layer) SYNCHRONOUSLY
|
||||
// between the `document` and `window` listeners — the real mid-dispatch flush. That makes the stack read
|
||||
// "nothing above the player" by the time the player's listener runs, so ONLY the event stamp can stop
|
||||
// the player from also closing. If the stamp regressed, test 1 would see the player close on the first
|
||||
// Escape. floating-ui isn't involved here (we drive `useLayer` directly), so no observer stubs needed.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { useEffect, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { render, act, cleanup } from "@testing-library/react";
|
||||
import { useLayer, escapeHandled } from "./layerStack";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// The player: registers a layer (no onEscape — it owns a richer handler) and a `window` listener that
|
||||
// mirrors PlayerModal's guard order (consumed-stamp first, then anyAbove).
|
||||
function Player({ onClose }: { onClose: () => void }) {
|
||||
const { anyAbove } = useLayer(true);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (escapeHandled(e)) return; // the UX-8 fix
|
||||
if (anyAbove()) return; // the old, race-prone guard
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [anyAbove, onClose]);
|
||||
return <div data-testid="player" />;
|
||||
}
|
||||
|
||||
// The popover: the real AnchoredPopover usage. flushSync makes the unmount (and its layer removal) run
|
||||
// synchronously inside the `document` listener — the production mid-dispatch flush the fix must survive.
|
||||
function Popover({ onClosed }: { onClosed: () => void }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
useLayer(open, () => {
|
||||
flushSync(() => setOpen(false));
|
||||
onClosed();
|
||||
});
|
||||
return open ? <div data-testid="popover" /> : null;
|
||||
}
|
||||
|
||||
function dispatchEscape(): KeyboardEvent {
|
||||
const e = new KeyboardEvent("keydown", { key: "Escape", bubbles: true });
|
||||
act(() => {
|
||||
document.dispatchEvent(e);
|
||||
});
|
||||
return e;
|
||||
}
|
||||
|
||||
describe("layerStack Escape coordination — live useLayer + real dispatch (UX-8)", () => {
|
||||
it("one Escape closes only the topmost popover; a second closes the player", () => {
|
||||
const playerClose = vi.fn();
|
||||
const popoverClosed = vi.fn();
|
||||
const { queryByTestId } = render(
|
||||
<>
|
||||
<Player onClose={playerClose} />
|
||||
<Popover onClosed={popoverClosed} />
|
||||
</>,
|
||||
);
|
||||
// Player registers first, popover second → popover is topmost.
|
||||
expect(queryByTestId("popover")).not.toBeNull();
|
||||
|
||||
// First Escape: popover acts (stamps + flushSync-closes, removing its layer synchronously), player
|
||||
// defers on the stamp even though the stack now shows nothing above it.
|
||||
const first = dispatchEscape();
|
||||
expect(escapeHandled(first)).toBe(true);
|
||||
expect(popoverClosed).toHaveBeenCalledOnce();
|
||||
expect(queryByTestId("popover")).toBeNull();
|
||||
expect(playerClose).not.toHaveBeenCalled();
|
||||
|
||||
// Second Escape: nothing above the player, unstamped → the player closes.
|
||||
const second = dispatchEscape();
|
||||
expect(escapeHandled(second)).toBe(false);
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("with no layer above, a lone player closes on the first Escape", () => {
|
||||
const playerClose = vi.fn();
|
||||
render(<Player onClose={playerClose} />);
|
||||
dispatchEscape();
|
||||
expect(playerClose).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -38,6 +38,23 @@ export function anyLayerAbove(id: number): boolean {
|
||||
return i !== -1 && i < stack.length - 1;
|
||||
}
|
||||
|
||||
// UX-8 — a layer that ACTS on an Escape stamps the event here; a lower layer with its own key handler
|
||||
// (the players, listening on `window`) reads the stamp instead of re-reading the stack. This closes
|
||||
// the "one Escape closes BOTH the popover and the player" bug: the popover's Escape listener is on
|
||||
// `document` and fires before the player's on `window` (bubble order), and closing the popover flushes
|
||||
// its layer off the stack synchronously — so by the time the player's `anyLayerAbove()` runs, the stack
|
||||
// already reads "nothing above" and the player ALSO closes. Tagging the one shared KeyboardEvent object
|
||||
// (every listener in a single dispatch is handed the same instance) is immune to that mid-dispatch
|
||||
// mutation; `anyLayerAbove` stays as the fallback for a layer above with no Escape handler of its own.
|
||||
// WeakSet so a spent event is GC'd without a manual sweep.
|
||||
const handledEscapes = new WeakSet<KeyboardEvent>();
|
||||
export function markEscapeHandled(e: KeyboardEvent): void {
|
||||
handledEscapes.add(e);
|
||||
}
|
||||
export function escapeHandled(e: KeyboardEvent): boolean {
|
||||
return handledEscapes.has(e);
|
||||
}
|
||||
|
||||
/** Register an open layer for as long as `active` is true, and get live predicates to read inside your
|
||||
* own key handler. Pass `onEscape` to also install a topmost-only Escape listener (the common case —
|
||||
* modals, popovers). Omit it for a layer that owns a bigger key handler and only needs `anyAbove()`
|
||||
@@ -57,7 +74,12 @@ export function useLayer(
|
||||
let onKey: ((e: KeyboardEvent) => void) | undefined;
|
||||
if (escRef.current) {
|
||||
onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && isTopmost(id)) escRef.current?.();
|
||||
if (e.key === "Escape" && isTopmost(id)) {
|
||||
// Stamp BEFORE closing so a player's `window` listener (fires after this `document` one)
|
||||
// sees the Escape as consumed even though closing us pops this layer off the stack.
|
||||
markEscapeHandled(e);
|
||||
escRef.current?.();
|
||||
}
|
||||
};
|
||||
document.addEventListener("keydown", onKey);
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export interface PanelLayout {
|
||||
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
|
||||
// appended by normalizeLayout, so nothing silently disappears.
|
||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||
feed: ["date", "channel", "tags", "language", "topic"],
|
||||
feed: ["date", "year", "channel", "tags", "language", "topic"],
|
||||
plex: [
|
||||
"playlists",
|
||||
"watch",
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { showEpisodeKeys } from "./plexQueue";
|
||||
import type { PlexCard, PlexSeasonDetail, PlexShowDetail } from "./api";
|
||||
|
||||
const ep = (id: string): PlexCard => ({ id }) as PlexCard;
|
||||
const season = (episodes: PlexCard[]): PlexSeasonDetail =>
|
||||
({ episodes }) as unknown as PlexSeasonDetail;
|
||||
const show = (seasons: PlexSeasonDetail[]): PlexShowDetail =>
|
||||
({ seasons }) as unknown as PlexShowDetail;
|
||||
|
||||
describe("showEpisodeKeys", () => {
|
||||
it("flattens seasons in order, episodes in order, concatenated across seasons", () => {
|
||||
const detail = show([
|
||||
season([ep("s1e1"), ep("s1e2")]),
|
||||
season([ep("s2e1"), ep("s2e2"), ep("s2e3")]),
|
||||
]);
|
||||
expect(showEpisodeKeys(detail)).toEqual(["s1e1", "s1e2", "s2e1", "s2e2", "s2e3"]);
|
||||
});
|
||||
|
||||
it("returns [] for a show with no seasons", () => {
|
||||
expect(showEpisodeKeys(show([]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for a season with no episodes (the enabled-but-empty Play-all case)", () => {
|
||||
expect(showEpisodeKeys(show([season([])]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for null/undefined so an in-flight query's data passes straight through", () => {
|
||||
expect(showEpisodeKeys(null)).toEqual([]);
|
||||
expect(showEpisodeKeys(undefined)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { PlexShowDetail } from "./api";
|
||||
|
||||
// The ordered episode rating-keys of a show: season order, then episode order within each season —
|
||||
// the play-queue order the show/season pages and the collection Play-all all rely on. Kept in ONE
|
||||
// place so that invariant (and any future explicit sort, e.g. pushing season 0 "Specials" last)
|
||||
// lives at a single site rather than the three hand-rolled `seasons.flatMap(...)` copies it grew
|
||||
// into. `detail` is nullable so callers can pass an in-flight query's `data` straight through.
|
||||
export function showEpisodeKeys(detail: PlexShowDetail | null | undefined): string[] {
|
||||
return (detail?.seasons ?? []).flatMap((s) => s.episodes.map((e) => e.id));
|
||||
}
|
||||
@@ -14,6 +14,9 @@ export const qk = {
|
||||
me: () => ["me"] as const,
|
||||
myStatus: () => ["my-status"] as const,
|
||||
tags: () => ["tags"] as const,
|
||||
// Per-channel suggested user tags, learned from the manual-tag pattern (E8·S4). Separate root so
|
||||
// accepting a suggestion can invalidate it without touching the whole tag catalog.
|
||||
tagSuggestions: () => ["tag-suggestions"] as const,
|
||||
|
||||
// --- feed / video ---
|
||||
// Every parameterized key guards on `!== undefined` (not truthiness), so "no arg → bare prefix
|
||||
@@ -29,6 +32,8 @@ export const qk = {
|
||||
filters !== undefined ? (["facets", filters] as const) : (["facets"] as const),
|
||||
feedSummary: (filters?: FeedFilters) =>
|
||||
filters !== undefined ? (["feed-summary", filters] as const) : (["feed-summary"] as const),
|
||||
feedYears: (filters?: FeedFilters) =>
|
||||
filters !== undefined ? (["feed-years", filters] as const) : (["feed-years"] as const),
|
||||
ytSearch: (q?: string | null, count?: number) =>
|
||||
q !== undefined ? (["yt-search", q, count] as const) : (["yt-search"] as const),
|
||||
videoDetail: (id: string) => ["video-detail", id] as const,
|
||||
|
||||
@@ -14,6 +14,38 @@ export interface ReleaseEntry {
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES: ReleaseEntry[] = [
|
||||
{
|
||||
version: "0.73.0",
|
||||
date: "2026-08-26",
|
||||
summary:
|
||||
"Feed filtering and the Channel Manager catch up with Plex: filter to channels you haven't tagged, filter the feed by year, hover any card for its full details, and let Siftlode suggest your own tags for untagged channels based on how you've tagged similar ones.",
|
||||
features: [
|
||||
"New “No tag” filter — narrow the feed and the Channel Manager to channels you haven't personally tagged yet, so what still needs organising is easy to find.",
|
||||
"Filter the feed by year: quick chips for recent years with per-year video counts, plus a “More…” dialog listing every year, sortable by year or count.",
|
||||
"Hover (or keyboard-focus) any feed card for an info box with the full title, channel, date, duration, a description snippet, and the channel's tags — without opening the video.",
|
||||
"The Channel Manager now suggests your own tags for untagged channels, learned from how you've tagged similar ones. Accept a suggestion with one tap, dismiss it, or accept all the confident ones at once.",
|
||||
],
|
||||
chores: [
|
||||
"The whole epic went through several multi-agent deep-review passes — findings fixed and re-reviewed each round until it came back clean.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.72.1",
|
||||
date: "2026-08-24",
|
||||
summary:
|
||||
"Polish and fixes on top of the Plex overhaul: Play-all now works on a collection of shows, your default Personal View applies on a fresh visit, and card buttons behave properly on touch and keyboard.",
|
||||
features: [
|
||||
"Play-all (and Shuffle) now work on a collection of shows — they play through every episode in order, not just standalone movies. If a collection has nothing playable yet, it now tells you instead of doing nothing.",
|
||||
"Your starred default Personal View now applies automatically the first time you open Siftlode in a fresh browser or tab — on both the feed and Plex — without needing a reload.",
|
||||
],
|
||||
fixes: [
|
||||
"Card actions that appear on hover (like Mark watched) no longer sit invisibly over the card on touchscreens, and no longer catch keyboard Tab focus while hidden. On touch you just tap the card; with a keyboard, focusing the card reveals them.",
|
||||
"In the video player, pressing Escape once now closes only the open “Add to playlist” menu; a second Escape closes the player — instead of both closing at once.",
|
||||
],
|
||||
chores: [
|
||||
"Shared the filter-chip overflow logic behind the feed and channel-tag rows, and put the whole batch through two multi-agent deep-review rounds (findings fixed and re-reviewed until clean).",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.72.0",
|
||||
date: "2026-08-23",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { preferSticky } from "./savedViews";
|
||||
import { pickDefaultToSeed, preferSticky } from "./savedViews";
|
||||
|
||||
const v = (id: number) => ({ id, name: `v${id}` });
|
||||
|
||||
@@ -24,3 +24,24 @@ describe("preferSticky", () => {
|
||||
expect(preferSticky([v(1), v(2)], null)?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pickDefaultToSeed", () => {
|
||||
const dv = (id: number, is_default = false) => ({ id, name: `v${id}`, is_default });
|
||||
|
||||
it("returns the starred default when the module is a fresh start", () => {
|
||||
expect(pickDefaultToSeed([dv(1), dv(2, true)], true)?.id).toBe(2);
|
||||
});
|
||||
|
||||
it("returns null when the module already has stored filters / a URL override (canSeed=false)", () => {
|
||||
expect(pickDefaultToSeed([dv(1), dv(2, true)], false)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when there is no starred default, even on a fresh start", () => {
|
||||
expect(pickDefaultToSeed([dv(1), dv(2)], true)).toBeNull();
|
||||
expect(pickDefaultToSeed([], true)).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the first default deterministically if several are somehow flagged", () => {
|
||||
expect(pickDefaultToSeed([dv(1, true), dv(2, true)], true)?.id).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,3 +10,18 @@ export function preferSticky<T extends { id: number }>(
|
||||
): T | null {
|
||||
return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null;
|
||||
}
|
||||
|
||||
/** The starred default view to seed a module's filters with on first load, or null to leave them.
|
||||
* Seeds ONLY when the module is a genuinely fresh start (`canSeed`: no stored filters for this
|
||||
* account and — feed only — no URL filter override); otherwise the last-session/URL/manual filters
|
||||
* win, exactly as they do after any edit. First `is_default` wins if somehow several are flagged, so
|
||||
* the choice is deterministic. This is the single default-application path for BOTH the feed and Plex
|
||||
* (SavedViewsProvider drives it on the views query's first resolve) — it replaced the feed-only
|
||||
* localStorage mirror that raced the login effect and left Plex with no default at all. */
|
||||
export function pickDefaultToSeed<T extends { is_default: boolean }>(
|
||||
views: T[],
|
||||
canSeed: boolean,
|
||||
): T | null {
|
||||
if (!canSeed) return null;
|
||||
return views.find((v) => v.is_default) ?? null;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@ export const LS = {
|
||||
hints: "siftlode.hints",
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
// Mirror of the default saved view's filters, so it can be applied synchronously on load
|
||||
// (before the async saved-views query resolves). Kept in sync by SavedViewsWidget.
|
||||
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
|
||||
|
||||
@@ -140,4 +140,21 @@ describe("filterParamUpdates", () => {
|
||||
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
|
||||
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
|
||||
});
|
||||
|
||||
it("round-trips the year filter; emitted only when set", () => {
|
||||
expect(filtersToParams({ ...DEFAULTS, year: 2024 }).get("year")).toBe("2024");
|
||||
expect(filtersToParams({ ...DEFAULTS, year: undefined }).has("year")).toBe(false);
|
||||
expect(paramsToFilters(new URLSearchParams("year=2024"), DEFAULTS).year).toBe(2024);
|
||||
// Junk / absent → undefined (off), never a NaN year.
|
||||
expect(paramsToFilters(new URLSearchParams("year=nope"), DEFAULTS).year).toBeUndefined();
|
||||
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).year).toBeUndefined();
|
||||
});
|
||||
|
||||
it("round-trips the 'No tag' (untagged) flag; emitted only when on", () => {
|
||||
expect(filtersToParams({ ...DEFAULTS, untagged: true }).get("notag")).toBe("1");
|
||||
expect(filtersToParams({ ...DEFAULTS, untagged: false }).has("notag")).toBe(false);
|
||||
expect(paramsToFilters(new URLSearchParams("notag=1"), DEFAULTS).untagged).toBe(true);
|
||||
// Absent → the base value is left untouched (undefined here, i.e. off).
|
||||
expect(paramsToFilters(new URLSearchParams(""), DEFAULTS).untagged).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,12 @@ const KEYS = [
|
||||
"tagmode_topic",
|
||||
"tagmode_other",
|
||||
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
|
||||
"notag",
|
||||
"channel",
|
||||
"maxage",
|
||||
"from",
|
||||
"to",
|
||||
"year",
|
||||
"mindur",
|
||||
"maxdur",
|
||||
] as const;
|
||||
@@ -44,10 +46,12 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
|
||||
for (const cat of TAG_CATEGORIES) {
|
||||
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
|
||||
}
|
||||
if (f.untagged) p.set("notag", "1");
|
||||
if (f.channelIds.length) p.set("channel", f.channelIds.join(","));
|
||||
if (f.maxAgeDays) p.set("maxage", String(f.maxAgeDays));
|
||||
if (f.dateFrom) p.set("from", f.dateFrom);
|
||||
if (f.dateTo) p.set("to", f.dateTo);
|
||||
if (f.year) p.set("year", String(f.year));
|
||||
if (f.minDuration != null) p.set("mindur", String(f.minDuration));
|
||||
if (f.maxDuration != null) p.set("maxdur", String(f.maxDuration));
|
||||
return p;
|
||||
@@ -95,10 +99,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
|
||||
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
|
||||
other: params.get("tagmode_other") === "and" ? "and" : "or",
|
||||
};
|
||||
if (params.has("notag")) f.untagged = params.get("notag") === "1";
|
||||
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
|
||||
f.maxAgeDays = num(params.get("maxage"));
|
||||
f.dateFrom = params.get("from") || undefined;
|
||||
f.dateTo = params.get("to") || undefined;
|
||||
f.year = num(params.get("year"));
|
||||
f.minDuration = num(params.get("mindur"));
|
||||
f.maxDuration = num(params.get("maxdur"));
|
||||
return f;
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ActiveFilter {
|
||||
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
||||
tags: [],
|
||||
tagModes: DEFAULT_TAG_MODES,
|
||||
untagged: false,
|
||||
channelIds: [],
|
||||
sort: "newest",
|
||||
includeNormal: true,
|
||||
@@ -66,6 +67,15 @@ export function useActiveFilters(
|
||||
});
|
||||
}
|
||||
|
||||
// "No tag" (item B): channels the user hasn't personally tagged. One removable chip.
|
||||
if (filters.untagged) {
|
||||
out.push({
|
||||
key: "untagged",
|
||||
label: t("feed.chips.untagged"),
|
||||
remove: () => setFilters({ ...filters, untagged: false }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.show !== "unwatched") {
|
||||
out.push({
|
||||
key: "show",
|
||||
@@ -110,6 +120,14 @@ export function useActiveFilters(
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.year) {
|
||||
out.push({
|
||||
key: "year",
|
||||
label: String(filters.year),
|
||||
remove: () => setFilters({ ...filters, year: undefined }),
|
||||
});
|
||||
}
|
||||
|
||||
if (filters.maxAgeDays || filters.dateFrom || filters.dateTo) {
|
||||
const label = filters.maxAgeDays
|
||||
? t("feed.chips.maxAge", { count: filters.maxAgeDays })
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The shared chip-overflow measure core (S3). jsdom has no layout engine — every offset* / clientWidth
|
||||
// is 0 — so we stub those on the real ref'd nodes and assert the hook (a) reads the geometry off them
|
||||
// (chip right edges, pill width, the gap between the last chip and the pill, and the lane's available
|
||||
// width), (b) returns the caller's `decide` result, and (c) re-measures when `deps` changes.
|
||||
import { describe, it, expect, afterEach } from "vitest";
|
||||
import { useRef } from "react";
|
||||
import { render, screen, cleanup, act } from "@testing-library/react";
|
||||
import { useChipOverflowFit, type ChipRowGeometry } from "./useChipOverflowFit";
|
||||
|
||||
function stub(
|
||||
el: HTMLElement,
|
||||
props: Partial<Record<"offsetLeft" | "offsetWidth" | "clientWidth", number>>,
|
||||
) {
|
||||
for (const [k, v] of Object.entries(props)) {
|
||||
Object.defineProperty(el, k, { configurable: true, get: () => v });
|
||||
}
|
||||
}
|
||||
|
||||
let lastGeom: ChipRowGeometry | null = null;
|
||||
|
||||
function Harness({ deps }: { deps: string }) {
|
||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||
const chipRefs = useRef<Array<HTMLElement | null>>([]);
|
||||
const pillRef = useRef<HTMLSpanElement | null>(null);
|
||||
const visible = useChipOverflowFit<number>({
|
||||
rowRef,
|
||||
chipsRef: chipRefs,
|
||||
pillRef,
|
||||
deps,
|
||||
initial: -1,
|
||||
decide: (g) => {
|
||||
lastGeom = g;
|
||||
// A trivial policy: count the chips whose right edge fits the lane.
|
||||
return g.chipRights.filter((r) => r <= g.avail).length;
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<div ref={rowRef} data-testid="row" />
|
||||
<span
|
||||
ref={(el) => {
|
||||
chipRefs.current[0] = el;
|
||||
}}
|
||||
data-testid="chip0"
|
||||
/>
|
||||
<span
|
||||
ref={(el) => {
|
||||
chipRefs.current[1] = el;
|
||||
}}
|
||||
data-testid="chip1"
|
||||
/>
|
||||
<span ref={pillRef} data-testid="pill" />
|
||||
<div data-testid="visible">{visible}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
lastGeom = null;
|
||||
});
|
||||
|
||||
describe("useChipOverflowFit", () => {
|
||||
it("reads geometry off the refs and returns the decide() result; re-measures on deps change", () => {
|
||||
const { rerender } = render(<Harness deps="a" />);
|
||||
|
||||
// First pass ran against jsdom's 0-sized boxes.
|
||||
expect(lastGeom).toEqual({ avail: 0, chipRights: [0, 0], pillW: 0, gap: 0 });
|
||||
|
||||
// Give the boxes real geometry, then bump deps to force a re-measure.
|
||||
stub(screen.getByTestId("row"), { clientWidth: 100 });
|
||||
stub(screen.getByTestId("chip0"), { offsetLeft: 0, offsetWidth: 30 }); // right edge 30
|
||||
stub(screen.getByTestId("chip1"), { offsetLeft: 36, offsetWidth: 30 }); // right edge 66
|
||||
stub(screen.getByTestId("pill"), { offsetLeft: 72, offsetWidth: 20 }); // gap = 72 - 66 = 6
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness deps="b" />);
|
||||
});
|
||||
|
||||
expect(lastGeom).toEqual({ avail: 100, chipRights: [30, 66], pillW: 20, gap: 6 });
|
||||
// decide counted both chips (30 and 66 both <= 100).
|
||||
expect(screen.getByTestId("visible").textContent).toBe("2");
|
||||
});
|
||||
|
||||
it("drops a chip whose right edge exceeds the lane, via the caller's policy", () => {
|
||||
const { rerender } = render(<Harness deps="a" />);
|
||||
stub(screen.getByTestId("row"), { clientWidth: 40 });
|
||||
stub(screen.getByTestId("chip0"), { offsetLeft: 0, offsetWidth: 30 }); // right edge 30 <= 40
|
||||
stub(screen.getByTestId("chip1"), { offsetLeft: 36, offsetWidth: 30 }); // right edge 66 > 40
|
||||
stub(screen.getByTestId("pill"), { offsetLeft: 72, offsetWidth: 20 });
|
||||
|
||||
act(() => {
|
||||
rerender(<Harness deps="b" />);
|
||||
});
|
||||
|
||||
expect(lastGeom).toEqual({ avail: 40, chipRights: [30, 66], pillW: 20, gap: 6 });
|
||||
// Only chip0 (right edge 30) fits the 40-wide lane; chip1 (66) is dropped by the policy.
|
||||
expect(screen.getByTestId("visible").textContent).toBe("1");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { useLayoutEffect, useRef, useState, type RefObject } from "react";
|
||||
|
||||
// The shared DOM-measure-and-fit core for a single-line chip lane that collapses its overflow into a
|
||||
// trailing "+N" pill. Owns the layout-effect + ResizeObserver + initial-measure wiring and reads the
|
||||
// common geometry off a hidden mirror (each chip's right edge, the real rem gap, the pill width, and
|
||||
// the lane's available width); each caller layers its OWN fit policy in `decide` and gets that result
|
||||
// back. This was hand-rolled twice — the feed's applied-filter row (ActiveFilterChips, whose policy
|
||||
// also reserves a leading label + a trailing "Clear all" and can collapse to a single count pill) and
|
||||
// the channel tag cell (ChannelTagsControl, chips-only) — with the same measurement half copied in
|
||||
// both. The policy halves live in lib/chipOverflow.ts; this owns the measurement half.
|
||||
//
|
||||
// Coordinate note: `chipRights` are offsets within the hidden mirror (its children's offsetParent),
|
||||
// while `avail` is the VISIBLE lane's clientWidth — the two are comparable because the mirror is laid
|
||||
// out `absolute left-0 top-0` over the lane's start, so a mirror-relative right edge is the position a
|
||||
// chip would take in the real lane. Reads real rem boxes, so nothing is frozen in px (the text-size
|
||||
// slider scales the gap and chip widths, and the fit follows).
|
||||
|
||||
export interface ChipRowGeometry {
|
||||
/** Available lane width — the visible row's clientWidth. */
|
||||
avail: number;
|
||||
/** Right edge (offsetLeft + offsetWidth) of each chip in the measurer, index-aligned to the chips. */
|
||||
chipRights: number[];
|
||||
/** Width of the trailing sample "+N" pill in the measurer. */
|
||||
pillW: number;
|
||||
/** The real rendered inter-item gap (rem-scaled), read off the box between the last chip and the pill. */
|
||||
gap: number;
|
||||
}
|
||||
|
||||
export function useChipOverflowFit<R>(args: {
|
||||
/** The VISIBLE lane; its clientWidth is the available width, and resize is observed on it. */
|
||||
rowRef: RefObject<HTMLElement | null>;
|
||||
/** The measurer's chip elements, index-aligned (a hidden mirror of the full chip set). */
|
||||
chipsRef: RefObject<Array<HTMLElement | null>>;
|
||||
/** The sample "+N" pill in the measurer. */
|
||||
pillRef: RefObject<HTMLElement | null>;
|
||||
/** Re-measure when this changes (a key over the chip set — count + labels, so a rename re-fits too). */
|
||||
deps: unknown;
|
||||
/** The caller's fit policy over the measured geometry. */
|
||||
decide: (geom: ChipRowGeometry) => R;
|
||||
/** Value used until the first measure (and while the refs aren't mounted). */
|
||||
initial: R;
|
||||
}): R {
|
||||
const { rowRef, chipsRef, pillRef, deps, decide, initial } = args;
|
||||
const [result, setResult] = useState<R>(initial);
|
||||
// Read `decide` fresh each run without making it an effect dependency: the effect re-runs on `deps`
|
||||
// (and resize), and a caller's decide closure is rebuilt every render — depending on it would
|
||||
// resubscribe the ResizeObserver on every render.
|
||||
const decideRef = useRef(decide);
|
||||
decideRef.current = decide;
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const row = rowRef.current;
|
||||
const pill = pillRef.current;
|
||||
if (!row || !pill) return;
|
||||
const rightOf = (el: HTMLElement | null | undefined) =>
|
||||
el ? el.offsetLeft + el.offsetWidth : 0;
|
||||
const run = () => {
|
||||
const chips = (chipsRef.current ?? []).filter((c): c is HTMLElement => c != null);
|
||||
const lastChip = chips[chips.length - 1];
|
||||
setResult(
|
||||
decideRef.current({
|
||||
avail: row.clientWidth,
|
||||
chipRights: chips.map(rightOf),
|
||||
pillW: pill.offsetWidth,
|
||||
gap: lastChip ? Math.max(0, pill.offsetLeft - rightOf(lastChip)) : 0,
|
||||
}),
|
||||
);
|
||||
};
|
||||
run();
|
||||
// jsdom has no ResizeObserver — the one-shot measure above still runs there (unit tests drive it).
|
||||
if (typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(run);
|
||||
ro.observe(row);
|
||||
return () => ro.disconnect();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [deps]);
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user