import { 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 (
{view.name} {view.is_default && } {active && }
); } /** The SELECTED view's two drafts: its name (the rename field) and a local, undoable draft of its * captured filters. Removing a chip prunes the filter 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: both re-seed only when the SELECTION changes (and on Save-current, * by the caller). Adjusted DURING render against the remembered selection — an effect painted the * previous view's filters and name for a frame first. `selectedId` is the trigger, `selected` only * the data. */ function useSelectedViewDrafts( selected: SavedView | null, selectedId: number | null, updateFilters: (id: number, filters: Record) => unknown, ) { const [nameDraft, setNameDraft] = useState(""); const filtersDraft = useUndoable>(selected?.filters ?? {}, { onApply: (f) => { if (selected) void updateFilters(selected.id, f); }, }); const [seededFor, setSeededFor] = useState(undefined); if (seededFor !== selectedId) { setSeededFor(selectedId); filtersDraft.reset(selected?.filters ?? {}); setNameDraft(selected?.name ?? ""); } return { filtersDraft, nameDraft, setNameDraft }; } 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(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 { filtersDraft, nameDraft, setNameDraft } = useSelectedViewDrafts( selected, selectedId, updateFilters, ); // 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); 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) => filtersDraft.set({ ...coercePlexFilters(filtersDraft.value), ...p, } as unknown as Record); 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 (
{/* Left: the list + create */}
{views.length === 0 && (

{t("views.empty")}

)} v.id)} strategy={verticalListSortingStrategy} > {views.map((v) => ( setSelectedId(v.id)} /> ))}
setCreateName(e.target.value)} onKeyDown={(e) => e.key === "Enter" && onCreate()} placeholder={t("views.namePlaceholder")} />
{/* Right: the selected view's detail */} {selected ? (
setNameDraft(e.target.value)} onKeyDown={(e) => e.key === "Enter" && void onRename()} onBlur={() => void onRename()} /> void onRename()} disabled={!nameChanged} label={t("views.rename")} >
{t("views.capturedFilters")} {(filtersDraft.canUndo || filtersDraft.canRedo) && ( )}
{captured.length === 0 ? (

{t("views.capturedNone")}

) : (
{captured.map((f) => ( {f.label} ))}
)}
{views.length > 1 && ( )}
) : (
{t("views.selectPrompt")}
)}
); }