Merge: saved smart views (named FeedFilters snapshots)
This commit is contained in:
commit
53e4cd9aa3
16 changed files with 664 additions and 3 deletions
59
backend/alembic/versions/0035_saved_views.py
Normal file
59
backend/alembic/versions/0035_saved_views.py
Normal 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")
|
||||||
|
|
@ -38,6 +38,7 @@ from app.routes import (
|
||||||
notifications,
|
notifications,
|
||||||
playlists,
|
playlists,
|
||||||
quota,
|
quota,
|
||||||
|
saved_views,
|
||||||
scheduler as scheduler_routes,
|
scheduler as scheduler_routes,
|
||||||
search as search_routes,
|
search as search_routes,
|
||||||
setup as setup_routes,
|
setup as setup_routes,
|
||||||
|
|
@ -120,6 +121,7 @@ app.include_router(notifications.router)
|
||||||
app.include_router(messages.router)
|
app.include_router(messages.router)
|
||||||
app.include_router(channels.router)
|
app.include_router(channels.router)
|
||||||
app.include_router(playlists.router)
|
app.include_router(playlists.router)
|
||||||
|
app.include_router(saved_views.router)
|
||||||
app.include_router(admin.router)
|
app.include_router(admin.router)
|
||||||
app.include_router(config_routes.router)
|
app.include_router(config_routes.router)
|
||||||
app.include_router(scheduler_routes.router)
|
app.include_router(scheduler_routes.router)
|
||||||
|
|
|
||||||
|
|
@ -593,6 +593,28 @@ class PlaylistItem(Base):
|
||||||
playlist: Mapped["Playlist"] = relationship(back_populates="items")
|
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):
|
class Notification(Base):
|
||||||
"""A durable, server-backed per-user notification (the inbox center, phase 1).
|
"""A durable, server-backed per-user notification (the inbox center, phase 1).
|
||||||
|
|
||||||
|
|
|
||||||
152
backend/app/routes/saved_views.py
Normal file
152
backend/app/routes/saved_views.py
Normal 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}
|
||||||
|
|
@ -19,7 +19,7 @@ import {
|
||||||
} from "./lib/sidebarLayout";
|
} from "./lib/sidebarLayout";
|
||||||
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
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 { useConfirm } from "./components/ConfirmProvider";
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import Welcome from "./components/Welcome";
|
import Welcome from "./components/Welcome";
|
||||||
|
|
@ -94,6 +94,10 @@ function loadStoredFilters(): FeedFilters {
|
||||||
function loadInitialFilters(): FeedFilters {
|
function loadInitialFilters(): FeedFilters {
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
if (hasFilterParams(params)) return paramsToFilters(params, DEFAULT_FILTERS);
|
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();
|
return loadStoredFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -597,6 +601,7 @@ export default function App() {
|
||||||
layout={sidebarLayout}
|
layout={sidebarLayout}
|
||||||
setLayout={setSidebarLayout}
|
setLayout={setSidebarLayout}
|
||||||
onFocusChannel={focusChannel}
|
onFocusChannel={focusChannel}
|
||||||
|
isDemo={meQuery.data!.is_demo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<main className="flex-1 min-w-0 overflow-y-auto">
|
<main className="flex-1 min-w-0 overflow-y-auto">
|
||||||
|
|
|
||||||
335
frontend/src/components/SavedViewsWidget.tsx
Normal file
335
frontend/src/components/SavedViewsWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,7 @@ import { notify } from "../lib/notifications";
|
||||||
import { useDebounced } from "../lib/useDebounced";
|
import { useDebounced } from "../lib/useDebounced";
|
||||||
import { Switch } from "./ui/form";
|
import { Switch } from "./ui/form";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./TagManager";
|
||||||
|
import SavedViewsWidget from "./SavedViewsWidget";
|
||||||
|
|
||||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||||
const SORT_IDS = [
|
const SORT_IDS = [
|
||||||
|
|
@ -118,12 +119,14 @@ export default function Sidebar({
|
||||||
layout,
|
layout,
|
||||||
setLayout,
|
setLayout,
|
||||||
onFocusChannel,
|
onFocusChannel,
|
||||||
|
isDemo = false,
|
||||||
}: {
|
}: {
|
||||||
filters: FeedFilters;
|
filters: FeedFilters;
|
||||||
setFilters: (f: FeedFilters) => void;
|
setFilters: (f: FeedFilters) => void;
|
||||||
layout: SidebarLayout;
|
layout: SidebarLayout;
|
||||||
setLayout: (l: SidebarLayout) => void;
|
setLayout: (l: SidebarLayout) => void;
|
||||||
onFocusChannel: (name: string) => void;
|
onFocusChannel: (name: string) => void;
|
||||||
|
isDemo?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
||||||
|
|
@ -228,6 +231,9 @@ export default function Sidebar({
|
||||||
}
|
}
|
||||||
|
|
||||||
const available: Record<WidgetId, boolean> = {
|
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,
|
date: true,
|
||||||
language: languages.length > 0,
|
language: languages.length > 0,
|
||||||
topic: topics.length > 0,
|
topic: topics.length > 0,
|
||||||
|
|
@ -251,6 +257,8 @@ export default function Sidebar({
|
||||||
|
|
||||||
function widgetBody(id: WidgetId): React.ReactNode {
|
function widgetBody(id: WidgetId): React.ReactNode {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
|
case "savedviews":
|
||||||
|
return <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
||||||
case "date":
|
case "date":
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
|
"shareCopied": "Ansichts-Link in die Zwischenablage kopiert",
|
||||||
"shareFailed": "Link konnte nicht kopiert werden",
|
"shareFailed": "Link konnte nicht kopiert werden",
|
||||||
"widget": {
|
"widget": {
|
||||||
|
"savedviews": "Gespeicherte Ansichten",
|
||||||
"show": "Anzeigen",
|
"show": "Anzeigen",
|
||||||
"sort": "Sortierung",
|
"sort": "Sortierung",
|
||||||
"date": "Upload-Datum",
|
"date": "Upload-Datum",
|
||||||
|
|
|
||||||
16
frontend/src/i18n/locales/de/views.json
Normal file
16
frontend/src/i18n/locales/de/views.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"shareCopied": "View link copied to clipboard",
|
"shareCopied": "View link copied to clipboard",
|
||||||
"shareFailed": "Couldn't copy the link",
|
"shareFailed": "Couldn't copy the link",
|
||||||
"widget": {
|
"widget": {
|
||||||
|
"savedviews": "Saved views",
|
||||||
"show": "Show",
|
"show": "Show",
|
||||||
"sort": "Sort",
|
"sort": "Sort",
|
||||||
"date": "Upload date",
|
"date": "Upload date",
|
||||||
|
|
|
||||||
16
frontend/src/i18n/locales/en/views.json
Normal file
16
frontend/src/i18n/locales/en/views.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -31,6 +31,7 @@
|
||||||
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
|
"shareCopied": "Nézet-hivatkozás a vágólapra másolva",
|
||||||
"shareFailed": "Nem sikerült a hivatkozás másolása",
|
"shareFailed": "Nem sikerült a hivatkozás másolása",
|
||||||
"widget": {
|
"widget": {
|
||||||
|
"savedviews": "Mentett nézetek",
|
||||||
"show": "Megjelenítés",
|
"show": "Megjelenítés",
|
||||||
"sort": "Rendezés",
|
"sort": "Rendezés",
|
||||||
"date": "Feltöltés dátuma",
|
"date": "Feltöltés dátuma",
|
||||||
|
|
|
||||||
16
frontend/src/i18n/locales/hu/views.json
Normal file
16
frontend/src/i18n/locales/hu/views.json
Normal 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"
|
||||||
|
}
|
||||||
|
|
@ -188,6 +188,15 @@ export interface FeedFilters {
|
||||||
dateTo?: string;
|
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 {
|
export interface VersionInfo {
|
||||||
app_version: string;
|
app_version: string;
|
||||||
git_sha: string;
|
git_sha: string;
|
||||||
|
|
@ -831,6 +840,20 @@ export const api = {
|
||||||
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
req(`/api/me/notifications/${id}/dismiss`, { method: "POST" }),
|
||||||
clearNotifications: () => req("/api/me/notifications/clear", { 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) ---
|
// --- direct messages (end-to-end encrypted) ---
|
||||||
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
messageMyKey: (): Promise<MyKeyBundle> => req("/api/messages/keys/me"),
|
||||||
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
setupMessageKey: (k: KeySetup): Promise<{ configured: boolean }> =>
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,12 @@
|
||||||
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||||
import { LS, readJSON, writeJSON } from "./storage";
|
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> = {
|
export const WIDGET_TITLES: Record<WidgetId, string> = {
|
||||||
|
savedviews: "Saved views",
|
||||||
date: "Upload date",
|
date: "Upload date",
|
||||||
language: "Language",
|
language: "Language",
|
||||||
topic: "Topic",
|
topic: "Topic",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ export const LS = {
|
||||||
hints: "siftlode.hints",
|
hints: "siftlode.hints",
|
||||||
lang: "siftlode.lang",
|
lang: "siftlode.lang",
|
||||||
filters: "siftlode.filters",
|
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",
|
page: "siftlode.page",
|
||||||
perfMode: "siftlode.perfMode",
|
perfMode: "siftlode.perfMode",
|
||||||
channelFilter: "siftlode.channelFilter",
|
channelFilter: "siftlode.channelFilter",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue