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:
parent
98025d8fab
commit
ae0cd89e20
5 changed files with 542 additions and 213 deletions
62
frontend/package-lock.json
generated
62
frontend/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,181 +139,178 @@ 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,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const available: Record<WidgetId, boolean> = {
|
||||||
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
|
show: true,
|
||||||
<div className="flex items-center justify-between -mb-1">
|
sort: true,
|
||||||
<div className="text-sm font-semibold">
|
date: true,
|
||||||
Filters
|
content: true,
|
||||||
{activeCount > 0 && (
|
language: languages.length > 0,
|
||||||
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
|
topic: topics.length > 0,
|
||||||
)}
|
};
|
||||||
</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 && (
|
function toggleCollapse(id: WidgetId) {
|
||||||
<Section title="Channel">
|
setLayout({ ...layout, collapsed: { ...layout.collapsed, [id]: !layout.collapsed[id] } });
|
||||||
<button
|
}
|
||||||
onClick={() => setFilters({ ...filters, channelId: undefined, channelName: undefined })}
|
function toggleHidden(id: WidgetId) {
|
||||||
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"
|
setLayout({ ...layout, hidden: { ...layout.hidden, [id]: !layout.hidden[id] } });
|
||||||
>
|
}
|
||||||
<span className="truncate">{filters.channelName ?? "This channel"}</span>
|
function onDragEnd(e: DragEndEvent) {
|
||||||
<X className="w-4 h-4 shrink-0" />
|
const { active: a, over } = e;
|
||||||
</button>
|
if (!over || a.id === over.id) return;
|
||||||
</Section>
|
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) });
|
||||||
|
}
|
||||||
|
|
||||||
<Section title="Show">
|
function widgetBody(id: WidgetId): React.ReactNode {
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
switch (id) {
|
||||||
{SHOWS.map((s) => (
|
case "show":
|
||||||
<button
|
return (
|
||||||
key={s.id}
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
onClick={() => setFilters({ ...filters, show: s.id })}
|
{SHOWS.map((s) => (
|
||||||
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
|
||||||
filters.show === s.id
|
|
||||||
? "bg-accent text-accent-fg border-accent"
|
|
||||||
: "bg-card border-border hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{s.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Sort">
|
|
||||||
<select
|
|
||||||
value={filters.sort}
|
|
||||||
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
|
||||||
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{SORTS.map((s) => (
|
|
||||||
<option key={s.id} value={s.id}>
|
|
||||||
{s.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<Section title="Upload date">
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{DATE_PRESETS.map((p) => {
|
|
||||||
const activePreset = filters.maxAgeDays === p.days && !customDates;
|
|
||||||
return (
|
|
||||||
<button
|
<button
|
||||||
key={p.days}
|
key={s.id}
|
||||||
onClick={() =>
|
onClick={() => setFilters({ ...filters, show: s.id })}
|
||||||
setFilters({
|
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
||||||
...filters,
|
filters.show === s.id
|
||||||
maxAgeDays: activePreset ? undefined : p.days,
|
|
||||||
dateFrom: undefined,
|
|
||||||
dateTo: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
|
||||||
activePreset
|
|
||||||
? "bg-accent text-accent-fg border-accent"
|
? "bg-accent text-accent-fg border-accent"
|
||||||
: "bg-card border-border hover:border-accent"
|
: "bg-card border-border hover:border-accent"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{p.label}
|
{s.label}
|
||||||
</button>
|
</button>
|
||||||
);
|
))}
|
||||||
})}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
const next = !customDates;
|
|
||||||
setCustomDates(next);
|
|
||||||
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
|
||||||
}}
|
|
||||||
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
|
||||||
customDates || filters.dateFrom || filters.dateTo
|
|
||||||
? "bg-accent text-accent-fg border-accent"
|
|
||||||
: "bg-card border-border hover:border-accent"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(customDates || filters.dateFrom || filters.dateTo) && (
|
|
||||||
<div className="flex flex-col gap-1.5 mt-2">
|
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="text-muted">From</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.dateFrom ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
dateFrom: e.target.value || undefined,
|
|
||||||
maxAgeDays: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center justify-between gap-2 text-xs">
|
|
||||||
<span className="text-muted">To</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={filters.dateTo ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
dateTo: e.target.value || undefined,
|
|
||||||
maxAgeDays: undefined,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
{(filters.dateFrom || filters.dateTo) && (
|
|
||||||
<button
|
|
||||||
onClick={() =>
|
|
||||||
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
|
||||||
}
|
|
||||||
className="text-[11px] text-muted hover:text-accent self-end"
|
|
||||||
>
|
|
||||||
clear dates
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
);
|
||||||
</Section>
|
case "sort":
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={filters.sort}
|
||||||
|
onChange={(e) => setFilters({ ...filters, sort: e.target.value })}
|
||||||
|
className="w-full bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
|
||||||
|
>
|
||||||
|
{SORTS.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
case "date":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{DATE_PRESETS.map((p) => {
|
||||||
|
const activePreset = filters.maxAgeDays === p.days && !customDates;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.days}
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
maxAgeDays: activePreset ? undefined : p.days,
|
||||||
|
dateFrom: undefined,
|
||||||
|
dateTo: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
||||||
|
activePreset
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const next = !customDates;
|
||||||
|
setCustomDates(next);
|
||||||
|
if (next) setFilters({ ...filters, maxAgeDays: undefined });
|
||||||
|
}}
|
||||||
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm active:translate-y-px transition ${
|
||||||
|
customDates || filters.dateFrom || filters.dateTo
|
||||||
|
? "bg-accent text-accent-fg border-accent"
|
||||||
|
: "bg-card border-border hover:border-accent"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Section title="Content type">
|
{(customDates || filters.dateFrom || filters.dateTo) && (
|
||||||
<Toggle
|
<div className="flex flex-col gap-1.5 mt-2">
|
||||||
label="Normal"
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
checked={filters.includeNormal}
|
<span className="text-muted">From</span>
|
||||||
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
<input
|
||||||
/>
|
type="date"
|
||||||
<Toggle
|
value={filters.dateFrom ?? ""}
|
||||||
label="Shorts"
|
onChange={(e) =>
|
||||||
checked={filters.includeShorts}
|
setFilters({
|
||||||
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
...filters,
|
||||||
/>
|
dateFrom: e.target.value || undefined,
|
||||||
<Toggle
|
maxAgeDays: undefined,
|
||||||
label="Live / Upcoming"
|
})
|
||||||
checked={filters.includeLive}
|
}
|
||||||
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
/>
|
/>
|
||||||
</Section>
|
</label>
|
||||||
|
<label className="flex items-center justify-between gap-2 text-xs">
|
||||||
{languages.length > 0 && (
|
<span className="text-muted">To</span>
|
||||||
<Section title="Language">
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFilters({
|
||||||
|
...filters,
|
||||||
|
dateTo: e.target.value || undefined,
|
||||||
|
maxAgeDays: undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(filters.dateFrom || filters.dateTo) && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ ...filters, dateFrom: undefined, dateTo: undefined })
|
||||||
|
}
|
||||||
|
className="text-[11px] text-muted hover:text-accent self-end"
|
||||||
|
>
|
||||||
|
clear dates
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "content":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Toggle
|
||||||
|
label="Normal"
|
||||||
|
checked={filters.includeNormal}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Shorts"
|
||||||
|
checked={filters.includeShorts}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Live / Upcoming"
|
||||||
|
checked={filters.includeLive}
|
||||||
|
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
case "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,57 +321,198 @@ export default function Sidebar({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
);
|
||||||
|
case "topic":
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-end mb-1.5">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
||||||
|
}
|
||||||
|
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
||||||
|
title="Match any vs all selected tags"
|
||||||
|
>
|
||||||
|
{filters.tagMode === "or" ? "Any" : "All"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{topics.map((t) => (
|
||||||
|
<TagChip
|
||||||
|
key={t.id}
|
||||||
|
tag={t}
|
||||||
|
active={filters.tags.includes(t.id)}
|
||||||
|
onClick={() => toggleTag(t.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{topics.length > 0 && (
|
{filters.channelId && !editing && (
|
||||||
<Section
|
<div className="rounded-xl border border-border bg-card/30">
|
||||||
title="Topic"
|
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">Channel</div>
|
||||||
right={
|
<div className="px-3 pb-3 pt-2">
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
setFilters({ ...filters, tagMode: filters.tagMode === "or" ? "and" : "or" })
|
setFilters({ ...filters, channelId: undefined, channelName: undefined })
|
||||||
}
|
}
|
||||||
className="text-[10px] uppercase tracking-wide text-muted hover:text-accent"
|
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"
|
||||||
title="Match any vs all selected tags"
|
|
||||||
>
|
>
|
||||||
{filters.tagMode === "or" ? "Any" : "All"}
|
<span className="truncate">{filters.channelName ?? "This channel"}</span>
|
||||||
|
<X className="w-4 h-4 shrink-0" />
|
||||||
</button>
|
</button>
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{topics.map((t) => (
|
|
||||||
<TagChip
|
|
||||||
key={t.id}
|
|
||||||
tag={t}
|
|
||||||
active={filters.tags.includes(t.id)}
|
|
||||||
onClick={() => toggleTag(t.id)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</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>
|
||||||
|
{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>
|
</div>
|
||||||
{children}
|
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
64
frontend/src/lib/sidebarLayout.ts
Normal file
64
frontend/src/lib/sidebarLayout.ts
Normal 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));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue