Merge: saved smart views (named FeedFilters snapshots)

This commit is contained in:
npeter83 2026-07-01 03:17:47 +02:00
commit 53e4cd9aa3
16 changed files with 664 additions and 3 deletions

View file

@ -0,0 +1,59 @@
"""per-user saved smart views
Revision ID: 0035_saved_views
Revises: 0034_blocked_channels
Create Date: 2026-07-01
Adds the `saved_views` table: per-user named snapshots of the feed's filter/sort state. Each
row stores the serialized FeedFilters (JSON), a sidebar `position` for drag-reorder, and an
`is_default` flag (at most one per user, enforced by the API) for the view applied on load.
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0035_saved_views"
down_revision: Union[str, None] = "0034_blocked_channels"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"saved_views",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=False),
sa.Column("filters", sa.JSON(), nullable=False),
sa.Column(
"position", sa.Integer(), server_default=sa.text("0"), nullable=False
),
sa.Column(
"is_default",
sa.Boolean(),
server_default=sa.text("false"),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("user_id", "name", name="uq_user_saved_view"),
)
op.create_index("ix_saved_views_user_id", "saved_views", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_saved_views_user_id", table_name="saved_views")
op.drop_table("saved_views")

View file

@ -38,6 +38,7 @@ from app.routes import (
notifications,
playlists,
quota,
saved_views,
scheduler as scheduler_routes,
search as search_routes,
setup as setup_routes,
@ -120,6 +121,7 @@ app.include_router(notifications.router)
app.include_router(messages.router)
app.include_router(channels.router)
app.include_router(playlists.router)
app.include_router(saved_views.router)
app.include_router(admin.router)
app.include_router(config_routes.router)
app.include_router(scheduler_routes.router)

View file

@ -593,6 +593,28 @@ class PlaylistItem(Base):
playlist: Mapped["Playlist"] = relationship(back_populates="items")
class SavedView(Base, TimestampMixin, UpdatedAtMixin):
"""A per-user named snapshot of the feed's filter/sort state (a "smart view").
`filters` is the serialized FeedFilters JSON the frontend applies with one click.
`position` orders them in the sidebar (drag-reorder); `is_default` marks the one view
applied automatically on load at most one per user, enforced by the API on set."""
__tablename__ = "saved_views"
__table_args__ = (UniqueConstraint("user_id", "name", name="uq_user_saved_view"),)
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
name: Mapped[str] = mapped_column(String(80))
filters: Mapped[dict] = mapped_column(JSON)
position: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
is_default: Mapped[bool] = mapped_column(
Boolean, default=False, server_default="false"
)
class Notification(Base):
"""A durable, server-backed per-user notification (the inbox center, phase 1).

View file

@ -0,0 +1,152 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.auth import require_human
from app.db import get_db
from app.models import SavedView, User
# Saved "smart views": per-user named snapshots of the feed's FeedFilters. Gated behind
# require_human so the shared demo account can't pollute a communal list (mirrors how other
# personal-state writes are scoped).
router = APIRouter(prefix="/api/saved-views", tags=["saved-views"])
def _serialize(v: SavedView) -> dict:
return {
"id": v.id,
"name": v.name,
"filters": v.filters or {},
"position": v.position,
"is_default": v.is_default,
}
@router.get("")
def list_views(
user: User = Depends(require_human), db: Session = Depends(get_db)
) -> list[dict]:
rows = (
db.execute(
select(SavedView)
.where(SavedView.user_id == user.id)
.order_by(SavedView.position, SavedView.id)
)
.scalars()
.all()
)
return [_serialize(v) for v in rows]
@router.post("")
def create_view(
payload: dict,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
filters = payload.get("filters")
if not isinstance(filters, dict):
raise HTTPException(status_code=400, detail="filters must be an object")
maxpos = db.execute(
select(func.coalesce(func.max(SavedView.position), -1)).where(
SavedView.user_id == user.id
)
).scalar_one()
view = SavedView(
user_id=user.id, name=name[:80], filters=filters, position=maxpos + 1
)
db.add(view)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=409, detail=f"You already have a view named “{name}”."
)
return _serialize(view)
def _own_view(db: Session, user: User, view_id: int) -> SavedView:
view = db.get(SavedView, view_id)
if view is None or view.user_id != user.id:
raise HTTPException(status_code=404, detail="Unknown view")
return view
@router.patch("/{view_id}")
def update_view(
view_id: int,
payload: dict,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
view = _own_view(db, user, view_id)
if "name" in payload:
name = (payload.get("name") or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name cannot be empty")
view.name = name[:80]
if "filters" in payload:
filters = payload.get("filters")
if not isinstance(filters, dict):
raise HTTPException(status_code=400, detail="filters must be an object")
view.filters = filters
if "is_default" in payload:
make_default = bool(payload.get("is_default"))
if make_default:
# At most one default per user: clear the flag on the others first.
db.execute(
update(SavedView)
.where(SavedView.user_id == user.id, SavedView.id != view.id)
.values(is_default=False)
)
view.is_default = make_default
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(
status_code=409, detail=f"You already have a view named “{view.name}”."
)
return _serialize(view)
@router.delete("/{view_id}")
def delete_view(
view_id: int,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
view = _own_view(db, user, view_id)
db.delete(view)
db.commit()
return {"deleted": view_id}
@router.post("/reorder")
def reorder_views(
payload: dict,
user: User = Depends(require_human),
db: Session = Depends(get_db),
) -> dict:
ids = payload.get("ids")
if not isinstance(ids, list):
raise HTTPException(status_code=400, detail="ids must be a list")
rows = (
db.execute(select(SavedView).where(SavedView.user_id == user.id))
.scalars()
.all()
)
by_id = {v.id: v for v in rows}
pos = 0
for vid in ids:
v = by_id.get(vid)
if v is not None:
v.position = pos
pos += 1
db.commit()
return {"ok": True}

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",