import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { BookmarkPlus, Check, GripVertical, Pencil, Share2, 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 { api, type FeedFilters, type SavedView } from "../lib/api"; import { filtersToParams, shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; import { LS, readJSON, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; // Compact, order-independent signature of a filter set (reuses the share serializer, which // emits only non-default values and ignores the transient shuffle seed). Used to highlight the // saved view that matches the feed's current filters. const keyOf = (f: FeedFilters) => filtersToParams(f).toString(); /** The default view's filters, or null. Read synchronously on load (App.loadInitialFilters) * from a localStorage mirror the widget keeps in sync — avoids a flash before the query loads. */ export function loadDefaultViewFilters(): FeedFilters | null { return readJSON(LS.defaultViewFilters, null); } function syncDefaultMirror(views: SavedView[]): void { const def = views.find((v) => v.is_default); writeJSON(LS.defaultViewFilters, def ? def.filters : null); } export default function SavedViewsWidget({ filters, onApply, }: { filters: FeedFilters; onApply: (f: FeedFilters) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); const viewsQuery = useQuery({ queryKey: ["saved-views"], queryFn: api.savedViews }); const views = viewsQuery.data ?? []; const [naming, setNaming] = useState(false); const [draftName, setDraftName] = useState(""); const [renameId, setRenameId] = useState(null); const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), ); // Keep the default-view mirror (read on load) in sync with the server state. useEffect(() => { if (viewsQuery.data) syncDefaultMirror(viewsQuery.data); }, [viewsQuery.data]); const refresh = () => qc.invalidateQueries({ queryKey: ["saved-views"] }); const currentKey = keyOf(filters); async function save() { const name = draftName.trim(); if (!name) return; // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. const { seed: _seed, ...clean } = filters; await api.createView(name, clean); setNaming(false); setDraftName(""); refresh(); } async function rename(id: number, name: string) { const next = name.trim(); if (next) await api.updateView(id, { name: next }); setRenameId(null); refresh(); } async function toggleDefault(v: SavedView) { await api.updateView(v.id, { is_default: !v.is_default }); refresh(); } async function remove(v: SavedView) { const ok = await confirm({ message: t("views.deleteConfirm", { name: v.name }), confirmLabel: t("views.delete"), danger: true, }); if (!ok) return; await api.deleteView(v.id); refresh(); } async function share(v: SavedView) { try { await navigator.clipboard.writeText(shareUrl(v.filters)); notify({ level: "success", message: t("views.shareCopied") }); } catch { notify({ level: "warning", message: t("sidebar.shareFailed") }); } } async function onDragEnd(e: DragEndEvent) { const { active, over } = e; if (!over || active.id === over.id) return; const ids = views.map((v) => v.id); const oldIndex = ids.indexOf(active.id as number); const newIndex = ids.indexOf(over.id as number); if (oldIndex < 0 || newIndex < 0) return; const next = arrayMove(ids, oldIndex, newIndex); // Optimistic reorder so the drop sticks visually before the round-trip. qc.setQueryData( ["saved-views"], next.map((id) => views.find((v) => v.id === id)!), ); await api.reorderViews(next); refresh(); } return (
{views.length === 0 && !naming && (

{t("views.empty")}

)} v.id)} strategy={verticalListSortingStrategy}>
{views.map((v) => ( onApply(v.filters)} onStartRename={() => setRenameId(v.id)} onRename={(name) => rename(v.id, name)} onCancelRename={() => setRenameId(null)} onToggleDefault={() => toggleDefault(v)} onShare={() => share(v)} onDelete={() => remove(v)} /> ))}
{naming ? (
setDraftName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") void save(); if (e.key === "Escape") setNaming(false); }} placeholder={t("views.namePlaceholder")} maxLength={80} className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" />
) : ( )}
); } function ViewRow({ view, active, renaming, onApply, onStartRename, onRename, onCancelRename, onToggleDefault, onShare, onDelete, }: { view: SavedView; active: boolean; renaming: boolean; onApply: () => void; onStartRename: () => void; onRename: (name: string) => void; onCancelRename: () => void; onToggleDefault: () => void; onShare: () => void; onDelete: () => void; }) { const { t } = useTranslation(); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: view.id, }); const [draft, setDraft] = useState(view.name); // Reset the edit draft to the current name each time rename mode opens (so a second rename // doesn't start from a stale value). useEffect(() => { if (renaming) setDraft(view.name); }, [renaming, view.name]); const style = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, }; if (renaming) { return (
setDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") onRename(draft); if (e.key === "Escape") onCancelRename(); }} maxLength={80} className="flex-1 min-w-0 bg-card border border-border rounded-lg px-2 py-1 text-xs outline-none focus:border-accent" />
); } return (
); }