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.
547 lines
17 KiB
TypeScript
547 lines
17 KiB
TypeScript
import { useState } from "react";
|
||
import { useQuery } from "@tanstack/react-query";
|
||
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 {
|
||
DEFAULT_LAYOUT,
|
||
WIDGET_TITLES,
|
||
type SidebarLayout,
|
||
type WidgetId,
|
||
} from "../lib/sidebarLayout";
|
||
|
||
const SORTS = [
|
||
{ id: "newest", label: "Newest" },
|
||
{ id: "oldest", label: "Oldest" },
|
||
{ id: "views", label: "Most viewed" },
|
||
{ id: "duration_desc", label: "Longest" },
|
||
{ id: "duration_asc", label: "Shortest" },
|
||
{ id: "title", label: "Name (A–Z)" },
|
||
{ id: "subscribers", label: "Channel subscribers" },
|
||
{ id: "shuffle", label: "Surprise me" },
|
||
];
|
||
|
||
const SHOWS = [
|
||
{ id: "unwatched", label: "Unwatched" },
|
||
{ id: "all", label: "All" },
|
||
{ id: "watched", label: "Watched" },
|
||
{ id: "saved", label: "Saved" },
|
||
{ 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({
|
||
tag,
|
||
active,
|
||
onClick,
|
||
}: {
|
||
tag: Tag;
|
||
active: boolean;
|
||
onClick: () => void;
|
||
}) {
|
||
return (
|
||
<button
|
||
onClick={onClick}
|
||
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
|
||
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
||
active
|
||
? "bg-accent text-accent-fg border-accent"
|
||
: "bg-card border-border text-fg hover:border-accent"
|
||
}`}
|
||
>
|
||
{tag.name}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
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);
|
||
setFilters({
|
||
...filters,
|
||
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
|
||
});
|
||
}
|
||
|
||
const dateActive = !!filters.maxAgeDays || !!filters.dateFrom || !!filters.dateTo;
|
||
const contentChanged =
|
||
!filters.includeNormal || filters.includeShorts || filters.includeLive;
|
||
const activeCount =
|
||
filters.tags.length +
|
||
(filters.show !== "unwatched" ? 1 : 0) +
|
||
(filters.sort !== "newest" ? 1 : 0) +
|
||
(contentChanged ? 1 : 0) +
|
||
(filters.channelId ? 1 : 0) +
|
||
(dateActive ? 1 : 0);
|
||
const active = activeCount > 0;
|
||
|
||
function clearAll() {
|
||
setCustomDates(false);
|
||
setFilters({ ...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 (
|
||
<div className="grid grid-cols-2 gap-1.5">
|
||
{SHOWS.map((s) => (
|
||
<button
|
||
key={s.id}
|
||
onClick={() => setFilters({ ...filters, show: s.id })}
|
||
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>
|
||
);
|
||
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>
|
||
|
||
{(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>
|
||
)}
|
||
</>
|
||
);
|
||
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">
|
||
{languages.map((t) => (
|
||
<TagChip
|
||
key={t.id}
|
||
tag={t}
|
||
active={filters.tags.includes(t.id)}
|
||
onClick={() => toggleTag(t.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
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>
|
||
)}
|
||
|
||
{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>
|
||
);
|
||
}
|
||
|
||
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 { 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={`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>
|
||
{showBody && <div className="px-3 pb-3">{children}</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Toggle({
|
||
label,
|
||
checked,
|
||
onChange,
|
||
}: {
|
||
label: string;
|
||
checked: boolean;
|
||
onChange: (v: boolean) => void;
|
||
}) {
|
||
return (
|
||
<label className="flex items-center justify-between py-1 cursor-pointer text-sm">
|
||
<span>{label}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => onChange(!checked)}
|
||
className={`w-9 h-5 rounded-full transition relative ${
|
||
checked ? "bg-accent" : "bg-border"
|
||
}`}
|
||
>
|
||
<span
|
||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-all ${
|
||
checked ? "left-[18px]" : "left-0.5"
|
||
}`}
|
||
/>
|
||
</button>
|
||
</label>
|
||
);
|
||
}
|