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 (
{view.name}
{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();
// 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 [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;
// 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);
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 (
}
onClick={() => apply(selected)}
>
{t("views.apply")}
}
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")}
}
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")}