feat(views): saved-views UI — sidebar widget, apply/rename/default/reorder/share

A new "Saved views" filter-sidebar widget (SavedViewsWidget): save the
current filters under a name, apply one with a click (active-highlighted
when it matches), rename, delete (confirm), drag-reorder, share by link
(reuses shareUrl), and star one as the default. The default view's filters
are mirrored to localStorage so loadInitialFilters applies it synchronously
on load/F5 (an api/url share link still wins; no default → last-session
filters as before). Hidden for the demo account. EN/HU/DE.
This commit is contained in:
npeter83 2026-07-01 03:17:36 +02:00
parent 3056734231
commit 0a358fa82e
12 changed files with 429 additions and 3 deletions

View file

@ -19,7 +19,7 @@ import {
} from "./lib/sidebarLayout";
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
import { LS, readMerged, usePersistedState } from "./lib/storage";
import { LS, readJSON, readMerged, usePersistedState } from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import Welcome from "./components/Welcome";
@ -94,6 +94,10 @@ function loadStoredFilters(): FeedFilters {
function loadInitialFilters(): FeedFilters {
const params = new URLSearchParams(window.location.search);
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
// A user-chosen default saved view takes precedence over the last-session filters on a fresh
// load / F5 (SavedViewsWidget mirrors its filters here). No default set → last-session filters.
const def = readJSON<FeedFilters | null>(LS.defaultViewFilters, null);
if (def) return { ...DEFAULT_FILTERS, ...def };
return loadStoredFilters();
}
@ -597,6 +601,7 @@ export default function App() {
layout={sidebarLayout}
setLayout={setSidebarLayout}
onFocusChannel={focusChannel}
isDemo={meQuery.data!.is_demo}
/>
)}
<main className="flex-1 min-w-0 overflow-y-auto">

View file

@ -0,0 +1,335 @@
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<FeedFilters | null>(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<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>
);
}

View file

@ -39,6 +39,7 @@ import { notify } from "../lib/notifications";
import { useDebounced } from "../lib/useDebounced";
import { Switch } from "./ui/form";
import TagManager from "./TagManager";
import SavedViewsWidget from "./SavedViewsWidget";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const SORT_IDS = [
@ -118,12 +119,14 @@ export default function Sidebar({
layout,
setLayout,
onFocusChannel,
isDemo = false,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
layout: SidebarLayout;
setLayout: (l: SidebarLayout) => void;
onFocusChannel: (name: string) => void;
isDemo?: boolean;
}) {
const { t } = useTranslation();
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
@ -228,6 +231,9 @@ export default function Sidebar({
}
const available: Record<WidgetId, boolean> = {
// Saved views are personal state; hide the widget for the shared demo account (the API
// also rejects the writes via require_human).
savedviews: !isDemo,
date: true,
language: languages.length > 0,
topic: topics.length > 0,
@ -251,6 +257,8 @@ export default function Sidebar({
function widgetBody(id: WidgetId): React.ReactNode {
switch (id) {
case "savedviews":
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
case "date":
return (
<>

View file

@ -31,6 +31,7 @@
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
"shareFailed": "Link konnte nicht kopiert werden",
"widget": {
"savedviews": "Gespeicherte Ansichten",
"show": "Anzeigen",
"sort": "Sortierung",
"date": "Upload-Datum",

View file

@ -0,0 +1,16 @@
{
"save": "Aktuelle Ansicht speichern",
"namePlaceholder": "Name der Ansicht",
"saveAction": "Speichern",
"cancel": "Abbrechen",
"empty": "Noch keine gespeicherten Ansichten. Filtere den Feed und speichere ihn.",
"apply": "Diese Ansicht anwenden",
"makeDefault": "Als Standard festlegen (wird beim Start geladen)",
"isDefault": "Standardansicht — wird beim Start geladen",
"share": "Link zu dieser Ansicht kopieren",
"shareCopied": "Ansichtslink kopiert",
"rename": "Umbenennen",
"delete": "Löschen",
"deleteConfirm": "Die gespeicherte Ansicht „{{name}}“ löschen?",
"reorder": "Zum Neuordnen ziehen"
}

View file

@ -31,6 +31,7 @@
"shareCopied": "View link copied to clipboard",
"shareFailed": "Couldn't copy the link",
"widget": {
"savedviews": "Saved views",
"show": "Show",
"sort": "Sort",
"date": "Upload date",

View file

@ -0,0 +1,16 @@
{
"save": "Save current view",
"namePlaceholder": "View name",
"saveAction": "Save",
"cancel": "Cancel",
"empty": "No saved views yet. Filter the feed, then save it.",
"apply": "Apply this view",
"makeDefault": "Set as default (loads on start)",
"isDefault": "Default view — loads on start",
"share": "Copy a link to this view",
"shareCopied": "View link copied",
"rename": "Rename",
"delete": "Delete",
"deleteConfirm": "Delete the saved view “{{name}}”?",
"reorder": "Drag to reorder"
}

View file

@ -31,6 +31,7 @@
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
"shareFailed": "Nem sikerült a hivatkozás másolása",
"widget": {
"savedviews": "Mentett nézetek",
"show": "Megjelenítés",
"sort": "Rendezés",
"date": "Feltöltés dátuma",

View file

@ -0,0 +1,16 @@
{
"save": "Aktuális nézet mentése",
"namePlaceholder": "Nézet neve",
"saveAction": "Mentés",
"cancel": "Mégse",
"empty": "Még nincs mentett nézet. Szűrd a feedet, majd mentsd el.",
"apply": "Nézet alkalmazása",
"makeDefault": "Beállítás alapértelmezettként (indításkor betölt)",
"isDefault": "Alapértelmezett nézet — indításkor betölt",
"share": "Hivatkozás másolása a nézethez",
"shareCopied": "Nézet hivatkozása a vágólapra másolva",
"rename": "Átnevezés",
"delete": "Törlés",
"deleteConfirm": "Törlöd a(z) „{{name}}” mentett nézetet?",
"reorder": "Húzd az átrendezéshez"
}

View file

@ -188,6 +188,15 @@ export interface FeedFilters {
dateTo?: string;
}
// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget).
export interface SavedView {
id: number;
name: string;
filters: FeedFilters;
position: number;
is_default: boolean;
}
export interface VersionInfo {
app_version: string;
git_sha: string;
@ -831,6 +840,20 @@ export const api = {
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
clearNotifications: () => req("/api/me/notifications/clear", { method: "POST" }),
// --- saved smart views (named FeedFilters snapshots) ---
savedViews: (): Promise<SavedView[]> => req("/api/saved-views"),
createView: (name: string, filters: FeedFilters): Promise<SavedView> =>
req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }),
updateView: (
id: number,
patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>,
): Promise<SavedView> =>
req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
deleteView: (id: number): Promise<{ deleted: number }> =>
req(`/api/saved-views/${id}`, { method: "DELETE" }),
reorderViews: (ids: number[]): Promise<{ ok: boolean }> =>
req("/api/saved-views/reorder", { method: "POST", body: JSON.stringify({ ids }) }),
// --- direct messages (end-to-end encrypted) ---
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>

View file

@ -6,11 +6,12 @@
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
import { LS, readJSON, writeJSON } from "./storage";
export type WidgetId = "date" | "language" | "topic" | "tags";
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic", "tags"];
export const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "language", "topic", "tags"];
export const WIDGET_TITLES: Record<WidgetId, string> = {
savedviews: "Saved views",
date: "Upload date",
language: "Language",
topic: "Topic",

View file

@ -9,6 +9,9 @@ export const LS = {
hints: "siftlode.hints",
lang: "siftlode.lang",
filters: "siftlode.filters",
// Mirror of the default saved view's filters, so it can be applied synchronously on load
// (before the async saved-views query resolves). Kept in sync by SavedViewsWidget.
defaultViewFilters: "siftlode.defaultViewFilters",
page: "siftlode.page",
perfMode: "siftlode.perfMode",
channelFilter: "siftlode.channelFilter",