feat(ui): unified floating side panels for filters and playlists
Replace the full-height filter rails with one shared floating glass SidePanel used by Feed, Plex and Playlists. Each collapses to a slim tab beside the nav; its sections are reorderable "islands" (drag / hide / per-group collapse) with a per-panel saved layout; the body hides its scrollbar with a soft top/bottom edge fade. The nav rail becomes a matching floating rounded card. - New shared SidePanel / PanelGroup / PanelGroups + useScrollFade hook. - Generalise the feed-only sidebarLayout into a per-panel panelLayout (feed keeps its storage key; plex/playlists get their own; persisted per account). - Lift the Playlists rail to App level (selected playlist is now App state) so it floats and collapses like the filter rails; remove the old CollapsedFilterRail. - The floating top header's fixed offset accounts for the panels' margins.
This commit is contained in:
parent
7f358f63e3
commit
d92e487cf4
18 changed files with 1111 additions and 872 deletions
|
|
@ -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<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(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<Record<PanelId, PanelLayout>>(() => ({
|
||||
feed: loadLayout("feed"),
|
||||
plex: loadLayout("plex"),
|
||||
playlists: loadLayout("playlists"),
|
||||
}));
|
||||
const [page, setPageState] = useState<Page>(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>(() =>
|
||||
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>(() =>
|
||||
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<number | null>(() => {
|
||||
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<string, unknown>;
|
||||
(["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() {
|
|||
<Sidebar
|
||||
filters={filters}
|
||||
setFilters={setFilters}
|
||||
layout={sidebarLayout}
|
||||
setLayout={setSidebarLayout}
|
||||
layout={panelLayouts.feed}
|
||||
setLayout={(l) => 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)}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{page === "playlists" && !channelView && (
|
||||
<PlaylistsRail
|
||||
canWrite={meQuery.data!.can_write}
|
||||
search={playlistsSearch}
|
||||
selectedId={selectedPlaylistId}
|
||||
setSelectedId={setSelectedPlaylistId}
|
||||
layout={panelLayouts.playlists}
|
||||
setLayout={(l) => setPanelLayout("playlists", l)}
|
||||
collapsed={playlistsCollapsed}
|
||||
onToggleCollapse={() => setPlaylistsCollapsed(!playlistsCollapsed)}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<Suspense fallback={pageFallback}>
|
||||
|
|
@ -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.) */}
|
||||
<div
|
||||
className={`flex-1 min-w-0 min-h-0 flex flex-col ${page === "playlists" ? "" : "pt-[var(--hdr-h)]"}`}
|
||||
className="flex-1 min-w-0 min-h-0 flex flex-col pt-[var(--hdr-h)]"
|
||||
>
|
||||
{meQuery.data!.is_demo && <DemoBanner />}
|
||||
<VersionBanner onOpen={() => openReleaseNotes(CURRENT_VERSION)} />
|
||||
|
|
@ -858,7 +908,11 @@ export default function App() {
|
|||
) : page === "audit" && meQuery.data!.role === "admin" ? (
|
||||
<AuditLog />
|
||||
) : page === "playlists" ? (
|
||||
<Playlists canWrite={meQuery.data!.can_write} search={playlistsSearch} />
|
||||
<Playlists
|
||||
canWrite={meQuery.data!.can_write}
|
||||
selectedId={selectedPlaylistId}
|
||||
setSelectedId={setSelectedPlaylistId}
|
||||
/>
|
||||
) : page === "notifications" ? (
|
||||
<NotificationsPanel filters={filters} setFilters={setFilters} setPage={setPage} onFocusChannel={focusChannel} />
|
||||
) : page === "messages" && !meQuery.data!.is_demo ? (
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<aside className="hidden md:flex w-[46px] shrink-0 flex-col items-center gap-2 glass py-3">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.expandPanel")}
|
||||
aria-label={t("sidebar.expandPanel")}
|
||||
className="p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.filters")}
|
||||
className="relative p-2 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
{activeCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 px-1 rounded-full bg-accent text-accent-fg text-[10px] font-bold leading-none grid place-items-center ring-2 ring-bg">
|
||||
{activeCount > 9 ? "9+" : activeCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -103,7 +103,9 @@ export default function Header({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[480px]">
|
||||
// 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.
|
||||
<div className="pointer-events-none fixed top-3 left-4 right-[10%] z-30 flex items-center gap-3 md:left-[504px]">
|
||||
<ModuleName
|
||||
label={t(moduleLabelKey[page])}
|
||||
labels={moduleLabels}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<nav
|
||||
className={`glass relative shrink-0 border-r border-border flex flex-col py-3 transition-[width] ${
|
||||
className={`glass relative shrink-0 rounded-2xl flex flex-col py-3 m-3 transition-[width] ${
|
||||
collapsed ? "w-[56px] px-2" : "w-52 px-3"
|
||||
}`}
|
||||
aria-label={t("nav.primary")}
|
||||
|
|
@ -307,7 +312,11 @@ export default function NavSidebar({
|
|||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-1">
|
||||
<div
|
||||
ref={listFade.ref}
|
||||
className="flex-1 min-h-0 overflow-y-auto no-scrollbar flex flex-col gap-1"
|
||||
style={listFade.style}
|
||||
>
|
||||
{userItems.map(renderItem)}
|
||||
{systemItems.length > 0 &&
|
||||
(collapsed ? (
|
||||
|
|
|
|||
84
frontend/src/components/PanelGroup.tsx
Normal file
84
frontend/src/components/PanelGroup.tsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`glass-card rounded-xl ${
|
||||
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
|
||||
} ${hidden && editing ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
{editing && (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={t("sidebar.dragToReorder")}
|
||||
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={editing ? undefined : onToggleCollapse}
|
||||
disabled={editing}
|
||||
className="flex-1 min-w-0 flex items-center text-left select-none text-xs uppercase tracking-wide text-muted enabled:hover:text-fg transition"
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</button>
|
||||
{editing ? (
|
||||
<button
|
||||
onClick={onToggleHidden}
|
||||
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||
className="text-muted hover:text-fg"
|
||||
>
|
||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
className="text-muted hover:text-fg"
|
||||
>
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/PanelGroups.tsx
Normal file
80
frontend/src/components/PanelGroups.tsx
Normal file
|
|
@ -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 (
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{renderedIds.map((id) => (
|
||||
<PanelGroup
|
||||
key={id}
|
||||
id={id}
|
||||
title={byId.get(id)!.title}
|
||||
editing={editing}
|
||||
collapsed={!!layout.collapsed[id]}
|
||||
hidden={!!layout.hidden[id]}
|
||||
onToggleCollapse={() => toggleCollapse(id)}
|
||||
onToggleHidden={() => toggleHidden(id)}
|
||||
>
|
||||
{byId.get(id)!.body}
|
||||
</PanelGroup>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<number | null>(() => {
|
||||
const s = getAccountRaw(LS.playlist);
|
||||
return s ? Number(s) : null;
|
||||
});
|
||||
useEffect(() => {
|
||||
if (selectedId != null) setAccountRaw(LS.playlist, String(selectedId));
|
||||
}, [selectedId]);
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const scrolledRef = useRef(false);
|
||||
// How the left playlist rail is ordered (persisted).
|
||||
const [plSort, setPlSort] = useState<PlSort>(() => 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 (
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="w-64 shrink-0 glass overflow-y-auto p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-semibold">{t("playlists.title")}</span>
|
||||
<button
|
||||
onClick={syncYoutube}
|
||||
disabled={syncing}
|
||||
title={t("playlists.syncYoutube")}
|
||||
aria-label={t("playlists.syncYoutube")}
|
||||
className="inline-flex items-center gap-1 text-[11px] text-muted hover:text-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
<Youtube className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{playlists.length > 1 && (
|
||||
<div className="flex items-center gap-1 mb-2">
|
||||
<select
|
||||
value={plSort.key}
|
||||
onChange={(e) =>
|
||||
setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })
|
||||
}
|
||||
aria-label={t("playlists.sortLabel")}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
|
||||
>
|
||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
||||
<option value="name">{t("playlists.railSortName")}</option>
|
||||
<option value="count">{t("playlists.railSortCount")}</option>
|
||||
<option value="duration">{t("playlists.railSortDuration")}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })
|
||||
}
|
||||
disabled={plSort.key === "custom"}
|
||||
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
||||
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
||||
>
|
||||
{plSort.dir === "asc" ? (
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||
title={t("playlists.dirtyFirst")}
|
||||
aria-pressed={plSort.dirtyFirst}
|
||||
className={`p-1 rounded-md border transition ${
|
||||
plSort.dirtyFirst
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) createMut.mutate(newName.trim());
|
||||
}}
|
||||
className="flex items-center gap-1.5 mb-3 pb-3 border-b border-border"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
placeholder={t("playlists.newPlaylist")}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
</form>
|
||||
{playlists.length === 0 && !listQuery.isLoading && (
|
||||
<div className="text-xs text-muted px-1 py-2">{t("playlists.noneYet")}</div>
|
||||
)}
|
||||
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
||||
<div className="text-xs text-muted px-1 py-2">{t("playlists.noMatches")}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visiblePlaylists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[13px] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 &&
|
||||
` · ${formatDuration(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex-1 min-w-0 overflow-y-auto px-4 pb-4 pt-[var(--hdr-h)]">
|
||||
<div className="px-4 pb-4">
|
||||
{selectedId == null || !detail ? (
|
||||
<div className="text-muted text-sm p-4">
|
||||
{selectedId == null ? t("playlists.pickOne") : t("playlists.loading")}
|
||||
|
|
@ -869,7 +658,6 @@ export default function Playlists({
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{playingIndex != null && items[playingIndex] && (
|
||||
<Suspense fallback={null}>
|
||||
|
|
|
|||
264
frontend/src/components/PlaylistsRail.tsx
Normal file
264
frontend/src/components/PlaylistsRail.tsx
Normal file
|
|
@ -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<PlSort>(() => 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<HTMLButtonElement | null>(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: (
|
||||
<div className="flex flex-col gap-2">
|
||||
<button
|
||||
onClick={syncYoutube}
|
||||
disabled={syncing}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent disabled:opacity-40 transition"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncing ? "animate-spin" : ""}`} />
|
||||
{t("playlists.syncYoutube")}
|
||||
</button>
|
||||
{playlists.length > 1 && (
|
||||
<div className="flex items-center gap-1">
|
||||
<select
|
||||
value={plSort.key}
|
||||
onChange={(e) => setPlSort({ ...plSort, key: e.target.value as PlSort["key"] })}
|
||||
aria-label={t("playlists.sortLabel")}
|
||||
className="flex-1 min-w-0 bg-card border border-border rounded-md px-1.5 py-1 text-[11px] outline-none focus:border-accent"
|
||||
>
|
||||
<option value="custom">{t("playlists.railSortCustom")}</option>
|
||||
<option value="name">{t("playlists.railSortName")}</option>
|
||||
<option value="count">{t("playlists.railSortCount")}</option>
|
||||
<option value="duration">{t("playlists.railSortDuration")}</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setPlSort({ ...plSort, dir: plSort.dir === "asc" ? "desc" : "asc" })}
|
||||
disabled={plSort.key === "custom"}
|
||||
title={plSort.dir === "asc" ? t("playlists.dirAsc") : t("playlists.dirDesc")}
|
||||
className="p-1 rounded-md border border-border text-muted enabled:hover:text-fg enabled:hover:border-accent disabled:opacity-30 transition"
|
||||
>
|
||||
{plSort.dir === "asc" ? <ArrowUp className="w-3.5 h-3.5" /> : <ArrowDown className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPlSort({ ...plSort, dirtyFirst: !plSort.dirtyFirst })}
|
||||
title={t("playlists.dirtyFirst")}
|
||||
aria-pressed={plSort.dirtyFirst}
|
||||
className={`p-1 rounded-md border transition ${
|
||||
plSort.dirtyFirst
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
>
|
||||
<Pin className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (newName.trim()) createMut.mutate(newName.trim());
|
||||
}}
|
||||
className="flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-muted shrink-0" />
|
||||
<input
|
||||
value={newName}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
title: t("playlists.listGroup"),
|
||||
body: (
|
||||
<>
|
||||
{playlists.length === 0 && !listQuery.isLoading && (
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noneYet")}</div>
|
||||
)}
|
||||
{playlists.length > 0 && visiblePlaylists.length === 0 && (
|
||||
<div className="text-xs text-muted px-1 py-1">{t("playlists.noMatches")}</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{visiblePlaylists.map((pl) => (
|
||||
<button
|
||||
key={pl.id}
|
||||
ref={pl.id === selectedId ? selectedRef : null}
|
||||
onClick={() => setSelectedId(pl.id)}
|
||||
className={`w-full flex items-center gap-2.5 p-2 rounded-lg border transition text-left ${
|
||||
pl.id === selectedId
|
||||
? "bg-accent/15 border-accent"
|
||||
: "border-transparent hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<div className="w-[34px] h-[34px] rounded-md bg-card overflow-hidden shrink-0">
|
||||
{pl.cover_thumbnail && (
|
||||
<img src={pl.cover_thumbnail} alt="" className="w-full h-full object-cover" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1 text-[13px] text-fg">
|
||||
<span className="truncate">{plName(pl)}</span>
|
||||
{(pl.source === "youtube" || pl.yt_playlist_id) && (
|
||||
<Youtube
|
||||
className={`w-3 h-3 shrink-0 ${pl.dirty ? "text-accent" : "text-muted"}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted">
|
||||
{t("playlists.itemCount", { count: pl.item_count })}
|
||||
{pl.total_duration_seconds > 0 && ` · ${formatDuration(pl.total_duration_seconds)}`}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<SidePanel
|
||||
title={t("playlists.title")}
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("playlists"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
|
|
@ -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", <plex_key>] 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 <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 glass overflow-y-auto p-4 space-y-4">
|
||||
{/* Collapse control + active-filter count — mirrors the feed Sidebar so the Plex filter panel
|
||||
can be collapsed from the Plex page too (the collapsed rail + shared state already existed). */}
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.collapsePanel")}
|
||||
aria-label={t("sidebar.collapsePanel")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
|
||||
{activeCount > 0 && (
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count: activeCount })}
|
||||
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Scope — one unified library, filtered to movies, shows, or both. */}
|
||||
<Section label={t("plex.filter.scope")}>
|
||||
<div className="flex gap-1">
|
||||
// Group bodies (no title — the PanelGroup card supplies it). Only available groups are pushed,
|
||||
// so facet-gated / scope-gated sections drop out cleanly; PanelGroups orders them by the saved
|
||||
// layout and lets the user reorder / hide / collapse them.
|
||||
const items: PanelItem[] = [
|
||||
{
|
||||
id: "scope",
|
||||
title: t("plex.filter.scope"),
|
||||
body: (
|
||||
<div className="grid grid-cols-3 gap-1">
|
||||
{(["both", "movie", "show"] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setScope(s)}
|
||||
className={`inline-flex flex-1 items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-sm transition ${
|
||||
className={`min-w-0 px-1.5 py-1.5 rounded-lg text-xs font-medium truncate transition ${
|
||||
s === scope ? "bg-accent text-accent-fg" : "glass-card glass-hover"
|
||||
}`}
|
||||
>
|
||||
{s === "movie" ? <Film className="w-4 h-4" /> : s === "show" ? <Tv2 className="w-4 h-4" /> : <Layers className="w-4 h-4" />}
|
||||
<span className="truncate">{t(`plex.filter.scopeOpt.${s}`)}</span>
|
||||
{t(`plex.filter.scopeOpt.${s}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Playlists — per-user, Siftlode-native ordered watch-lists. Near the top: it's navigation, not
|
||||
a filter. Available in any library. */}
|
||||
<Section label={t("plex.playlist.section")}>
|
||||
<div className="mb-1.5 flex gap-1.5">
|
||||
<input
|
||||
value={newPlaylist}
|
||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||
placeholder={t("plex.playlist.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylist.trim()}
|
||||
title={t("plex.playlist.create")}
|
||||
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{playlistsQ.data!.playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onOpenPlaylist(p.id)}
|
||||
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
||||
>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||
</button>
|
||||
))}
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "playlists",
|
||||
title: t("plex.playlist.section"),
|
||||
body: (
|
||||
<>
|
||||
<div className="mb-1.5 flex gap-1.5">
|
||||
<input
|
||||
value={newPlaylist}
|
||||
onChange={(e) => setNewPlaylist(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && createPlaylist()}
|
||||
placeholder={t("plex.playlist.newPlaceholder")}
|
||||
className="glass-card min-w-0 flex-1 rounded-lg px-2 py-1 text-xs outline-none focus:border-accent"
|
||||
/>
|
||||
<button
|
||||
onClick={createPlaylist}
|
||||
disabled={!newPlaylist.trim()}
|
||||
title={t("plex.playlist.create")}
|
||||
className="glass-card glass-hover shrink-0 rounded-lg p-1.5 disabled:opacity-50"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{anyActive && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="w-full inline-flex items-center justify-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs text-muted hover:border-accent hover:text-accent transition"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")} ({activeCount})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Watch state — movies per-title, shows aggregated across their episodes (0052) */}
|
||||
<Section label={t("plex.filter.show")}>
|
||||
{(playlistsQ.data?.playlists ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted">{t("plex.playlist.none")}</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{playlistsQ.data!.playlists.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => onOpenPlaylist(p.id)}
|
||||
className="glass-hover flex w-full items-center gap-2 rounded px-1.5 py-1 text-left text-sm"
|
||||
>
|
||||
<ListMusic className="h-3.5 w-3.5 shrink-0 text-muted" />
|
||||
<span className="min-w-0 flex-1 truncate">{p.title}</span>
|
||||
<span className="shrink-0 text-xs text-muted">{p.item_count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "watch",
|
||||
title: t("plex.filter.show"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
{SHOW_OPTS.map((s) => (
|
||||
<Chip key={s} active={show === s} onClick={() => setShow(s)}>
|
||||
|
|
@ -219,32 +183,36 @@ export default function PlexSidebar({
|
|||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Sort + direction */}
|
||||
<Section label={t("plex.filter.sort")}>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{(["asc", "desc"] as const).map((d) => (
|
||||
<Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
|
||||
{t(`plex.filter.dir.${d}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Metadata filters — movie AND show libraries (0052); the duration bucket is movie-only. */}
|
||||
{(
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "sort",
|
||||
title: t("plex.filter.sort"),
|
||||
body: (
|
||||
<>
|
||||
{/* Active people / studios — set by clicking the info page (stackable). */}
|
||||
{filters.directors.length + filters.actors.length + filters.studios.length > 0 && (
|
||||
<Section label={t("plex.filter.active")}>
|
||||
<ChipRow>
|
||||
{sorts.map((s) => (
|
||||
<Chip key={s} active={sort === s} onClick={() => setSort(s)}>
|
||||
{t(`plex.sort.${s}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
<div className="mt-1.5 flex gap-1">
|
||||
{(["asc", "desc"] as const).map((d) => (
|
||||
<Chip key={d} active={(filters.sortDir ?? "asc") === d} onClick={() => patch({ sortDir: d })}>
|
||||
{t(`plex.filter.dir.${d}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
...(peopleActive
|
||||
? [
|
||||
{
|
||||
id: "people",
|
||||
title: t("plex.filter.active"),
|
||||
body: (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{filters.directors.map((d) => (
|
||||
<RemovableChip
|
||||
|
|
@ -268,116 +236,128 @@ export default function PlexSidebar({
|
|||
/>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Collection — the active one shows as a chip; otherwise a searchable picker over the union
|
||||
of all enabled libraries' collections (also settable from an item info page's "Browse
|
||||
collection"). */}
|
||||
<Section label={t("plex.filter.collection")}>
|
||||
{filters.collection ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
value={collSearch}
|
||||
onChange={(e) => setCollSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
<span className="text-xs text-muted">{c.child_count}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "collection",
|
||||
title: t("plex.filter.collection"),
|
||||
body: filters.collection ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<RemovableChip
|
||||
label={filters.collectionTitle || filters.collection}
|
||||
onRemove={() => patch({ collection: null, collectionTitle: null })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<input
|
||||
value={collSearch}
|
||||
onChange={(e) => setCollSearch(e.target.value)}
|
||||
placeholder={t("plex.filter.collectionSearch")}
|
||||
className="mb-1.5 w-full rounded-lg border border-border bg-card px-2 py-1 text-sm focus:border-accent focus:outline-none"
|
||||
/>
|
||||
<div className="flex max-h-56 flex-col gap-0.5 overflow-y-auto">
|
||||
{collections.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => patch({ collection: c.id, collectionTitle: c.title })}
|
||||
className="glass-hover flex items-center gap-2 rounded-lg px-2 py-1 text-left text-sm"
|
||||
>
|
||||
<Layers className="w-3.5 h-3.5 shrink-0 text-muted" />
|
||||
<span className="flex-1 truncate">{c.title}</span>
|
||||
<span className="text-xs text-muted">{c.child_count}</span>
|
||||
</button>
|
||||
))}
|
||||
{collections.length === 0 && (
|
||||
<span className="px-2 py-1 text-xs text-muted">{t("plex.filter.collectionEmpty")}</span>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* IMDb rating min */}
|
||||
<Section label={t("plex.filter.rating")}>
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Genres (from facets) + Any/All when more than one is picked */}
|
||||
{facets && facets.genres.length > 0 && (
|
||||
<Section label={t("plex.filter.genre")}>
|
||||
{filters.genres.length > 1 && (
|
||||
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => patch({ genreMode: m })}
|
||||
className={`px-2 py-0.5 transition ${
|
||||
(filters.genreMode ?? "any") === m
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:bg-surface"
|
||||
}`}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "rating",
|
||||
title: t("plex.filter.rating"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip active={filters.ratingMin == null} onClick={() => patch({ ratingMin: null })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{RATING_STEPS.map((r) => (
|
||||
<Chip key={r} active={filters.ratingMin === r} onClick={() => patch({ ratingMin: r })}>
|
||||
{r}+
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
),
|
||||
},
|
||||
...(facets && facets.genres.length > 0
|
||||
? [
|
||||
{
|
||||
id: "genre",
|
||||
title: t("plex.filter.genre"),
|
||||
body: (
|
||||
<>
|
||||
{filters.genres.length > 1 && (
|
||||
<div className="mb-1.5 inline-flex overflow-hidden rounded-lg border border-border text-[11px]">
|
||||
{(["any", "all"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => patch({ genreMode: m })}
|
||||
className={`px-2 py-0.5 transition ${
|
||||
(filters.genreMode ?? "any") === m
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:bg-surface"
|
||||
}`}
|
||||
>
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChipRow>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
>
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
{g.value}
|
||||
</Chip>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ChipRow>
|
||||
{facets.genres.map((g) => (
|
||||
<Chip
|
||||
key={g.value}
|
||||
active={filters.genres.includes(g.value)}
|
||||
onClick={() => patch({ genres: toggleIn(filters.genres, g.value) })}
|
||||
>
|
||||
{g.value}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Year range */}
|
||||
<Section label={t("plex.filter.year")}>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Duration buckets — movie-only (a show has no single runtime) */}
|
||||
{scope === "movie" && (
|
||||
<Section label={t("plex.filter.duration")}>
|
||||
</ChipRow>
|
||||
</>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "year",
|
||||
title: t("plex.filter.year"),
|
||||
body: (
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberInput
|
||||
value={filters.yearMin ?? null}
|
||||
placeholder={facets?.year_min ?? undefined}
|
||||
onChange={(v) => patch({ yearMin: v })}
|
||||
/>
|
||||
<span className="text-muted text-xs">–</span>
|
||||
<NumberInput
|
||||
value={filters.yearMax ?? null}
|
||||
placeholder={facets?.year_max ?? undefined}
|
||||
onChange={(v) => patch({ yearMax: v })}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
...(scope === "movie"
|
||||
? [
|
||||
{
|
||||
id: "duration",
|
||||
title: t("plex.filter.duration"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip
|
||||
active={!durBucketKey && filters.durationMin == null && filters.durationMax == null}
|
||||
|
|
@ -395,26 +375,32 @@ export default function PlexSidebar({
|
|||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Added to Plex */}
|
||||
<Section label={t("plex.filter.added")}>
|
||||
<ChipRow>
|
||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{ADDED_OPTS.map((a) => (
|
||||
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
||||
{t(`plex.filter.addedOpt.${a}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
|
||||
{/* Content rating (from facets) */}
|
||||
{facets && facets.content_ratings.length > 0 && (
|
||||
<Section label={t("plex.filter.contentRating")}>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "added",
|
||||
title: t("plex.filter.added"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
<Chip active={!filters.addedWithin} onClick={() => patch({ addedWithin: "" })}>
|
||||
{t("plex.filter.any")}
|
||||
</Chip>
|
||||
{ADDED_OPTS.map((a) => (
|
||||
<Chip key={a} active={filters.addedWithin === a} onClick={() => patch({ addedWithin: a })}>
|
||||
{t(`plex.filter.addedOpt.${a}`)}
|
||||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
),
|
||||
},
|
||||
...(facets && facets.content_ratings.length > 0
|
||||
? [
|
||||
{
|
||||
id: "contentrating",
|
||||
title: t("plex.filter.contentRating"),
|
||||
body: (
|
||||
<ChipRow>
|
||||
{facets.content_ratings.map((c) => (
|
||||
<Chip
|
||||
|
|
@ -426,20 +412,37 @@ export default function PlexSidebar({
|
|||
</Chip>
|
||||
))}
|
||||
</ChipRow>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
function Section({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted mb-1.5">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
<SidePanel
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("plex"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={
|
||||
anyActive ? (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted hover:text-accent transition px-1 whitespace-nowrap"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
{t("plex.filter.clearAll")}
|
||||
</button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
127
frontend/src/components/SidePanel.tsx
Normal file
127
frontend/src/components/SidePanel.tsx
Normal file
|
|
@ -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 (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.expand")}
|
||||
aria-label={t("sidebar.expand")}
|
||||
className="hidden md:flex my-3 mr-3 w-11 shrink-0 rounded-2xl glass glass-hover flex-col items-center justify-center gap-3 text-fg"
|
||||
>
|
||||
<span className="text-accent">{icon}</span>
|
||||
{count > 0 && (
|
||||
<span className="bg-accent text-accent-fg text-[10px] font-bold px-1.5 py-0.5 rounded-full tabular-nums">
|
||||
{count > 9 ? "9+" : count}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted"
|
||||
style={{ writingMode: "vertical-rl", transform: "rotate(180deg)" }}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="hidden md:flex my-3 mr-3 w-64 shrink-0 rounded-2xl glass flex-col overflow-hidden">
|
||||
{/* Row 1 — identity only: collapse, icon, title (flexes + truncates last), count, customize.
|
||||
Module actions live on their own row below so the title always has room. */}
|
||||
<div className="flex items-center gap-1.5 px-3 py-2.5 border-b border-border/60 flex-none">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.collapsePanel")}
|
||||
aria-label={t("sidebar.collapsePanel")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="shrink-0 flex text-accent">{icon}</span>
|
||||
<span className="flex-1 min-w-0 truncate text-xs font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count })}
|
||||
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
{editing && onReset && (
|
||||
<button
|
||||
onClick={onReset}
|
||||
title={t("sidebar.resetDefaults")}
|
||||
className="shrink-0 p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{onToggleEditing && (
|
||||
<button
|
||||
onClick={onToggleEditing}
|
||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Row 2 — module actions (e.g. Clear all / Share), only when not customizing. */}
|
||||
{!editing && actions && (
|
||||
<div className="flex items-center justify-between gap-2 px-3 py-1.5 border-b border-border/60 flex-none">
|
||||
{actions}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div ref={fade.ref} style={fade.style} className="flex-1 min-h-0 overflow-y-auto no-scrollbar">
|
||||
<div className="p-3 space-y-3">
|
||||
{editing && editHint && <div className="text-[11px] text-muted">{editHint}</div>}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.<id>") 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<WidgetId, boolean> = {
|
||||
// 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<string, boolean> = {
|
||||
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 <SavedViewsWidget filters={filters} onApply={setFilters} />;
|
||||
|
|
@ -447,23 +370,51 @@ export default function Sidebar({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
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 <CollapsedFilterRail activeCount={activeCount} onToggleCollapse={onToggleCollapse} />;
|
||||
}
|
||||
const actions = (
|
||||
<>
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={!active}
|
||||
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
<button
|
||||
onClick={shareView}
|
||||
title={t("sidebar.shareView")}
|
||||
aria-label={t("sidebar.shareView")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside className="hidden md:block w-64 shrink-0 glass overflow-y-auto p-4 space-y-3">
|
||||
{/* Scope: your own subscriptions (Mine) vs the shared Library — moved out of the top bar. */}
|
||||
<SidePanel
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
collapsed={collapsed}
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout("feed"))}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={actions}
|
||||
>
|
||||
{/* Scope: your own subscriptions (Mine) vs the shared Library. */}
|
||||
<div
|
||||
className="flex items-center rounded-full border border-border bg-card p-0.5 text-xs"
|
||||
role="group"
|
||||
|
|
@ -479,83 +430,12 @@ export default function Sidebar({
|
|||
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{s === "my" ? (
|
||||
<User className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<Library className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{s === "my" ? <User className="w-3.5 h-3.5" /> : <Library className="w-3.5 h-3.5" />}
|
||||
<span>{t("header.scope." + s)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={t("sidebar.collapsePanel")}
|
||||
aria-label={t("sidebar.collapsePanel")}
|
||||
className="shrink-0 -ml-1 p-1 rounded-md text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold shrink-0">{t("sidebar.filters")}</span>
|
||||
{activeCount > 0 && (
|
||||
// Compact count pill (not "{n} active" text) so it never truncates or forces the
|
||||
// action buttons to wrap in a narrow / zoomed-in sidebar.
|
||||
<span
|
||||
title={t("sidebar.activeCount", { count: activeCount })}
|
||||
className="shrink-0 min-w-[18px] h-[18px] px-1.5 rounded-full bg-accent/15 text-accent text-[11px] font-semibold inline-flex items-center justify-center tabular-nums"
|
||||
>
|
||||
{activeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={clearAll}
|
||||
disabled={!active}
|
||||
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1 whitespace-nowrap"
|
||||
>
|
||||
{t("sidebar.clearAll")}
|
||||
</button>
|
||||
)}
|
||||
{!editing && (
|
||||
<button
|
||||
onClick={shareView}
|
||||
title={t("sidebar.shareView")}
|
||||
aria-label={t("sidebar.shareView")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{editing && (
|
||||
<button
|
||||
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
||||
title={t("sidebar.resetDefaults")}
|
||||
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
className={`p-1.5 rounded-lg transition hover:bg-card ${
|
||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div className="text-[11px] text-muted -mt-1">{t("sidebar.editHint")}</div>
|
||||
)}
|
||||
|
||||
{filters.channelId && !editing && (
|
||||
<div className="glass-card rounded-xl">
|
||||
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
|
||||
|
|
@ -575,107 +455,11 @@ export default function Sidebar({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={onDragEnd}>
|
||||
<SortableContext items={renderedIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="space-y-3">
|
||||
{renderedIds.map((id) => (
|
||||
<WidgetCard
|
||||
key={id}
|
||||
id={id}
|
||||
title={t("sidebar.widget." + id)}
|
||||
editing={editing}
|
||||
collapsed={!!layout.collapsed[id]}
|
||||
hidden={!!layout.hidden[id]}
|
||||
onToggleCollapse={() => toggleCollapse(id)}
|
||||
onToggleHidden={() => toggleHidden(id)}
|
||||
>
|
||||
{widgetBody(id)}
|
||||
</WidgetCard>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<PanelGroups items={items} layout={layout} setLayout={setLayout} editing={editing} />
|
||||
|
||||
{tagManagerOpen && (
|
||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||
)}
|
||||
</aside>
|
||||
</SidePanel>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`glass-card rounded-xl ${
|
||||
isDragging ? "relative z-10 shadow-2xl ring-1 ring-accent" : ""
|
||||
} ${hidden && editing ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
{editing && (
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={t("sidebar.dragToReorder")}
|
||||
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1 select-none text-xs uppercase tracking-wide text-muted">
|
||||
{title}
|
||||
</div>
|
||||
{editing ? (
|
||||
<button
|
||||
onClick={onToggleHidden}
|
||||
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||
className="text-muted hover:text-fg"
|
||||
>
|
||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
className="text-muted hover:text-fg"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"title": "Playlists",
|
||||
"manageGroup": "Manage",
|
||||
"listGroup": "Your playlists",
|
||||
"watchLater": "Watch later",
|
||||
"syncYoutube": "Sync from YouTube",
|
||||
"syncedToast": "Synced {{count}} playlists from YouTube",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
{
|
||||
"title": "Lejátszási listák",
|
||||
"manageGroup": "Kezelés",
|
||||
"listGroup": "Listáid",
|
||||
"watchLater": "Megnézem később",
|
||||
"syncYoutube": "Szinkron YouTube-ról",
|
||||
"syncedToast": "{{count}} lista szinkronizálva a YouTube-ról",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,16 @@ body {
|
|||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
/* Hide the native scrollbar while keeping the element scrollable (wheel/trackpad/keyboard).
|
||||
Used on the nav rail so a high browser-zoom overflow scrolls without showing a scrollbar. */
|
||||
.no-scrollbar {
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* legacy Edge/IE */
|
||||
}
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chromium/WebKit */
|
||||
}
|
||||
|
||||
/* ===== Liquid-glass surface system (theme-aware, GPU-light) ===== */
|
||||
.glass {
|
||||
/* Frosted overlay surface: translucent enough that the blurred backdrop shows
|
||||
|
|
|
|||
73
frontend/src/lib/panelLayout.ts
Normal file
73
frontend/src/lib/panelLayout.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// Per-user customization of a side panel's group cards: their order, which are collapsed, and
|
||||
// which are hidden — the same "customize" affordance the feed filter rail had, generalized so
|
||||
// every panel (feed / plex / playlists) has its own saved layout. Persisted to localStorage and
|
||||
// the server-side `preferences` blob (schemaless), so it follows the account across devices.
|
||||
import { LS, readAccount, writeAccount } from "./storage";
|
||||
|
||||
export type PanelId = "feed" | "plex" | "playlists";
|
||||
|
||||
export interface PanelLayout {
|
||||
order: string[];
|
||||
collapsed: Record<string, boolean>;
|
||||
hidden: Record<string, boolean>;
|
||||
}
|
||||
|
||||
// The default group order per panel. Group ids must match the ids each panel feeds to PanelGroups.
|
||||
// Unknown/stale ids are dropped and any missing (e.g. a group added in a later version) are
|
||||
// appended by normalizeLayout, so nothing silently disappears.
|
||||
const PANEL_ORDER: Record<PanelId, string[]> = {
|
||||
feed: ["savedviews", "date", "tags", "language", "topic"],
|
||||
plex: [
|
||||
"scope",
|
||||
"playlists",
|
||||
"watch",
|
||||
"sort",
|
||||
"people",
|
||||
"collection",
|
||||
"rating",
|
||||
"genre",
|
||||
"year",
|
||||
"duration",
|
||||
"added",
|
||||
"contentrating",
|
||||
],
|
||||
playlists: ["manage", "list"],
|
||||
};
|
||||
|
||||
// localStorage key per panel (feed keeps its original key for backward compatibility).
|
||||
const LS_KEY: Record<PanelId, string> = {
|
||||
feed: LS.sidebarLayout,
|
||||
plex: LS.plexLayout,
|
||||
playlists: LS.playlistsLayout,
|
||||
};
|
||||
|
||||
// Key on the server `preferences` blob per panel (feed keeps its original key).
|
||||
export const PREF_KEY: Record<PanelId, "sidebarLayout" | "plexLayout" | "playlistsLayout"> = {
|
||||
feed: "sidebarLayout",
|
||||
plex: "plexLayout",
|
||||
playlists: "playlistsLayout",
|
||||
};
|
||||
|
||||
export function defaultLayout(panel: PanelId): PanelLayout {
|
||||
return { order: [...PANEL_ORDER[panel]], collapsed: {}, hidden: {} };
|
||||
}
|
||||
|
||||
export function normalizeLayout(panel: PanelId, raw: unknown): PanelLayout {
|
||||
const all = PANEL_ORDER[panel];
|
||||
const r = (raw ?? {}) as Partial<PanelLayout>;
|
||||
const order = Array.isArray(r.order) ? r.order.filter((x) => all.includes(x)) : [];
|
||||
for (const id of all) if (!order.includes(id)) order.push(id);
|
||||
return {
|
||||
order,
|
||||
collapsed: { ...(r.collapsed ?? {}) },
|
||||
hidden: { ...(r.hidden ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLayout(panel: PanelId): PanelLayout {
|
||||
return normalizeLayout(panel, readAccount<unknown>(LS_KEY[panel], {}));
|
||||
}
|
||||
|
||||
export function saveLayoutLocal(panel: PanelId, l: PanelLayout): void {
|
||||
writeAccount(LS_KEY[panel], l);
|
||||
}
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
// Per-user customization of the filter sidebar: order of the widget cards, which are
|
||||
// collapsed, and which are hidden. Persisted to localStorage and the server-side
|
||||
// `preferences` blob so it follows the account.
|
||||
|
||||
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
|
||||
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
|
||||
import { LS, readAccount, writeAccount } from "./storage";
|
||||
|
||||
export type WidgetId = "savedviews" | "date" | "language" | "topic" | "tags";
|
||||
|
||||
const ALL_WIDGETS: WidgetId[] = ["savedviews", "date", "tags", "language", "topic"];
|
||||
|
||||
export interface SidebarLayout {
|
||||
order: WidgetId[];
|
||||
collapsed: Partial<Record<WidgetId, boolean>>;
|
||||
hidden: Partial<Record<WidgetId, boolean>>;
|
||||
}
|
||||
|
||||
export const DEFAULT_LAYOUT: SidebarLayout = {
|
||||
order: [...ALL_WIDGETS],
|
||||
collapsed: {},
|
||||
hidden: {},
|
||||
};
|
||||
|
||||
// Tolerate stale/partial data: keep only known widgets and append any that are missing
|
||||
// (e.g. a widget added in a later version) so nothing silently disappears.
|
||||
export function normalizeLayout(raw: unknown): SidebarLayout {
|
||||
const r = (raw ?? {}) as Partial<SidebarLayout>;
|
||||
const order: WidgetId[] = Array.isArray(r.order)
|
||||
? (r.order.filter((x) => ALL_WIDGETS.includes(x as WidgetId)) as WidgetId[])
|
||||
: [];
|
||||
for (const w of ALL_WIDGETS) if (!order.includes(w)) order.push(w);
|
||||
return {
|
||||
order,
|
||||
collapsed: { ...(r.collapsed ?? {}) },
|
||||
hidden: { ...(r.hidden ?? {}) },
|
||||
};
|
||||
}
|
||||
|
||||
export function loadLayout(): SidebarLayout {
|
||||
return normalizeLayout(readAccount<unknown>(LS.sidebarLayout, {}));
|
||||
}
|
||||
|
||||
export function saveLayoutLocal(l: SidebarLayout): void {
|
||||
writeAccount(LS.sidebarLayout, l);
|
||||
}
|
||||
|
|
@ -6,6 +6,9 @@ import { useCallback, useState } from "react";
|
|||
export const LS = {
|
||||
theme: "siftlode.theme",
|
||||
sidebarLayout: "siftlode.sidebarLayout",
|
||||
plexLayout: "siftlode.plexLayout",
|
||||
playlistsLayout: "siftlode.playlistsLayout",
|
||||
playlistsCollapsed: "siftlode.playlistsCollapsed",
|
||||
hints: "siftlode.hints",
|
||||
lang: "siftlode.lang",
|
||||
filters: "siftlode.filters",
|
||||
|
|
|
|||
39
frontend/src/lib/useScrollFade.ts
Normal file
39
frontend/src/lib/useScrollFade.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
|
||||
// Scroll-affordance for a scroll container whose native scrollbar is hidden (`.no-scrollbar`):
|
||||
// fades the top/bottom edge of the content to hint there's more to scroll, but only on the edge
|
||||
// that's actually clipped (no fade at a boundary, none when it all fits). Returns a callback ref
|
||||
// to attach to the scroller and the mask `style` to spread onto it. Uses a node-state callback
|
||||
// ref (not useRef) so the effect (re)runs whenever the element attaches — e.g. a SidePanel that
|
||||
// mounts collapsed and only renders its scroll body once expanded still wires up correctly.
|
||||
export function useScrollFade(fadePx = 20): {
|
||||
ref: (node: HTMLDivElement | null) => void;
|
||||
style: CSSProperties;
|
||||
} {
|
||||
const [el, setEl] = useState<HTMLDivElement | null>(null);
|
||||
const [fade, setFade] = useState({ up: false, down: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!el) return;
|
||||
const update = () =>
|
||||
setFade({
|
||||
up: el.scrollTop > 1,
|
||||
down: el.scrollTop + el.clientHeight < el.scrollHeight - 1,
|
||||
});
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
// Observe the content wrapper too (if present) so growth/shrink of the list re-evaluates.
|
||||
if (el.firstElementChild) ro.observe(el.firstElementChild);
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
};
|
||||
}, [el]);
|
||||
|
||||
const mask = `linear-gradient(to bottom, transparent, #000 ${fade.up ? fadePx : 0}px, #000 calc(100% - ${
|
||||
fade.down ? fadePx : 0
|
||||
}px), transparent)`;
|
||||
return { ref: setEl, style: { maskImage: mask, WebkitMaskImage: mask } };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue