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,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user