diff --git a/VERSION b/VERSION index 1cf0537..5a03fb7 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.19.0 +0.20.0 diff --git a/backend/alembic/versions/0035_saved_views.py b/backend/alembic/versions/0035_saved_views.py new file mode 100644 index 0000000..4a43a80 --- /dev/null +++ b/backend/alembic/versions/0035_saved_views.py @@ -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") diff --git a/backend/app/main.py b/backend/app/main.py index a404305..944e294 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 5788589..3160514 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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). diff --git a/backend/app/routes/saved_views.py b/backend/app/routes/saved_views.py new file mode 100644 index 0000000..dd8a41e --- /dev/null +++ b/backend/app/routes/saved_views.py @@ -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} 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/Header.tsx b/frontend/src/components/Header.tsx index 5291e92..9938a48 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Library, Search, User, Youtube } from "lucide-react"; +import { Library, Search, User, X, Youtube } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; import SyncStatus from "./SyncStatus"; @@ -81,8 +81,18 @@ export default function Header({ if (e.key === "Enter" && trimmedQ && canSearchYt) onYtSearch(trimmedQ); }} placeholder={t("header.searchPlaceholder")} - className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" + className="w-full bg-card border border-border rounded-full pl-9 pr-9 py-2 text-sm outline-none focus:border-accent" /> + {filters.q && ( + + )} {trimmedQ && canSearchYt && ( + + + ) : ( + + )} + + ); +} + +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..17d2b65 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 }); @@ -204,13 +207,23 @@ export default function Sidebar({ const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo; const contentChanged = !filters.includeNormal || filters.includeShorts || filters.includeLive; + // Library (vs the default "my"/Mine) and a non-default provenance Source both narrow the feed, + // so they count as active filters too (Source only applies in Library scope). The header search + // `q` deliberately stays out — it has its own clear (✕) in the search box and isn't reset here. + const scopeActive = filters.scope === "all"; + const sourceActive = + filters.scope === "all" && !!filters.librarySource && filters.librarySource !== "organic"; const activeCount = filters.tags.length + (filters.show !== "unwatched" ? 1 : 0) + - (filters.sort !== "newest" ? 1 : 0) + + // "relevance" is auto-applied while searching (no manual dropdown option), so it's a search + // artifact, not a user-chosen sort filter — don't let typing a query inflate the count. + (filters.sort !== "newest" && filters.sort !== "relevance" ? 1 : 0) + (contentChanged ? 1 : 0) + (filters.channelId ? 1 : 0) + - (dateActive ? 1 : 0); + (dateActive ? 1 : 0) + + (scopeActive ? 1 : 0) + + (sourceActive ? 1 : 0); const active = activeCount > 0; async function shareView() { @@ -224,10 +237,16 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope }); + // Reset every filter dimension to its default — including scope back to "my" (Mine) and the + // provenance Source (dropped with the rest, so it falls back to "organic"). The search `q` is + // intentionally preserved (it has its own ✕ in the search box). + setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: "my" }); } 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 +270,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/header.json b/frontend/src/i18n/locales/de/header.json index 129fa93..1e233fe 100644 --- a/frontend/src/i18n/locales/de/header.json +++ b/frontend/src/i18n/locales/de/header.json @@ -1,6 +1,7 @@ { "feed": "Feed", "searchPlaceholder": "Deine Abos durchsuchen…", + "clearSearch": "Suche löschen", "searchYoutube": "Auf YouTube suchen", "searchYoutubeTip": "Live auf YouTube nach diesem Begriff suchen (verbraucht gemeinsames API-Kontingent). Du kannst auch Enter drücken.", "channelManager": "Kanalverwaltung", 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/header.json b/frontend/src/i18n/locales/en/header.json index c1fdac5..fa2eae8 100644 --- a/frontend/src/i18n/locales/en/header.json +++ b/frontend/src/i18n/locales/en/header.json @@ -1,6 +1,7 @@ { "feed": "Feed", "searchPlaceholder": "Search your subscriptions…", + "clearSearch": "Clear search", "searchYoutube": "Search YouTube", "searchYoutubeTip": "Search live on YouTube for this term (uses shared API quota). You can also press Enter.", "channelManager": "Channel manager", 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/header.json b/frontend/src/i18n/locales/hu/header.json index 910350e..974897c 100644 --- a/frontend/src/i18n/locales/hu/header.json +++ b/frontend/src/i18n/locales/hu/header.json @@ -1,6 +1,7 @@ { "feed": "Hírfolyam", "searchPlaceholder": "Keresés a feliratkozásaid között…", + "clearSearch": "Keresés törlése", "searchYoutube": "Keresés a YouTube-on", "searchYoutubeTip": "Élő keresés a YouTube-on erre a kifejezésre (a közös API-kvótát fogyasztja). Entert is nyomhatsz.", "channelManager": "Csatornakezelő", 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/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index c06ca0a..1fbc8f8 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,18 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.20.0", + date: "2026-07-01", + summary: "Saved views: keep several filter setups and switch between them in one click.", + features: [ + "Saved views: save your current feed filters (and sort) under a name, then apply them again with a single click. Rename them, drag to reorder, share a view by link, and star one as your default — the default loads automatically when you open the app. They live in a new “Saved views” box at the top of the filter sidebar.", + ], + fixes: [ + "The “Filters active” count and Clear all now also cover the Source filter and the Mine/Library switch, so clearing gives a truly clean slate — back to Mine with search results hidden. The default view (Mine, unwatched, no search results) is unchanged.", + "The search box now has a clear (✕) button. Clearing your filters keeps the search text — the search has its own ✕ — and clearing the search returns the feed to its normal order.", + ], + }, { version: "0.19.0", date: "2026-07-01", diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index 0b7508f..5cd388d 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", "tags", "language", "topic"]; 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",