diff --git a/VERSION b/VERSION index a918a2a..faef31a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.7.0 diff --git a/backend/app/routes/channels.py b/backend/app/routes/channels.py index fc4e0d3..04ad3f7 100644 --- a/backend/app/routes/channels.py +++ b/backend/app/routes/channels.py @@ -9,6 +9,7 @@ from app import quota from app.auth import current_user, has_write_scope from app.db import get_db from app.models import Channel, ChannelTag, Subscription, Tag, User, Video +from app.routes.admin import admin_user from app.sync.runner import run_recent_backfill from app.youtube.client import YouTubeClient, YouTubeError @@ -134,6 +135,30 @@ def update_channel( } +@router.post("/{channel_id}/reset-backfill") +def reset_backfill( + channel_id: str, + user: User = Depends(admin_user), + db: Session = Depends(get_db), +) -> dict: + """Admin-only reset: re-fetch this channel from scratch regardless of its current sync + state (a "reset" trigger). Clears the channel's backfill markers, opts it back into deep + backfill, and re-runs its recent pull immediately; the deep scheduler re-pages the full + back-catalog on its next run. Idempotent — videos upsert by id, so nothing duplicates.""" + channel = db.get(Channel, channel_id) + if channel is None: + raise HTTPException(status_code=404, detail="Unknown channel") + sub = _user_subscription(db, user, channel_id) + channel.backfill_done = False + channel.backfill_cursor = None + channel.recent_synced_at = None + sub.deep_requested = True + db.commit() + with quota.attribute(user.id, "backfill_recent"): + run_recent_backfill(db, [channel], max_channels=1) + return {"id": channel_id, "reset": True} + + @router.delete("/{channel_id}/subscription") def unsubscribe( channel_id: str, diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 03863a1..8db08b6 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -2,7 +2,9 @@ from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import Select, and_, false, func, or_, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm.exc import StaleDataError from app import quota from app.auth import current_user @@ -387,29 +389,48 @@ def set_video_state( if db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") - row = db.execute( - select(VideoState).where( - VideoState.user_id == user.id, VideoState.video_id == video_id - ) - ).scalar_one_or_none() + now = datetime.now(timezone.utc) if status == "new": + # Un-marking: keep the row if it still holds a resume position (un-marking + # "watched" should restore the in-progress state, not wipe where the user left + # off); otherwise drop it. The in-app player fires this alongside a progress + # checkpoint that may DELETE the same row, so tolerate it vanishing under us. + row = db.execute( + select(VideoState).where( + VideoState.user_id == user.id, VideoState.video_id == video_id + ) + ).scalar_one_or_none() if row is not None: - # Keep the row if it still holds a resume position (un-marking "watched" - # should restore the in-progress state, not wipe where the user left off). if row.position_seconds: row.status = "new" row.watched_at = None else: db.delete(row) - db.commit() + try: + db.commit() + except StaleDataError: + db.rollback() return {"video_id": video_id, "status": "new"} - if row is None: - row = VideoState(user_id=user.id, video_id=video_id) - db.add(row) - row.status = status - row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at + # watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at + # the same instant a progress checkpoint may DELETE the in-progress row; a plain + # SELECT-then-UPDATE then raises StaleDataError ("0 rows matched") on that race. An + # INSERT … ON CONFLICT DO UPDATE keyed on uq_user_video is immune to it. + set_: dict = {"status": status, "updated_at": now} + if status == "watched": + set_["watched_at"] = now # hidden keeps any existing watched_at + stmt = ( + pg_insert(VideoState) + .values( + user_id=user.id, + video_id=video_id, + status=status, + watched_at=now if status == "watched" else None, + ) + .on_conflict_do_update(constraint="uq_user_video", set_=set_) + ) + db.execute(stmt) db.commit() return {"video_id": video_id, "status": status} @@ -455,7 +476,12 @@ def set_video_progress( else: row.position_seconds = 0 row.progress_updated_at = datetime.now(timezone.utc) - db.commit() + # A concurrent state change (e.g. the end-of-playback "watched" mark) may + # have removed/updated this row already — tolerate the lost race. + try: + db.commit() + except StaleDataError: + db.rollback() return {"video_id": video_id, "position_seconds": 0} if row is None: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7501839..f3feb95 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,14 +86,25 @@ function loadInitialFilters(): FeedFilters { } export default function App() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); const [page, setPageState] = useState(loadInitialPage); const [wizardOpen, setWizardOpen] = useState(false); - const [channelFilter, setChannelFilter] = useState("all"); + const CHANNEL_FILTER_KEY = "siftlode.channelFilter"; + const [channelFilter, setChannelFilterState] = useState(() => { + const v = localStorage.getItem(CHANNEL_FILTER_KEY); + return v === "needs_full" || v === "fully_synced" || v === "hidden" || v === "all" + ? v + : "all"; + }); + // Persist the channel status chip so a reload (F5) keeps it. + const setChannelFilter = (f: ChannelStatusFilter) => { + setChannelFilterState(f); + localStorage.setItem(CHANNEL_FILTER_KEY, f); + }; const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -235,14 +246,17 @@ export default function App() { page={page} setPage={setPage} onOpenAbout={() => setAboutOpen(true)} + onChangeLanguage={changeLanguage} + language={i18n.language as LangCode} + filters={filters} + setFilters={setFilters} /> -
+
{ setChannelFilter("needs_full"); setPage("channels"); @@ -263,6 +277,7 @@ export default function App() { {page === "channels" ? ( setWizardOpen(true)} @@ -298,6 +313,10 @@ export default function App() { )}
+ {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. + Anchored inside the content column so they clear the sidebar automatically + (collapsed or expanded) without tracking its width. */} +
{wizardOpen && meQuery.data && !meQuery.data.is_demo && ( setWizardOpen(false)} /> @@ -314,7 +333,6 @@ export default function App() { {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )} - ); } diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3db9eb1..e9eb9a0 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -4,12 +4,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, + Check, + ExternalLink, Eye, EyeOff, History, Plus, RefreshCw, - Search, UserMinus, X, } from "lucide-react"; @@ -18,6 +19,7 @@ import { formatEta } from "../lib/format"; import { notify } from "../lib/notifications"; import Tooltip from "./Tooltip"; import Avatar from "./Avatar"; +import DataTable, { type Column } from "./DataTable"; import { useConfirm } from "./ConfirmProvider"; export type ChannelStatusFilter = "all" | "needs_full" | "fully_synced" | "hidden"; @@ -31,12 +33,14 @@ const STATUS_FILTERS: { id: ChannelStatusFilter; labelKey: string }[] = [ export default function Channels({ canWrite, + isAdmin, onViewChannel, statusFilter, setStatusFilter, onOpenWizard, }: { canWrite: boolean; + isAdmin: boolean; onViewChannel: (id: string, name: string) => void; statusFilter: ChannelStatusFilter; setStatusFilter: (f: ChannelStatusFilter) => void; @@ -62,7 +66,6 @@ export default function Channels({ const channelsQuery = useQuery({ queryKey: ["channels"], queryFn: api.channels }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const statusQuery = useQuery({ queryKey: ["my-status"], queryFn: api.myStatus }); - const [q, setQ] = useState(""); const [newTag, setNewTag] = useState(""); const invalidate = () => { @@ -100,14 +103,26 @@ export default function Channels({ .catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); }; - const attach = useMutation({ - mutationFn: (v: { id: string; tagId: number }) => api.attachChannelTag(v.id, v.tagId), - onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), - }); - const detach = useMutation({ - mutationFn: (v: { id: string; tagId: number }) => api.detachChannelTag(v.id, v.tagId), - onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }), - }); + // Tagging is updated optimistically in place (no refetch of the whole channel list, which + // made the toggle feel ~2s slow); only revert by refetching if the server call fails. + const toggleTag = (id: string, tagId: number, hasTag: boolean) => { + qc.setQueryData(["channels"], (old) => + (old ?? []).map((ch) => + ch.id === id + ? { + ...ch, + tag_ids: hasTag + ? ch.tag_ids.filter((x) => x !== tagId) + : [...ch.tag_ids, tagId], + } + : ch + ) + ); + const call = hasTag + ? api.detachChannelTag(id, tagId) + : api.attachChannelTag(id, tagId); + call.catch(() => qc.invalidateQueries({ queryKey: ["channels"] })); + }; const syncSubs = useMutation({ mutationFn: () => api.syncSubscriptions(), onSuccess: (r: { subscriptions?: number }) => { @@ -136,6 +151,15 @@ export default function Channels({ }, onError: (e) => notifyActionError(e, "channels.notify.unsubscribeFailed"), }); + const resetBackfill = useMutation({ + mutationFn: (id: string) => api.resetChannelBackfill(id), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["channels"] }); + qc.invalidateQueries({ queryKey: ["my-status"] }); + notify({ level: "success", message: t("channels.notify.resetDone") }); + }, + onError: (e) => notifyActionError(e, "channels.notify.resetFailed"), + }); const deepAll = useMutation({ mutationFn: () => api.deepAll(true), onSuccess: (r: { updated?: number }) => { @@ -149,62 +173,184 @@ export default function Channels({ onError: (e) => notifyActionError(e, "channels.notify.fullHistoryFailed"), }); - const channels = (channelsQuery.data ?? []) - .filter((c) => !q || (c.title ?? "").toLowerCase().includes(q.toLowerCase())) - .filter((c) => - statusFilter === "needs_full" - ? !c.backfill_done - : statusFilter === "fully_synced" - ? c.backfill_done - : statusFilter === "hidden" - ? c.hidden - : true - ); + // The Sync-status filter stays a top-level chip set (not a column filter) because the + // header's "go to full history" deep-link drives it; the DataTable then handles name + // search, tag filtering, sort and pagination over whatever the status chip leaves. + const channels = (channelsQuery.data ?? []).filter((c) => + statusFilter === "needs_full" + ? !c.backfill_done + : statusFilter === "fully_synced" + ? c.backfill_done + : statusFilter === "hidden" + ? c.hidden + : true + ); const s = statusQuery.data; - return ( -
- {/* Per-user sync status */} - {s && ( -
- - - - {s.deep_pending_count > 0 && ( - - )} - - -
- )} + const onView = (c: ManagedChannel) => + onViewChannel(c.id, c.title ?? t("channels.row.thisChannel")); + const onUnsub = async (c: ManagedChannel) => { + const ok = await confirm({ + title: t("channels.row.unsubscribeOnYoutube"), + message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.unsubscribeOnYoutube"), + danger: true, + }); + if (ok) unsubscribe.mutate(c.id); + }; + const onReset = async (c: ManagedChannel) => { + const ok = await confirm({ + title: t("channels.row.backfillThis"), + message: t("channels.confirmReset", { name: c.title ?? c.id }), + confirmLabel: t("channels.row.backfillThis"), + }); + if (ok) resetBackfill.mutate(c.id); + }; - {/* Toolbar */} -
-
- - setQ(e.target.value)} - placeholder={t("channels.filterPlaceholder")} - className="w-full bg-card border border-border rounded-full pl-9 pr-4 py-2 text-sm outline-none focus:border-accent" - /> + const columns: Column[] = [ + { + key: "priority", + header: t("channels.cols.priority"), + align: "center", + width: "56px", + sortable: true, + hideInCard: true, + sortValue: (c) => c.priority, + render: (c) => bumpPriority(c.id, c.priority, d)} />, + }, + { + key: "channel", + header: t("channels.cols.channel"), + sortable: true, + sortValue: (c) => (c.title ?? c.id).toLowerCase(), + filter: { kind: "text", get: (c) => `${c.title ?? ""} ${c.handle ?? ""}` }, + cardPrimary: true, + render: (c) => onView(c)} />, + }, + { + key: "stored", + header: t("channels.cols.stored"), + align: "right", + width: "84px", + sortable: true, + sortValue: (c) => c.stored_videos, + render: (c) => {c.stored_videos.toLocaleString()}, + }, + { + key: "subs", + header: t("channels.cols.subs"), + align: "right", + width: "92px", + sortable: true, + sortValue: (c) => c.subscriber_count ?? -1, + render: (c) => ( + + {c.subscriber_count != null ? c.subscriber_count.toLocaleString() : "—"} + + ), + }, + { + key: "sync", + header: t("channels.cols.sync"), + width: "130px", + cardLabel: false, + render: (c) => , + }, + { + key: "tags", + header: t("channels.cols.tags"), + width: "360px", + cardLabel: false, + filter: { + kind: "multi", + options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })), + // OR semantics: show channels carrying any of the selected tags. + test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))), + }, + render: (c) => ( + toggleTag(c.id, tagId, c.tag_ids.includes(tagId))} + /> + ), + }, + { + key: "actions", + header: t("channels.cols.actions"), + align: "right", + width: "104px", + cardLabel: false, + render: (c) => ( + patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} + onDeep={() => patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } })} + onReset={() => onReset(c)} + onUnsubscribe={() => onUnsub(c)} + /> + ), + }, + ]; + + // Status chips sit in the table's controls row (next to paging) — compact and close to + // where they act. + const statusChips = ( +
+ {STATUS_FILTERS.map((f) => ( + + ))} +
+ ); + + return ( +
+ {/* Per-user sync status + catalog-wide actions on one row (search/tags filtering + now lives in the table headers). */} +
+
+ {s && ( + <> + + + + {s.deep_pending_count > 0 && ( + + )} + + + + )}
+
-
- - {/* Status filter */} -
- {STATUS_FILTERS.map((f) => ( - - ))} +

@@ -298,42 +428,20 @@ export default function Channels({

- {/* Channel list */} + {/* Channel table */} {channelsQuery.isLoading ? (
{t("channels.loading")}
- ) : channels.length === 0 ? ( -
{t("channels.empty")}
) : ( -
- {channels.map((c) => ( - { - const ok = await confirm({ - title: t("channels.row.unsubscribeOnYoutube"), - message: t("channels.confirmUnsubscribe", { name: c.title ?? c.id }), - confirmLabel: t("channels.row.unsubscribeOnYoutube"), - danger: true, - }); - if (ok) unsubscribe.mutate(c.id); - }} - onView={() => onViewChannel(c.id, c.title ?? t("channels.row.thisChannel"))} - onPriority={(d) => bumpPriority(c.id, c.priority, d)} - onHide={() => patch.mutate({ id: c.id, body: { hidden: !c.hidden } })} - onDeep={() => - patch.mutate({ id: c.id, body: { deep_requested: !c.deep_requested } }) - } - onToggleTag={(tagId) => - c.tag_ids.includes(tagId) - ? detach.mutate({ id: c.id, tagId }) - : attach.mutate({ id: c.id, tagId }) - } - /> - ))} -
+ c.id} + persistKey="siftlode.channelsTable" + controlsPosition="top" + controlsLeading={statusChips} + rowClassName={(c) => (c.hidden ? "opacity-60" : "")} + emptyText={t("channels.empty")} + /> )}
); @@ -361,133 +469,191 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str return ( + {ok && } {label} ); } -function ChannelRow({ +function PriorityCell({ c, onPriority }: { c: ManagedChannel; onPriority: (delta: number) => void }) { + const { t } = useTranslation(); + return ( + +
+ + {c.priority} + +
+
+ ); +} + +function NameCell({ c, onView }: { c: ManagedChannel; onView: () => void }) { + const { t } = useTranslation(); + const ytUrl = c.handle + ? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}` + : `https://www.youtube.com/channel/${c.id}`; + return ( +
+ + + + + + + +
+ ); +} + +// Status only — the backfill *action* lives in the Actions column. When both recent and +// full history are in, collapse to a single "fully synced" chip; otherwise show what's +// present plus the missing/queued state. +function SyncCell({ c }: { c: ManagedChannel }) { + const { t } = useTranslation(); + if (c.recent_synced && c.backfill_done) { + return ; + } + return ( +
+ + {c.backfill_done ? ( + + ) : c.deep_requested ? ( + + ) : c.deep_in_queue ? ( + + ) : ( + + )} +
+ ); +} + +function TagsCell({ c, userTags, - canWrite, - onUnsubscribe, - onView, - onPriority, - onHide, - onDeep, onToggleTag, }: { c: ManagedChannel; userTags: Tag[]; - canWrite: boolean; - onUnsubscribe: () => void; - onView: () => void; - onPriority: (delta: number) => void; - onHide: () => void; - onDeep: () => void; onToggleTag: (tagId: number) => void; }) { - const { t } = useTranslation(); + if (userTags.length === 0) return null; return ( -
- -
- - {c.priority} - -
-
+ ); + })} +
+ ); +} - - -
- -
- {t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })} - {c.subscriber_count != null && · {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}} -
-
- - {c.backfill_done ? ( - - ) : c.deep_requested ? ( - - - - ) : c.deep_in_queue ? ( - - - - {t("channels.row.fullHistoryComing")} - - - ) : ( - - - - )} - {userTags.map((t) => { - const on = c.tag_ids.includes(t.id); - return ( - - ); - })} -
-
- - + + - {canWrite && ( + {f.options.map((o) => ( + + ))} +
+ )} + {f.kind === "multi" && ( +
+ {f.options.length === 0 && ( + {t("datatable.noOptions")} + )} + {f.options.map((o) => { + const arr = (val as string[]) ?? []; + const on = arr.includes(o.value); + return ( + + ); + })} +
+ )} + {isActive(val) && ( + + )} +
+ ); + } + + const controls = + controlsLeading != null || sorted.length > 0 ? ( +
+
{controlsLeading}
+
+ {totalPages > 1 && ( +
+ + + {t("datatable.pager.pageLabel")} + setPageInput(e.target.value)} + onBlur={commitPageInput} + onKeyDown={(e) => e.key === "Enter" && commitPageInput()} + aria-label={t("datatable.pager.pageLabel")} + className="w-14 bg-card border border-border rounded-md px-1.5 py-1 text-xs text-center tabular-nums outline-none focus:border-accent" + /> + {t("datatable.pager.ofTotal", { total: totalPages })} + + +
+ )} + {sorted.length > 0 && ( + + )} +
+
+ ) : null; + + return ( +
+ {(controlsPosition === "top" || controlsPosition === "both") && controls} + {/* Wide screens: table. The wrapper isn't clipped so header filter popovers can overflow. */} +
+ + + + {columns.map((col) => { + const sorted_ = sort?.key === col.key; + const filterOn = isActive(filters[col.key]); + return ( + + ); + })} + + + + {paged.map((row) => ( + + {columns.map((col) => ( + + ))} + + ))} + +
+ + + {col.filter && ( + + )} + + {openFilter === col.key && col.filter && } +
+ {col.render(row)} +
+
+ + {/* Narrow screens: a compact card per row — the primary column as the heading, the + rest flowing in a single wrapping meta line (no full-width label/value gaps). */} +
+ {paged.map((row) => ( +
+ {columns + .filter((c) => c.cardPrimary) + .map((col) => ( +
+ {col.render(row)} +
+ ))} +
+ {columns + .filter((c) => !c.hideInCard && !c.cardPrimary) + .map((col) => ( + + {col.cardLabel !== false && ( + {col.header} + )} + {col.render(row)} + + ))} +
+
+ ))} +
+ + {paged.length === 0 && ( +
{emptyText ?? t("datatable.empty")}
+ )} + + {(controlsPosition === "bottom" || controlsPosition === "both") && controls} +
+ ); +} diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ff4c09d..6b0e0f5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -147,7 +147,15 @@ export default function Feed({ api .setState(id, status) .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) - .catch(() => {}); + .catch(() => + // Server rejected the change — drop the optimistic override so the card + // reverts to the real (unchanged) state instead of staying phantom-hidden. + setOverrides((o) => { + const next = { ...o }; + delete next[id]; + return next; + }) + ); if (status === "hidden") { const v = loadedRef.current.find((x) => x.id === id); notify({ diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 5afe782..72d498e 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next"; import { Library, Search, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import { type LangCode } from "../i18n"; import SyncStatus from "./SyncStatus"; -import NotificationCenter from "./NotificationCenter"; -import LanguageSwitcher from "./LanguageSwitcher"; // Contextual top bar. Primary navigation + account now live in the left NavSidebar; the // header carries the global sync status, the feed's scope toggle + search (or the current @@ -15,17 +12,15 @@ export default function Header({ filters, setFilters, page, - onChangeLanguage, onGoToFullHistory, }: { me: Me; filters: FeedFilters; setFilters: (f: FeedFilters) => void; page: Page; - onChangeLanguage: (code: LangCode) => void; onGoToFullHistory: () => void; }) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); return (
@@ -83,11 +78,6 @@ export default function Header({ : t("header.channelManager")} )} - -
- - -
); } diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 62e90cf..598f3d6 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,20 +1,31 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). +// +// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left +// nav's bottom icon cluster — an icon-only button whose menu is portaled to and +// anchored to the right + above the button, escaping the nav's backdrop-filter which would +// otherwise trap an absolutely-positioned popover). export default function LanguageSwitcher({ value, onChange, align = "right", + variant = "header", }: { value: LangCode; onChange: (code: LangCode) => void; align?: "left" | "right"; + variant?: "header" | "rail"; }) { const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); + const btnRef = useRef(null); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0]; function openNow() { @@ -24,6 +35,77 @@ export default function LanguageSwitcher({ function closeSoon() { closeTimer.current = setTimeout(() => setOpen(false), 200); } + function toggleRail() { + if (!open) { + const r = btnRef.current?.getBoundingClientRect(); + if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); + } + setOpen((o) => !o); + } + + // Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check + // spans both the button and the floating panel). + useEffect(() => { + if (variant !== "rail" || !open) return; + function onDoc(e: MouseEvent) { + const target = e.target as Node; + if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return; + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [variant, open]); + + const menu = ( +
+ {LANGUAGES.map((l) => ( + + ))} +
+ ); + + if (variant === "rail") { + return ( + <> + + {open && + createPortal( +
+ {menu} +
, + document.body + )} + + ); + } return (
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index cb6d989..d9ce0fe 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -16,9 +16,12 @@ import { Tv, UserPlus, } from "lucide-react"; -import { api, type Me } from "../lib/api"; +import { api, type FeedFilters, type Me } from "../lib/api"; import type { Page } from "../lib/urlState"; +import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; +import LanguageSwitcher from "./LanguageSwitcher"; +import NotificationCenter from "./NotificationCenter"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes @@ -28,11 +31,19 @@ export default function NavSidebar({ page, setPage, onOpenAbout, + onChangeLanguage, + language, + filters, + setFilters, }: { me: Me; page: Page; setPage: (p: Page) => void; onOpenAbout: () => void; + onChangeLanguage: (code: LangCode) => void; + language: LangCode; + filters: FeedFilters; + setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( @@ -105,20 +116,40 @@ export default function NavSidebar({ } } - const items: { page: Page; icon: typeof Home; label: string }[] = [ + type NavItem = { page: Page; icon: typeof Home; label: string }; + // User-facing content modules vs. admin/system modules, separated by a divider in the rail. + const userItems: NavItem[] = [ { page: "feed", icon: Home, label: t("header.account.feed") }, { page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, ]; - if (me.role === "admin") { - items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); - items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }); - } + const systemItems: NavItem[] = + me.role === "admin" + ? [ + { page: "stats", icon: BarChart3, label: t("header.account.stats") }, + { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, + ] + : []; const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; const name = me.display_name ?? me.email.split("@")[0]; + const renderItem = ({ page: p, icon: Icon, label }: NavItem) => ( + + ); + return (