Files
siftlode/frontend/src/components/PersonalViewsModal.tsx
T
peter 3ef2c38eb4 feat(design): dialogs open by purpose and keep their commit row pinned (E28 S3f1)
Modal takes a required initialFocus (field / close / action), a field that
loads late takes the focus while it still rests on the close button, and
ModalFooter portals a dialog's commit row under the scrolling body from any
depth. The header and footer fades follow the body's scroll timeline, and the
viewport's interactive-widget=resizes-content keeps the footer above an
on-screen keyboard.
2026-09-27 01:33:37 +02:00

364 lines
14 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 { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BookmarkPlus, Check, Play, Save, Star, Trash2 } from "lucide-react";
import {
closestCenter,
DndContext,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
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 { IconButton } from "./ui/IconButton";
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 { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { OrderButtons } from "./ui/OrderButtons";
import { Pressable } from "./ui/Pressable";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
// reorder, or the selected one's order buttons on the right) 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, its place in the order,
// 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" : "state-layer"
}`}
>
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} />
<Pressable
selectable
onClick={onSelect}
className={`flex-1 min-w-0 truncate py-1.5 text-sm ${selected ? "text-accent" : "text-fg"}`}
>
{view.name}
</Pressable>
{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();
// The grip's keyboard pick-up (space, the arrows, space) is what its screen-reader instructions
// promise; without the sensor they described a gesture the list did not have.
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
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);
}
// The non-drag way (WCAG 2.5.7): the selected view's Top / Up / Down / Bottom, beside its editor.
const selectedAt = views.findIndex((v) => v.id === selectedId);
function moveSelected(to: number) {
const ids = views.map((v) => v.id);
void reorder(arrayMove(ids, selectedAt, to));
}
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
initialFocus="close"
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
className="flex-1"
value={createName}
onChange={(e) => setCreateName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && onCreate()}
placeholder={t("views.namePlaceholder")}
/>
<IconButton
label={t("views.save")}
size="sm"
variant="primary"
onClick={onCreate}
disabled={!createName.trim()}
>
<BookmarkPlus className="h-4 w-4" />
</IconButton>
</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
className="flex-1 font-medium"
value={nameDraft}
onChange={(e) => setNameDraft(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void onRename()}
onBlur={() => void onRename()}
/>
<IconButton
variant="outline"
size="md"
onClick={() => void onRename()}
disabled={!nameChanged}
label={t("views.rename")}
>
<Check />
</IconButton>
</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) => (
<DismissibleChip
key={f.key}
size="xs"
onDismiss={f.remove}
dismissLabel={t("feed.chips.remove", { name: f.label })}
>
{f.label}
</DismissibleChip>
))}
</div>
)}
</div>
{views.length > 1 && (
<OrderButtons from={selectedAt} count={views.length} onMove={moveSelected} />
)}
<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>
<Button
variant="outline"
size="sm"
icon={<Save />}
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")
}
>
{t("views.saveCurrent")}
</Button>
<Button
variant="outline"
size="sm"
pressed={selected.is_default}
onClick={() => void toggleDefault(selected)}
icon={<Star className={selected.is_default ? "fill-current" : ""} />}
>
{t("views.default")}
</Button>
<Button
variant="danger-soft"
size="sm"
icon={<Trash2 />}
className="ml-auto"
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);
}}
>
{t("views.delete")}
</Button>
</div>
</div>
) : (
<div className="flex items-center justify-center text-sm text-muted">
{t("views.selectPrompt")}
</div>
)}
</div>
</Modal>
);
}