feat(ui): customizable sidebar — collapsible, reorderable, toggleable widgets

Each filter group is now a card with a collapse chevron. An Edit mode (pencil)
reveals drag handles (@dnd-kit) to reorder and eye toggles to show/hide widgets,
plus Reset to defaults. Layout (order/collapsed/hidden) persists to localStorage
and the server preferences blob, adopted on login.
This commit is contained in:
npeter83 2026-06-11 19:21:48 +02:00
parent 98025d8fab
commit ae0cd89e20
5 changed files with 542 additions and 213 deletions

View file

@ -6,6 +6,9 @@
"": { "": {
"name": "subfeed-frontend", "name": "subfeed-frontend",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.408.0", "lucide-react": "^0.408.0",
@ -318,6 +321,59 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5", "version": "0.21.5",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
@ -2641,6 +2697,12 @@
"dev": true, "dev": true,
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "5.9.3", "version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",

View file

@ -8,6 +8,9 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@tanstack/react-query": "^5.51.0", "@tanstack/react-query": "^5.51.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^0.408.0", "lucide-react": "^0.408.0",

View file

@ -9,6 +9,12 @@ import {
type ThemePrefs, type ThemePrefs,
} from "./lib/theme"; } from "./lib/theme";
import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState"; import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState";
import {
loadLayout,
normalizeLayout,
saveLayoutLocal,
type SidebarLayout,
} from "./lib/sidebarLayout";
import Login from "./components/Login"; import Login from "./components/Login";
import Header from "./components/Header"; import Header from "./components/Header";
import Sidebar from "./components/Sidebar"; import Sidebar from "./components/Sidebar";
@ -47,6 +53,7 @@ export default function App() {
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme()); const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters); const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
const [view, setView] = useState<"grid" | "list">("grid"); const [view, setView] = useState<"grid" | "list">("grid");
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
function setFilters(next: FeedFilters) { function setFilters(next: FeedFilters) {
setFiltersState(next); setFiltersState(next);
@ -54,6 +61,12 @@ export default function App() {
syncUrl(next); syncUrl(next);
} }
function setSidebarLayout(next: SidebarLayout) {
setSidebarLayoutState(next);
saveLayoutLocal(next);
api.savePrefs({ sidebarLayout: next }).catch(() => {});
}
useEffect(() => applyTheme(theme), [theme]); useEffect(() => applyTheme(theme), [theme]);
// Reflect the initial filters (from localStorage or URL) into the address bar // Reflect the initial filters (from localStorage or URL) into the address bar
@ -72,6 +85,11 @@ export default function App() {
saveLocalTheme(merged); saveLocalTheme(merged);
} }
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view); if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
if (prefs.sidebarLayout) {
const l = normalizeLayout(prefs.sidebarLayout);
setSidebarLayoutState(l);
saveLayoutLocal(l);
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.id]); }, [meQuery.data?.id]);
@ -108,7 +126,12 @@ export default function App() {
setView={changeView} setView={changeView}
/> />
<div className="flex flex-1 min-h-0"> <div className="flex flex-1 min-h-0">
<Sidebar filters={filters} setFilters={setFilters} /> <Sidebar
filters={filters}
setFilters={setFilters}
layout={sidebarLayout}
setLayout={setSidebarLayout}
/>
<main className="flex-1 min-w-0 overflow-y-auto"> <main className="flex-1 min-w-0 overflow-y-auto">
<Feed filters={filters} setFilters={setFilters} view={view} /> <Feed filters={filters} setFilters={setFilters} view={view} />
</main> </main>

View file

@ -1,26 +1,37 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { X } from "lucide-react"; import {
Check,
ChevronDown,
Eye,
EyeOff,
GripVertical,
Pencil,
RotateCcw,
X,
} from "lucide-react";
import {
closestCenter,
DndContext,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { api, type FeedFilters, type Tag } from "../lib/api"; import { api, type FeedFilters, type Tag } from "../lib/api";
import {
const DATE_PRESETS: { days: number; label: string }[] = [ DEFAULT_LAYOUT,
{ days: 1, label: "24h" }, WIDGET_TITLES,
{ days: 7, label: "1 week" }, type SidebarLayout,
{ days: 30, label: "1 month" }, type WidgetId,
{ days: 180, label: "6 months" }, } from "../lib/sidebarLayout";
{ days: 365, label: "1 year" },
];
// Filter values owned by the sidebar (everything except the header search `q`).
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
tags: [],
tagMode: "or",
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
const SORTS = [ const SORTS = [
{ id: "newest", label: "Newest" }, { id: "newest", label: "Newest" },
@ -41,6 +52,25 @@ const SHOWS = [
{ id: "hidden", label: "Hidden" }, { id: "hidden", label: "Hidden" },
]; ];
const DATE_PRESETS: { days: number; label: string }[] = [
{ days: 1, label: "24h" },
{ days: 7, label: "1 week" },
{ days: 30, label: "1 month" },
{ days: 180, label: "6 months" },
{ days: 365, label: "1 year" },
];
// Filter values owned by the sidebar (everything except the header search `q`).
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q"> = {
tags: [],
tagMode: "or",
sort: "newest",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
function TagChip({ function TagChip({
tag, tag,
active, active,
@ -68,15 +98,24 @@ function TagChip({
export default function Sidebar({ export default function Sidebar({
filters, filters,
setFilters, setFilters,
layout,
setLayout,
}: { }: {
filters: FeedFilters; filters: FeedFilters;
setFilters: (f: FeedFilters) => void; setFilters: (f: FeedFilters) => void;
layout: SidebarLayout;
setLayout: (l: SidebarLayout) => void;
}) { }) {
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
const tags = tagsQuery.data ?? []; const tags = tagsQuery.data ?? [];
const languages = tags.filter((t) => t.category === "language"); const languages = tags.filter((t) => t.category === "language");
const topics = tags.filter((t) => t.category === "topic"); const topics = tags.filter((t) => t.category === "topic");
const [customDates, setCustomDates] = useState(false); const [customDates, setCustomDates] = useState(false);
const [editing, setEditing] = useState(false);
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 4 } })
);
function toggleTag(id: number) { function toggleTag(id: number) {
const has = filters.tags.includes(id); const has = filters.tags.includes(id);
@ -100,43 +139,37 @@ export default function Sidebar({
function clearAll() { function clearAll() {
setCustomDates(false); setCustomDates(false);
setFilters({ setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q });
...DEFAULT_SIDEBAR_FILTERS,
q: filters.q,
});
} }
const available: Record<WidgetId, boolean> = {
show: true,
sort: true,
date: true,
content: true,
language: languages.length > 0,
topic: topics.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 {
switch (id) {
case "show":
return ( return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
<div className="flex items-center justify-between -mb-1">
<div className="text-sm font-semibold">
Filters
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
)}
</div>
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition"
>
Clear all
</button>
</div>
{filters.channelId && (
<Section title="Channel">
<button
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
>
<span className="truncate">{filters.channelName ?? "This channel"}</span>
<X className="w-4 h-4 shrink-0" />
</button>
</Section>
)}
<Section title="Show">
<div className="grid grid-cols-2 gap-1.5"> <div className="grid grid-cols-2 gap-1.5">
{SHOWS.map((s) => ( {SHOWS.map((s) => (
<button <button
@ -152,9 +185,9 @@ export default function Sidebar({
</button> </button>
))} ))}
</div> </div>
</Section> );
case "sort":
<Section title="Sort"> return (
<select <select
value={filters.sort} value={filters.sort}
onChange={(e) => setFilters({ ...filters, sort: e.target.value })} onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
@ -166,9 +199,10 @@ export default function Sidebar({
</option> </option>
))} ))}
</select> </select>
</Section> );
case "date":
<Section title="Upload date"> return (
<>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{DATE_PRESETS.map((p) => { {DATE_PRESETS.map((p) => {
const activePreset = filters.maxAgeDays === p.days && !customDates; const activePreset = filters.maxAgeDays === p.days && !customDates;
@ -253,9 +287,11 @@ export default function Sidebar({
)} )}
</div> </div>
)} )}
</Section> </>
);
<Section title="Content type"> case "content":
return (
<>
<Toggle <Toggle
label="Normal" label="Normal"
checked={filters.includeNormal} checked={filters.includeNormal}
@ -271,10 +307,10 @@ export default function Sidebar({
checked={filters.includeLive} checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })} onChange={(v) => setFilters({ ...filters, includeLive: v })}
/> />
</Section> </>
);
{languages.length > 0 && ( case "language":
<Section title="Language"> return (
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{languages.map((t) => ( {languages.map((t) => (
<TagChip <TagChip
@ -285,13 +321,11 @@ export default function Sidebar({
/> />
))} ))}
</div> </div>
</Section> );
)} case "topic":
return (
{topics.length > 0 && ( <>
<Section <div className="flex justify-end mb-1.5">
title="Topic"
right={
<button <button
onClick={() => onClick={() =>
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" }) setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
@ -301,8 +335,7 @@ export default function Sidebar({
> >
{filters.tagMode === "or" ? "Any" : "All"} {filters.tagMode === "or" ? "Any" : "All"}
</button> </button>
} </div>
>
<div className="flex flex-wrap gap-1.5"> <div className="flex flex-wrap gap-1.5">
{topics.map((t) => ( {topics.map((t) => (
<TagChip <TagChip
@ -313,29 +346,173 @@ export default function Sidebar({
/> />
))} ))}
</div> </div>
</Section> </>
);
}
}
const orderedAvailable = layout.order.filter((id) => available[id]);
const renderedIds = editing
? orderedAvailable
: orderedAvailable.filter((id) => !layout.hidden[id]);
return (
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-3 hidden md:block">
<div className="flex items-center justify-between">
<div className="text-sm font-semibold">
Filters
{activeCount > 0 && (
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
)}
</div>
<div className="flex items-center gap-0.5">
{!editing && (
<button
onClick={clearAll}
disabled={!active}
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition px-1"
>
Clear all
</button>
)}
{editing && (
<button
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
title="Reset to defaults"
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 ? "Done" : "Customize sidebar"}
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">Drag to reorder · eye to show/hide</div>
)} )}
{filters.channelId && !editing && (
<div className="rounded-xl border border-border bg-card/30">
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
<div className="px-3 pb-3 pt-2">
<button
onClick={() =>
setFilters({ ...filters, channelId: undefined, channelName: undefined })
}
className="w-full flex items-center justify-between gap-2 text-sm px-3 py-2 rounded-lg bg-accent text-accent-fg shadow-sm hover:opacity-90 transition"
>
<span className="truncate">{filters.channelName ?? "This channel"}</span>
<X className="w-4 h-4 shrink-0" />
</button>
</div>
</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={WIDGET_TITLES[id]}
editing={editing}
collapsed={!!layout.collapsed[id]}
hidden={!!layout.hidden[id]}
onToggleCollapse={() => toggleCollapse(id)}
onToggleHidden={() => toggleHidden(id)}
>
{widgetBody(id)}
</WidgetCard>
))}
</div>
</SortableContext>
</DndContext>
</aside> </aside>
); );
} }
function Section({ function WidgetCard({
id,
title, title,
right, editing,
collapsed,
hidden,
onToggleCollapse,
onToggleHidden,
children, children,
}: { }: {
id: WidgetId;
title: string; title: string;
right?: React.ReactNode; editing: boolean;
collapsed: boolean;
hidden: boolean;
onToggleCollapse: () => void;
onToggleHidden: () => void;
children: React.ReactNode; children: React.ReactNode;
}) { }) {
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 ( return (
<div> <div
<div className="flex items-center justify-between mb-2"> ref={setNodeRef}
<div className="text-xs uppercase tracking-wide text-muted">{title}</div> style={style}
{right} className={`rounded-xl border border-border bg-card/30 ${
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="Drag to reorder"
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> </div>
{children} {editing ? (
<button
onClick={onToggleHidden}
title={hidden ? "Show widget" : "Hide widget"}
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 ? "Expand" : "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> </div>
); );
} }

View file

@ -0,0 +1,64 @@
// 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.
export type WidgetId = "show" | "sort" | "date" | "content" | "language" | "topic";
export const ALL_WIDGETS: WidgetId[] = [
"show",
"sort",
"date",
"content",
"language",
"topic",
];
export const WIDGET_TITLES: Record<WidgetId, string> = {
show: "Show",
sort: "Sort",
date: "Upload date",
content: "Content type",
language: "Language",
topic: "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: {},
};
const KEY = "subfeed.sidebarLayout";
// 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 {
try {
return normalizeLayout(JSON.parse(localStorage.getItem(KEY) || "{}"));
} catch {
return DEFAULT_LAYOUT;
}
}
export function saveLayoutLocal(l: SidebarLayout): void {
localStorage.setItem(KEY, JSON.stringify(l));
}