From 0a358fa82eaa79a5ad89ad8af47e0780bdd3bf69 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 1 Jul 2026 03:17:36 +0200 Subject: [PATCH] =?UTF-8?q?feat(views):=20saved-views=20UI=20=E2=80=94=20s?= =?UTF-8?q?idebar=20widget,=20apply/rename/default/reorder/share?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/App.tsx | 7 +- frontend/src/components/SavedViewsWidget.tsx | 335 +++++++++++++++++++ frontend/src/components/Sidebar.tsx | 8 + frontend/src/i18n/locales/de/sidebar.json | 1 + frontend/src/i18n/locales/de/views.json | 16 + frontend/src/i18n/locales/en/sidebar.json | 1 + frontend/src/i18n/locales/en/views.json | 16 + frontend/src/i18n/locales/hu/sidebar.json | 1 + frontend/src/i18n/locales/hu/views.json | 16 + frontend/src/lib/api.ts | 23 ++ frontend/src/lib/sidebarLayout.ts | 5 +- frontend/src/lib/storage.ts | 3 + 12 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/SavedViewsWidget.tsx create mode 100644 frontend/src/i18n/locales/de/views.json create mode 100644 frontend/src/i18n/locales/en/views.json create mode 100644 frontend/src/i18n/locales/hu/views.json diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 18aa039..5897f07 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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} /> )}
diff --git a/frontend/src/components/SavedViewsWidget.tsx b/frontend/src/components/SavedViewsWidget.tsx new file mode 100644 index 0000000..45d9717 --- /dev/null +++ b/frontend/src/components/SavedViewsWidget.tsx @@ -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(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 ( +
+ + +
+ + + + +
+
+ ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 53a69a9..8277bcb 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -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.") 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 = { + // 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 ; case "date": return ( <> diff --git a/frontend/src/i18n/locales/de/sidebar.json b/frontend/src/i18n/locales/de/sidebar.json index 2bb2de5..bbcdf96 100644 --- a/frontend/src/i18n/locales/de/sidebar.json +++ b/frontend/src/i18n/locales/de/sidebar.json @@ -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", diff --git a/frontend/src/i18n/locales/de/views.json b/frontend/src/i18n/locales/de/views.json new file mode 100644 index 0000000..a176243 --- /dev/null +++ b/frontend/src/i18n/locales/de/views.json @@ -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" +} diff --git a/frontend/src/i18n/locales/en/sidebar.json b/frontend/src/i18n/locales/en/sidebar.json index 3e36a6d..7ecdc12 100644 --- a/frontend/src/i18n/locales/en/sidebar.json +++ b/frontend/src/i18n/locales/en/sidebar.json @@ -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", diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json new file mode 100644 index 0000000..487a87a --- /dev/null +++ b/frontend/src/i18n/locales/en/views.json @@ -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" +} diff --git a/frontend/src/i18n/locales/hu/sidebar.json b/frontend/src/i18n/locales/hu/sidebar.json index 030a877..82aacad 100644 --- a/frontend/src/i18n/locales/hu/sidebar.json +++ b/frontend/src/i18n/locales/hu/sidebar.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json new file mode 100644 index 0000000..652ba09 --- /dev/null +++ b/frontend/src/i18n/locales/hu/views.json @@ -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" +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b77c16b..852150a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 => req("/api/saved-views"), + createView: (name: string, filters: FeedFilters): Promise => + req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }), + updateView: ( + id: number, + patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>, + ): Promise => + 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 => req("/api/messages/keys/me"), setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> => diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index 0b7508f..04ee250 100644 --- a/frontend/src/lib/sidebarLayout.ts +++ b/frontend/src/lib/sidebarLayout.ts @@ -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 = { + savedviews: "Saved views", date: "Upload date", language: "Language", topic: "Topic", diff --git a/frontend/src/lib/storage.ts b/frontend/src/lib/storage.ts index 80648d4..7c78cea 100644 --- a/frontend/src/lib/storage.ts +++ b/frontend/src/lib/storage.ts @@ -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",