navigator.clipboard.writeText is intermittent on iOS Safari (bug 9: the download share-link copy 'often does neither'). Add lib/clipboard.ts copyText(): a gesture-safe execCommand path FIRST (before any await can spend the transient user activation), async Clipboard API as fallback. Migrate all five copy call sites to it.
342 lines
11 KiB
TypeScript
342 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 { copyText } from "../lib/clipboard";
|
|
import { accountKey, LS, 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(asFilters(f)).toString();
|
|
|
|
// A saved view's `filters` is a JSON blob straight from the DB — the FeedFilters type says nothing
|
|
// about what an older build actually wrote there, so coerce the array fields before the blob reaches
|
|
// app state or a serializer. Deliberately NOT a migration: a view saved against a dropped field just
|
|
// loses that filter.
|
|
const asFilters = (raw: FeedFilters): FeedFilters => ({
|
|
...raw,
|
|
tags: raw.tags ?? [],
|
|
channelIds: raw.channelIds ?? [],
|
|
});
|
|
|
|
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) {
|
|
if (await copyText(shareUrl(asFilters(v.filters)))) {
|
|
notify({ level: "success", message: t("views.shareCopied") });
|
|
} else {
|
|
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(asFilters(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>
|
|
);
|
|
}
|