Merge: YT feed filter-bar overhaul + 3 deep-review rounds
gate / frontend (push) Failing after 38s
gate / backend (push) Successful in 1m57s

Flattened, more compact YT feed filter rail and a responsive applied-filter row:
- Rail: removed the in-rail Clear all; Upload date is now a single-select dropdown
  (Any time + more presets + reveal-on-select Custom range); widgets are plain
  label + control rows (non-collapsible), with the "Your tags" title opening the
  tag manager.
- Sort + View moved into the applied-filter row (pinned right, above the grid)
  with responsive overflow: "+N" pill on wide lanes, a single count pill on narrow.
- Earlier branch work also merges here: typeable combobox filters, hidden-channel
  empty->Library hint, Mine/Library scope toggle in the header, per-category
  any/all tag match, and the saved-views redesign (popover + modal + chip).

Three USER-triggered deep-review rounds (trend 14 -> 4 -> 1); round 2 caught a real
coerce-on-seed migration bug. Gate-green: tsc, 397 vitest, lint, prettier, backend
pytest. Not yet released -- prod stays 0.71.0.
This commit is contained in:
2026-08-22 07:09:25 +02:00
45 changed files with 1882 additions and 731 deletions
+11 -10
View File
@@ -117,7 +117,7 @@ def filtered_query(
user: User,
*,
tags: list[int],
tag_mode: str,
tag_modes: dict[str, str],
channel_ids: list[str],
q: str | None,
min_duration: int | None,
@@ -293,9 +293,10 @@ def filtered_query(
query = query.where(or_(func.unaccent(Video.title).ilike(like), channel_match))
if tags:
# AND across tag categories (e.g. language AND topic narrows), OR within a category; the any/all
# toggle controls multiple topic tags. ⚠️ feed_summary.passes() reimplements this exact rule in
# Python for the sidebar count/facets — keep the two in lockstep (equivalence-tested by
# AND across tag categories (e.g. language AND topic narrows), OR within a category — unless that
# category's mode is "and" (its per-category any/all toggle), which requires a channel to carry
# ALL its selected tags. ⚠️ feed_summary.passes() reimplements this exact rule in Python for the
# sidebar count/facets — keep the two in lockstep (equivalence-tested by
# test_feed_summary_matches_count_plus_facets).
by_category = tags_by_category(db, tags)
# Facet counting drops the category being counted so its own chips don't zero each
@@ -305,7 +306,7 @@ def filtered_query(
by_category.pop(exclude_tag_category, None)
visible = or_(ChannelTag.user_id.is_(None), ChannelTag.user_id == user.id)
for category, ids in by_category.items():
if category == "topic" and tag_mode == "and" and len(ids) > 1:
if tag_modes.get(category, "or") == "and" and len(ids) > 1:
sub = (
select(ChannelTag.channel_id)
.where(ChannelTag.tag_id.in_(ids), visible)
@@ -363,8 +364,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
(the tag's own category dropped, unless it's a conjunctive topic-AND — mirroring the
old /facets exclude_tag_category logic exactly).
Tag matching is channel-based, identical to filtered_query's tag block (AND across categories, OR
within, topic-AND requires ALL selected topics)."""
tag_mode = params.get("tag_mode", "or")
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.
@@ -401,8 +402,8 @@ def feed_summary(db: Session, user: User, **params) -> dict:
for cat, sel in selected_by_cat.items():
if cat == exclude_cat:
continue
if cat == "topic" and tag_mode == "and" and len(sel) > 1:
if not sel <= ch_tags: # conjunctive: channel must carry ALL selected topics
if tag_modes.get(cat, "or") == "and" and len(sel) > 1:
if not sel <= ch_tags: # conjunctive: channel must carry ALL this category's selected tags
return False
elif not (sel & ch_tags): # disjunctive: channel must carry ANY selected tag
return False
@@ -416,7 +417,7 @@ def feed_summary(db: Session, user: User, **params) -> dict:
for category in ("language", "topic", "other"):
# Drop this category's own selections so its chips keep independent counts; a conjunctive
# topic-AND keeps them (each remaining chip narrows to channels that ALSO have the selected).
exclude = None if (category == "topic" and tag_mode == "and") else category
exclude = None if tag_modes.get(category, "or") == "and" else category
for cid, ch_tags in channel_tags.items():
if not passes(ch_tags, exclude):
continue
+11 -3
View File
@@ -48,7 +48,11 @@ FINISH_MARGIN_SECONDS = 10
# Shared query parameters for /feed and /feed/count.
def _feed_params(
tags: list[int] = Query(default=[]),
tag_mode: str = "or",
# Per-category tag match: each of language / topic / other independently ORs (any) or ANDs (all)
# its own selected tags. Defaults to "or". (Superseded the single global `tag_mode`.)
tag_mode_language: str = "or",
tag_mode_topic: str = "or",
tag_mode_other: str = "or",
channel_ids: list[str] = Query(default=[]),
q: str | None = None,
min_duration: int | None = None,
@@ -65,7 +69,11 @@ def _feed_params(
) -> dict:
return {
"tags": tags,
"tag_mode": tag_mode,
"tag_modes": {
"language": tag_mode_language,
"topic": tag_mode_topic,
"other": tag_mode_other,
},
"channel_ids": channel_ids,
"q": q,
"min_duration": min_duration,
@@ -162,7 +170,7 @@ def get_facets(
# independent counts (you can OR more of them in). Conjunctive (AND, topics only)
# keeps them applied, so each remaining chip narrows to channels that ALSO have all
# already-selected topics — and tags that can't co-occur drop to zero (hidden).
conjunctive = category == "topic" and params.get("tag_mode") == "and"
conjunctive = params.get("tag_modes", {}).get(category) == "and"
base, _rank = filtered_query(
db,
user,
+18 -4
View File
@@ -96,6 +96,7 @@ def _seed_tagged(db, make_user):
"T1": Tag(user_id=None, name="Tech", category="topic"),
"T2": Tag(user_id=None, name="Gaming", category="topic"),
"O1": Tag(user_id=None, name="Favourite", category="other"),
"O2": Tag(user_id=None, name="Docs", category="other"),
# A USER-owned tag (+ a user-owned ChannelTag link below) so the visibility branch
# `ChannelTag.user_id == user.id` is exercised, not only the system `user_id IS NULL` one.
"U1": Tag(user_id=user.id, name="MyPin", category="other"),
@@ -104,8 +105,17 @@ def _seed_tagged(db, make_user):
chans = {c: Channel(id=c, title=c, subscriber_count=10) for c in ("chA", "chB", "chC", "chD")}
db.add_all(chans.values())
db.flush()
# channel -> (system) tag keys
links = {"chA": ["L1", "T1"], "chB": ["L1", "T2"], "chC": ["L2", "T1", "O1"], "chD": []}
# channel -> (system) tag keys. chA carries two languages (L1+L2); chC two "other" tags (O1+O2)
# AND chA carries O1 too — so the per-category any/all match narrows NON-TRIVIALLY (AND != OR) for
# language and other, not only topic: e.g. other {O1,O2} → OR {chA,chC} vs AND {chC}; language
# {L1,L2} → OR {chA,chB,chC} vs AND {chA}. (For topic, chA and chB each carry a DIFFERENT topic tag
# — T1 vs T2 — so topic {T1,T2} → OR {chA,chB,chC} vs AND {} empty.)
links = {
"chA": ["L1", "L2", "T1", "O1"],
"chB": ["L1", "T2"],
"chC": ["L2", "T1", "O1", "O2"],
"chD": [],
}
for cid, tkeys in links.items():
db.add(Subscription(user_id=user.id, channel_id=cid, priority=0, hidden=False))
for tk in tkeys:
@@ -127,8 +137,12 @@ _SUMMARY_CASES = [
"show=all&tags={L1}",
"show=all&tags={T1}",
"show=all&tags={L1}&tags={T1}",
"show=all&tags={T1}&tags={T2}&tag_mode=or",
"show=all&tags={T1}&tags={T2}&tag_mode=and",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=or",
"show=all&tags={T1}&tags={T2}&tag_mode_topic=and",
# Per-category any/all is NOT topic-only — language and other must drill down identically too.
"show=all&tags={L1}&tags={L2}&tag_mode_language=or",
"show=all&tags={L1}&tags={L2}&tag_mode_language=and",
"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
"show=all&q=clip",
+234 -27
View File
@@ -1,51 +1,258 @@
import { useLayoutEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
// open the panel just to see what you'd filtered by.
//
// The row is a single flex line that shares its width with the Sort/View controls pinned to its right,
// so the chips must never push those off-screen. When they'd overflow it trims responsively:
// - wide -> show as many chips as fit, then a "+N" pill whose popover holds the rest;
// - narrow -> collapse every chip into one "N filters" pill whose popover holds them all.
// Fit is measured off a hidden mirror of the full chip set (so slicing the visible lane can't feed
// back into the measurement and oscillate).
// Below this lane width an overflow collapses all the way to a single count pill (mobile), instead of
// the "fit some + N more" treatment used on wider lanes.
const NARROW_PX = 420;
const chipCls =
"chip group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 text-sm whitespace-nowrap hover:border-accent hover:bg-accent/20 transition";
function ChipButton({ f, testId }: { f: ActiveFilter; testId?: string }) {
const { t } = useTranslation();
return (
<button
data-testid={testId}
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className={chipCls}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
);
}
// The "+N" / "N filters" pill and its popover listing the (overflowed / all) chips, each removable.
function OverflowPopover({
label,
ariaLabel,
chips,
onClearAll,
icon,
}: {
label: string;
/** Accessible name for the trigger + popover — describes what's inside (e.g. "3 more filters"),
* since the visible label can be a bare "+3". Defaults to `label`. */
ariaLabel?: string;
chips: ActiveFilter[];
onClearAll: () => void;
icon?: boolean;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-end",
});
const name = ariaLabel ?? label;
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={name}
className={`chip inline-flex shrink-0 items-center gap-1 px-2.5 py-1 text-sm whitespace-nowrap transition ${
open ? "border-accent text-accent" : "hover:border-accent"
}`}
>
{icon && <Filter className="w-3.5 h-3.5 shrink-0" aria-hidden="true" />}
{label}
</button>
<PopoverSurface
popover={pop}
ariaLabel={name}
className="glass-menu min-w-48 max-w-72 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div role="group" aria-label={name} className="max-h-72 overflow-y-auto no-scrollbar">
{chips.map((f) => (
<PopoverItem
key={f.key}
role="menuitem"
onSelect={f.remove}
label={f.label}
className="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-fg transition"
>
<span className="flex-1 truncate">{f.label}</span>
<X className="w-3.5 h-3.5 shrink-0 opacity-60 group-hover:opacity-100" />
</PopoverItem>
))}
</div>
<div className="mt-1 border-t border-border px-1 pt-1.5">
<button
type="button"
data-testid="active-filter-clear-all"
onClick={() => {
onClearAll();
setOpen(false);
}}
className="text-xs text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
</div>
</PopoverSurface>
</>
);
}
export default function ActiveFilterChips({
filters,
onClearAll,
className,
}: {
filters: ActiveFilter[];
onClearAll: () => void;
className?: string;
}) {
const { t } = useTranslation();
if (filters.length === 0) return null;
const laneRef = useRef<HTMLDivElement | null>(null);
const labelRef = useRef<HTMLSpanElement | null>(null);
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]);
if (n === 0) return null;
const hiddenCount = n - layout.visible;
const overflowChips = filters.slice(layout.visible);
return (
<div
ref={laneRef}
data-testid="active-filter-chips"
className="flex flex-wrap items-center gap-1.5 pb-3"
className={`relative flex min-w-0 items-center gap-1.5 ${className ?? ""}`}
aria-label={t("feed.chips.label")}
>
<span className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f) => (
<button
key={f.key}
data-testid="active-filter-chip"
data-filter-key={f.key}
onClick={f.remove}
title={t("feed.chips.remove", { name: f.label })}
aria-label={t("feed.chips.remove", { name: f.label })}
className="chip group pl-2.5 pr-1.5 py-1 text-sm hover:border-accent hover:bg-accent/20 transition"
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
</button>
))}
{filters.length > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
>
{/* Hidden mirror of the full set, used only to measure natural widths. Kept out of the a11y tree
and without test ids so it never doubles the visible chip count. */}
<div
aria-hidden="true"
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5"
>
<span ref={labelRef} className="text-xs uppercase tracking-wider text-muted mr-0.5">
{t("feed.chips.label")}
</span>
{filters.map((f, i) => (
<span
key={f.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className={chipCls}
>
<span className="max-w-56 truncate">{f.label}</span>
<X className="w-3 h-3 shrink-0" />
</span>
))}
<span ref={plusRef} className="chip inline-flex items-center gap-1 px-2.5 py-1 text-sm">
+{n}
</span>
<span ref={clearRef} className="ml-0.5 text-sm underline underline-offset-2">
{t("sidebar.clearAll")}
</button>
</span>
</div>
{/* Visible lane. */}
{layout.mode === "count" ? (
<OverflowPopover
label={t("feed.chips.count", { count: n })}
chips={filters}
onClearAll={onClearAll}
icon
/>
) : (
<>
<span className="text-xs uppercase tracking-wider text-muted mr-0.5 shrink-0">
{t("feed.chips.label")}
</span>
{filters.slice(0, layout.visible).map((f) => (
<ChipButton key={f.key} f={f} testId="active-filter-chip" />
))}
{layout.mode === "some" && hiddenCount > 0 && (
<OverflowPopover
label={`+${hiddenCount}`}
ariaLabel={t("feed.chips.moreCount", { count: hiddenCount })}
chips={overflowChips}
onClearAll={onClearAll}
/>
)}
{layout.mode === "all" && n > 1 && (
<button
data-testid="active-filter-clear-all"
onClick={onClearAll}
className="ml-0.5 shrink-0 text-sm text-muted hover:text-fg underline underline-offset-2 transition"
>
{t("sidebar.clearAll")}
</button>
)}
</>
)}
</div>
);
+2 -2
View File
@@ -27,7 +27,7 @@ import { useFeedFilters } from "./FeedFiltersProvider";
import { useConfirm } from "./ConfirmProvider";
import { notify } from "../lib/notifications";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { api, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import { api, DEFAULT_TAG_MODES, type FeedFilters, type Me, type ChannelDetail } from "../lib/api";
import type { FeedView } from "../lib/feedView";
import { channelYouTubeUrl, formatViews } from "../lib/format";
import { ChannelTagsControl } from "./ChannelTags";
@@ -231,7 +231,7 @@ export default function ChannelPage({
// the explored, per-user-private videos show — the backend gates them to this explorer).
const [filters, setFilters] = useState<FeedFilters>(() => ({
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
q: "",
sort: "newest",
scope: "all",
@@ -64,6 +64,19 @@ describe("Combobox", () => {
expect(document.getElementById(controls!)?.getAttribute("role")).toBe("listbox");
});
it("focuses the input when the field chrome is pressed (merged ref, not a dead inputRef)", () => {
// Regression: the input carried only floating-ui's ref, so `inputRef` stayed null. The field's
// onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was
// always true) and its compensating inputRef.current?.focus() was a no-op → the box couldn't be
// typed into with the mouse. A merged ref feeds the node to both, so pressing the chrome focuses it.
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
const input = screen.getByRole("combobox");
const field = input.parentElement as HTMLElement;
expect(document.activeElement).not.toBe(input);
fireEvent.mouseDown(field); // press the field padding, not the input itself
expect(document.activeElement).toBe(input);
});
it("keeps the option list unmounted until the field is focused", () => {
wrap(<Combobox label="Genre" options={OPTIONS} selected={[]} onChange={vi.fn()} />);
expect(screen.queryByRole("option", { hidden: true })).toBeNull();
@@ -192,6 +205,31 @@ describe("Combobox", () => {
expect(onChange).toHaveBeenCalledWith(["id1"]); // single-select replaces
});
it("shows the any/all match footer only at 2+ selections and reports a mode change", () => {
// The per-category any/all toggle: meaningless below 2 selections (any === all), so it appears
// only at >= 2, and clicking 'all' reports the mode change out.
const onMatchModeChange = vi.fn();
const props = {
label: "Topic",
options: OPTIONS,
onChange: vi.fn(),
matchMode: "or" as const,
onMatchModeChange,
};
// One selection: no footer.
const { rerender } = wrap(<Combobox {...props} selected={["Drama"]} />);
fireEvent.focus(screen.getByRole("combobox"));
expect(screen.queryByText("sidebar.all")).toBeNull();
// Two selections: footer appears with an any/all segment.
rerender(<FloatingTree>{<Combobox {...props} selected={["Drama", "Action"]} />}</FloatingTree>);
fireEvent.focus(screen.getByRole("combobox"));
const all = screen.getByText("sidebar.all");
expect(all).toBeTruthy();
fireEvent.click(all);
expect(onMatchModeChange).toHaveBeenCalledWith("and");
});
it("single-select: picking replaces the value and closes the list", () => {
const onChange = vi.fn();
wrap(
+53 -4
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, ChevronDown, X } from "lucide-react";
import { useAnchoredPopover, PopoverSurface, PopoverItem } from "./AnchoredPopover";
@@ -55,6 +55,11 @@ export interface ComboboxProps {
/** Chip/label text for a selected value whose option is not in the current (e.g. server-narrowed)
* list — the collection picker passes the stored title here so a reloaded filter still names itself. */
selectedLabels?: Record<string, string>;
/** Optional "any/all" match segment in the popover footer (like SortControl's direction segment):
* "or" = a match on ANY selected value, "and" = must match ALL. Provide BOTH to show it; it appears
* only once 2+ values are selected (below that, any and all are the same). */
matchMode?: "or" | "and";
onMatchModeChange?: (mode: "or" | "and") => void;
}
export default function Combobox({
@@ -73,6 +78,8 @@ export default function Combobox({
maxVisible = 50,
renderOption,
selectedLabels,
matchMode,
onMatchModeChange,
}: ComboboxProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
@@ -83,7 +90,7 @@ export default function Combobox({
if (queryProp === undefined) setQueryState(q);
onQueryChange?.(q);
};
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const fade = useScrollFade();
// Remember each value's display label as options flow through, so a selected chip can name itself
@@ -100,6 +107,19 @@ export default function Combobox({
onOpenChange?.(next);
};
const pop = useAnchoredPopover({ open, onOpenChange: setOpenAnd, mode: "combobox" });
// The input is the combobox reference for floating-ui AND our own focus target (the field's
// click-to-focus handler, and commit() re-focusing after a pick). A merged callback ref feeds the
// node to BOTH — without it `inputRef` stays null, so the field's onMouseDown guard (`e.target !==
// inputRef.current`) is always true and preventDefault() cancels the input's own click-focus, making
// the field impossible to type into with the mouse. Memoised so floating-ui's ref isn't re-attached
// every render.
const setInputRef = useCallback(
(node: HTMLInputElement | null) => {
pop.refs.setReference(node);
inputRef.current = node;
},
[pop.refs],
);
// `highlightIndex` = the row the user actually SEES highlighted (pointer hover, else keyboard); Enter
// commits THAT, not the bare keyboard `activeIndex` — otherwise arrow-then-hover-then-Enter would toggle
// a different option than the highlighted one, and a hover with no prior arrow (activeIndex still null)
@@ -129,11 +149,14 @@ export default function Combobox({
onChange(
selected.includes(value) ? selected.filter((x) => x !== value) : [...selected, value],
);
// Keep the field focused so the user can keep picking / typing; the list stays open.
inputRef.current?.focus();
} else {
// Single-select is final: close and DON'T re-focus — a refocus fires onFocus, which would
// immediately re-open the list (now that the merged ref makes focus() actually work).
onChange([value]);
setOpenAnd(false);
}
inputRef.current?.focus();
};
return (
@@ -172,7 +195,7 @@ export default function Combobox({
</span>
))}
<input
ref={pop.refs.setReference}
ref={setInputRef}
{...pop.getReferenceProps({
onChange: (e) => {
setQuery((e.target as HTMLInputElement).value);
@@ -266,6 +289,32 @@ export default function Combobox({
</>
)}
</div>
{/* Any/all match segment (like SortControl's direction footer) — only meaningful, so only shown,
once 2+ values are picked. mousedown-preventDefault keeps the input focused so the list stays
open in the combobox's focus model. */}
{matchMode != null && onMatchModeChange && selected.length >= 2 && (
<div className="mt-1 flex items-center gap-2 border-t border-border px-1 pt-2">
<span className="text-2xs text-muted">{t("sidebar.match")}</span>
<div className="ml-auto flex gap-1">
{(["or", "and"] as const).map((m) => (
<button
key={m}
type="button"
aria-pressed={matchMode === m}
onMouseDown={(e) => e.preventDefault()}
onClick={() => onMatchModeChange(m)}
className={`rounded-md border px-2 py-0.5 text-2xs transition ${
matchMode === m
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t(m === "or" ? "sidebar.any" : "sidebar.all")}
</button>
))}
</div>
</div>
)}
</PopoverSurface>
</div>
);
@@ -0,0 +1,191 @@
import { useLayoutEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { CalendarDays, Check, ChevronDown } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { type FeedFilters } from "../lib/api";
import { deriveDateKey, selectDateChip, type DatePreset } from "../lib/dateFilter";
// The feed rail's "Upload date" control: a single-select dropdown (the SortControl/FilterSelect pill,
// but full-width for the rail) over "Any time" + the day presets + a "Custom range". Picking Custom
// reveals the from/to fields in the popover footer (reveal-on-select) and the trigger then reads the
// chosen range; the presets and Any time close on pick. Selection logic is the pure lib/dateFilter so
// it stays testable without mounting this. The popover hosts free-text (date) inputs, so it opens as a
// `hostsInput` menu and the inputs stop non-Escape keys from reaching the list navigation.
// yyyy-mm-dd -> a local Date at midnight (split so it isn't parsed as UTC, which would shift a day).
function parseDay(s: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
export default function DateFilterSelect({
filters,
setFilters,
presets,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
presets: readonly DatePreset[];
}) {
const { t, i18n } = useTranslation();
const [open, setOpen] = useState(false);
const [customOpen, setCustomOpen] = useState<boolean>(!!(filters.dateFrom || filters.dateTo));
// If the range is cleared elsewhere (chip-row Clear all / removing the date chip) while this popover
// is closed, drop the custom reveal so the trigger reverts to "Any time" instead of lingering on
// "Custom range" with empty fields. Guarded on !open so it never closes a freshly-picked Custom the
// user hasn't filled in yet — that stays open until they close it (handled by onOpenChange below).
// useLayoutEffect (not useEffect) so the reset commits BEFORE paint — otherwise the trigger flashes
// one stale "Custom range" frame between the external clear and this reset.
useLayoutEffect(() => {
if (!open && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
}, [open, customOpen, filters.dateFrom, filters.dateTo]);
const key = deriveDateKey(filters, customOpen, presets);
const fmt = (s: string) => {
const d = parseDay(s);
return d
? new Intl.DateTimeFormat(i18n.language, { month: "short", day: "numeric" }).format(d)
: s;
};
const rangeSummary = () => {
const { dateFrom: f, dateTo: to } = filters;
if (f && to) return `${fmt(f)} – ${fmt(to)}`;
if (f) return t("sidebar.date.fromShort", { date: fmt(f) });
if (to) return t("sidebar.date.untilShort", { date: fmt(to) });
return t("sidebar.date.customRange");
};
const summary =
key === ""
? t("sidebar.date.anyTime")
: key === "custom"
? rangeSummary()
: t("sidebar.datePreset." + presets.find((p) => String(p.days) === key)!.key);
const options = [
{ value: "any", label: t("sidebar.date.anyTime") },
...presets.map((p) => ({ value: String(p.days), label: t("sidebar.datePreset." + p.key) })),
{ value: "custom", label: t("sidebar.date.customRange") },
];
const pop = useAnchoredPopover({
open,
onOpenChange: (next) => {
setOpen(next);
// Opened Custom but committed no range → drop the reveal on close so it doesn't stick.
if (!next && customOpen && !filters.dateFrom && !filters.dateTo) setCustomOpen(false);
},
mode: "menu",
placement: "bottom-start",
hostsInput: true,
});
const pick = (value: string) => {
const change = selectDateChip(value, filters);
setCustomOpen(change.customOpen);
setFilters(change.filters);
if (value !== "custom") setOpen(false); // Custom stays open to reveal the range fields
};
const dateInputCls =
"bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40";
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={`${t("sidebar.datePresetLabel")}: ${summary}`}
className={`glass-card glass-hover flex w-full items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-sm transition ${
open ? "border-accent" : "border-border"
}`}
>
<CalendarDays className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate text-left text-fg">{summary}</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.datePresetLabel")}
className="glass-menu min-w-52 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div role="group" aria-label={t("sidebar.datePresetLabel")}>
{options.map((o) => {
const on = o.value === key || (o.value === "any" && key === "");
return (
<PopoverItem
key={o.value}
role="menuitemradio"
checked={on}
onSelect={() => pick(o.value)}
label={o.label}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<Check className={`h-3.5 w-3.5 shrink-0 ${on ? "text-accent" : "opacity-0"}`} />
<span className="flex-1 truncate">{o.label}</span>
</PopoverItem>
);
})}
</div>
{/* Reveal-on-select: the from/to fields appear once Custom is the chosen option. The inputs
stop non-Escape keys so the date-segment arrows act on the field, not the menu's list nav. */}
{key === "custom" && (
<div className="mt-1 flex flex-col gap-1.5 border-t border-border px-1 pt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className={dateInputCls}
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
onKeyDown={(e) => {
if (e.key !== "Escape") e.stopPropagation();
}}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className={dateInputCls}
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
type="button"
onClick={() => setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })}
className="text-2xs text-muted hover:text-accent self-end"
>
{t("sidebar.clearDates")}
</button>
)}
</div>
)}
</PopoverSurface>
</>
);
}
+83 -49
View File
@@ -13,6 +13,7 @@ import {
Grid3x3,
Info,
LayoutGrid,
Library,
List,
RefreshCw,
Rows3,
@@ -36,6 +37,7 @@ import { type SortOption } from "../lib/columnSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import SelectedViewChip from "./SelectedViewChip";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
@@ -568,6 +570,54 @@ export default function Feed({
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
}));
// Sort + view mode are tightly bound to the grid below, so they live pinned to the right of the
// applied-filter row (directly above the cards), not up in the filter toolbar. Extracted here so the
// same controls render on both the feed and channel-scoped rows.
const sortView = (
<>
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
onChange={setView}
label={t("feed.viewLabel")}
// The mode's name only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</>
);
const toolbar = (
<div className="pb-1.5">
<div className="flex items-center gap-1.5 gap-y-2 flex-wrap">
@@ -617,49 +667,6 @@ export default function Feed({
}
/>
)}
<div className="ml-auto flex items-center gap-1.5">
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
sortKey === "shuffle" ? (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}
options={FEED_VIEWS.map((id) => ({
id,
label: t("feed.view." + id),
icon: VIEW_ICON[id],
}))}
onChange={setView}
label={t("feed.viewLabel")}
// This toolbar is packed — show/content chips, source, count, sort — so the mode's name
// only earns its place once the window is wide.
labelClassName="hidden xl:inline"
/>
</div>
</div>
</div>
);
@@ -675,17 +682,44 @@ export default function Feed({
<PageToolbar>
<div className={channelScoped ? "px-4 sm:px-6" : "px-4 pt-2 pb-1"}>
{toolbar}
{!channelScoped && (
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
/>
)}
{/* The controls row sits directly above the grid: the applied-filter chips flow from the left
(overflowing to a "+N"/count pill so they never crowd the controls), while Sort + View stay
pinned to the right. Channel-scoped feeds show no chips (their baseline isn't a "filter you
applied"), but still get the right-pinned Sort/View. */}
<div className="flex items-center gap-2 pb-2">
{!channelScoped && (
<>
{/* The active personal view (if any) leads the applied-filter row. */}
<SelectedViewChip />
<ActiveFilterChips
filters={activeFilters}
onClearAll={() => setFilters(clearedFilters(filters))}
className="flex-1"
/>
</>
)}
<div className="ml-auto flex shrink-0 items-center gap-1.5">{sortView}</div>
</div>
</div>
</PageToolbar>
{items.length === 0 ? (
<div className="py-16 text-center text-muted">
<p>{t("feed.noMatches")}</p>
{/* A channel filter under the "Mine" scope returns nothing when that channel is hidden from
your subscriptions (or you don't subscribe to it) — its videos only live in the shared
Library. Point there rather than leaving a bare "no matches". */}
{filters.channelIds.length > 0 && filters.scope === "my" && (
<div className="mt-4">
<p className="text-sm">{t("feed.channelScopeHint")}</p>
<button
onClick={() => setFilters({ ...filters, scope: "all" })}
className="mt-3 inline-flex items-center gap-2 px-4 py-2 rounded-xl font-semibold btn-primary transition"
>
<Library className="w-4 h-4" />
{t("feed.viewInLibrary")}
</button>
</div>
)}
{filters.q.trim() && !isDemo && (
<button
onClick={() => onYtSearch(filters.q.trim())}
@@ -0,0 +1,45 @@
// @vitest-environment jsdom
//
// loadAccountFilters is the localStorage seed path for the feed filters. It must migrate a persisted
// legacy global `tagMode` (older builds wrote it) into the per-category `tagModes` — the same coercion
// the saved-view and URL paths do — so an upgrading user's topic any/all match isn't silently lost.
import { describe, it, expect, beforeEach } from "vitest";
import { loadAccountFilters } from "./FeedFiltersProvider";
import { accountKey, LS } from "../lib/storage";
const ID = 7;
beforeEach(() => localStorage.clear());
describe("loadAccountFilters — legacy tagMode migration on seed", () => {
it("migrates a persisted legacy tagMode:'and' from the last-session blob into topic", () => {
localStorage.setItem(accountKey(LS.filters, ID)!, JSON.stringify({ q: "hi", tagMode: "and" }));
const f = loadAccountFilters(ID);
expect(f.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
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" });
});
it("preserves an already-migrated per-category tagModes blob", () => {
localStorage.setItem(
accountKey(LS.filters, ID)!,
JSON.stringify({ tagModes: { language: "and", topic: "or", other: "and" } }),
);
expect(loadAccountFilters(ID).tagModes).toEqual({
language: "and",
topic: "or",
other: "and",
});
});
});
@@ -9,10 +9,16 @@ import {
} from "react";
import { qk } from "../lib/queryKeys";
import { useQuery } from "@tanstack/react-query";
import { api, getActiveAccount, type FeedFilters } from "../lib/api";
import {
api,
getActiveAccount,
coerceTagModes,
DEFAULT_TAG_MODES,
type FeedFilters,
} from "../lib/api";
import { filterParamUpdates, hasFilterParams, paramsToFilters } from "../lib/urlState";
import { useUrlMirror } from "../lib/useUrlMirror";
import { accountKey, LS, readJSON, readMerged, writeJSON } from "../lib/storage";
import { accountKey, LS, readJSON, writeJSON } from "../lib/storage";
import { useNavigation } from "./NavigationProvider";
// Owns the GLOBAL feed-filter state (the feed's scope/tags/content/sort/show), lifted out of the App
@@ -32,7 +38,7 @@ import { useNavigation } from "./NavigationProvider";
const DEFAULT_FILTERS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
@@ -49,7 +55,7 @@ const FILTERS_KEY = LS.filters;
// default saved view wins over the last-session filters (SavedViewsWidget mirrors it here). When
// the account isn't known yet (first load, before the tab is pinned), start from defaults; the
// post-login effect loads the real ones once the id arrives.
function loadAccountFilters(id: number | null): FeedFilters {
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
@@ -62,10 +68,19 @@ function loadAccountFilters(id: number | null): FeedFilters {
} catch {
/* localStorage may be unavailable */
}
if (hasStored && fKey) return readMerged(fKey, DEFAULT_FILTERS);
// 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.
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<FeedFilters | null>(dvKey, null) : null;
if (def) return { ...DEFAULT_FILTERS, ...def };
const def = dvKey ? readJSON<Partial<FeedFilters> | null>(dvKey, null) : null;
if (def) {
return { ...DEFAULT_FILTERS, ...def, tagModes: coerceTagModes(def) };
}
return { ...DEFAULT_FILTERS };
}
@@ -0,0 +1,39 @@
import { useTranslation } from "react-i18next";
import { Library, User } from "lucide-react";
import SegmentedToggle from "./SegmentedToggle";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// The feed's Mine/Library scope, relocated from the filter sidebar to sit just right of the header
// search box — mirroring PlexScopeToggle's placement, so the primary "what am I browsing" control is
// always in reach instead of buried in the (possibly collapsed) filter rail. Same `filters.scope`
// field, so behaviour is unchanged. The `pointer-events-auto`/`shrink-0` wrapper matches the other
// header pills: the header band is `pointer-events-none`, and the pill must not collapse in the flex row.
export default function FeedScopeToggle() {
const { t } = useTranslation();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
return (
<div className="pointer-events-auto shrink-0">
<SegmentedToggle
size="md"
ariaLabel={t("header.scope.label")}
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
</div>
);
}
+22 -8
View File
@@ -8,10 +8,9 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
// the layout default, edit hint); each rail supplies only what actually differs — its identity
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content above the
// groups (`top`, e.g. the feed's scope toggle) or below (`children`, e.g. the feed's tag manager).
// `kind` is the PanelId (also the layout key), so `top`/`children` default to nothing for the rails
// that need neither.
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
// `children` defaults to nothing for the rails that need none.
export default function FilterRail({
kind,
title,
@@ -23,7 +22,10 @@ export default function FilterRail({
layout,
setLayout,
items,
top,
collapsible = true,
iconExpanded,
onTitleClick,
titleActionLabel,
children,
}: {
kind: PanelId;
@@ -36,7 +38,11 @@ export default function FilterRail({
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
items: PanelItem[];
top?: ReactNode;
/** Passed to PanelGroups: false renders plain always-open label + control rows (no collapse). */
collapsible?: boolean;
iconExpanded?: ReactNode;
onTitleClick?: () => void;
titleActionLabel?: string;
children?: ReactNode;
}) {
const { t } = useTranslation();
@@ -53,9 +59,17 @@ export default function FilterRail({
onReset={() => setLayout(defaultLayout(kind))}
editHint={t("sidebar.editHint")}
actions={actions}
iconExpanded={iconExpanded}
onTitleClick={onTitleClick}
titleActionLabel={titleActionLabel}
>
{top}
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
<PanelGroups
items={items}
layout={layout}
setLayout={setLayout}
editing={editing}
collapsible={collapsible}
/>
{children}
</SidePanel>
);
+4
View File
@@ -5,6 +5,7 @@ import type { Me } from "../lib/api";
import SearchBar from "./SearchBar";
import PlexSearchBox from "./PlexSearchBox";
import PlexScopeToggle from "./PlexScopeToggle";
import FeedScopeToggle from "./FeedScopeToggle";
import SyncStatus from "./SyncStatus";
import { useNavigation } from "./NavigationProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
@@ -98,6 +99,9 @@ const Header = forwardRef<
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
>
{search}
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
{page === "feed" && <FeedScopeToggle />}
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
the filter sidebar (S3) so the primary "what am I browsing" control is always in reach. */}
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
+51 -21
View File
@@ -1,29 +1,37 @@
import { type ReactNode, type CSSProperties } from "react";
import { useTranslation } from "react-i18next";
import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react";
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse,
// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from
// the feed sidebar's WidgetCard so all three panels share the same island look and behavior.
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
// panels share the same island look and behavior. `collapsible` groups (the default) get a per-group
// collapse chevron; non-collapsible groups render as a plain always-open label + control row, and
// their title can instead act as a trigger via `onTitleClick` (e.g. feed "Your tags" -> tag manager).
export default function PanelGroup({
id,
title,
editing,
collapsible = true,
collapsed,
hidden,
onToggleCollapse,
onToggleHidden,
onTitleClick,
titleActionLabel,
children,
}: {
id: string;
title: string;
editing: boolean;
collapsible?: boolean;
collapsed: boolean;
hidden: boolean;
onToggleCollapse: () => void;
onToggleHidden: () => void;
onTitleClick?: () => void;
titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -32,7 +40,13 @@ export default function PanelGroup({
disabled: !editing,
});
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition };
const showBody = !editing && !collapsed;
// Non-collapsible groups are always open (outside edit mode); collapsible ones honour `collapsed`.
const showBody = !editing && (!collapsible || !collapsed);
// A non-collapsible group's title acts as a trigger when one is provided.
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
const titleCls =
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
return (
<div
@@ -53,13 +67,27 @@ export default function PanelGroup({
<GripVertical className="w-4 h-4" />
</button>
)}
<button
onClick={editing ? undefined : onToggleCollapse}
disabled={editing}
className="flex-1 min-w-0 flex items-center text-left select-none text-sm uppercase tracking-wide text-muted enabled:hover:text-fg transition"
>
<span className="truncate">{title}</span>
</button>
{collapsible && !editing ? (
<button
onClick={onToggleCollapse}
className={`${titleCls} text-muted hover:text-fg transition`}
>
<span className="truncate">{title}</span>
</button>
) : titleIsTrigger ? (
<button
onClick={onTitleClick}
title={titleActionLabel}
className={`${titleCls} group text-muted hover:text-accent transition`}
>
<span className="truncate">{title}</span>
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
</button>
) : (
<span className={`${titleCls} text-muted`}>
<span className="truncate">{title}</span>
</span>
)}
{editing ? (
<button
onClick={onToggleHidden}
@@ -69,15 +97,17 @@ export default function PanelGroup({
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
) : (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
collapsible && (
<button
onClick={onToggleCollapse}
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
className="text-muted hover:text-fg"
>
<ChevronDown
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
/>
</button>
)
)}
</div>
{showBody && <div className="px-3 pb-3">{children}</div>}
+17 -1
View File
@@ -17,7 +17,16 @@ import {
import type { PanelLayout } from "../lib/panelLayout";
import PanelGroup from "./PanelGroup";
export type PanelItem = { id: string; title: string; body: ReactNode };
export type PanelItem = {
id: string;
title: string;
body: ReactNode;
/** When set (and the panel is non-collapsible), the group's title becomes a button firing this —
* e.g. the feed's "Your tags" opens the tag manager. */
onTitleClick?: () => void;
/** Accessible name / tooltip for the title action (what onTitleClick does). */
titleActionLabel?: string;
};
// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group
// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE
@@ -29,11 +38,15 @@ export default function PanelGroups({
layout,
setLayout,
editing,
collapsible = true,
}: {
items: PanelItem[];
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
editing: boolean;
/** When false, groups render as plain (always-open) label + control rows — no collapse chevron.
* Reorder + hide (in edit mode) stay. Default true (the collapsible-island look). */
collapsible?: boolean;
}) {
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
@@ -76,10 +89,13 @@ export default function PanelGroups({
id={id}
title={byId.get(id)!.title}
editing={editing}
collapsible={collapsible}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
onTitleClick={byId.get(id)!.onTitleClick}
titleActionLabel={byId.get(id)!.titleActionLabel}
>
{byId.get(id)!.body}
</PanelGroup>
@@ -0,0 +1,222 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, GripVertical, Play, Star, Trash2 } from "lucide-react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import type { SavedView } from "../lib/api";
import Modal from "./Modal";
import { useSavedViews, asFilters } from "./SavedViewsProvider";
import { useActiveFilters } from "../lib/useActiveFilters";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder) plus a "save the current filters" input; the right column shows the selected view's name,
// what filters it captures, its default flag, apply, and delete. Opened from the filter panel's title
// or the saved-views popover; all mutations flow through SavedViewsProvider.
function ViewRow({
view,
selected,
active,
onSelect,
}: {
view: SavedView;
selected: boolean;
active: boolean;
onSelect: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: view.id,
});
return (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
selected ? "bg-accent/15" : "hover:bg-card"
}`}
>
<button
{...attributes}
{...listeners}
aria-label={t("views.reorder")}
className="cursor-grab p-1.5 text-muted hover:text-fg"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
selected ? "text-accent" : "text-fg"
}`}
>
{view.name}
</button>
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
</div>
);
}
export default function PersonalViewsModal() {
const { t } = useTranslation();
const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } =
useSavedViews();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null;
const [draftName, setDraftName] = useState("");
// The selected view's captured filters, as the same human labels the toolbar chip row shows.
const captured = useActiveFilters(
selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never),
() => {},
);
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const ids = views.map((v) => v.id);
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
await reorder(next);
}
async function onCreate() {
const name = draftName.trim();
if (!name) return;
if (await create(name)) setDraftName("");
}
return (
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
{/* Left: the list + create */}
<div className="flex flex-col border-r border-border/60 pr-4">
<div className="flex-1 space-y-1 overflow-y-auto">
{views.length === 0 && (
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={views.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
selected={v.id === selectedId}
active={v.id === activeViewId}
onSelect={() => setSelectedId(v.id)}
/>
))}
</SortableContext>
</DndContext>
</div>
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
<input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<button
onClick={onCreate}
disabled={!draftName.trim()}
title={t("views.save")}
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
>
<BookmarkPlus className="h-4 w-4" />
</button>
</div>
</div>
{/* Right: the selected view's detail */}
{selected ? (
<div className="flex flex-col gap-3">
<input
key={selected.id}
defaultValue={selected.name}
onBlur={(e) => {
const next = e.target.value.trim();
if (next && next !== selected.name) void rename(selected.id, next);
}}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<div>
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
{t("views.capturedFilters")}
</div>
{captured.length === 0 ? (
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{captured.map((f) => (
<span
key={f.key}
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
>
{f.label}
</span>
))}
</div>
)}
</div>
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
<button
onClick={() => apply(selected)}
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
>
<Play className="h-3.5 w-3.5" />
{t("views.apply")}
</button>
<button
onClick={() => void toggleDefault(selected)}
title={t("views.default")}
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
selected.is_default
? "border-accent text-accent"
: "border-border text-muted hover:text-fg"
}`}
>
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
{t("views.default")}
</button>
<button
onClick={async () => {
const next = views.find((v) => v.id !== selected.id);
// Only move the selection if the delete actually happened (remove awaits a confirm
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
if (await remove(selected)) setSelectedId(next?.id ?? null);
}}
title={t("views.delete")}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
>
<Trash2 className="h-3.5 w-3.5" />
{t("views.delete")}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center text-sm text-muted">
{t("views.selectPrompt")}
</div>
)}
</div>
</Modal>
);
}
@@ -0,0 +1,84 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Bookmark, Check, Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { useSavedViews } from "./SavedViewsProvider";
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
// checked) + a "Manage views…" entry that opens the Personal Views modal, where the editing lives.
export default function SavedViewsPopover({
trigger,
className,
ariaLabel,
placement = "bottom-start",
}: {
trigger: ReactNode;
className?: string;
ariaLabel?: string;
placement?: "bottom-start" | "bottom-end";
}) {
const { t } = useTranslation();
const { views, activeViewId, apply, openModal } = useSavedViews();
const [open, setOpen] = useState(false);
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement });
const label = ariaLabel ?? t("views.saved");
return (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={label}
className={className ?? "shrink-0 flex text-accent transition hover:text-fg"}
>
{trigger}
</button>
<PopoverSurface
popover={pop}
ariaLabel={label}
className="glass-menu min-w-56 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
{views.length === 0 ? (
<p className="px-2 py-1.5 text-2xs leading-snug text-muted">{t("views.empty")}</p>
) : (
<div role="group" aria-label={label}>
{views.map((v) => (
<PopoverItem
key={v.id}
role="menuitemradio"
checked={v.id === activeViewId}
onSelect={() => {
apply(v);
setOpen(false);
}}
label={v.name}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
v.id === activeViewId ? "text-fg" : "text-muted"
}`}
>
<Bookmark className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
<span className="flex-1 truncate">{v.name}</span>
{v.id === activeViewId && <Check className="h-3.5 w-3.5 shrink-0 text-accent" />}
</PopoverItem>
))}
</div>
)}
<div className="mt-1 border-t border-border pt-1">
<PopoverItem
role="menuitem"
onSelect={() => {
openModal();
setOpen(false);
}}
label={t("views.manage")}
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-muted transition hover:text-fg"
>
<Settings2 className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<span className="flex-1">{t("views.manage")}</span>
</PopoverItem>
</div>
</PopoverSurface>
</>
);
}
@@ -0,0 +1,184 @@
import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import {
api,
getActiveAccount,
coerceTagModes,
type FeedFilters,
type SavedView,
} from "../lib/api";
import { filtersToParams } from "../lib/urlState";
import { preferSticky } from "../lib/savedViews";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
// A saved view's `filters` is an opaque JSON blob from the DB — the FeedFilters type says nothing about
// what an older build wrote there, so coerce the array/shape fields (and migrate the legacy global
// tagMode) before the blob reaches app state or a serializer. NOT a migration: a view saved against a
// dropped field just loses that filter.
export const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
tagModes: coerceTagModes(raw),
});
// A stable signature of a filter set (only non-default keys, shuffle seed ignored) so a view can be
// matched against the live filters regardless of key order or transient fields.
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
// Mirror the default view's filters to localStorage so the app can seed them on next load before the
// network returns (read by FeedFiltersProvider).
function syncDefaultMirror(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);
}
export interface SavedViewsContext {
views: SavedView[];
loading: boolean;
/** The view whose saved filters EXACTLY match the live filters (null if none) — highlighted as active. */
activeViewId: number | null;
/** The view to name in the chip: the exact match, else the last-applied ("sticky") one it diverged from. */
displayView: SavedView | null;
/** True when a sticky view is shown but the live filters no longer match it (the chip's "modified" dot). */
isDirty: boolean;
modalOpen: boolean;
openModal: () => void;
closeModal: () => void;
/** Apply a view's filters and remember it as the active/sticky one. */
apply: (v: SavedView) => void;
/** Forget the active/sticky view (the chip disappears) without touching the filters. */
clearActive: () => void;
/** Save the CURRENT filters as a new view; returns success so a caller can keep its editor open on fail. */
create: (name: string) => Promise<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
toggleDefault: (v: SavedView) => Promise<void>;
/** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
const Ctx = createContext<SavedViewsContext | null>(null);
export function SavedViewsProvider({ children }: { children: ReactNode }) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
const [stickyId, setStickyId] = useState<number | null>(null);
const [modalOpen, setModalOpen] = useState(false);
// Keep the default-view mirror (read on load) in sync with the server state.
useEffect(() => {
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]);
const currentKey = keyOf(filters);
const activeView = useMemo(
// When several views capture identical filters, prefer the one the user explicitly applied
// (sticky) so apply(B) isn't silently re-attributed to an earlier duplicate A; else the first.
() =>
preferSticky(
views.filter((v) => keyOf(v.filters) === currentKey),
stickyId,
),
[views, currentKey, stickyId],
);
// An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link,
// manual, or an applied view) name that view in the chip and keep naming it after a later tweak.
useEffect(() => {
if (activeView) setStickyId(activeView.id);
}, [activeView]);
const stickyView = useMemo(
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
[views, stickyId],
);
const displayView = activeView ?? stickyView;
const isDirty = displayView != null && activeView == null;
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
try {
await fn();
return true;
} catch (e) {
notifyActionError(e, failMsg);
return false;
} finally {
refresh();
}
}
const value: SavedViewsContext = {
views,
loading: viewsQuery.isLoading,
activeViewId: activeView?.id ?? null,
displayView,
isDirty,
modalOpen,
openModal: () => setModalOpen(true),
closeModal: () => setModalOpen(false),
apply: (v) => {
setFilters(asFilters(v.filters));
setStickyId(v.id);
},
clearActive: () => setStickyId(null),
create: async (name) => {
const clean = name.trim();
if (!clean) return false;
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
const { seed: _seed, ...rest } = filters;
return runViewMutation(() => api.createView(clean, rest), t("views.actionFailed"));
},
rename: async (id, name) => {
const next = name.trim();
if (!next) return false;
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
},
toggleDefault: (v) =>
runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
).then(() => undefined),
remove: async (v) => {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return false;
if (stickyId === v.id) setStickyId(null);
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
},
reorder: async (nextIds) => {
// Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to
// the server's truth on failure (with a toast, so it doesn't silently snap back).
qc.setQueryData<SavedView[]>(
qk.savedViews(),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
return ctx;
}
@@ -1,344 +0,0 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { BookmarkPlus, Check, GripVertical, Pencil, Star, Trash2, X } from "lucide-react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
import { filtersToParams } from "../lib/urlState";
import { notifyActionError } from "../lib/actionErrors";
import { accountKey, LS, writeJSON } from "../lib/storage";
import { useConfirm } from "./ConfirmProvider";
// Compact, order-independent signature of a filter set (reuses the share serializer, which
// emits only non-default values and ignores the transient shuffle seed). Used to highlight the
// saved view that matches the feed's current filters.
const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString();
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
// loses that filter.
const asFilters = (raw: FeedFilters): FeedFilters => ({
...raw,
tags: raw.tags ?? [],
channelIds: raw.channelIds ?? [],
});
function syncDefaultMirror(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);
}
export default function SavedViewsWidget({
filters,
onApply,
}: {
filters: FeedFilters;
onApply: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const qc = useQueryClient();
const confirm = useConfirm();
const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews });
const views = viewsQuery.data ?? [];
const [naming, setNaming] = useState(false);
const [draftName, setDraftName] = useState("");
const [renameId, setRenameId] = useState<number | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
// Keep the default-view mirror (read on load) in sync with the server state.
useEffect(() => {
if (viewsQuery.data) syncDefaultMirror(viewsQuery.data);
}, [viewsQuery.data]);
const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() });
// Run a saved-view mutation with uniform feedback: on failure surface the server's reason instead
// of a silent swallow, and always refetch afterwards (which also reverts an optimistic change to
// the server's truth). Returns whether it succeeded, so callers can gate success-only side effects
// (e.g. closing an editor) and keep the input open — with the user's text — on failure.
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
try {
await fn();
return true;
} catch (e) {
notifyActionError(e, failMsg);
return false;
} finally {
refresh();
}
}
const currentKey = keyOf(filters);
async function save() {
const name = draftName.trim();
if (!name) return;
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
const { seed: _seed, ...clean } = filters;
// Close the naming input only on success, so a failed create keeps the user's typed name.
if (await runViewMutation(() => api.createView(name, clean), t("views.actionFailed"))) {
setNaming(false);
setDraftName("");
}
}
async function rename(id: number, name: string) {
const next = name.trim();
if (!next) {
setRenameId(null);
return;
}
// Close the rename editor only on success, so a failed rename stays open for a retry.
if (await runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"))) {
setRenameId(null);
}
}
async function toggleDefault(v: SavedView) {
await runViewMutation(
() => api.updateView(v.id, { is_default: !v.is_default }),
t("views.actionFailed"),
);
}
async function remove(v: SavedView) {
const ok = await confirm({
message: t("views.deleteConfirm", { name: v.name }),
confirmLabel: t("views.delete"),
danger: true,
});
if (!ok) return;
await runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
}
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const ids = views.map((v) => v.id);
const oldIndex = ids.indexOf(active.id as number);
const newIndex = ids.indexOf(over.id as number);
if (oldIndex < 0 || newIndex < 0) return;
const next = arrayMove(ids, oldIndex, newIndex);
// Optimistic reorder so the drop sticks visually before the round-trip.
qc.setQueryData<SavedView[]>(
qk.savedViews(),
next.map((id) => views.find((v) => v.id === id)!),
);
// On failure runViewMutation's refetch reverts the optimistic order to the server's truth;
// without the toast the drop would silently snap back with no explanation.
await runViewMutation(() => api.reorderViews(next), t("views.reorderFailed"));
}
return (
<div className="space-y-2">
{views.length === 0 && !naming && (
<p className="text-2xs text-muted leading-snug">{t("views.empty")}</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext items={views.map((v) => v.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-1">
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
active={keyOf(v.filters) === currentKey}
renaming={renameId === v.id}
onApply={() => onApply(asFilters(v.filters))}
onStartRename={() => setRenameId(v.id)}
onRename={(name) => rename(v.id, name)}
onCancelRename={() => setRenameId(null)}
onToggleDefault={() => toggleDefault(v)}
onDelete={() => remove(v)}
/>
))}
</div>
</SortableContext>
</DndContext>
{naming ? (
<div className="flex items-center gap-1">
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void save();
if (e.key === "Escape") setNaming(false);
}}
placeholder={t("views.namePlaceholder")}
maxLength={80}
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={() => void save()}
title={t("views.saveAction")}
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={() => setNaming(false)}
title={t("views.cancel")}
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<button
onClick={() => {
setDraftName("");
setNaming(true);
}}
className="w-full inline-flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<BookmarkPlus className="w-3.5 h-3.5" />
{t("views.save")}
</button>
)}
</div>
);
}
function ViewRow({
view,
active,
renaming,
onApply,
onStartRename,
onRename,
onCancelRename,
onToggleDefault,
onDelete,
}: {
view: SavedView;
active: boolean;
renaming: boolean;
onApply: () => void;
onStartRename: () => void;
onRename: (name: string) => void;
onCancelRename: () => void;
onToggleDefault: () => void;
onDelete: () => void;
}) {
const { t } = useTranslation();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: view.id,
});
const [draft, setDraft] = useState(view.name);
// Reset the edit draft to the current name each time rename mode opens (so a second rename
// doesn't start from a stale value).
useEffect(() => {
if (renaming) setDraft(view.name);
}, [renaming, view.name]);
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
if (renaming) {
return (
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
<input
autoFocus
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onRename(draft);
if (e.key === "Escape") onCancelRename();
}}
maxLength={80}
className="flex-1 min-w-0 bg-surface border border-border rounded-lg px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
<button
onClick={() => onRename(draft)}
title={t("views.saveAction")}
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
>
<Check className="w-4 h-4" />
</button>
<button
onClick={onCancelRename}
title={t("views.cancel")}
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
>
<X className="w-4 h-4" />
</button>
</div>
);
}
return (
<div
ref={setNodeRef}
style={style}
className={`group flex items-center gap-0.5 rounded-lg pr-1 ${
active ? "bg-accent/10" : "hover:bg-card"
}`}
>
<button
{...attributes}
{...listeners}
title={t("views.reorder")}
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0"
>
<GripVertical className="w-3.5 h-3.5" />
</button>
<button
onClick={onApply}
title={t("views.apply")}
aria-current={active ? "true" : undefined}
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
active ? "text-accent font-medium" : "text-fg"
}`}
>
{view.name}
</button>
<div className="flex items-center shrink-0">
<button
onClick={onToggleDefault}
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
className={`p-1 rounded transition ${
view.is_default ? "text-accent" : "text-muted reveal hover:text-accent"
}`}
>
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
</button>
<button
onClick={onStartRename}
title={t("views.rename")}
className="p-1 rounded-sm text-muted reveal hover:text-fg transition"
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={onDelete}
title={t("views.delete")}
className="p-1 rounded-sm text-muted reveal hover:text-danger transition"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import { useTranslation } from "react-i18next";
import { Bookmark, ChevronDown } from "lucide-react";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
// from the saved view. Clicking it opens the saved-views picker to switch views. Renders nothing when
// no view is active (nothing applied and the filters match no saved view).
export default function SelectedViewChip() {
const { t } = useTranslation();
const { displayView, isDirty } = useSavedViews();
if (!displayView) return null;
return (
<SavedViewsPopover
ariaLabel={t("views.saved")}
className="glass-card glass-hover inline-flex items-center gap-1.5 rounded-full border border-border px-3 py-1 text-sm text-fg transition hover:border-accent/60"
trigger={
<>
<Bookmark className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden="true" />
<span className="max-w-40 truncate">{displayView.name}</span>
{isDirty && (
<span
className="h-1.5 w-1.5 shrink-0 rounded-full bg-warn"
title={t("views.modified")}
aria-label={t("views.modified")}
/>
)}
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted" aria-hidden="true" />
</>
}
/>
);
}
+25 -4
View File
@@ -20,6 +20,9 @@ export default function SidePanel({
onReset,
editHint,
actions,
iconExpanded,
onTitleClick,
titleActionLabel,
children,
}: {
title: string;
@@ -34,6 +37,13 @@ export default function SidePanel({
editHint?: string;
// Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view).
actions?: ReactNode;
// Optional override for the icon in the EXPANDED header only (the collapsed tab keeps the plain
// `icon`) — the feed passes its saved-views popover trigger here so the header icon opens the picker.
iconExpanded?: ReactNode;
// When set, the expanded-header title becomes a button (the feed opens its Personal Views modal).
onTitleClick?: () => void;
// Accessible name / tooltip describing what onTitleClick does (the plain title text alone doesn't).
titleActionLabel?: string;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -99,10 +109,21 @@ export default function SidePanel({
>
{pinned ? <PinOff className="w-4 h-4" /> : <Pin className="w-4 h-4" />}
</button>
<span className="shrink-0 flex text-accent">{icon}</span>
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
</span>
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
{onTitleClick ? (
<button
onClick={onTitleClick}
title={titleActionLabel}
aria-label={titleActionLabel}
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
>
{title}
</button>
) : (
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
{title}
</span>
)}
{count > 0 && (
<span
title={t("sidebar.activeCount", { count })}
+57 -158
View File
@@ -2,20 +2,19 @@ import { type ReactNode, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { Library, Pencil, SlidersHorizontal, User } from "lucide-react";
import { api, type FeedFilters, type Tag } from "../lib/api";
import { SlidersHorizontal } from "lucide-react";
import { api, type FeedFilters, type Tag, type TagCategory } from "../lib/api";
import { defaultLayout, type PanelLayout } from "../lib/panelLayout";
import { useDebounced } from "../lib/useDebounced";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import { applyDateChip, deriveDateKey } from "../lib/dateFilter";
import { useActiveFilters } from "../lib/useActiveFilters";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
import SavedViewsPopover from "./SavedViewsPopover";
import { useSavedViews } from "./SavedViewsProvider";
import FilterRail from "./FilterRail";
import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import DateFilterSelect from "./DateFilterSelect";
import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
@@ -25,9 +24,13 @@ import { useMe } from "../lib/useMe";
const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1, key: "24h" },
{ days: 7, key: "1week" },
{ days: 14, key: "2weeks" },
{ days: 30, key: "1month" },
{ days: 90, key: "3months" },
{ days: 180, key: "6months" },
{ days: 365, key: "1year" },
{ days: 730, key: "2years" },
{ days: 1825, key: "5years" },
];
export default function Sidebar({
@@ -43,6 +46,7 @@ export default function Sidebar({
}) {
const { t } = useTranslation();
const isDemo = useMe().is_demo;
const { openModal } = useSavedViews();
const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions();
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
@@ -75,7 +79,6 @@ export default function Sidebar({
const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic");
const userTags = tags.filter((t) => !t.system);
const [customDates, setCustomDates] = useState(false);
const [tagManagerOpen, setTagManagerOpen] = useState(false);
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
// the panel layout key. `FilterRail` owns the customize `editing` state now.
@@ -97,19 +100,15 @@ export default function Sidebar({
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
// Each tag category (language/topic/other) has its OWN any/all match, set from its combobox footer.
const setTagMode = (category: TagCategory, mode: "or" | "and") =>
setFilters({ ...filters, tagModes: { ...filters.tagModes, [category]: mode } });
// 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 active = activeCount > 0;
function clearAll() {
setCustomDates(false);
setFilters(clearedFilters(filters));
}
const available: Record<string, boolean> = {
savedviews: !isDemo,
date: true,
channel: true,
language: languages.length > 0,
@@ -119,79 +118,12 @@ export default function Sidebar({
function widgetBody(id: string): ReactNode {
switch (id) {
case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "channel":
return <ChannelPicker filters={filters} setFilters={setFilters} />;
case "date": {
// Single-select across the presets + Custom; pure logic lives in lib/dateFilter.
const dateKey = deriveDateKey(filters, customDates, DATE_PRESETS);
case "date":
return (
<>
<FilterChipGroup
ariaLabel={t("sidebar.datePresetLabel")}
value={dateKey}
options={[
...DATE_PRESETS.map((p) => ({
value: String(p.days),
label: t("sidebar.datePreset." + p.key),
})),
{ value: "custom", label: t("sidebar.custom") },
]}
onChange={(key) => {
const change = applyDateChip(key, filters, customDates);
if (change.customDates !== undefined) setCustomDates(change.customDates);
if (change.filters) setFilters(change.filters);
}}
/>
{(customDates || filters.dateFrom || filters.dateTo) && (
<div className="flex flex-col gap-1.5 mt-2">
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.from")}</span>
<input
type="date"
value={filters.dateFrom ?? ""}
onChange={(e) =>
setFilters({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</label>
<label className="flex items-center justify-between gap-2 text-xs">
<span className="text-muted">{t("sidebar.to")}</span>
<input
type="date"
value={filters.dateTo ?? ""}
onChange={(e) =>
setFilters({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
})
}
className="bg-surface border border-border rounded-md px-2 py-1 text-xs focus:outline-hidden focus:ring-2 focus:ring-accent/40"
/>
</label>
{(filters.dateFrom || filters.dateTo) && (
<button
onClick={() =>
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
}
className="text-2xs text-muted hover:text-accent self-end"
>
{t("sidebar.clearDates")}
</button>
)}
</div>
)}
</>
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
}
case "language": {
return (
<Combobox
@@ -200,52 +132,37 @@ export default function Sidebar({
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.language}
onMatchModeChange={(m) => setTagMode("language", m)}
/>
);
}
case "topic": {
return (
<div className="flex flex-col gap-2">
<SegmentedToggle
label={t("sidebar.match")}
title={t("sidebar.tagModeTooltip")}
ariaLabel={t("sidebar.match")}
options={[
{ value: "or", label: t("sidebar.any") },
{ value: "and", label: t("sidebar.all") },
]}
value={filters.tagMode}
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
/>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</div>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
matchMode={filters.tagModes.topic}
onMatchModeChange={(m) => setTagMode("topic", m)}
/>
);
}
case "tags": {
// The widget's own "Manage" trigger is its title (see the PanelItem onTitleClick below), so the
// body is just the picker.
return (
<div className="flex flex-col gap-2">
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
<button
onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")}
className="inline-flex items-center gap-1 self-start text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("sidebar.manageTags")}
</button>
</div>
<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)}
/>
);
}
default:
@@ -257,19 +174,15 @@ export default function Sidebar({
// the actual render order is the user's saved layout (PanelGroups orders by it).
const items: PanelItem[] = defaultLayout(source)
.order.filter((id) => available[id])
.map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) }));
const actions = (
<>
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
>
{t("sidebar.clearAll")}
</button>
</>
);
.map((id) => ({
id,
title: t("sidebar.widget." + id),
body: widgetBody(id),
// The widgets aren't collapsible, so a widget's title can act as its own trigger: "Your tags"
// opens the tag manager (replacing the old inline Manage button).
onTitleClick: id === "tags" ? () => setTagManagerOpen(true) : undefined,
titleActionLabel: id === "tags" ? t("sidebar.manageTags") : undefined,
}));
return (
<FilterRail
@@ -282,31 +195,17 @@ export default function Sidebar({
layout={layout}
setLayout={setLayout}
items={items}
actions={actions}
top={
/* Scope: your own subscriptions (Mine) vs the shared Library. */
<SegmentedToggle
ariaLabel={t("header.scope.label")}
fullWidth
size="md"
options={[
{
value: "my",
label: t("header.scope.my"),
icon: <User className="w-3.5 h-3.5" />,
title: t("header.scope.myTip"),
},
{
value: "all",
label: t("header.scope.all"),
icon: <Library className="w-3.5 h-3.5" />,
title: t("header.scope.allTip"),
},
]}
value={filters.scope}
onChange={(scope) => setFilters({ ...filters, scope })}
/>
// The feed widgets are simple label + control rows, not collapsible islands.
collapsible={false}
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
// full manager modal. Hidden for the demo account (no per-user views). Feed rail only.
iconExpanded={
!isDemo && source === "feed" ? (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
) : undefined
}
onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
titleActionLabel={!isDemo && source === "feed" ? t("views.manageTitle") : undefined}
>
{tagManagerOpen && (
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
+20 -11
View File
@@ -7,6 +7,8 @@ import { useFeedView, useFeedViewActions } from "./FeedViewProvider";
import { useNavigation, useNavigationActions } from "./NavigationProvider";
import Sidebar from "./Sidebar";
import PlaylistsRail from "./PlaylistsRail";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
// The RENDER half of the module registry: which component (and side rail) each page renders. The
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
@@ -45,18 +47,25 @@ function FeedPage() {
const { setView } = useFeedViewActions();
const { ytSearch } = useNavigation();
const { enterYtSearch, exitYtSearch } = useNavigationActions();
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
const { modalOpen } = useSavedViews();
return (
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
<>
<Feed
filters={filters}
setFilters={setFilters}
view={view}
setView={setView}
canRead={me.can_read}
isDemo={me.is_demo}
ytSearch={ytSearch}
onYtSearch={enterYtSearch}
onExitYtSearch={exitYtSearch}
/>
{modalOpen && !me.is_demo && <PersonalViewsModal />}
</>
);
}
+6
View File
@@ -3,6 +3,10 @@
"label": "Filtered by",
"remove": "Remove filter: {{name}}",
"contentNone": "No content types",
"count_one": "{{count}} filter",
"count_other": "{{count}} filters",
"moreCount_one": "{{count}} more filter",
"moreCount_other": "{{count}} more filters",
"maxAge_one": "Last {{count}} day",
"maxAge_other": "Last {{count}} days"
},
@@ -16,6 +20,8 @@
"demoEmptyBody": "The demo account has no subscriptions of its own — but the whole shared library is yours to explore, filter and sort.",
"browseLibrary": "Browse the library",
"noMatches": "No videos match these filters.",
"channelScopeHint": "This channel isn't in your subscriptions feed — it may be hidden. Its videos are in the shared library.",
"viewInLibrary": "View in Library",
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
+11 -1
View File
@@ -67,9 +67,19 @@
"datePreset": {
"24h": "24h",
"1week": "1 week",
"2weeks": "2 weeks",
"1month": "1 month",
"3months": "3 months",
"6months": "6 months",
"1year": "1 year"
"1year": "1 year",
"2years": "2 years",
"5years": "5 years"
},
"date": {
"anyTime": "Any time",
"customRange": "Custom range",
"fromShort": "From {{date}}",
"untilShort": "Until {{date}}"
},
"channelSearch": "Search channels…",
"noChannelMatch": "No channel matches.",
+9 -1
View File
@@ -12,5 +12,13 @@
"deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder",
"actionFailed": "Couldn't update your saved views — please try again.",
"reorderFailed": "Couldn't save the new order — please try again."
"reorderFailed": "Couldn't save the new order — please try again.",
"saved": "Saved views",
"manage": "Manage views…",
"manageTitle": "Personal views",
"capturedFilters": "Captured filters",
"capturedNone": "No filters — matches every video.",
"default": "Default",
"selectPrompt": "Select a view to see and edit it.",
"modified": "Modified — the current filters differ from this saved view."
}
+6
View File
@@ -3,6 +3,10 @@
"label": "Szűrve",
"remove": "Szűrő eltávolítása: {{name}}",
"contentNone": "Nincs tartalomtípus",
"count_one": "{{count}} szűrő",
"count_other": "{{count}} szűrő",
"moreCount_one": "még {{count}} szűrő",
"moreCount_other": "még {{count}} szűrő",
"maxAge_one": "Utolsó {{count}} nap",
"maxAge_other": "Utolsó {{count}} nap"
},
@@ -16,6 +20,8 @@
"demoEmptyBody": "A demo fióknak nincsenek saját feliratkozásai — de a teljes közös könyvtár a tiéd: böngészd, szűrd és rendezd kedvedre.",
"browseLibrary": "Könyvtár böngészése",
"noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.",
"channelScopeHint": "Ez a csatorna nincs a feliratkozásaid feedjében – lehet, hogy el van rejtve. A videói a közös könyvtárban vannak.",
"viewInLibrary": "Megnézés a Könyvtárban",
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
+11 -1
View File
@@ -67,9 +67,19 @@
"datePreset": {
"24h": "24 óra",
"1week": "1 hét",
"2weeks": "2 hét",
"1month": "1 hónap",
"3months": "3 hónap",
"6months": "6 hónap",
"1year": "1 év"
"1year": "1 év",
"2years": "2 év",
"5years": "5 év"
},
"date": {
"anyTime": "Bármikor",
"customRange": "Egyéni tartomány",
"fromShort": "{{date}}-től",
"untilShort": "{{date}}-ig"
},
"channelSearch": "Csatornák keresése…",
"noChannelMatch": "Nincs találat.",
+9 -1
View File
@@ -12,5 +12,13 @@
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
"reorder": "Húzd az átrendezéshez",
"actionFailed": "Nem sikerült frissíteni a mentett nézeteket — próbáld újra.",
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra."
"reorderFailed": "Nem sikerült menteni az új sorrendet — próbáld újra.",
"saved": "Mentett nézetek",
"manage": "Nézetek kezelése…",
"manageTitle": "Személyes nézetek",
"capturedFilters": "Rögzített szűrők",
"capturedNone": "Nincs szűrő — minden videóra illeszkedik.",
"default": "Alapértelmezett",
"selectPrompt": "Válassz egy nézetet a megtekintéshez és szerkesztéshez.",
"modified": "Módosítva — a jelenlegi szűrők eltérnek a mentett nézettől."
}
+2
View File
@@ -31,6 +31,8 @@ import { setupApi } from "./api/setup";
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";
import { channelsApi } from "./api/channels";
export type * from "./api/channels";
import { syncApi } from "./api/sync";
+7 -2
View File
@@ -2,6 +2,7 @@
// the shared FeedFilters into the query string the feed + facet endpoints both consume; they stay
// module-local (no consumer names them). Only touches core's req/beacon.
import { req, beacon } from "./core";
import { TAG_CATEGORIES, type TagModes } from "./tagModes";
// Per-user watch status stored server-side (VALID_STATES in routes/feed.py). "in_progress" and
// "unwatched" are NOT statuses — they're derived feed filters (status "new" + a resume position),
@@ -54,7 +55,8 @@ export interface FeedResponse {
export interface FeedFilters {
tags: number[];
tagMode: "or" | "and";
/** Per-category tag match (language / topic / other each independently any/all). */
tagModes: TagModes;
q: string;
sort: string;
// Re-roll token for the "shuffle" sort; bumped by the reshuffle button so the
@@ -83,7 +85,10 @@ export interface FeedFilters {
function filterParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams();
f.tags.forEach((t) => p.append("tags", String(t)));
p.set("tag_mode", f.tagMode);
// Only emit the categories set to "and"; the backend defaults each to "or".
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tag_mode_${cat}`, "and");
}
if (f.q) p.set("q", f.q);
p.set("scope", f.scope);
p.set("show_normal", String(f.includeNormal));
+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import { coerceTagModes, 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`.
describe("coerceTagModes", () => {
it("defaults to all-'or' for empty/undefined input", () => {
expect(coerceTagModes(undefined)).toEqual(DEFAULT_TAG_MODES);
expect(coerceTagModes({})).toEqual(DEFAULT_TAG_MODES);
expect(coerceTagModes(null)).toEqual(DEFAULT_TAG_MODES);
});
it("migrates a legacy global tagMode:'and' into the topic slot only", () => {
expect(coerceTagModes({ tagMode: "and" })).toEqual({
language: "or",
topic: "and",
other: "or",
});
});
it("ignores a legacy tagMode:'or' (stays all-'or')", () => {
expect(coerceTagModes({ tagMode: "or" })).toEqual(DEFAULT_TAG_MODES);
});
it("preserves an explicit per-category tagModes object", () => {
expect(coerceTagModes({ tagModes: { language: "and", topic: "or", other: "and" } })).toEqual({
language: "and",
topic: "or",
other: "and",
});
});
it("an explicit per-category value wins over the legacy scalar", () => {
// A blob carrying both should honour the new per-category topic, not the legacy global.
expect(coerceTagModes({ tagMode: "and", tagModes: { topic: "or" } }).topic).toBe("or");
});
it("coerces invalid/garbage values to 'or'", () => {
expect(coerceTagModes({ tagModes: { topic: "x" as unknown as "or" } }).topic).toBe("or");
expect(coerceTagModes({ tagModes: { language: 1 as unknown as "or" } }).language).toBe("or");
});
});
+24
View File
@@ -0,0 +1,24 @@
// Per-category tag match — the small, dependency-free vocabulary shared by the feed filter model, the
// URL (de)serializer, the sidebar comboboxes, and the saved-view migration. Kept in its own leaf (no
// imports) so node-env tests and any module can pull it in without dragging the HTTP/i18n machinery.
/** A tag category's match: "or" = a channel with ANY selected tag passes, "and" = it must carry ALL. */
export type TagMode = "or" | "and";
/** The three tag categories, each with its OWN independent match mode. */
export type TagCategory = "language" | "topic" | "other";
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" };
/** 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 {
const r = (raw ?? {}) as { tagModes?: Partial<TagModes>; tagMode?: unknown };
const legacyTopic = r.tagMode === "and" ? "and" : undefined;
const pick = (cat: TagCategory): TagMode => {
const v = r.tagModes?.[cat];
if (v === "and" || v === "or") return v;
return cat === "topic" && legacyTopic ? "and" : "or";
};
return { language: pick("language"), topic: pick("topic"), other: pick("other") };
}
+93
View File
@@ -0,0 +1,93 @@
import { describe, it, expect } from "vitest";
import { decideChipLayout, 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[] {
const out: number[] = [];
let cursor = labelRight;
for (const w of widths) {
cursor += gap + w;
out.push(cursor);
}
return out;
}
const base = (over: Partial<ChipFitInput> = {}): ChipFitInput => {
const gap = 6;
const labelRight = 60;
const widths = [50, 50, 50]; // 3 chips
return {
n: 3,
labelRight,
chipRights: rights(labelRight, widths, gap),
plusW: 40,
clearW: 6 + 60,
avail: 1000,
gap,
narrowPx: 420,
...over,
};
};
describe("decideChipLayout", () => {
it("shows all chips when the whole set plus Clear all fits", () => {
expect(decideChipLayout(base({ avail: 1000 }))).toEqual({ mode: "all", visible: 3 });
});
it("empty chip set is a no-op 'all' with 0 visible", () => {
expect(decideChipLayout(base({ n: 0, chipRights: [] }))).toEqual({ mode: "all", visible: 0 });
});
it("collapses to a single count pill on a narrow lane when overflowing", () => {
// last chip right edge is ~228; avail below NARROW and below that -> count.
expect(decideChipLayout(base({ avail: 200, narrowPx: 420 }))).toEqual({
mode: "count",
visible: 0,
});
});
it("fits as many chips as leave room for the +N pill on a wide-but-tight lane", () => {
// chip right edges: 116, 172, 228; clearW 66. all-fit needs 228+66=294 <= avail; keep avail below
// that (so not 'all') but >= narrowPx (so not 'count'). Loop reserves gap(6)+plus(40)=46 per chip:
// chip2 228+46=274 > 250 -> dropped; chip1 218 <= 250 -> visible 2.
expect(decideChipLayout(base({ avail: 250, narrowPx: 200 }))).toEqual({
mode: "some",
visible: 2,
});
});
it("reserves the +N pill width so a later chip is dropped when it wouldn't fit beside it", () => {
// Two chips of width 100 after a label, gap 6, plus pill 40. Right edges: 166, 272. avail 300,
// narrowPx 100 (so 300 >= narrow -> 'some'): chip0 166+46=212 <= 300 ok; chip1 272+46=318 > 300
// -> dropped. visible 1.
const input = base({
n: 2,
chipRights: rights(60, [100, 100], 6),
plusW: 40,
clearW: 6 + 60,
avail: 300,
gap: 6,
narrowPx: 100,
});
expect(decideChipLayout(input)).toEqual({ mode: "some", visible: 1 });
});
it("falls back to the count pill when not even one chip fits beside the +N pill", () => {
// Wide-ish lane (>= narrow) but a single very wide chip: label 60, chip width 500 -> right 566.
// avail 430 >= narrow(420); all-fit 566+clearW > 430; loop: 566+6+40 > 430 -> k=0; labelRight
// 60+6+40=106 <= 430 so NOT count by that guard... use narrow-avail instead: keep avail 430 but
// make label+gap+plus exceed it to force count.
const input = base({
n: 1,
labelRight: 400,
chipRights: [966],
plusW: 40,
clearW: 0,
avail: 430,
gap: 6,
narrowPx: 420,
});
// label 400 + gap 6 + plus 40 = 446 > 430 and k===0 -> count.
expect(decideChipLayout(input)).toEqual({ mode: "count", visible: 0 });
});
});
+53
View File
@@ -0,0 +1,53 @@
// 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.
export interface ChipFitInput {
/** Number of chips. */
n: number;
/** Right edge (offsetLeft + offsetWidth) of the leading label, in the measurer. */
labelRight: number;
/** Right edge of each chip, in the measurer (index-aligned to the chips). */
chipRights: number[];
/** Width of the "+N" overflow pill. */
plusW: number;
/** Width the trailing "Clear all" button needs INCLUDING its leading gap (0 when n <= 1, since it
* only renders with more than one chip). Reserved only in the "all" branch, which renders it. */
clearW: number;
/** Available lane width. */
avail: number;
/** The real (rem-scaled) inter-item gap. */
gap: number;
/** At or below this lane width an overflow collapses to a single count pill instead of "+N". */
narrowPx: number;
}
export type ChipFit = { mode: "all" | "some" | "count"; visible: number };
export function decideChipLayout(p: ChipFitInput): ChipFit {
const { n, labelRight, chipRights, plusW, clearW, avail, gap, narrowPx } = p;
if (n <= 0) return { mode: "all", visible: 0 };
// Do all chips (plus the Clear all button it renders) fit? Use the last chip's right edge.
const allRight = chipRights[n - 1] ?? labelRight;
if (allRight + clearW <= avail) return { mode: "all", visible: n };
// Overflow. On a narrow lane, collapse everything into one count pill.
if (avail < narrowPx) return { mode: "count", visible: 0 };
// Otherwise fit as many chips as leave room for the "+N" pill after them.
let k = 0;
for (let i = 0; i < n; i++) {
const right = chipRights[i] ?? 0;
if (right + gap + plusW <= avail) k = i + 1;
else break;
}
// If not even one chip fits beside the pill, fall back to the count pill.
if (k === 0 && labelRight + gap + plusW > avail) return { mode: "count", visible: 0 };
return { mode: "some", visible: k };
}
+26 -24
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { applyDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
import { selectDateChip, deriveDateKey, type DatePreset } from "./dateFilter";
import { type FeedFilters } from "./api";
const PRESETS: DatePreset[] = [
@@ -12,7 +12,7 @@ const PRESETS: DatePreset[] = [
const base = (over: Partial<FeedFilters> = {}): FeedFilters => ({
tags: [],
tagMode: "or",
tagModes: { language: "or", topic: "or", other: "or" },
q: "",
sort: "date",
scope: "my",
@@ -48,41 +48,43 @@ describe("deriveDateKey", () => {
});
});
describe("applyDateChip", () => {
describe("selectDateChip", () => {
it("picking a preset sets maxAgeDays and clears any custom range", () => {
const change = applyDateChip(
"7",
base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }),
false,
);
expect(change.customDates).toBeUndefined();
const change = selectDateChip("7", base({ dateFrom: "2026-01-01", dateTo: "2026-02-01" }));
expect(change.customOpen).toBe(false);
expect(change.filters).toMatchObject({ maxAgeDays: 7, dateFrom: undefined, dateTo: undefined });
});
it("re-picking the active preset toggles it off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), false);
expect(change.filters?.maxAgeDays).toBeUndefined();
it("picking a preset always sets it (no toggle-off) even when it's already active", () => {
const change = selectDateChip("7", base({ maxAgeDays: 7 }));
expect(change.filters.maxAgeDays).toBe(7);
expect(change.customOpen).toBe(false);
});
it("a preset is not 'active' while a custom range is open, so it sets rather than toggling off", () => {
const change = applyDateChip("7", base({ maxAgeDays: 7 }), true);
expect(change.filters?.maxAgeDays).toBe(7);
it("'any' clears the preset and any custom range", () => {
const change = selectDateChip("any", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
expect(change.filters).toMatchObject({
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
});
expect(change.customOpen).toBe(false);
});
it("opening Custom sets the flag and clears a preset's maxAgeDays", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), false);
expect(change.customDates).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined });
it("an empty key behaves like 'any'", () => {
const change = selectDateChip("", base({ maxAgeDays: 7 }));
expect(change.filters.maxAgeDays).toBeUndefined();
expect(change.customOpen).toBe(false);
});
it("closing Custom clears the flag and leaves filters untouched", () => {
const change = applyDateChip("custom", base({ maxAgeDays: 7 }), true);
expect(change.customDates).toBe(false);
expect(change.filters).toBeUndefined();
it("opening Custom sets the flag and clears a preset's maxAgeDays but keeps a typed range", () => {
const change = selectDateChip("custom", base({ maxAgeDays: 7, dateFrom: "2026-01-01" }));
expect(change.customOpen).toBe(true);
expect(change.filters).toMatchObject({ maxAgeDays: undefined, dateFrom: "2026-01-01" });
});
it("preserves unrelated filter fields when picking a preset", () => {
const change = applyDateChip("30", base({ q: "hello", tags: [1, 2] }), false);
const change = selectDateChip("30", base({ q: "hello", tags: [1, 2] }));
expect(change.filters).toMatchObject({ q: "hello", tags: [1, 2], maxAgeDays: 30 });
});
});
+24 -26
View File
@@ -1,51 +1,49 @@
import { type FeedFilters } from "./api";
// Pure date-filter selection logic for the feed sidebar's "Upload date" chip row, lifted out of the
// Pure date-filter selection logic for the feed sidebar's "Upload date" selector, lifted out of the
// component so the single-select derivation + the preset/custom dispatch can be unit-tested without
// mounting Sidebar (same split as columnSort.ts for sort). The row is single-select across the day
// presets plus a "Custom" range chip.
// mounting Sidebar (same split as columnSort.ts for sort). The control is a single-select dropdown
// across an "Any time" clear, the day presets, and a "Custom range" (reveal-on-select from/to).
export interface DatePreset {
days: number;
key: string;
}
/** Which date chip is active: a preset's day-count (as a string), "custom", or "" (none). A custom
* range (the editor is open, or a from/to is set) owns the selection; otherwise a preset matches only
* when no custom range is active; a maxAge that matches no preset (e.g. a shared-link value) is none. */
/** Which date option is active: a preset's day-count (as a string), "custom", or "" (none / "Any
* time"). A custom range (the reveal is open, or a from/to is set) owns the selection; otherwise a
* preset matches only when no custom range is active; a maxAge that matches no preset (e.g. a
* shared-link value) is none. */
export function deriveDateKey(
filters: Pick<FeedFilters, "maxAgeDays" | "dateFrom" | "dateTo">,
customDates: boolean,
customOpen: boolean,
presets: readonly DatePreset[],
): string {
if (customDates || filters.dateFrom || filters.dateTo) return "custom";
if (customOpen || filters.dateFrom || filters.dateTo) return "custom";
const preset = presets.find((p) => filters.maxAgeDays === p.days);
return preset ? String(preset.days) : "";
}
/** The state change when a date chip is picked. A preset is single-select with toggle-off (re-picking
* the active preset clears it) and clears any custom range; "custom" toggles the range editor and,
* when opened, clears a preset. Returns the next `filters` (only when they change) and/or the next
* `customDates` flag (only when it changes) so the caller applies exactly what moved. */
export function applyDateChip(
/** The state change when a date option is picked from the dropdown. Unlike the old chip row there is no
* toggle-off — "Any time" is the explicit clear. A preset sets maxAge and clears any custom range;
* "custom" opens the reveal and clears the preset (keeping any typed range); "any"/"" clears everything.
* Returns the next `filters` and the next `customOpen` flag so the caller applies both. */
export function selectDateChip(
key: string,
filters: FeedFilters,
customDates: boolean,
): { filters?: FeedFilters; customDates?: boolean } {
): { filters: FeedFilters; customOpen: boolean } {
if (key === "custom") {
const next = !customDates;
return next
? { customDates: true, filters: { ...filters, maxAgeDays: undefined } }
: { customDates: false };
return { filters: { ...filters, maxAgeDays: undefined }, customOpen: true };
}
if (key === "any" || key === "") {
return {
filters: { ...filters, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined },
customOpen: false,
};
}
const days = Number(key);
const activePreset = filters.maxAgeDays === days && !customDates;
return {
filters: {
...filters,
maxAgeDays: activePreset ? undefined : days,
dateFrom: undefined,
dateTo: undefined,
},
filters: { ...filters, maxAgeDays: days, dateFrom: undefined, dateTo: undefined },
customOpen: false,
};
}
+1 -1
View File
@@ -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: ["savedviews", "date", "channel", "tags", "language", "topic"],
feed: ["date", "channel", "tags", "language", "topic"],
plex: [
"playlists",
"watch",
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from "vitest";
import { preferSticky } from "./savedViews";
const v = (id: number) => ({ id, name: `v${id}` });
describe("preferSticky", () => {
it("returns null when nothing matches", () => {
expect(preferSticky([], 3)).toBeNull();
expect(preferSticky([], null)).toBeNull();
});
it("returns the only match regardless of stickyId", () => {
expect(preferSticky([v(5)], null)?.id).toBe(5);
expect(preferSticky([v(5)], 99)?.id).toBe(5);
});
it("prefers the sticky view among several identical-filter matches", () => {
// A (id 1) is earlier in the list, B (id 2) was explicitly applied (sticky).
expect(preferSticky([v(1), v(2)], 2)?.id).toBe(2);
});
it("falls back to the first match when the sticky id isn't among the matches", () => {
expect(preferSticky([v(1), v(2)], 99)?.id).toBe(1);
expect(preferSticky([v(1), v(2)], null)?.id).toBe(1);
});
});
+12
View File
@@ -0,0 +1,12 @@
// Pure selection helpers for saved ("personal") views, split out of SavedViewsProvider so the
// tie-break logic is unit-testable without mounting the provider (its React Query / context deps).
/** Among the views whose filters match the live filters, pick the one to treat as active: prefer the
* explicitly-applied ("sticky") view when several capture identical filters, so apply(B) isn't
* silently re-attributed to an earlier duplicate A; otherwise the first match. Null if none match. */
export function preferSticky<T extends { id: number }>(
matches: T[],
stickyId: number | null,
): T | null {
return matches.find((v) => v.id === stickyId) ?? matches[0] ?? null;
}
+21 -2
View File
@@ -12,7 +12,7 @@ import {
filtersToParams,
paramsToFilters,
} from "./urlState";
import type { FeedFilters } from "./api";
import { DEFAULT_TAG_MODES, type FeedFilters } from "./api";
function setLocation(search: string) {
window.history.replaceState(null, "", `/app${search}`);
@@ -23,7 +23,7 @@ function queryOf(url: string): URLSearchParams {
const DEFAULTS: FeedFilters = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
@@ -121,4 +121,23 @@ describe("filterParamUpdates", () => {
expect(back.scope).toBe("all");
expect(back.includeShorts).toBe(true);
});
it("round-trips per-category tag modes (only 'and' is emitted)", () => {
const f: FeedFilters = {
...DEFAULTS,
tags: [1, 2],
tagModes: { language: "and", topic: "and", other: "or" },
};
const params = filtersToParams(f);
expect(params.get("tagmode_language")).toBe("and");
expect(params.get("tagmode_topic")).toBe("and");
expect(params.has("tagmode_other")).toBe(false); // 'or' is the default, not emitted
const back = paramsToFilters(params, DEFAULTS);
expect(back.tagModes).toEqual({ language: "and", topic: "and", other: "or" });
});
it("migrates a legacy global ?tagmode=and into the topic slot only", () => {
const back = paramsToFilters(new URLSearchParams("tagmode=and"), DEFAULTS);
expect(back.tagModes).toEqual({ language: "or", topic: "and", other: "or" });
});
});
+14 -4
View File
@@ -4,7 +4,7 @@
// bookmarkable/shareable — the old "Share view" workaround (and the whole-query stripUrlParams) is
// retired. Only non-default values are emitted so URLs stay clean. NOTE: the browser TAB title stays
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import type { FeedFilters } from "./api";
import { TAG_CATEGORIES, type FeedFilters } from "./api";
const KEYS = [
"q",
@@ -16,7 +16,10 @@ const KEYS = [
"shorts",
"live",
"tags",
"tagmode",
"tagmode_language",
"tagmode_topic",
"tagmode_other",
"tagmode", // legacy global (topic-only) — still recognised so old shared links resolve
"channel",
"maxage",
"from",
@@ -38,7 +41,9 @@ export function filtersToParams(f: FeedFilters): URLSearchParams {
if (f.includeShorts) p.set("shorts", "1");
if (f.includeLive) p.set("live", "1");
if (f.tags.length) p.set("tags", f.tags.join(","));
if (f.tagMode === "and") p.set("tagmode", "and");
for (const cat of TAG_CATEGORIES) {
if (f.tagModes[cat] === "and") p.set(`tagmode_${cat}`, "and");
}
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);
@@ -84,7 +89,12 @@ export function paramsToFilters(params: URLSearchParams, base: FeedFilters): Fee
.split(",")
.map((s) => Number(s))
.filter((n) => Number.isInteger(n));
if (params.has("tagmode")) f.tagMode = params.get("tagmode") === "and" ? "and" : "or";
// Per-category tag match; a legacy global `tagmode=and` (topic-only) migrates into the topic slot.
f.tagModes = {
language: params.get("tagmode_language") === "and" ? "and" : "or",
topic: params.get("tagmode_topic") === "and" || params.get("tagmode") === "and" ? "and" : "or",
other: params.get("tagmode_other") === "and" ? "and" : "or",
};
f.channelIds = (params.get("channel") ?? "").split(",").filter(Boolean);
f.maxAgeDays = num(params.get("maxage"));
f.dateFrom = params.get("from") || undefined;
+2 -2
View File
@@ -1,7 +1,7 @@
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { qk } from "./queryKeys";
import { api, type FeedFilters } from "./api";
import { api, DEFAULT_TAG_MODES, type FeedFilters } from "./api";
import { parseSort } from "./feedSort";
/** One narrowing the user has applied, with the label to show and the way to undo just this one. */
@@ -14,7 +14,7 @@ export interface ActiveFilter {
/** What the feed looks like with nothing applied — the baseline every "active" check reads against. */
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
tags: [],
tagMode: "or",
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
sort: "newest",
includeNormal: true,
+16 -13
View File
@@ -6,6 +6,7 @@ import ErrorBoundary from "./components/ErrorBoundary";
import { ConfirmProvider } from "./components/ConfirmProvider";
import { NavigationProvider } from "./components/NavigationProvider";
import { FeedFiltersProvider } from "./components/FeedFiltersProvider";
import { SavedViewsProvider } from "./components/SavedViewsProvider";
import { PlaylistsProvider } from "./components/PlaylistsProvider";
import { PlexProvider } from "./components/PlexProvider";
import { ChannelsProvider } from "./components/ChannelsProvider";
@@ -57,19 +58,21 @@ const root =
<ConfirmProvider>
<NavigationProvider>
<FeedFiltersProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
<SavedViewsProvider>
<PlaylistsProvider>
<PlexProvider>
<ChannelsProvider>
<FeedViewProvider>
<PrefsProvider>
<WizardProvider>
<App />
</WizardProvider>
</PrefsProvider>
</FeedViewProvider>
</ChannelsProvider>
</PlexProvider>
</PlaylistsProvider>
</SavedViewsProvider>
</FeedFiltersProvider>
</NavigationProvider>
</ConfirmProvider>