diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx
index 3b0cc628..5a122f70 100644
--- a/frontend/src/components/Feed.tsx
+++ b/frontend/src/components/Feed.tsx
@@ -37,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";
@@ -677,10 +678,15 @@ export default function Feed({
{toolbar}
{!channelScoped && (
-
setFilters(clearedFilters(filters))}
- />
+
+ {/* The active personal view (if any) leads the applied-filter row; ActiveFilterChips
+ brings the row's own bottom padding. */}
+
+
setFilters(clearedFilters(filters))}
+ />
+
)}
diff --git a/frontend/src/components/FilterRail.tsx b/frontend/src/components/FilterRail.tsx
index 48bf4068..deeffdc5 100644
--- a/frontend/src/components/FilterRail.tsx
+++ b/frontend/src/components/FilterRail.tsx
@@ -22,6 +22,8 @@ export default function FilterRail({
layout,
setLayout,
items,
+ iconExpanded,
+ onTitleClick,
children,
}: {
kind: PanelId;
@@ -34,6 +36,8 @@ export default function FilterRail({
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
items: PanelItem[];
+ iconExpanded?: ReactNode;
+ onTitleClick?: () => void;
children?: ReactNode;
}) {
const { t } = useTranslation();
@@ -50,6 +54,8 @@ export default function FilterRail({
onReset={() => setLayout(defaultLayout(kind))}
editHint={t("sidebar.editHint")}
actions={actions}
+ iconExpanded={iconExpanded}
+ onTitleClick={onTitleClick}
>
{children}
diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx
new file mode 100644
index 00000000..93490f9e
--- /dev/null
+++ b/frontend/src/components/PersonalViewsModal.tsx
@@ -0,0 +1,220 @@
+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 { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: view.id,
+ });
+ return (
+
+
+
+
+
+ {view.name}
+
+ {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}
+
+ ))}
+
+ )}
+
+
+
apply(selected)}
+ className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
+ >
+
+ {t("views.apply")}
+
+
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"
+ }`}
+ >
+
+ {t("views.default")}
+
+
{
+ const next = views.find((v) => v.id !== selected.id);
+ void 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"
+ >
+
+ {t("views.delete")}
+
+
+
+ ) : (
+
+ {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 (
+ <>
+
+ {trigger}
+
+
+ {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"
+ }`}
+ >
+
+ {v.name}
+ {v.id === activeViewId && }
+
+ ))}
+
+ )}
+
+
{
+ 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"
+ >
+
+ {t("views.manage")}
+
+
+
+ >
+ );
+}
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"
- />
- void save()}
- title={t("views.saveAction")}
- className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
- >
-
-
- setNaming(false)}
- title={t("views.cancel")}
- className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
- >
-
-
-
- ) : (
-
{
- 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"
- >
-
- {t("views.save")}
-
- )}
-
- );
-}
-
-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"
- />
- onRename(draft)}
- title={t("views.saveAction")}
- className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
- >
-
-
-
-
-
-
- );
- }
-
- return (
-
-
-
-
-
- {view.name}
-
-
-
- );
-}
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 (
+
+
+ {displayView.name}
+ {isDirty && (
+
+ )}
+
+ >
+ }
+ />
+ );
+}
diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx
index 897ab19f..c10580c8 100644
--- a/frontend/src/components/SidePanel.tsx
+++ b/frontend/src/components/SidePanel.tsx
@@ -20,6 +20,8 @@ export default function SidePanel({
onReset,
editHint,
actions,
+ iconExpanded,
+ onTitleClick,
children,
}: {
title: string;
@@ -34,6 +36,11 @@ 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;
children: ReactNode;
}) {
const { t } = useTranslation();
@@ -99,10 +106,19 @@ export default function SidePanel({
>
{pinned ? : }
- {icon}
-
- {title}
-
+ {iconExpanded ?? {icon} }
+ {onTitleClick ? (
+
+ {title}
+
+ ) : (
+
+ {title}
+
+ )}
{count > 0 && (
= {
- savedviews: !isDemo,
date: true,
channel: true,
language: languages.length > 0,
@@ -121,8 +123,6 @@ export default function Sidebar({
function widgetBody(id: string): ReactNode {
switch (id) {
- case "savedviews":
- return ;
case "channel":
return ;
case "date": {
@@ -278,10 +278,19 @@ export default function Sidebar({
setLayout={setLayout}
items={items}
actions={actions}
+ // 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" ? (
+ } />
+ ) : undefined
+ }
+ onTitleClick={!isDemo && source === "feed" ? openModal : undefined}
>
{tagManagerOpen && (
setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
)}
+ {modalOpen && source === "feed" && }
);
}
diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json
index 8a8b835b..4497730f 100644
--- a/frontend/src/i18n/locales/en/views.json
+++ b/frontend/src/i18n/locales/en/views.json
@@ -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."
}
diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json
index 018a9138..8ea38330 100644
--- a/frontend/src/i18n/locales/hu/views.json
+++ b/frontend/src/i18n/locales/hu/views.json
@@ -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."
}
diff --git a/frontend/src/lib/panelLayout.ts b/frontend/src/lib/panelLayout.ts
index ed443e84..88eebc04 100644
--- a/frontend/src/lib/panelLayout.ts
+++ b/frontend/src/lib/panelLayout.ts
@@ -16,7 +16,7 @@ export interface PanelLayout {
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
// appended by normalizeLayout, so nothing silently disappears.
const PANEL_ORDER: Record = {
- feed: ["savedviews", "date", "channel", "tags", "language", "topic"],
+ feed: ["date", "channel", "tags", "language", "topic"],
plex: [
"playlists",
"watch",
diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx
index 328b17bb..0b39bfb1 100644
--- a/frontend/src/main.tsx
+++ b/frontend/src/main.tsx
@@ -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 =
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+