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.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 ( + +