import { useEffect, 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, useSortable, 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 "./Modal"; import { Button } from "./ui/Button"; import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useActiveFilters } from "../lib/useActiveFilters"; import { buildPlexActiveFilters } from "./usePlexActiveFilters"; import { useUndoable } from "../lib/useUndoable"; import UndoToolbar from "./UndoToolbar"; // 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 } = useSortable({ id: view.id, }); return (
{view.is_default && } {active && }
); } 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(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>(selected?.filters ?? {}, { onApply: (f) => { if (selected) void updateFilters(selected.id, f); }, }); const resetDraft = filtersDraft.reset; useEffect(() => { resetDraft(selected?.filters ?? {}); setNameDraft(selected?.name ?? ""); // Re-seed only when the selection changes (not on every same-view refetch), so pruning keeps its // undo history. Save-current re-seeds explicitly in its own handler. // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId]); // 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; 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 (
{/* 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")} 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" />
{/* Right: the selected view's detail */} {selected ? (
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" />
{t("views.capturedFilters")} {(filtersDraft.canUndo || filtersDraft.canRedo) && ( )}
{captured.length === 0 ? (

{t("views.capturedNone")}

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