From 01960d3bb7df942440393b36d816b5cfc4c601b9 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Thu, 11 Jun 2026 19:21:48 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20customizable=20sidebar=20=E2=80=94?= =?UTF-8?q?=20collapsible,=20reorderable,=20toggleable=20widgets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/package-lock.json | 62 +++ frontend/package.json | 3 + frontend/src/App.tsx | 25 +- frontend/src/components/Sidebar.tsx | 601 ++++++++++++++++++---------- frontend/src/lib/sidebarLayout.ts | 64 +++ 5 files changed, 542 insertions(+), 213 deletions(-) create mode 100644 frontend/src/lib/sidebarLayout.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 02b8356..3f93ad2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -6,6 +6,9 @@ "": { "name": "subfeed-frontend", "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", "clsx": "^2.1.1", "lucide-react": "^0.408.0", @@ -318,6 +321,59 @@ "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": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", @@ -2641,6 +2697,12 @@ "dev": true, "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": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 8ef7c07..f0847f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -8,6 +8,9 @@ "preview": "vite preview" }, "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", "clsx": "^2.1.1", "lucide-react": "^0.408.0", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f75b329..9dd4d3c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,12 @@ import { type ThemePrefs, } from "./lib/theme"; import { hasFilterParams, paramsToFilters, syncUrl } from "./lib/urlState"; +import { + loadLayout, + normalizeLayout, + saveLayoutLocal, + type SidebarLayout, +} from "./lib/sidebarLayout"; import Login from "./components/Login"; import Header from "./components/Header"; import Sidebar from "./components/Sidebar"; @@ -47,6 +53,7 @@ export default function App() { const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); + const [sidebarLayout, setSidebarLayoutState] = useState(loadLayout); function setFilters(next: FeedFilters) { setFiltersState(next); @@ -54,6 +61,12 @@ export default function App() { syncUrl(next); } + function setSidebarLayout(next: SidebarLayout) { + setSidebarLayoutState(next); + saveLayoutLocal(next); + api.savePrefs({ sidebarLayout: next }).catch(() => {}); + } + useEffect(() => applyTheme(theme), [theme]); // Reflect the initial filters (from localStorage or URL) into the address bar @@ -72,6 +85,11 @@ export default function App() { saveLocalTheme(merged); } 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 }, [meQuery.data?.id]); @@ -108,7 +126,12 @@ export default function App() { setView={changeView} />
- +
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 24944d0..d7258e6 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,26 +1,37 @@ import { useState } from "react"; 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"; - -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 = { - tags: [], - tagMode: "or", - sort: "newest", - includeNormal: true, - includeShorts: false, - includeLive: false, - show: "unwatched", -}; +import { + DEFAULT_LAYOUT, + WIDGET_TITLES, + type SidebarLayout, + type WidgetId, +} from "../lib/sidebarLayout"; const SORTS = [ { id: "newest", label: "Newest" }, @@ -41,6 +52,25 @@ const SHOWS = [ { 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 = { + tags: [], + tagMode: "or", + sort: "newest", + includeNormal: true, + includeShorts: false, + includeLive: false, + show: "unwatched", +}; + function TagChip({ tag, active, @@ -68,15 +98,24 @@ function TagChip({ export default function Sidebar({ filters, setFilters, + layout, + setLayout, }: { filters: FeedFilters; setFilters: (f: FeedFilters) => void; + layout: SidebarLayout; + setLayout: (l: SidebarLayout) => void; }) { const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags }); const tags = tagsQuery.data ?? []; const languages = tags.filter((t) => t.category === "language"); const topics = tags.filter((t) => t.category === "topic"); const [customDates, setCustomDates] = 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); @@ -100,181 +139,178 @@ export default function Sidebar({ function clearAll() { setCustomDates(false); - setFilters({ - ...DEFAULT_SIDEBAR_FILTERS, - q: filters.q, - }); + setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q }); } - return ( -
)} + + +
+ {renderedIds.map((id) => ( + + ))} +
+
+
); } -function Section({ +function WidgetCard({ + id, title, - right, + editing, + collapsed, + hidden, + onToggleCollapse, + onToggleHidden, children, }: { + id: WidgetId; title: string; - right?: React.ReactNode; + editing: boolean; + collapsed: boolean; + hidden: boolean; + onToggleCollapse: () => void; + onToggleHidden: () => void; 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 ( -
-
-
{title}
- {right} +
+
+ {editing && ( + + )} +
+ {title} +
+ {editing ? ( + + ) : ( + + )}
- {children} + {showBody &&
{children}
}
); } diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts new file mode 100644 index 0000000..a9c0676 --- /dev/null +++ b/frontend/src/lib/sidebarLayout.ts @@ -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 = { + show: "Show", + sort: "Sort", + date: "Upload date", + content: "Content type", + language: "Language", + topic: "Topic", +}; + +export interface SidebarLayout { + order: WidgetId[]; + collapsed: Partial>; + hidden: Partial>; +} + +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; + 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)); +}