From 4ee0dab68f0636a7f07bbaa6ce647661fe292db1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 25 Aug 2026 01:44:34 +0200 Subject: [PATCH 1/7] =?UTF-8?q?feat(feed,channels):=20"No=20tag"=20filter?= =?UTF-8?q?=20for=20untagged=20channels=20(E8=C2=B7S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filter to channels you haven't personally tagged in the channel manager (no user-owned ChannelTag; auto language/topic tags don't count): - Feed: a "No tag" sentinel in the "Your tags" sidebar combobox (exclusive with real tag picks) + a removable "Untagged" chip; new `untagged` bool feed param / `notag` URL key, persisted + coerced on every filter path. - Channel manager: a virtual "No tag" chip in the tag-chip row filters the table to untagged channels (client-side, shared NO_TAG_SENTINEL). Backend `untagged` is a plain channel-level narrowing, so feed_summary keeps it applied in the base pass — the count/facet lockstep holds with no mirror. Note: item E (per-tag counts) and item D (Any/All toggle) were already shipped; S1 delivers only item B. --- backend/app/feedquery.py | 28 +++++++-- backend/app/routes/feed.py | 3 + backend/tests/test_feed_views.py | 34 +++++++++++ frontend/src/components/Channels.tsx | 18 +++++- .../src/components/FeedFiltersProvider.tsx | 9 ++- .../src/components/SavedViewsProvider.tsx | 2 + frontend/src/components/Sidebar.tsx | 60 +++++++++++++------ .../components/channelColumns.dom.test.tsx | 24 +++++++- frontend/src/components/channelColumns.tsx | 12 +++- frontend/src/i18n/locales/en/channels.json | 3 +- frontend/src/i18n/locales/en/feed.json | 1 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/hu/channels.json | 3 +- frontend/src/i18n/locales/hu/feed.json | 1 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/lib/api.ts | 8 ++- frontend/src/lib/api/feed.ts | 4 ++ frontend/src/lib/api/tagModes.test.ts | 14 ++++- frontend/src/lib/api/tagModes.ts | 10 ++++ frontend/src/lib/urlState.dom.test.ts | 8 +++ frontend/src/lib/urlState.ts | 3 + frontend/src/lib/useActiveFilters.ts | 10 ++++ 22 files changed, 223 insertions(+), 34 deletions(-) diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index 87aa2be9..c432917c 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -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 diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index cedf2b21..e694f15c 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -53,6 +53,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 +76,7 @@ def _feed_params( "topic": tag_mode_topic, "other": tag_mode_other, }, + "untagged": untagged, "channel_ids": channel_ids, "q": q, "min_duration": min_duration, diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index 90c197b5..deaa3cd6 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -162,11 +162,45 @@ _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"} + + @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, diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 7abe86c0..46388acf 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -4,7 +4,7 @@ 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 { notifyYouTubeActionError } from "../lib/youtubeErrors"; import { accountKey, LS } from "../lib/storage"; import { buildChannelColumns, applySyncFilter } from "./channelColumns"; @@ -217,11 +217,18 @@ 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] }); // A focus-channel intent (header "without full history" / tag manager) seeds the search box. useEffect(() => { if (focusChannelName) setSearch(focusChannelName); @@ -451,6 +458,13 @@ export default function Channels() { {tg.name} ))} + {tagFilter.length > 0 && ( - - {userTags.map((tg) => ( - - ))} + {/* 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. */} +
+ {/* 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. */} + - {tagFilter.length > 0 && ( - - )} -
- {actionButtons} + + + {tagFilter.length > 0 && ( + + )} +
{actionButtons}
)} diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx new file mode 100644 index 00000000..e2efd945 --- /dev/null +++ b/frontend/src/components/TagFilterChips.tsx @@ -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 ( + + ); +} + +export default function TagFilterChips({ + chips, + className, +}: { + chips: FilterChip[]; + className?: string; +}) { + const { t } = useTranslation(); + const rowRef = useRef(null); + const chipRefs = useRef>([]); + const pillRef = useRef(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({ + 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 ( +
+ {/* 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. */} +
+ {chips.map((c, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + {c.label} + + ))} + + +{chips.length} + +
+ +
+ {chips.slice(0, visible).map((c) => ( + + ))} + {hidden > 0 && ( + + )} +
+ + + {chips.slice(visible).map((c) => ( + + ))} + +
+ ); +} diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index f3d76b7b..9cebcbec 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -52,7 +52,9 @@ "create": "Create tag", "createPlaceholder": "New tag…", "createFailed": "Could not create the tag.", - "noTag": "No tag" + "noTag": "No tag", + "moreCount_one": "{{count}} more tag", + "moreCount_other": "{{count}} more tags" }, "loading": "Loading channels…", "empty": "No channels.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 78accf16..6b1189e0 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -52,7 +52,9 @@ "create": "Címke létrehozása", "createPlaceholder": "Új címke…", "createFailed": "A címke létrehozása nem sikerült.", - "noTag": "Nincs címke" + "noTag": "Nincs címke", + "moreCount_one": "még {{count}} címke", + "moreCount_other": "még {{count}} címke" }, "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.", From edcea87ea59cdaaccd1773e493e1918b079d5071 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 25 Aug 2026 02:55:12 +0200 Subject: [PATCH 3/7] =?UTF-8?q?feat(feed):=20filter=20by=20year=20?= =?UTF-8?q?=E2=80=94=20per-year=20count=20facet=20+=20year=20control=20(E8?= =?UTF-8?q?=C2=B7S2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Year filter to the feed rail: quick chips for recent years plus a 'More…' popover listing all years with per-year video counts, sortable by year or count. Selecting a year narrows the feed to that whole calendar year (a published_after/before range applied in the api layer) and is mutually exclusive with the Upload-date control. Backend: new /api/feed/years endpoint + feed_years() — one grouped query that honours every filter EXCEPT the publish-date narrowing (dropped), so the year list stays a live drill-down and switching years never zeroes the others. FE keys the list on the date-stripped filters, so picking a year re-queries the feed but not the list. Wires year through FeedFilters, the filterParams/URL (?year) round-trip, the active-filter chip row, and the panel layout. i18n HU+EN. Tests: feed_years count query (date narrowing ignored, other filters honoured); dateFilter year-clearing; urlState year round-trip. --- backend/app/feedquery.py | 20 ++ backend/app/routes/feed.py | 14 ++ backend/tests/test_feed_views.py | 52 +++++ frontend/src/components/DateFilterSelect.tsx | 2 + frontend/src/components/Sidebar.tsx | 4 + frontend/src/components/YearFilter.tsx | 196 +++++++++++++++++++ frontend/src/i18n/locales/en/sidebar.json | 11 +- frontend/src/i18n/locales/hu/sidebar.json | 11 +- frontend/src/lib/api/feed.ts | 27 ++- frontend/src/lib/dateFilter.test.ts | 7 + frontend/src/lib/dateFilter.ts | 14 +- frontend/src/lib/panelLayout.ts | 2 +- frontend/src/lib/queryKeys.ts | 2 + frontend/src/lib/urlState.dom.test.ts | 9 + frontend/src/lib/urlState.ts | 3 + frontend/src/lib/useActiveFilters.ts | 8 + 16 files changed, 373 insertions(+), 9 deletions(-) create mode 100644 frontend/src/components/YearFilter.tsx diff --git a/backend/app/feedquery.py b/backend/app/feedquery.py index c432917c..ec78d644 100644 --- a/backend/app/feedquery.py +++ b/backend/app/feedquery.py @@ -437,6 +437,26 @@ 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() + year = func.extract("year", 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 diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index e694f15c..2d6f3635 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -14,6 +14,7 @@ from app.feedquery import ( decode_cursor, encode_cursor, feed_summary, + feed_years, filtered_query, keyset_predicate, order_by, @@ -206,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": , "count": }]}, + newest year first.""" + return {"years": feed_years(db, user, **params)} + + class VideoStateIn(BaseModel): status: str diff --git a/backend/tests/test_feed_views.py b/backend/tests/test_feed_views.py index deaa3cd6..9ac5a603 100644 --- a/backend/tests/test_feed_views.py +++ b/backend/tests/test_feed_views.py @@ -201,6 +201,58 @@ def test_untagged_filter_keeps_only_channels_the_user_has_not_tagged(db, as_user 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}, + ] + + @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, diff --git a/frontend/src/components/DateFilterSelect.tsx b/frontend/src/components/DateFilterSelect.tsx index 9df1cd33..f4fe6587 100644 --- a/frontend/src/components/DateFilterSelect.tsx +++ b/frontend/src/components/DateFilterSelect.tsx @@ -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} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 9f5b5442..efe3baa2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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"; @@ -145,6 +146,7 @@ export default function Sidebar({ const available: Record = { date: true, + year: true, channel: true, language: languages.length > 0, topic: topics.length > 0, @@ -159,6 +161,8 @@ export default function Sidebar({ return ( ); + case "year": + return ; case "language": return ( void; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [sortByCount, setSortByCount] = useState(false); + + const facetFilters: FeedFilters = { + ...filters, + 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 ( +

+ {t("common.loading")} +

+ ); + } + if (yearsQuery.isError) { + return ( +

+ {t("common.loadError")} +

+ ); + } + return

{t("sidebar.year.none")}

; + } + + return ( +
+ {quick.map((y) => { + const on = selected === y.year; + return ( + + ); + })} + + {years.length > quick.length && ( + <> + + + +
+ {t("sidebar.year.sortBy")} +
+ {( + [ + ["sortYear", false], + ["sortCount", true], + ] as const + ).map(([key, v]) => ( + + ))} +
+
+
+ {sorted.map((y) => { + const on = selected === y.year; + return ( + { + 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" + }`} + > + + {y.year} + {y.count} + + ); + })} +
+
+ + )} +
+ ); +} diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index db1b8e3c..b524d0c5 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -38,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", diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 4fe76126..622df4ef 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -38,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", diff --git a/frontend/src/lib/api/feed.ts b/frontend/src/lib/api/feed.ts index a690d498..8ff19306 100644 --- a/frontend/src/lib/api/feed.ts +++ b/frontend/src/lib/api/feed.ts @@ -81,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 @@ -100,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. @@ -148,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 }> => 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) => diff --git a/frontend/src/lib/dateFilter.test.ts b/frontend/src/lib/dateFilter.test.ts index 3d8bb58c..666348e4 100644 --- a/frontend/src/lib/dateFilter.test.ts +++ b/frontend/src/lib/dateFilter.test.ts @@ -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(); + }); }); diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts index ebec8287..1f0684b7 100644 --- a/frontend/src/lib/dateFilter.ts +++ b/frontend/src/lib/dateFilter.ts @@ -32,18 +32,26 @@ 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, }; } diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts index 88eebc04..f1f66c73 100644 --- a/frontend/src/lib/panelLayout.ts +++ b/frontend/src/lib/panelLayout.ts @@ -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 = { - feed: ["date", "channel", "tags", "language", "topic"], + feed: ["date", "year", "channel", "tags", "language", "topic"], plex: [ "playlists", "watch", diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 356d5910..703c322c 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -29,6 +29,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, diff --git a/frontend/src/lib/urlState.dom.test.ts b/frontend/src/lib/urlState.dom.test.ts index a89b5911..051f1a9c 100644 --- a/frontend/src/lib/urlState.dom.test.ts +++ b/frontend/src/lib/urlState.dom.test.ts @@ -141,6 +141,15 @@ describe("filterParamUpdates", () => { 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); diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 5541c9c4..c5a3801f 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -25,6 +25,7 @@ const KEYS = [ "maxage", "from", "to", + "year", "mindur", "maxdur", ] as const; @@ -50,6 +51,7 @@ export function filtersToParams(f: FeedFilters): URLSearchParams { 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; @@ -102,6 +104,7 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee 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; diff --git a/frontend/src/lib/useActiveFilters.ts b/frontend/src/lib/useActiveFilters.ts index 956f2bdd..e246dd8e 100644 --- a/frontend/src/lib/useActiveFilters.ts +++ b/frontend/src/lib/useActiveFilters.ts @@ -120,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 }) From fbb961b5b7f6ed1600b8020eb45caf61ffe84a12 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 26 Aug 2026 00:44:27 +0200 Subject: [PATCH 4/7] =?UTF-8?q?feat(feed):=20hover=20infobox=20on=20feed?= =?UTF-8?q?=20cards=20=E2=80=94=20full=20title,=20description,=20tags=20(E?= =?UTF-8?q?8=C2=B7S3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/components/VideoCard.tsx | 27 +++- .../src/components/VideoInfoCard.dom.test.tsx | 130 ++++++++++++++++ frontend/src/components/VideoInfoCard.tsx | 143 ++++++++++++++++++ frontend/src/i18n/locales/en/card.json | 4 +- frontend/src/i18n/locales/hu/card.json | 4 +- 5 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 frontend/src/components/VideoInfoCard.dom.test.tsx create mode 100644 frontend/src/components/VideoInfoCard.tsx diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index e502722c..f50c573f 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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( @@ -312,6 +324,10 @@ function Thumb({ )} )} + {/* 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. */} + {/* Resume progress bar (sits at the very bottom, under the duration badge). */} {pct > 0 && (
@@ -330,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 @@ -508,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. */} -
{actions}
+ 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. */} +
+
{actions}
+ +
{/* 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 diff --git a/frontend/src/components/VideoInfoCard.dom.test.tsx b/frontend/src/components/VideoInfoCard.dom.test.tsx new file mode 100644 index 00000000..a175a3b0 --- /dev/null +++ b/frontend/src/components/VideoInfoCard.dom.test.tsx @@ -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(); + 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
)} + {/* 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 && ( +
+ + + + {t("channels.tags.suggestionsCount", { count: visibleSuggestions.length })} + + + {highConfidence.length > 0 && ( + + )} +
+ )}
{actionButtons}
diff --git a/frontend/src/components/channelColumns.tsx b/frontend/src/components/channelColumns.tsx index b8a1c865..f5104fdd 100644 --- a/frontend/src/components/channelColumns.tsx +++ b/frontend/src/components/channelColumns.tsx @@ -9,7 +9,7 @@ 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 @@ -166,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[] { @@ -185,6 +189,9 @@ export function buildChannelColumns(deps: ChannelColumnDeps): Column 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)} /> ), }, diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 9cebcbec..af5efc67 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -54,7 +54,19 @@ "createFailed": "Could not create the tag.", "noTag": "No tag", "moreCount_one": "{{count}} more tag", - "moreCount_other": "{{count}} more tags" + "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.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 6b1189e0..1450c60b 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -54,7 +54,19 @@ "createFailed": "A címke létrehozása nem sikerült.", "noTag": "Nincs címke", "moreCount_one": "még {{count}} címke", - "moreCount_other": "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.", diff --git a/frontend/src/lib/api/tags.ts b/frontend/src/lib/api/tags.ts index afb17edd..e1f9ec2b 100644 --- a/frontend/src/lib/api/tags.ts +++ b/frontend/src/lib/api/tags.ts @@ -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; + export const tagsApi = { tags: (): Promise => req("/api/tags"), + tagSuggestions: (): Promise => req("/api/tags/suggestions"), createTag: (t: { name: string; color?: string; category?: string }): Promise => req("/api/tags", { method: "POST", body: JSON.stringify(t) }), updateTag: (id: number, patch: { name?: string; color?: string }) => diff --git a/frontend/src/lib/dateFilter.ts b/frontend/src/lib/dateFilter.ts index 1f0684b7..a9dd594b 100644 --- a/frontend/src/lib/dateFilter.ts +++ b/frontend/src/lib/dateFilter.ts @@ -51,7 +51,13 @@ export function selectDateChip( } const days = Number(key); return { - filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined, year: undefined }, + filters: { + ...filters, + maxAgeDays: days, + dateFrom: undefined, + dateTo: undefined, + year: undefined, + }, customOpen: false, }; } diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 703c322c..e690c564 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -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 From 17f8885fb11fd4f65e850630cbd167fb370b0bf5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 26 Aug 2026 02:39:00 +0200 Subject: [PATCH 6/7] fix(channels): collapse overflowing tag suggestions into a "+N" pill With 2+ suggestions the narrow tags cell clipped the second ghost chip mid-word right next to the edit "+", reading as the icon covering it. Route the ghost chips through the same fit machinery the attached chips use: a dedicated SuggestedChipsLane fits what the cell holds and collapses the rest into a "+N" pill whose hover popover still one-tap accepts / dismisses each. The lane is attached-XOR-ghost (suggestions are untagged-only, and accepting one now drops the whole channel from the suggestion cache), so each lane owns the full cell width instead of clipping the other. --- .../ChannelTags.suggest.dom.test.tsx | 27 ++- frontend/src/components/ChannelTags.tsx | 213 +++++++++++++----- frontend/src/components/Channels.tsx | 11 +- 3 files changed, 183 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/ChannelTags.suggest.dom.test.tsx b/frontend/src/components/ChannelTags.suggest.dom.test.tsx index 3730f5fe..cefa69eb 100644 --- a/frontend/src/components/ChannelTags.suggest.dom.test.tsx +++ b/frontend/src/components/ChannelTags.suggest.dom.test.tsx @@ -11,9 +11,13 @@ import { FloatingTree } from "@floating-ui/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); + // Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu"). return { ...actual, - useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), + useTranslation: () => ({ + t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key), + i18n: { language: "en" }, + }), }; }); @@ -72,8 +76,8 @@ function renderControl(props: Partial[0]> describe("ChannelTagsControl suggested ghost chips", () => { it("renders a ghost chip per suggestion and accepts on click", () => { const { onAccept } = renderControl(); - // Both suggested tag names show (attached row is empty on an untagged channel). - fireEvent.click(screen.getByText("docu")); + // 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); }); @@ -82,19 +86,20 @@ describe("ChannelTagsControl suggested ghost chips", () => { const dismissButtons = screen.getAllByRole("button", { name: "channels.tags.dismissSuggestion", }); - // Order matches the suggestions array — the second × is the "history" suggestion. + // 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("never shows a suggestion whose tag the channel already carries", () => { + it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => { renderControl({ attachedIds: [docu.id] }); - // docu is attached → its ghost chip is suppressed (only history's × remains). Counting the - // dismiss buttons avoids colliding with docu's now-present *attached* chip text. - const dismissButtons = screen.getAllByRole("button", { - name: "channels.tags.dismissSuggestion", - }); - expect(dismissButtons).toHaveLength(1); + // 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); }); }); diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index d8521ad4..32feff4e 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -85,6 +85,110 @@ function SuggestedChip({ ); } +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 rowRef = useRef(null); + const chipRefs = useRef>([]); + const pillRef = useRef(null); + const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|"); + const visible = useChipOverflowFit({ + 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 ( +
+ {/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */} +
+ {suggestions.map((s, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + {}} onDismiss={() => {}} /> + + ))} + + +{suggestions.length} + +
+ +
+ {suggestions.slice(0, visible).map((s) => ( + onAccept(s.tag.id)} + onDismiss={() => onDismiss(s.tag.id)} + /> + ))} + {hidden > 0 && ( + + )} +
+ + {/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */} + + {suggestions.slice(visible).map((s) => ( + onAccept(s.tag.id)} + onDismiss={() => onDismiss(s.tag.id)} + /> + ))} + +
+ ); +} + /** 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.) */ @@ -270,60 +374,65 @@ export function ChannelTagsControl({
- {/* 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. */} -
- {attached.map((tg, i) => ( - { - chipRefs.current[i] = el; - }} - className="inline-flex" + {/* 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 ? ( + 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. */} +
- - - ))} - - +{attached.length} - -
+ {attached.map((tg, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + + + ))} + + +{attached.length} + +
-
- {attached.slice(0, visible).map((tg) => ( - onChipClick(tg.id, tg.name) : undefined} - title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} - /> - ))} - {hidden > 0 && ( - - )} - {/* Suggested ghost chips (E8·S4) ride in the same overflow-hidden row. They only appear on - untagged channels (the attached slice above is empty then), so they get the full width and - can't fight the fit measurer. Extras past the row clip like any overflow. */} - {shownSuggestions.map((s) => ( - onAcceptSuggestion?.(s.tag.id)} - onDismiss={() => onDismissSuggestion?.(s.tag.id)} - /> - ))} -
+
+ {attached.slice(0, visible).map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={ + onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined + } + /> + ))} + {hidden > 0 && ( + + )} +
+ + )}