Correctness / behaviour:
- Move the Personal Views modal from inside the collapsible filter rail to the
feed page (moduleRegistry FeedPage), so opening it from the always-visible
toolbar view chip works while the rail is unpinned (its default) instead of
no-op'ing and latching modalOpen into a later stale pop-open.
- Migrate a persisted legacy global `tagMode` into per-category `tagModes` on the
localStorage seed paths too (loadAccountFilters), matching the saved-view and
URL paths — an upgrading user's topic any/all no longer silently reverts to 'or'.
- DateFilterSelect: reset the custom reveal on an external clear while the popover
is closed (guard on !open, not maxAgeDays), so the trigger reverts to "Any time"
instead of lingering on "Custom range" with empty fields.
- PersonalViewsModal delete: only advance the detail selection when the delete
actually happened (remove() now resolves a boolean) — cancelling the confirm no
longer switches the selected view.
- SavedViewsProvider: prefer the explicitly-applied (sticky) view among several
that capture identical filters, so apply(B) isn't re-attributed to an earlier
duplicate A (wrong "modified" chip name).
A11y:
- Give the clickable panel title a descriptive accessible name (titleActionLabel,
wired to "Personal views") instead of announcing only "Filters".
- Give the "+N" overflow pill an aria-label ("N more filters").
- Use t("views.reorder") for the saved-view drag handle instead of a hardcoded
English "reorder" literal.
Also extract the chip-fit decision into a pure, testable lib/chipOverflow.ts and
measure the inter-chip gap live (rem-safe) instead of a frozen 6px constant.
223 lines
8.6 KiB
TypeScript
223 lines
8.6 KiB
TypeScript
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 { t } = useTranslation();
|
||
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={t("views.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={async () => {
|
||
const next = views.find((v) => v.id !== selected.id);
|
||
// Only move the selection if the delete actually happened (remove awaits a confirm
|
||
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
|
||
if (await 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>
|
||
);
|
||
}
|