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:
2026-08-22 04:03:58 +02:00
parent 829cadf5fb
commit 7ebbf45921
13 changed files with 598 additions and 380 deletions
@@ -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 (
<div
ref={setNodeRef}
style={{ transform: CSS.Transform.toString(transform), transition }}
className={`flex items-center gap-1.5 rounded-lg pr-1.5 ${isDragging ? "opacity-60" : ""} ${
selected ? "bg-accent/15" : "hover:bg-card"
}`}
>
<button
{...attributes}
{...listeners}
aria-label="reorder"
className="cursor-grab p-1.5 text-muted hover:text-fg"
>
<GripVertical className="h-3.5 w-3.5" />
</button>
<button
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
selected ? "text-accent" : "text-fg"
}`}
>
{view.name}
</button>
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
</div>
);
}
export default function PersonalViewsModal() {
const { t } = useTranslation();
const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } =
useSavedViews();
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
const [selectedId, setSelectedId] = useState<number | null>(activeViewId ?? views[0]?.id ?? null);
const selected = views.find((v) => v.id === selectedId) ?? null;
const [draftName, setDraftName] = useState("");
// The selected view's captured filters, as the same human labels the toolbar chip row shows.
const captured = useActiveFilters(
selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never),
() => {},
);
async function onDragEnd(e: DragEndEvent) {
const { active, over } = e;
if (!over || active.id === over.id) return;
const ids = views.map((v) => v.id);
const next = arrayMove(ids, ids.indexOf(active.id as number), ids.indexOf(over.id as number));
await reorder(next);
}
async function onCreate() {
const name = draftName.trim();
if (!name) return;
if (await create(name)) setDraftName("");
}
return (
<Modal title={t("views.manageTitle")} onClose={closeModal} maxWidth="max-w-2xl">
<div className="grid min-h-[20rem] grid-cols-[minmax(0,14rem)_1fr] gap-4">
{/* Left: the list + create */}
<div className="flex flex-col border-r border-border/60 pr-4">
<div className="flex-1 space-y-1 overflow-y-auto">
{views.length === 0 && (
<p className="px-1 py-2 text-2xs leading-snug text-muted">{t("views.empty")}</p>
)}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
<SortableContext
items={views.map((v) => v.id)}
strategy={verticalListSortingStrategy}
>
{views.map((v) => (
<ViewRow
key={v.id}
view={v}
selected={v.id === selectedId}
active={v.id === activeViewId}
onSelect={() => setSelectedId(v.id)}
/>
))}
</SortableContext>
</DndContext>
</div>
<div className="mt-2 flex items-center gap-1.5 border-t border-border/60 pt-2">
<input
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")}
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<button
onClick={onCreate}
disabled={!draftName.trim()}
title={t("views.save")}
className="inline-flex shrink-0 items-center gap-1 rounded-lg btn-primary px-2.5 py-1.5 text-sm font-semibold disabled:opacity-40"
>
<BookmarkPlus className="h-4 w-4" />
</button>
</div>
</div>
{/* Right: the selected view's detail */}
{selected ? (
<div className="flex flex-col gap-3">
<input
key={selected.id}
defaultValue={selected.name}
onBlur={(e) => {
const next = e.target.value.trim();
if (next && next !== selected.name) void rename(selected.id, next);
}}
className="rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
/>
<div>
<div className="mb-1.5 text-2xs font-semibold uppercase tracking-wider text-muted">
{t("views.capturedFilters")}
</div>
{captured.length === 0 ? (
<p className="text-xs text-muted">{t("views.capturedNone")}</p>
) : (
<div className="flex flex-wrap gap-1.5">
{captured.map((f) => (
<span
key={f.key}
className="rounded-full bg-accent/10 px-2 py-0.5 text-2xs text-accent"
>
{f.label}
</span>
))}
</div>
)}
</div>
<div className="mt-auto flex items-center gap-2 border-t border-border/60 pt-3">
<button
onClick={() => apply(selected)}
className="inline-flex items-center gap-1.5 rounded-lg btn-primary px-3 py-1.5 text-sm font-semibold"
>
<Play className="h-3.5 w-3.5" />
{t("views.apply")}
</button>
<button
onClick={() => void toggleDefault(selected)}
title={t("views.default")}
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
selected.is_default
? "border-accent text-accent"
: "border-border text-muted hover:text-fg"
}`}
>
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
{t("views.default")}
</button>
<button
onClick={() => {
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"
>
<Trash2 className="h-3.5 w-3.5" />
{t("views.delete")}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-center text-sm text-muted">
{t("views.selectPrompt")}
</div>
)}
</div>
</Modal>
);
}