From d2193ccd1b125d52c037bcc1980ee3ddb4733cb5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 03:22:03 +0200 Subject: [PATCH 01/11] fix(combobox): merged input ref so the field is typeable by mouse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The carried only floating-ui's reference ref, so the component's own inputRef stayed null. The field container's onMouseDown then preventDefault()-ed every press (its `e.target !== inputRef.current` guard was always true) and the compensating inputRef.current?.focus() was a no-op — so clicking the box opened the list but never focused the input, and mouse users could not type to filter. Tab focus still worked, which is why it read as 'click, can't type'. Feed a merged callback ref that assigns the node to both floating-ui's setReference and inputRef. Fixes the Channel, Your tags, Topic, and Language sidebar filters at once (all use Combobox); the channel list's 50-row cap is now reachable past 50 via search. Single-select commit no longer re-focuses (that now actually fires and would re-open the just-closed list). Regression test presses the field chrome and asserts the input receives focus. Verified live: clicking the Channel field focuses it and typing 'cold' narrows the list to ColdFusion / wehatethecold. --- frontend/src/components/Combobox.dom.test.tsx | 13 ++++++++++ frontend/src/components/Combobox.tsx | 24 +++++++++++++++---- 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Combobox.dom.test.tsx b/frontend/src/components/Combobox.dom.test.tsx index 7fb2a690..e675a58f 100644 --- a/frontend/src/components/Combobox.dom.test.tsx +++ b/frontend/src/components/Combobox.dom.test.tsx @@ -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(); + 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(); expect(screen.queryByRole("option", { hidden: true })).toBeNull(); diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index fe459da5..963a64a1 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -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"; @@ -83,7 +83,7 @@ export default function Combobox({ if (queryProp === undefined) setQueryState(q); onQueryChange?.(q); }; - const inputRef = useRef(null); + const inputRef = useRef(null); const fade = useScrollFade(); // Remember each value's display label as options flow through, so a selected chip can name itself @@ -100,6 +100,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 +142,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 +188,7 @@ export default function Combobox({ ))} { setQuery((e.target as HTMLInputElement).value); From af08a7850b308e54d63f3af6592e6856c10245d6 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 22 Aug 2026 03:25:49 +0200 Subject: [PATCH 02/11] feat(feed): guide a hidden-channel empty result to the Library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filtering by a channel under the Mine scope returns nothing when that channel's subscription is hidden (or you don't subscribe to it) — its videos only live in the shared Library, since the Mine feed excludes hidden subscriptions by design. Previously this left a bare 'No videos match these filters', which reads as a bug when you know you're subscribed. Add an empty-state hint + a 'View in Library' button (switches scope to all) when there's a channel filter under Mine and zero results. Bilingual EN/HU. Verified live: ?channel=&show=all under Mine shows the hint; the button switches to Library and surfaces the 36 videos. --- frontend/src/components/Feed.tsx | 16 ++++++++++++++++ frontend/src/i18n/locales/en/feed.json | 2 ++ frontend/src/i18n/locales/hu/feed.json | 2 ++ 3 files changed, 20 insertions(+) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 2d46c475..3b0cc628 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -13,6 +13,7 @@ import { Grid3x3, Info, LayoutGrid, + Library, List, RefreshCw, Rows3, @@ -686,6 +687,21 @@ export default function Feed({ {items.length === 0 ? (

{t("feed.noMatches")}

+ {/* 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" && ( +
+

{t("feed.channelScopeHint")}

+ +
+ )} {filters.q.trim() && !isDemo && (
+ {/* 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 && ( +
+ {t("sidebar.match")} +
+ {(["or", "and"] as const).map((m) => ( + + ))} +
+
+ )} ); diff --git a/frontend/src/components/FeedFiltersProvider.tsx b/frontend/src/components/FeedFiltersProvider.tsx index 3156533a..e59cb362 100644 --- a/frontend/src/components/FeedFiltersProvider.tsx +++ b/frontend/src/components/FeedFiltersProvider.tsx @@ -9,7 +9,7 @@ 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, 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"; @@ -32,7 +32,7 @@ import { useNavigation } from "./NavigationProvider"; const DEFAULT_FILTERS: FeedFilters = { tags: [], - tagMode: "or", + tagModes: DEFAULT_TAG_MODES, channelIds: [], q: "", sort: "newest", diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx index ac61b64f..5ce32f65 100644 --- a/frontend/src/components/SavedViewsWidget.tsx +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -18,7 +18,13 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import { api, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api"; +import { + api, + getActiveAccount, + coerceTagModes, + type FeedFilters, + type SavedView, +} from "../lib/api"; import { filtersToParams } from "../lib/urlState"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS, writeJSON } from "../lib/storage"; @@ -37,6 +43,8 @@ const asFilters = (raw: FeedFilters): FeedFilters => ({ ...raw, tags: raw.tags ?? [], channelIds: raw.channelIds ?? [], + // Migrate the legacy global `tagMode` (older blobs) into the per-category shape. + tagModes: coerceTagModes(raw), }); function syncDefaultMirror(views: SavedView[]): void { diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 1b549101..24b46963 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { Pencil, SlidersHorizontal } from "lucide-react"; -import { api, type FeedFilters, type Tag } from "../lib/api"; +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"; @@ -14,7 +14,6 @@ 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 Combobox, { type ComboboxOption } from "./Combobox"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; @@ -97,6 +96,9 @@ 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. @@ -200,31 +202,22 @@ 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 ( -
- setFilters({ ...filters, tagMode })} - /> - setTagSelection(topics, next)} - emptyText={t("sidebar.noMatchingTags")} - /> -
+ setTagSelection(topics, next)} + emptyText={t("sidebar.noMatchingTags")} + matchMode={filters.tagModes.topic} + onMatchModeChange={(m) => setTagMode("topic", m)} + /> ); } case "tags": { @@ -236,6 +229,8 @@ export default function Sidebar({ selected={selectedIn(userTags)} onChange={(next) => setTagSelection(userTags, next)} emptyText={t("sidebar.noMatchingTags")} + matchMode={filters.tagModes.other} + onMatchModeChange={(m) => setTagMode("other", m)} /> + + {view.is_default && } + {active && } + + ); +} + +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(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 ( + +
+ {/* Left: the list + create */} +
+
+ {views.length === 0 && ( +

{t("views.empty")}

+ )} + + v.id)} + strategy={verticalListSortingStrategy} + > + {views.map((v) => ( + setSelectedId(v.id)} + /> + ))} + + +
+
+ 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" + /> + +
+
+ + {/* Right: the selected view's detail */} + {selected ? ( +
+ { + 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" + /> +
+
+ {t("views.capturedFilters")} +
+ {captured.length === 0 ? ( +

{t("views.capturedNone")}

+ ) : ( +
+ {captured.map((f) => ( + + {f.label} + + ))} +
+ )} +
+
+ + + +
+
+ ) : ( +
+ {t("views.selectPrompt")} +
+ )} +
+
+ ); +} diff --git a/frontend/src/components/SavedViewsPopover.tsx b/frontend/src/components/SavedViewsPopover.tsx new file mode 100644 index 00000000..fe2a3296 --- /dev/null +++ b/frontend/src/components/SavedViewsPopover.tsx @@ -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 ( + <> + + + {views.length === 0 ? ( +

{t("views.empty")}

+ ) : ( +
+ {views.map((v) => ( + { + 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" + }`} + > + + ))} +
+ )} +
+ { + 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" + > + +
+
+ + ); +} diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx new file mode 100644 index 00000000..78bfe283 --- /dev/null +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -0,0 +1,176 @@ +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 { 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; + rename: (id: number, name: string) => Promise; + toggleDefault: (v: SavedView) => Promise; + remove: (v: SavedView) => Promise; + reorder: (nextIds: number[]) => Promise; +} + +const Ctx = createContext(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(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( + () => views.find((v) => keyOf(v.filters) === currentKey) ?? null, + [views, currentKey], + ); + // 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, failMsg: string): Promise { + 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; + if (stickyId === v.id) setStickyId(null); + await 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( + qk.savedViews(), + nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), + ); + await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); + }, + }; + + return {children}; +} + +export function useSavedViews(): SavedViewsContext { + const ctx = useContext(Ctx); + if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider"); + return ctx; +} diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx deleted file mode 100644 index 5ce32f65..00000000 --- a/frontend/src/components/SavedViewsWidget.tsx +++ /dev/null @@ -1,352 +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, - coerceTagModes, - 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 ?? [], - // Migrate the legacy global `tagMode` (older blobs) into the per-category shape. - tagModes: coerceTagModes(raw), -}); - -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(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, failMsg: string): Promise { - 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( - 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 ( -
- {views.length === 0 && !naming && ( -

{t("views.empty")}

- )} - - - v.id)} strategy={verticalListSortingStrategy}> -
- {views.map((v) => ( - onApply(asFilters(v.filters))} - onStartRename={() => setRenameId(v.id)} - onRename={(name) => rename(v.id, name)} - onCancelRename={() => setRenameId(null)} - onToggleDefault={() => toggleDefault(v)} - onDelete={() => remove(v)} - /> - ))} -
-
-
- - {naming ? ( -
- 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" - /> - - -
- ) : ( - - )} -
- ); -} - -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 ( -
- 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" - /> - - -
- ); - } - - return ( -
- - -
- - - -
-
- ); -} diff --git a/frontend/src/components/SelectedViewChip.tsx b/frontend/src/components/SelectedViewChip.tsx new file mode 100644 index 00000000..65aa98f3 --- /dev/null +++ b/frontend/src/components/SelectedViewChip.tsx @@ -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 ( + +