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:
2026-07-13 05:13:40 +02:00
parent c2d914bacb
commit facb3ac9cb
18 changed files with 1111 additions and 872 deletions
+54 -270
View File
@@ -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>
);
}