Closes the burn-down. Hook suppressions 113 -> 34, `exhaustive-deps` 45 -> 2 (the target was <10), and every one of the 34 survivors is an inline directive NAMING the invariant it protects. The suppressions file is deleted, because a bulk file hides the next violation instead of answering it — and it cannot hold a warning-level rule at all. The sprint plan's three verdicts missed the answer that turned out to be the most common one: adjust the state DURING RENDER. React re-runs the component and discards that pass rather than committing it, so the stale frame an effect-based reset paints does not exist. Applied to the Plex player's up-next card, the playlist view reset, the feed's optimistic overrides, the channel banner's retry count, the download center's tab guard, the Plex search highlight, the comfort-pin latch, Welcome's per-view outcome and the card pager's page clamp. It is legal only for a component's own state — PlexBrowse's language commit resets PlexProvider's filters, so that one stays an effect and says why. Mirrors of a prop or query became derived values or `draft ?? server`: - `downloadRow` had ui/Avatar's defect exactly — a boolean "poster failed" that an effect reset, in rows recycled down a virtualized list, so one job's missing poster painted over the next one's for a frame. Keyed by URL now. - Scheduler's two number editors held a mirror an effect re-synced, on a page that POLLS — the shape a poll can overwrite mid-edit. A draft while editing, the server's value otherwise; six tests where there were none, the one behavioural change (the save button stops being dirty at commit rather than at the echo) mutation-checked. - SetupWizard's "no token" and WatchPage's "gone" are facts about the URL, so they are seeded and derived rather than pushed into state by an effect that had to commit first. Elsewhere: PlexInfo's rating chip was a component declared during render — a new component type every render, so React remounted the subtree and dropped the focus on its filter button. Hoisted. Feed's undo action and PlexPlayer's session reclaim both re-entered their own useCallback, which the checker reads as a forward reference; both go through the ref idiom this file already uses. BootGate's dep list was load-bearing for correctness — a re-run meant `location.reload()` — so the "already adopted THIS identity" comparison now enforces it in code, with a test that fails without it (the existing reload tests all install their spy too late to catch it). One correction to the batch before this: keying ChatThread's and Messages' decrypt passes on `q.data` instead of `q.dataUpdatedAt` was wrong. react-query's structural sharing makes it safe in production, but the component must not melt down when something hands back a fresh object per render — and Messages.keyboard.dom.test does exactly that, so the effect span the suite. Both are back on the fetch as the trigger with the payload behind a ref, which is honest AND loop-proof; the replaced comment had warned about precisely this. CLAUDE.md gains four canonical-patterns rows for the above, and its "React 18" line is now true.
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
|
||
import {
|
||
closestCenter,
|
||
DndContext,
|
||
PointerSensor,
|
||
useSensor,
|
||
useSensors,
|
||
type DragEndEvent,
|
||
} from "@dnd-kit/core";
|
||
import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
|
||
import { CSS } from "@dnd-kit/utilities";
|
||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||
import Modal from "./ui/Modal";
|
||
import { Button } from "./ui/Button";
|
||
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||
import { useUndoable } from "../lib/useUndoable";
|
||
import UndoToolbar from "./ui/UndoToolbar";
|
||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||
|
||
// 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
|
||
// (renamable), its captured filters as REMOVABLE chips (pruning writes back to the stored view,
|
||
// undoable), its default flag, apply, save-current, 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 } = useSortableItem({
|
||
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 {
|
||
source,
|
||
views,
|
||
activeViewId,
|
||
apply,
|
||
create,
|
||
rename,
|
||
updateFilters,
|
||
saveCurrent,
|
||
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;
|
||
// Two independent name fields: the left one names a NEW view; the right one renames the selected one.
|
||
const [createName, setCreateName] = useState("");
|
||
const [nameDraft, setNameDraft] = useState("");
|
||
|
||
// A local, undoable draft of the SELECTED view's captured filters. Removing a chip prunes the draft
|
||
// and — via onApply — writes the pruned set straight back to the stored view; Undo/Redo re-persist
|
||
// the same way. The draft is the single source of truth for the chip row, so a chip-prune refetch
|
||
// (which returns the value we just wrote) never clobbers the undo history. We only re-seed it on an
|
||
// external change of the visible filters: switching the selection, or Save-current below.
|
||
const filtersDraft = useUndoable<Record<string, unknown>>(selected?.filters ?? {}, {
|
||
onApply: (f) => {
|
||
if (selected) void updateFilters(selected.id, f);
|
||
},
|
||
});
|
||
const resetDraft = filtersDraft.reset;
|
||
// Re-seed only when the SELECTION changes, not on every same-view refetch — a refetch returns the
|
||
// value we just wrote, and re-seeding on it would throw away the prune's undo history. The chosen
|
||
// view is therefore read through a ref: `selectedId` is the trigger, `selected` is only the data.
|
||
const selectedRef = useRef(selected);
|
||
selectedRef.current = selected;
|
||
useEffect(() => {
|
||
const view = selectedRef.current;
|
||
resetDraft(view?.filters ?? {});
|
||
setNameDraft(view?.name ?? "");
|
||
}, [selectedId, resetDraft]);
|
||
|
||
// The selected view's captured filters, as the same human labels its module's chip row shows, read
|
||
// from the undoable draft. The feed hook runs unconditionally (fed empty filters off-feed); Plex
|
||
// uses the pure builder. A plex view captures its rail filters, not the watch-state (show="all").
|
||
const noop = () => {};
|
||
const setDraftFeed = (f: FeedFilters) =>
|
||
filtersDraft.set(f as unknown as Record<string, unknown>);
|
||
const feedCaptured = useActiveFilters(
|
||
source === "feed" && selected
|
||
? asFilters(filtersDraft.value as unknown as FeedFilters)
|
||
: ({ tags: [], channelIds: [] } as never),
|
||
source === "feed" && selected ? setDraftFeed : noop,
|
||
);
|
||
const patchDraftPlex = (p: Partial<PlexFilters>) =>
|
||
filtersDraft.set({
|
||
...coercePlexFilters(filtersDraft.value),
|
||
...p,
|
||
} as unknown as Record<string, unknown>);
|
||
const captured =
|
||
source === "plex" && selected
|
||
? buildPlexActiveFilters(
|
||
coercePlexFilters(filtersDraft.value),
|
||
"all",
|
||
patchDraftPlex,
|
||
noop,
|
||
t,
|
||
)
|
||
: feedCaptured;
|
||
|
||
const dndA11y = useSortableA11y((id) => ({
|
||
title: views.find((v) => v.id === id)?.name ?? "",
|
||
pos: views.findIndex((v) => v.id === id) + 1,
|
||
total: views.length,
|
||
}));
|
||
|
||
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 = createName.trim();
|
||
if (!name) return;
|
||
if (await create(name)) setCreateName("");
|
||
}
|
||
|
||
const nameChanged = !!selected && nameDraft.trim() !== "" && nameDraft.trim() !== selected.name;
|
||
async function onRename() {
|
||
if (!selected || !nameChanged) return;
|
||
await rename(selected.id, nameDraft.trim());
|
||
}
|
||
|
||
// Save-current is a no-op when the live filters already equal this exact view (it's the active one).
|
||
const saveCurrentDisabled = selected != null && selected.id === activeViewId;
|
||
|
||
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}
|
||
accessibility={dndA11y}
|
||
>
|
||
<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={createName}
|
||
onChange={(e) => setCreateName(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
|
||
variant="primary"
|
||
size="sm"
|
||
iconOnly
|
||
icon={<BookmarkPlus className="h-4 w-4" />}
|
||
label={t("views.save")}
|
||
onClick={onCreate}
|
||
disabled={!createName.trim()}
|
||
className="shrink-0"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right: the selected view's detail */}
|
||
{selected ? (
|
||
<div className="flex flex-col gap-3">
|
||
<div className="flex items-center gap-1.5">
|
||
<input
|
||
value={nameDraft}
|
||
onChange={(e) => setNameDraft(e.target.value)}
|
||
onKeyDown={(e) => e.key === "Enter" && void onRename()}
|
||
onBlur={() => void onRename()}
|
||
className="min-w-0 flex-1 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"
|
||
/>
|
||
<FocusSafeButton
|
||
onClick={() => void onRename()}
|
||
disabled={!nameChanged}
|
||
title={t("views.rename")}
|
||
aria-label={t("views.rename")}
|
||
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-30"
|
||
>
|
||
<Check className="h-4 w-4" />
|
||
</FocusSafeButton>
|
||
</div>
|
||
<div>
|
||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||
<span className="text-2xs font-semibold uppercase tracking-wider text-muted">
|
||
{t("views.capturedFilters")}
|
||
</span>
|
||
{(filtersDraft.canUndo || filtersDraft.canRedo) && (
|
||
<UndoToolbar
|
||
canUndo={filtersDraft.canUndo}
|
||
canRedo={filtersDraft.canRedo}
|
||
onUndo={filtersDraft.undo}
|
||
onRedo={filtersDraft.redo}
|
||
/>
|
||
)}
|
||
</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="group inline-flex items-center gap-1 rounded-full bg-accent/10 py-0.5 pl-2 pr-1 text-2xs text-accent"
|
||
>
|
||
{f.label}
|
||
<button
|
||
onClick={f.remove}
|
||
title={t("feed.chips.remove", { name: f.label })}
|
||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||
className="rounded-full p-0.5 opacity-60 transition hover:bg-accent/20 hover:opacity-100"
|
||
>
|
||
<X className="h-3 w-3" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="mt-auto flex flex-wrap items-center gap-2 border-t border-border/60 pt-3">
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
icon={<Play className="h-3.5 w-3.5" />}
|
||
onClick={() => apply(selected)}
|
||
>
|
||
{t("views.apply")}
|
||
</Button>
|
||
<FocusSafeButton
|
||
onClick={async () => {
|
||
const saved = await saveCurrent(selected.id);
|
||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||
// reflects the save; clears the now-stale undo history.
|
||
if (saved) resetDraft(saved);
|
||
}}
|
||
disabled={saveCurrentDisabled}
|
||
title={
|
||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||
}
|
||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-40"
|
||
>
|
||
<Save className="h-3.5 w-3.5" />
|
||
{t("views.saveCurrent")}
|
||
</FocusSafeButton>
|
||
<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>
|
||
);
|
||
}
|