feat(feed): saved views as a header popover + Personal Views modal + a view chip
Retire the standalone 'Saved views' sidebar panel. The saved-views feature now
lives in three lighter surfaces, coordinated by a new SavedViewsProvider (query +
CRUD + the active/sticky view + modal state):
- the filter panel's header ICON opens a quick-pick popover (apply a view, active
one checked, plus 'Manage views…');
- the header TITLE ('Filters') opens a new Personal Views modal — a master/detail
manager: left = the reorderable list + a save-current-filters input, right = the
selected view's name, its captured filters (shown as the same chips the toolbar
row uses), default toggle, apply, and delete;
- a SelectedViewChip leads the applied-filter row, naming the active view with a
warn-coloured 'modified' dot once the live filters diverge from it; clicking it
reopens the picker.
SidePanel/FilterRail gain optional iconExpanded + onTitleClick slots (feed-only,
hidden for the demo account); the collapsed tab keeps its plain pin behaviour.
SavedViewsWidget is deleted and dropped from the panel layout.
Verified live: icon popover lists HH + Manage; applying HH surfaces the chip and
switches scope; removing a filter lights the dirty dot; the title opens the
master/detail modal with the captured-filters summary. 374 vitest, tsc, eslint
(0 err).
This commit is contained in:
@@ -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<boolean>;
|
||||
rename: (id: number, name: string) => Promise<boolean>;
|
||||
toggleDefault: (v: SavedView) => Promise<void>;
|
||||
remove: (v: SavedView) => Promise<void>;
|
||||
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(
|
||||
() => 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<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;
|
||||
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<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;
|
||||
}
|
||||
Reference in New Issue
Block a user