Feed filters and the default-saved-view mirror lived under shared localStorage keys, so one account's view (incl. its starred default) leaked into another account signed into the same browser — visible with per-tab accounts as a fresh account showing the previous account's 'N active' filters instead of a clean default. Key both by the tab's active account (siftlode.filters.<id> / siftlode.defaultViewFilters.<id>); load a tab's filters from its own account on login/switch/add, falling back to defaults when the account isn't known yet. A share link's filters still win and are persisted to the account. The old shared keys are simply no longer read (a one-time reset to defaults on the first load after this change; migrating them would risk re-leaking across accounts).
339 lines
11 KiB
TypeScript
339 lines
11 KiB
TypeScript
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, getActiveAccount, type FeedFilters, type SavedView } from "../lib/api";
|
|
import { filtersToParams, shareUrl } from "../lib/urlState";
|
|
import { notify } from "../lib/notifications";
|
|
import { accountKey, 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.loadAccountFilters) from a
|
|
* per-account localStorage mirror the widget keeps in sync — avoids a flash before the query loads.
|
|
* Per-account so one account's default view never seeds another account's feed. */
|
|
export function loadDefaultViewFilters(): FeedFilters | null {
|
|
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
|
return key ? readJSON<FeedFilters | null>(key, null) : null;
|
|
}
|
|
|
|
function syncDefaultMirror(views: SavedView[]): void {
|
|
const key = accountKey(LS.defaultViewFilters, getActiveAccount());
|
|
if (!key) return;
|
|
const def = views.find((v) => v.is_default);
|
|
writeJSON(key, 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<number | null>(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<SavedView[]>(
|
|
["saved-views"],
|
|
next.map((id) => views.find((v) => v.id === id)!),
|
|
);
|
|
await api.reorderViews(next);
|
|
refresh();
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{views.length === 0 && !naming && (
|
|
<p className="text-[11px] text-muted leading-snug">{t("views.empty")}</p>
|
|
)}
|
|
|
|
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
|
<SortableContext items={views.map((v) => v.id)} strategy={verticalListSortingStrategy}>
|
|
<div className="space-y-1">
|
|
{views.map((v) => (
|
|
<ViewRow
|
|
key={v.id}
|
|
view={v}
|
|
active={keyOf(v.filters) === currentKey}
|
|
renaming={renameId === v.id}
|
|
onApply={() => 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)}
|
|
/>
|
|
))}
|
|
</div>
|
|
</SortableContext>
|
|
</DndContext>
|
|
|
|
{naming ? (
|
|
<div className="flex items-center gap-1">
|
|
<input
|
|
autoFocus
|
|
value={draftName}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={() => void save()}
|
|
title={t("views.saveAction")}
|
|
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={() => setNaming(false)}
|
|
title={t("views.cancel")}
|
|
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<button
|
|
onClick={() => {
|
|
setDraftName("");
|
|
setNaming(true);
|
|
}}
|
|
className="w-full inline-flex items-center justify-center gap-1.5 text-xs px-2 py-1.5 rounded-lg border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
|
|
>
|
|
<BookmarkPlus className="w-3.5 h-3.5" />
|
|
{t("views.save")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div ref={setNodeRef} style={style} className="flex items-center gap-1">
|
|
<input
|
|
autoFocus
|
|
value={draft}
|
|
onChange={(e) => 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"
|
|
/>
|
|
<button
|
|
onClick={() => onRename(draft)}
|
|
title={t("views.saveAction")}
|
|
className="p-1 rounded-lg text-muted hover:text-accent hover:bg-card transition"
|
|
>
|
|
<Check className="w-4 h-4" />
|
|
</button>
|
|
<button
|
|
onClick={onCancelRename}
|
|
title={t("views.cancel")}
|
|
className="p-1 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={`group flex items-center gap-0.5 rounded-lg pr-1 ${
|
|
active ? "bg-accent/10" : "hover:bg-card"
|
|
}`}
|
|
>
|
|
<button
|
|
{...attributes}
|
|
{...listeners}
|
|
title={t("views.reorder")}
|
|
className="p-1 text-muted/60 hover:text-muted cursor-grab touch-none shrink-0"
|
|
>
|
|
<GripVertical className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={onApply}
|
|
title={t("views.apply")}
|
|
className={`flex-1 min-w-0 text-left text-xs py-1 truncate ${
|
|
active ? "text-accent font-medium" : "text-fg"
|
|
}`}
|
|
>
|
|
{view.name}
|
|
</button>
|
|
<div className="flex items-center shrink-0">
|
|
<button
|
|
onClick={onToggleDefault}
|
|
title={view.is_default ? t("views.isDefault") : t("views.makeDefault")}
|
|
className={`p-1 rounded transition ${
|
|
view.is_default
|
|
? "text-accent"
|
|
: "text-muted opacity-0 group-hover:opacity-100 hover:text-accent"
|
|
}`}
|
|
>
|
|
<Star className={`w-3.5 h-3.5 ${view.is_default ? "fill-current" : ""}`} />
|
|
</button>
|
|
<button
|
|
onClick={onShare}
|
|
title={t("views.share")}
|
|
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
|
>
|
|
<Share2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={onStartRename}
|
|
title={t("views.rename")}
|
|
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-fg transition"
|
|
>
|
|
<Pencil className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={onDelete}
|
|
title={t("views.delete")}
|
|
className="p-1 rounded text-muted opacity-0 group-hover:opacity-100 hover:text-red-400 transition"
|
|
>
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|