diff --git a/README.md b/README.md index 22ef321..ebc3b11 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,8 @@ blocker and SponsorBlock keep working. presets, per-user storage quota), trim / crop / split & join them in a built-in editor, then save to your device, share with another user, or hand out a public watch link. - **Multi-user** with per-user private state, a shared catalog, and a fair daily API-quota guard. -- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English, - Hungarian and German**. +- **Self-hosted & private**, with a first-run web setup wizard and the interface in **English + and Hungarian**. ## Quick start (self-hosting) diff --git a/backend/app/auth.py b/backend/app/auth.py index 5f814d6..cdd8490 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -364,7 +364,7 @@ async def callback( prefs = dict(user.preferences or {}) if not prefs.get("language"): loc = (userinfo.get("locale") or "").split("-")[0].lower() - prefs["language"] = loc if loc in {"en", "hu", "de"} else "en" + prefs["language"] = loc if loc in {"en", "hu"} else "en" user.preferences = prefs db.flush() diff --git a/backend/app/routes/messages.py b/backend/app/routes/messages.py index de41d13..d40d3e7 100644 --- a/backend/app/routes/messages.py +++ b/backend/app/routes/messages.py @@ -51,14 +51,6 @@ WELCOME = { "jelszóval.\n\n" "Jó böngészést!" ), - "de": ( - "Willkommen bei Siftlode! 👋\n\n" - "Das ist dein privater Nachrichten-Posteingang. Unterhaltungen mit anderen Mitgliedern " - "sind Ende-zu-Ende-verschlüsselt — nur du und dein Gegenüber könnt sie lesen, nicht " - "einmal ein Admin. Um jemandem zu schreiben, richte zuerst die sichere " - "Nachrichtenübermittlung mit einem Passwort ein.\n\n" - "Viel Spaß mit deinem Feed!" - ), } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3ea73a7..edaebca 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -23,9 +23,11 @@ import { pageTitleKey } from "./lib/pageMeta"; import { loadLayout, normalizeLayout, + PREF_KEY, saveLayoutLocal, - type SidebarLayout, -} from "./lib/sidebarLayout"; + type PanelId, + type PanelLayout, +} from "./lib/panelLayout"; import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; import { hintsEnabled, setHintsEnabled } from "./lib/hints"; import { @@ -49,6 +51,7 @@ import SetupWizard from "./components/SetupWizard"; import Header from "./components/Header"; import NavSidebar from "./components/NavSidebar"; import Sidebar from "./components/Sidebar"; +import PlaylistsRail from "./components/PlaylistsRail"; import BackToTop from "./components/BackToTop"; import GlassTuner from "./components/GlassTuner"; import ChatDock from "./components/ChatDock"; @@ -180,7 +183,13 @@ export default function App() { })); const [prefsSaveState, setPrefsSaveState] = useState("idle"); const saveMsgTimer = useRef(undefined); - const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); + // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), + // persisted per-account. One record so feed/plex/playlists share the same mechanism. + const [panelLayouts, setPanelLayouts] = useState>(() => ({ + feed: loadLayout("feed"), + plex: loadLayout("plex"), + playlists: loadLayout("playlists"), + })); const [page, setPageState] = useState(loadInitialPage); // Client-side name filters for the Channels + Playlists lists — lifted here so the shared top // SearchBar can drive them (the modules read/seed them via props). @@ -351,10 +360,10 @@ export default function App() { document.title = label ? `${label} · ${brand}` : brand; }, [page, channelView, i18n.language, t]); - function setSidebarLayout(next: SidebarLayout) { - setSidebarLayoutState(next); - saveLayoutLocal(next); - api.savePrefs({ sidebarLayout: next }).catch(() => {}); + function setPanelLayout(panel: PanelId, next: PanelLayout) { + setPanelLayouts((prev) => ({ ...prev, [panel]: next })); + saveLayoutLocal(panel, next); + api.savePrefs({ [PREF_KEY[panel]]: next }).catch(() => {}); } // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the @@ -366,6 +375,10 @@ export default function App() { const [filterCollapsed, setFilterCollapsedState] = useState(() => Boolean(readAccount(LS.filterCollapsed, false)) ); + // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). + const [playlistsCollapsed, setPlaylistsCollapsedState] = useState(() => + Boolean(readAccount(LS.playlistsCollapsed, false)) + ); function setNavCollapsed(next: boolean) { setNavCollapsedState(next); writeAccount(LS.navCollapsed, next); @@ -376,6 +389,21 @@ export default function App() { writeAccount(LS.filterCollapsed, next); api.savePrefs({ filterCollapsed: next }).catch(() => {}); } + function setPlaylistsCollapsed(next: boolean) { + setPlaylistsCollapsedState(next); + writeAccount(LS.playlistsCollapsed, next); + api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); + } + // The selected playlist — App state so the App-level PlaylistsRail and the module's detail pane + // (Playlists) stay in sync. Persisted so a reload keeps it (it's not in the URL). + const [selectedPlaylistId, setSelectedPlaylistIdState] = useState(() => { + const s = getAccountRaw(LS.playlist); + return s ? Number(s) : null; + }); + const setSelectedPlaylistId = (id: number | null) => { + setSelectedPlaylistIdState(id); + if (id != null) setAccountRaw(LS.playlist, String(id)); + }; useEffect(() => applyTheme(theme), [theme]); @@ -590,11 +618,15 @@ export default function App() { setNotif(adopted.notifications); setSavedPrefs(adopted); // Out-of-scope prefs stay instant (edited outside the Settings page). - if (prefs.sidebarLayout) { - const l = normalizeLayout(prefs.sidebarLayout); - setSidebarLayoutState(l); - saveLayoutLocal(l); - } + const prefsRec = prefs as Record; + (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => { + const raw = prefsRec[PREF_KEY[p]]; + if (raw) { + const l = normalizeLayout(p, raw); + setPanelLayouts((prev) => ({ ...prev, [p]: l })); + saveLayoutLocal(p, l); + } + }); if (typeof prefs.navCollapsed === "boolean") { setNavCollapsedState(prefs.navCollapsed); writeAccount(LS.navCollapsed, prefs.navCollapsed); @@ -603,6 +635,10 @@ export default function App() { setFilterCollapsedState(prefs.filterCollapsed); writeAccount(LS.filterCollapsed, prefs.filterCollapsed); } + if (typeof prefsRec.playlistsCollapsed === "boolean") { + setPlaylistsCollapsedState(prefsRec.playlistsCollapsed); + writeAccount(LS.playlistsCollapsed, prefsRec.playlistsCollapsed); + } if (isSupported(prefs.language)) setLanguage(prefs.language); // (Per-account filters — incl. the demo account's whole-library default — are loaded by the // dedicated effect above, which also covers accounts that have no stored preferences.) @@ -761,8 +797,8 @@ export default function App() { setPanelLayout("feed", l)} onFocusChannel={focusChannel} isDemo={meQuery.data!.is_demo} collapsed={filterCollapsed} @@ -781,11 +817,25 @@ export default function App() { filters={plexFilters} setFilters={setPlexFilters} onOpenPlaylist={setPlexPlaylistOpen} + layout={panelLayouts.plex} + setLayout={(l) => setPanelLayout("plex", l)} collapsed={filterCollapsed} onToggleCollapse={() => setFilterCollapsed(!filterCollapsed)} /> )} + {page === "playlists" && !channelView && ( + setPanelLayout("playlists", l)} + collapsed={playlistsCollapsed} + onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)} + /> + )}
{channelView ? ( @@ -815,11 +865,11 @@ export default function App() { setPage={setPage} onYtSearch={enterYtSearch} /> - {/* The header floats (fixed) over the content, so it no longer occupies flow space. This - wrapper clears it with a top pad — EXCEPT on Playlists, whose own left rail must reach - the very top (it pads only its content pane internally instead). */} + {/* The header floats (fixed) over the content, so it no longer occupies flow space; this + wrapper clears it with a top pad. (All left rails, incl. Playlists, are App-level + floating panels beside the nav — none sit inside this content column anymore.) */}
{meQuery.data!.is_demo && } openReleaseNotes(CURRENT_VERSION)} /> @@ -858,7 +908,11 @@ export default function App() { ) : page === "audit" && meQuery.data!.role === "admin" ? ( ) : page === "playlists" ? ( - + ) : page === "notifications" ? ( ) : page === "messages" && !meQuery.data!.is_demo ? ( diff --git a/frontend/src/components/CollapsedFilterRail.tsx b/frontend/src/components/CollapsedFilterRail.tsx deleted file mode 100644 index 948e1b5..0000000 --- a/frontend/src/components/CollapsedFilterRail.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { ChevronRight, SlidersHorizontal } from "lucide-react"; -import { useTranslation } from "react-i18next"; - -// The thin 46px rail shown when a filter sidebar is collapsed (both the feed Sidebar and the Plex -// PlexSidebar rendered byte-identical copies): an expand control + a filter icon carrying the -// active-filter count so you can tell filters are on without opening the panel. -export default function CollapsedFilterRail({ - activeCount, - onToggleCollapse, -}: { - activeCount: number; - onToggleCollapse: () => void; -}) { - const { t } = useTranslation(); - return ( - - ); -} diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 9ee22d1..57d7d20 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -103,7 +103,9 @@ export default function Header({ } return ( -
+ // Fixed left = the left zone at its widest: nav card (12 margin + 208 + 12 margin = 232) + + // filter sidebar (256) + 16 gap = 504. Constant, so it never shifts on collapse/module change. +
- {/* Current-language badge (HU/EN/DE) so the active language reads at a glance without + {/* Current-language badge (HU/EN) so the active language reads at a glance without opening the menu. The ring matches the page background to detach it from the icon. */} {current.code.toUpperCase()} diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 2e38008..854e4f9 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -29,6 +29,7 @@ import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; +import { useScrollFade } from "../lib/useScrollFade"; import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; import LanguageSwitcher from "./LanguageSwitcher"; @@ -168,6 +169,10 @@ export default function NavSidebar({ }); const dlActive = Object.values(dlIndexQuery.data ?? {}).filter((s) => s !== "done").length; + // Scroll-affordance for the module list: hidden scrollbar + top/bottom edge fade (shared hook, + // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. + const listFade = useScrollFade(); + type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift @@ -268,7 +273,7 @@ export default function NavSidebar({ return (
-
+
{userItems.map(renderItem)} {systemItems.length > 0 && (collapsed ? ( diff --git a/frontend/src/components/NotificationsPanel.tsx b/frontend/src/components/NotificationsPanel.tsx index 07fcf0e..4c14a12 100644 --- a/frontend/src/components/NotificationsPanel.tsx +++ b/frontend/src/components/NotificationsPanel.tsx @@ -320,7 +320,14 @@ function ClientActivityRow({
{n.title &&
{n.title}
} -
{n.message}
+
+ {n.message} + {n.repeat > 1 && ( + + ×{n.repeat} + + )} +
{relativeFromMs(n.ts)} {needsAction && ( diff --git a/frontend/src/components/PanelGroup.tsx b/frontend/src/components/PanelGroup.tsx new file mode 100644 index 0000000..56cb359 --- /dev/null +++ b/frontend/src/components/PanelGroup.tsx @@ -0,0 +1,84 @@ +import { type ReactNode, type CSSProperties } from "react"; +import { useTranslation } from "react-i18next"; +import { ChevronDown, Eye, EyeOff, GripVertical } from "lucide-react"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; + +// One collapsible "island" card inside a SidePanel: a titled glass-card with per-group collapse, +// and — in the panel's edit mode — a drag handle (reorder) and a hide toggle. Generalized from +// the feed sidebar's WidgetCard so all three panels share the same island look and behavior. +export default function PanelGroup({ + id, + title, + editing, + collapsed, + hidden, + onToggleCollapse, + onToggleHidden, + children, +}: { + id: string; + title: string; + editing: boolean; + collapsed: boolean; + hidden: boolean; + onToggleCollapse: () => void; + onToggleHidden: () => void; + children: ReactNode; +}) { + const { t } = useTranslation(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + disabled: !editing, + }); + const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition }; + const showBody = !editing && !collapsed; + + return ( +
+
+ {editing && ( + + )} + + {editing ? ( + + ) : ( + + )} +
+ {showBody &&
{children}
} +
+ ); +} diff --git a/frontend/src/components/PanelGroups.tsx b/frontend/src/components/PanelGroups.tsx new file mode 100644 index 0000000..be4d270 --- /dev/null +++ b/frontend/src/components/PanelGroups.tsx @@ -0,0 +1,80 @@ +import { type ReactNode } from "react"; +import { + closestCenter, + DndContext, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from "@dnd-kit/core"; +import { arrayMove, SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import type { PanelLayout } from "../lib/panelLayout"; +import PanelGroup from "./PanelGroup"; + +export type PanelItem = { id: string; title: string; body: ReactNode }; + +// Renders a panel's islands in the user's saved order, with drag-to-reorder + hide + per-group +// collapse driven by `layout` (see lib/panelLayout). `items` holds only the currently AVAILABLE +// groups (a caller drops facet-gated / empty ones); the layout order is filtered to them so a +// stored order that references a now-absent group is tolerated. Reordering/hiding is active only +// in `editing` mode. Shared by all three side panels. +export default function PanelGroups({ + items, + layout, + setLayout, + editing, +}: { + items: PanelItem[]; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + editing: boolean; +}) { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); + const byId = new Map(items.map((it) => [it.id, it])); + + const orderedAvailable = layout.order.filter((id) => byId.has(id)); + // Append any available id missing from the stored order (e.g. a group that became available + // after the layout was saved) so it never silently disappears. + for (const it of items) if (!orderedAvailable.includes(it.id)) orderedAvailable.push(it.id); + const renderedIds = editing + ? orderedAvailable + : orderedAvailable.filter((id) => !layout.hidden[id]); + + function toggleCollapse(id: string) { + setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); + } + function toggleHidden(id: string) { + setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); + } + function onDragEnd(e: DragEndEvent) { + const { active, over } = e; + if (!over || active.id === over.id) return; + const from = layout.order.indexOf(active.id as string); + const to = layout.order.indexOf(over.id as string); + if (from < 0 || to < 0) return; + setLayout({ ...layout, order: arrayMove(layout.order, from, to) }); + } + + return ( + + +
+ {renderedIds.map((id) => ( + + ))} +
+
+
+ ); +} diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index ee7f9b3..bb48586 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -1,6 +1,6 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { DndContext, PointerSensor, @@ -23,9 +23,7 @@ import { GripVertical, ListPlus, Pencil, - Pin, Play, - Plus, RefreshCw, RotateCcw, Trash2, @@ -33,11 +31,10 @@ import { X, Youtube, } from "lucide-react"; -import { api, type Playlist, type Video } from "../lib/api"; +import { api, type Video } from "../lib/api"; import { formatDuration } from "../lib/format"; import { notify } from "../lib/notifications"; import { notifyYouTubeActionError } from "../lib/youtubeErrors"; -import { getAccountRaw, LS, readAccountMerged, setAccountRaw, writeAccount } from "../lib/storage"; import { useUndoable } from "../lib/useUndoable"; const PlayerModal = lazy(() => import("./PlayerModal")); import UndoToolbar from "./UndoToolbar"; @@ -95,9 +92,6 @@ const idsKey = (items: Video[]) => .sort() .join(","); -type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: SortDir; dirtyFirst: boolean }; -const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; - function Row({ video, index, @@ -185,31 +179,18 @@ function Row({ export default function Playlists({ canWrite, - search, + selectedId, + setSelectedId, }: { canWrite: boolean; - // The playlist-name filter, from the shared top SearchBar (App-owned state). - search: string; + // The selected playlist is App state, shared with the App-level PlaylistsRail (which owns the + // list + its rail controls). This detail pane reacts to the selection. + selectedId: number | null; + setSelectedId: (id: number | null) => void; }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - // Persist the selected playlist so F5 keeps it (the de-URL refactor dropped it from the URL). - const [selectedId, setSelectedId] = useState(() => { - const s = getAccountRaw(LS.playlist); - return s ? Number(s) : null; - }); - useEffect(() => { - if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId)); - }, [selectedId]); - const selectedRef = useRef(null); - const scrolledRef = useRef(false); - // How the left playlist rail is ordered (persisted). - const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); - useEffect(() => { - writeAccount(LS.plSort, plSort); - }, [plSort]); - const [newName, setNewName] = useState(""); const [renaming, setRenaming] = useState(false); const [renameValue, setRenameValue] = useState(""); // The item order is undoable: drag, sort and group all go through `order.set`, so each is @@ -244,21 +225,6 @@ export default function Playlists({ }); const playlists = listQuery.data ?? []; - // Keep the selection valid: default to the first playlist, and if the selected one - // disappears (e.g. just deleted) drop to the next available, or clear when none remain. - useEffect(() => { - // Wait for the first load before adjusting the selection — otherwise the empty - // placeholder list would null the restored selection and we'd fall back to the first. - if (!listQuery.data) return; - if (!playlists.length) { - if (selectedId != null) setSelectedId(null); - return; - } - if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { - setSelectedId(playlists[0].id); - } - }, [playlists, selectedId, listQuery.data]); - const detailQuery = useQuery({ queryKey: ["playlist", selectedId], queryFn: () => api.playlist(selectedId as number), @@ -308,48 +274,11 @@ export default function Playlists({ const plName = (p: { kind: string; name: string }) => p.kind === "watch_later" ? t("playlists.watchLater") : p.name; - // The left rail in the chosen order. "custom" keeps the server position order (Array.sort - // is stable); "dirty first" floats playlists with unpushed edits to the top. - const sortedPlaylists = useMemo(() => { - const sign = plSort.dir === "asc" ? 1 : -1; - return [...playlists].sort((a, b) => { - if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; - switch (plSort.key) { - case "name": - return sign * plName(a).localeCompare(plName(b)); - case "count": - return sign * (a.item_count - b.item_count); - case "duration": - return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); - default: - return 0; // custom: server order - } - }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playlists, plSort]); - - // Client-side name filter driven by the shared top SearchBar. - const q = search.trim().toLowerCase(); - const visiblePlaylists = useMemo( - () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), - // eslint-disable-next-line react-hooks/exhaustive-deps - [sortedPlaylists, q] - ); - - // Scroll the restored selection into view once after the rail first renders. - useEffect(() => { - if (!scrolledRef.current && selectedRef.current) { - selectedRef.current.scrollIntoView({ block: "nearest" }); - scrolledRef.current = true; - } - }, [sortedPlaylists]); - const builtin = detail?.kind === "watch_later"; // YouTube-mirrored playlists can be edited locally and synced back (the read-sync skips a // dirty mirror so it won't clobber unpushed edits); the YT-link button marks their origin. const editable = !builtin; const linked = editable && !!detail?.yt_playlist_id; - const [syncing, setSyncing] = useState(false); const [pushing, setPushing] = useState(false); const [reverting, setReverting] = useState(false); @@ -428,30 +357,6 @@ export default function Playlists({ } } - async function syncYoutube() { - if (syncing) return; - setSyncing(true); - try { - const r = await api.syncYoutubePlaylists(); - qc.invalidateQueries({ queryKey: ["playlists"] }); - qc.invalidateQueries({ queryKey: ["playlist"] }); - notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); - } catch (e) { - notifyYouTubeActionError(e, t("playlists.syncFailed")); - } finally { - setSyncing(false); - } - } - - const createMut = useMutation({ - mutationFn: (name: string) => api.createPlaylist(name), - onSuccess: (pl: Playlist) => { - setNewName(""); - qc.invalidateQueries({ queryKey: ["playlists"] }); - setSelectedId(pl.id); - }, - }); - function refreshAll() { qc.invalidateQueries({ queryKey: ["playlists"] }); qc.invalidateQueries({ queryKey: ["playlist", selectedId] }); @@ -538,123 +443,7 @@ export default function Playlists({ } return ( -
- - -
+
{selectedId == null || !detail ? (
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")} @@ -869,7 +658,6 @@ export default function Playlists({ )} )} -
{playingIndex != null && items[playingIndex] && ( diff --git a/frontend/src/components/PlaylistsRail.tsx b/frontend/src/components/PlaylistsRail.tsx new file mode 100644 index 0000000..534f35e --- /dev/null +++ b/frontend/src/components/PlaylistsRail.tsx @@ -0,0 +1,264 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { ArrowDown, ArrowUp, ListVideo, Pin, Plus, RefreshCw, Youtube } from "lucide-react"; +import { api, type Playlist } from "../lib/api"; +import { formatDuration } from "../lib/format"; +import { notify } from "../lib/notifications"; +import { notifyYouTubeActionError } from "../lib/youtubeErrors"; +import { LS, readAccountMerged, writeAccount } from "../lib/storage"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; + +type PlSort = { key: "custom" | "name" | "count" | "duration"; dir: "asc" | "desc"; dirtyFirst: boolean }; +const PL_SORT_DEFAULT: PlSort = { key: "custom", dir: "asc", dirtyFirst: false }; + +// The Playlists module's left rail — lifted to App level (like the filter rails) so it floats in the +// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail +// controls (sort / new / sync); the selected playlist is App state so the module's detail pane +// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. +export default function PlaylistsRail({ + canWrite, + search, + selectedId, + setSelectedId, + layout, + setLayout, + collapsed, + onToggleCollapse, +}: { + canWrite: boolean; + search: string; + selectedId: number | null; + setSelectedId: (id: number | null) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; + collapsed: boolean; + onToggleCollapse: () => void; +}) { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [plSort, setPlSort] = useState(() => readAccountMerged(LS.plSort, PL_SORT_DEFAULT)); + useEffect(() => { + writeAccount(LS.plSort, plSort); + }, [plSort]); + const [newName, setNewName] = useState(""); + const [syncing, setSyncing] = useState(false); + const [editing, setEditing] = useState(false); + const selectedRef = useRef(null); + const scrolledRef = useRef(false); + + const listQuery = useQuery({ + queryKey: ["playlists"], + queryFn: () => api.playlists(), + refetchOnWindowFocus: true, + }); + const playlists = listQuery.data ?? []; + + // Keep the selection valid: default to the first playlist, and if the selected one disappears + // drop to the next available (or clear when none remain). Waits for the first load. + useEffect(() => { + if (!listQuery.data) return; + if (!playlists.length) { + if (selectedId != null) setSelectedId(null); + return; + } + if (selectedId == null || !playlists.some((p) => p.id === selectedId)) { + setSelectedId(playlists[0].id); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, selectedId, listQuery.data]); + + const plName = (p: { kind: string; name: string }) => + p.kind === "watch_later" ? t("playlists.watchLater") : p.name; + + const sortedPlaylists = useMemo(() => { + const sign = plSort.dir === "asc" ? 1 : -1; + return [...playlists].sort((a, b) => { + if (plSort.dirtyFirst && a.dirty !== b.dirty) return a.dirty ? -1 : 1; + switch (plSort.key) { + case "name": + return sign * plName(a).localeCompare(plName(b)); + case "count": + return sign * (a.item_count - b.item_count); + case "duration": + return sign * ((a.total_duration_seconds ?? 0) - (b.total_duration_seconds ?? 0)); + default: + return 0; + } + }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playlists, plSort]); + + const q = search.trim().toLowerCase(); + const visiblePlaylists = useMemo( + () => (q ? sortedPlaylists.filter((p) => plName(p).toLowerCase().includes(q)) : sortedPlaylists), + // eslint-disable-next-line react-hooks/exhaustive-deps + [sortedPlaylists, q] + ); + + useEffect(() => { + if (!scrolledRef.current && selectedRef.current) { + selectedRef.current.scrollIntoView({ block: "nearest" }); + scrolledRef.current = true; + } + }, [sortedPlaylists]); + + async function syncYoutube() { + if (syncing) return; + setSyncing(true); + try { + const r = await api.syncYoutubePlaylists(); + qc.invalidateQueries({ queryKey: ["playlists"] }); + qc.invalidateQueries({ queryKey: ["playlist"] }); + notify({ level: "success", message: t("playlists.syncedToast", { count: r.synced }) }); + } catch (e) { + notifyYouTubeActionError(e, t("playlists.syncFailed")); + } finally { + setSyncing(false); + } + } + + const createMut = useMutation({ + mutationFn: (name: string) => api.createPlaylist(name), + onSuccess: (pl: Playlist) => { + setNewName(""); + qc.invalidateQueries({ queryKey: ["playlists"] }); + setSelectedId(pl.id); + }, + }); + + const items: PanelItem[] = [ + { + id: "manage", + title: t("playlists.manageGroup"), + body: ( +
+ + {playlists.length > 1 && ( +
+ + + +
+ )} +
{ + e.preventDefault(); + if (newName.trim()) createMut.mutate(newName.trim()); + }} + className="flex items-center gap-1.5" + > + + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + disabled={!canWrite} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent disabled:opacity-50" + /> + +
+ ), + }, + { + id: "list", + title: t("playlists.listGroup"), + body: ( + <> + {playlists.length === 0 && !listQuery.isLoading && ( +
{t("playlists.noneYet")}
+ )} + {playlists.length > 0 && visiblePlaylists.length === 0 && ( +
{t("playlists.noMatches")}
+ )} +
+ {visiblePlaylists.map((pl) => ( + + ))} +
+ + ), + }, + ]; + + return ( + } + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("playlists"))} + editHint={t("sidebar.editHint")} + > + + + ); +} diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index bdd8f47..40c0117 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -1,16 +1,17 @@ import { useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { ChevronLeft, Film, Layers, ListMusic, Plus, Tv2, X } from "lucide-react"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import { Layers, ListMusic, Plus, SlidersHorizontal, X } from "lucide-react"; import { api, EMPTY_PLEX_FILTERS, plexFilterCount, type PlexFilters } from "../lib/api"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { useDebounced } from "../lib/useDebounced"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; -// The Plex module's left filter column (mirrors the feed Sidebar's shell). Movie libraries get the -// full metadata filter set (genre / rating / year / duration / added / content rating + the -// director/actor/studio chips set by clicking the info page); shows keep library + sort only. State -// is owned by App (per-account persisted). Facets (available genres/ratings + bounds) come from the -// backend so the sidebar only offers what the library actually contains. +// The Plex module's left filter column — now the shared floating SidePanel with its filter groups +// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata +// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from +// the backend so the panel only offers what the library actually contains. type Props = { scope: string; // movie | show | both (unified cross-library scope) @@ -22,17 +23,17 @@ type Props = { filters: PlexFilters; setFilters: (f: PlexFilters) => void; onOpenPlaylist: (id: number) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; collapsed: boolean; onToggleCollapse: () => void; }; const SHOW_OPTS = ["all", "unwatched", "in_progress", "watched"] as const; const MOVIE_SORTS = ["added", "release", "year", "rating", "duration", "title"] as const; -// Shows carry the same metadata now (0052) — same sorts minus duration (a show isn't one runtime). const SHOW_SORTS = ["added", "release", "year", "rating", "title"] as const; const RATING_STEPS = [5, 6, 7, 8, 9]; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; -// Duration buckets → [minSeconds|null, maxSeconds|null]. const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [ { key: "short", min: null, max: 90 * 60 }, { key: "mid", min: 90 * 60, max: 120 * 60 }, @@ -49,12 +50,15 @@ export default function PlexSidebar({ filters, setFilters, onOpenPlaylist, + layout, + setLayout, collapsed, onToggleCollapse, }: Props) { const { t } = useTranslation(); const playlistsQ = useQuery({ queryKey: ["plex-playlists"], queryFn: () => api.plexPlaylists() }); const [newPlaylist, setNewPlaylist] = useState(""); + const [editing, setEditing] = useState(false); const qc = useQueryClient(); async function createPlaylist() { const title = newPlaylist.trim(); @@ -64,29 +68,18 @@ export default function PlexSidebar({ qc.invalidateQueries({ queryKey: ["plex-playlists"] }); onOpenPlaylist(pl.id); } - // Facet values (genres/ratings/bounds) for the current scope, ADAPTED to the active filters - // (faceted search) — so picking one filter narrows what the others offer. Refetches on any change. - // Key only on the fields plexFacets actually sends — sortDir/collectionTitle don't reach /facets, - // so keying on the whole `filters` object would re-run the heavy facet computation on every - // sort-direction toggle (or chip-title change) for an identical request. const { sortDir: _sd, collectionTitle: _ct, ...facetKey } = filters; const facetsQ = useQuery({ queryKey: ["plex-facets", scope, show, facetKey], queryFn: () => api.plexFacets(scope, filters, show), enabled: !!scope, - placeholderData: (prev) => prev, // keep the old chips visible during the refetch (no flicker) + placeholderData: (prev) => prev, }); const facets = facetsQ.data; - // Collections picker (searchable; library-agnostic UNION across all enabled libraries, since the - // unified scope has no single library). Only fetched while no collection is active (else the chip - // shows instead). Applying one sets the collection filter; the actual filtering already flows through - // /library + /facets by rating_key. const [collSearch, setCollSearch] = useState(""); const collSearchDeb = useDebounced(collSearch.trim(), 300); const collectionsQ = useQuery({ - // "union" disambiguates from the editor's per-library ["plex-collections", ] key (a search - // term equal to a plex_key would otherwise collide); the shared prefix keeps editor invalidation working. queryKey: ["plex-collections", "union", collSearchDeb], queryFn: () => api.plexCollections(undefined, collSearchDeb || undefined), enabled: !filters.collection, @@ -106,112 +99,83 @@ export default function PlexSidebar({ setSort("title"); }; - // Movie-only scope offers the duration sort; mixed/show scopes drop it (a show has no runtime). - // Ordered alphabetically by their localized label. const sorts = [...(scope === "movie" ? MOVIE_SORTS : SHOW_SORTS)].sort((a, b) => t(`plex.sort.${a}`).localeCompare(t(`plex.sort.${b}`)), ); const durBucketKey = DURATION_BUCKETS.find( (b) => (filters.durationMin ?? null) === b.min && (filters.durationMax ?? null) === b.max, )?.key; + const peopleActive = filters.directors.length + filters.actors.length + filters.studios.length > 0; - if (collapsed) { - return ; - } - - return ( -
+ ), + }, + { + id: "rating", + title: t("plex.filter.rating"), + body: ( + + patch({ ratingMin: null })}> + {t("plex.filter.any")} + + {RATING_STEPS.map((r) => ( + patch({ ratingMin: r })}> + {r}+ + + ))} + + ), + }, + ...(facets && facets.genres.length > 0 + ? [ + { + id: "genre", + title: t("plex.filter.genre"), + body: ( + <> + {filters.genres.length > 1 && ( +
+ {(["any", "all"] as const).map((m) => ( + + ))} +
+ )} + + {facets.genres.map((g) => ( + patch({ genres: toggleIn(filters.genres, g.value) })} > - {t(`plex.filter.match.${m}`)} - + {g.value} + ))} -
- )} - - {facets.genres.map((g) => ( - patch({ genres: toggleIn(filters.genres, g.value) })} - > - {g.value} - - ))} - - - )} - - {/* Year range */} -
-
- patch({ yearMin: v })} - /> - - patch({ yearMax: v })} - /> -
-
- - {/* Duration buckets — movie-only (a show has no single runtime) */} - {scope === "movie" && ( -
+ + + ), + }, + ] + : []), + { + id: "year", + title: t("plex.filter.year"), + body: ( +
+ patch({ yearMin: v })} + /> + + patch({ yearMax: v })} + /> +
+ ), + }, + ...(scope === "movie" + ? [ + { + id: "duration", + title: t("plex.filter.duration"), + body: ( ))} -
- )} - - {/* Added to Plex */} -
- - patch({ addedWithin: "" })}> - {t("plex.filter.any")} - - {ADDED_OPTS.map((a) => ( - patch({ addedWithin: a })}> - {t(`plex.filter.addedOpt.${a}`)} - - ))} - -
- - {/* Content rating (from facets) */} - {facets && facets.content_ratings.length > 0 && ( -
+ ), + }, + ] + : []), + { + id: "added", + title: t("plex.filter.added"), + body: ( + + patch({ addedWithin: "" })}> + {t("plex.filter.any")} + + {ADDED_OPTS.map((a) => ( + patch({ addedWithin: a })}> + {t(`plex.filter.addedOpt.${a}`)} + + ))} + + ), + }, + ...(facets && facets.content_ratings.length > 0 + ? [ + { + id: "contentrating", + title: t("plex.filter.contentRating"), + body: ( {facets.content_ratings.map((c) => ( ))} -
- )} - - )} - - ); -} + ), + }, + ] + : []), + ]; -function Section({ label, children }: { label: string; children: ReactNode }) { return ( -
-
{label}
- {children} -
+ } + count={activeCount} + collapsed={collapsed} + onToggleCollapse={onToggleCollapse} + editing={editing} + onToggleEditing={() => setEditing((e) => !e)} + onReset={() => setLayout(defaultLayout("plex"))} + editHint={t("sidebar.editHint")} + actions={ + anyActive ? ( + + ) : undefined + } + > + + ); } diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx new file mode 100644 index 0000000..5ca0593 --- /dev/null +++ b/frontend/src/components/SidePanel.tsx @@ -0,0 +1,127 @@ +import { type ReactNode } from "react"; +import { useTranslation } from "react-i18next"; +import { Check, ChevronLeft, Pencil, RotateCcw } from "lucide-react"; +import { useScrollFade } from "../lib/useScrollFade"; + +// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read +// as one system: a rounded glass card that floats beside the nav (aligned with the header top, +// not reaching the page bottom), with a header (title + active count + Customize/Reset) and a +// scrollable body whose native scrollbar is hidden and edges fade to hint more content. Collapses +// to a slim vertical tab docked at the nav's right (it's the flex sibling right after the nav). +export default function SidePanel({ + title, + icon, + count = 0, + collapsed, + onToggleCollapse, + editing = false, + onToggleEditing, + onReset, + editHint, + actions, + children, +}: { + title: string; + icon: ReactNode; + count?: number; + collapsed: boolean; + onToggleCollapse: () => void; + // Customize (reorder/hide) mode — omit onToggleEditing to hide the pencil entirely. + editing?: boolean; + onToggleEditing?: () => void; + onReset?: () => void; + editHint?: string; + // Extra header controls shown when NOT editing (e.g. the feed's Clear all + Share view). + actions?: ReactNode; + children: ReactNode; +}) { + const { t } = useTranslation(); + const fade = useScrollFade(); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index eec0175..3b0ee85 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,47 +1,16 @@ -import { useState } from "react"; +import { type ReactNode, useState } from "react"; import { useTranslation } from "react-i18next"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { - Check, - ChevronDown, - ChevronLeft, - Eye, - EyeOff, - GripVertical, - Library, - Pencil, - RotateCcw, - Share2, - User, - 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 { Library, Pencil, Share2, SlidersHorizontal, User, X } from "lucide-react"; import { api, type FeedFilters, type Tag } from "../lib/api"; -import { - DEFAULT_LAYOUT, - type SidebarLayout, - type WidgetId, -} from "../lib/sidebarLayout"; +import { defaultLayout, type PanelLayout } from "../lib/panelLayout"; import { shareUrl } from "../lib/urlState"; import { notify } from "../lib/notifications"; import { useDebounced } from "../lib/useDebounced"; import TagManager from "./TagManager"; import SavedViewsWidget from "./SavedViewsWidget"; -import CollapsedFilterRail from "./CollapsedFilterRail"; +import SidePanel from "./SidePanel"; +import PanelGroups, { type PanelItem } from "./PanelGroups"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -110,12 +79,10 @@ export default function Sidebar({ }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; - layout: SidebarLayout; - setLayout: (l: SidebarLayout) => void; + layout: PanelLayout; + setLayout: (l: PanelLayout) => void; onFocusChannel: (name: string) => void; isDemo?: boolean; - // Full-height collapse (state owned by App, persisted to the user's preferences), mirroring - // the left nav rail. collapsed: boolean; onToggleCollapse: () => void; }) { @@ -123,11 +90,8 @@ export default function Sidebar({ const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; - // Live per-tag channel counts for the current filter context (scope, channel, date, - // search, watch state, other category's tags). Lets us show contextual counts and hide - // chips that no longer match anything. Keyed on filters so it refetches as they change. - // Debounce the search term (matching the feed) so typing doesn't refire facets per - // keystroke, and keep the previous counts on screen during a refetch so chips don't flicker. + // Live per-tag channel counts for the current filter context. Debounce the search term so + // typing doesn't refire facets per keystroke, and keep previous counts during a refetch. const facetFilters: FeedFilters = { ...filters, q: useDebounced(filters.q, 300) }; const facetsQuery = useQuery({ queryKey: ["facets", facetFilters], @@ -137,12 +101,8 @@ export default function Sidebar({ }); const facetsReady = !!facetsQuery.data; const facetCounts = facetsQuery.data?.counts ?? {}; - // Before facets load, fall back to the static global count so chips don't flash empty. const chipCount = (tag: Tag): number => facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count; - // Visible chips: hide zero-count ones once facets are in, but always keep selected ones - // so an active filter can still be cleared. Sorted by count (largest first), then name — - // so scanning top→bottom the smallest counts end up at the very bottom. const visibleChips = (list: Tag[]): Tag[] => { const shown = facetsReady ? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id)) @@ -152,10 +112,6 @@ export default function Sidebar({ ); }; - // After a page refresh the channel name isn't in the URL (only the id is), so - // filters.channelName is undefined and the chip would fall back to "This channel". - // Resolve the title from the (cached) channel list keyed by id. Only fetch when a - // channel filter is active but its name is missing. const needChannelName = !!filters.channelId && !filters.channelName; const channelsQuery = useQuery({ queryKey: ["channels"], @@ -167,7 +123,6 @@ export default function Sidebar({ filters.channelName ?? channelsQuery.data?.find((c) => c.id === filters.channelId)?.title ?? undefined; - // Don't flash the misleading "This channel" fallback while the name is still resolving. const channelChipLabel = resolvedChannelName ?? (needChannelName && channelsQuery.isLoading @@ -175,18 +130,11 @@ export default function Sidebar({ : t("sidebar.thisChannel")); const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); - // The user's own (non-system) tags — those assigned to channels in the Channel manager. - // Facets cover them too now (the "other" category), so they're contextual like the rest: - // zero-count chips hide once facets load, counts reflect the current filter. const userTags = tags.filter((t) => !t.system); const [customDates, setCustomDates] = useState(false); const [tagManagerOpen, setTagManagerOpen] = useState(false); const [editing, setEditing] = useState(false); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { distance: 4 } }) - ); - function toggleTag(id: number) { const has = filters.tags.includes(id); setFilters({ @@ -198,17 +146,12 @@ 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) + - // "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) + @@ -228,15 +171,10 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - // 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). + const available: Record = { savedviews: !isDemo, date: true, language: languages.length > 0, @@ -244,22 +182,7 @@ export default function Sidebar({ tags: userTags.length > 0, }; - function toggleCollapse(id: WidgetId) { - setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } }); - } - function toggleHidden(id: WidgetId) { - setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } }); - } - function onDragEnd(e: DragEndEvent) { - const { active: a, over } = e; - if (!over || a.id === over.id) return; - const oldIndex = layout.order.indexOf(a.id as WidgetId); - const newIndex = layout.order.indexOf(over.id as WidgetId); - if (oldIndex < 0 || newIndex < 0) return; - setLayout({ ...layout, order: arrayMove(layout.order, oldIndex, newIndex) }); - } - - function widgetBody(id: WidgetId): React.ReactNode { + function widgetBody(id: string): ReactNode { switch (id) { case "savedviews": return ; @@ -447,23 +370,51 @@ export default function Sidebar({
); } + default: + return null; } } - const orderedAvailable = layout.order.filter((id) => available[id]); - const renderedIds = editing - ? orderedAvailable - : orderedAvailable.filter((id) => !layout.hidden[id]); + // Group ids come from the shared default order (panelLayout) so there's one source of truth; + // the actual render order is the user's saved layout (PanelGroups orders by it). + const items: PanelItem[] = defaultLayout("feed") + .order.filter((id) => available[id]) + .map((id) => ({ id, title: t("sidebar.widget." + id), body: widgetBody(id) })); - // Collapsed: a thin rail (mirroring the nav) — an expand control plus a filter icon that - // carries the active-filter count so you can tell filters are on without opening it. - if (collapsed) { - return ; - } + const actions = ( + <> + + + + ); return ( - + ); } - -function WidgetCard({ - id, - title, - editing, - collapsed, - hidden, - onToggleCollapse, - onToggleHidden, - children, -}: { - id: WidgetId; - title: string; - editing: boolean; - collapsed: boolean; - hidden: boolean; - onToggleCollapse: () => void; - onToggleHidden: () => void; - children: React.ReactNode; -}) { - const { t } = useTranslation(); - const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ - id, - disabled: !editing, - }); - const style: React.CSSProperties = { - transform: CSS.Transform.toString(transform), - transition, - }; - const showBody = !editing && !collapsed; - - return ( -
-
- {editing && ( - - )} -
- {title} -
- {editing ? ( - - ) : ( - - )} -
- {showBody &&
{children}
} -
- ); -} - diff --git a/frontend/src/components/Toaster.tsx b/frontend/src/components/Toaster.tsx index 0b94953..4f4af84 100644 --- a/frontend/src/components/Toaster.tsx +++ b/frontend/src/components/Toaster.tsx @@ -42,7 +42,14 @@ export default function Toaster() {
{toast.title &&
{toast.title}
} -
{toast.message}
+
+ {toast.message} + {toast.repeat > 1 && ( + + ×{toast.repeat} + + )} +
{toast.action && (