Files
siftlode/frontend/src/components/PersonalViewsModal.tsx
T
peter e90603d4db fix(feed): deep-review remediations for the filter-bar overhaul
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.
2026-08-22 06:33:48 +02:00

223 lines
8.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}