Video cards show a resume progress bar for started-but-unfinished videos and a hover overlay: Play on every card, Continue + Restart on in-progress ones. The in-app player now resumes from (and checkpoints to) the server position instead of localStorage, accepts an explicit startAt (Restart -> 0), and refreshes the feed on close so the card bar reflects the session. Sidebar gains an 'In progress' show filter.
568 lines
18 KiB
TypeScript
568 lines
18 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: "priority", label: "Channel priority" },
|
||
{ id: "shuffle", label: "Surprise me" },
|
||
];
|
||
|
||
const SHOWS = [
|
||
{ id: "unwatched", label: "Unwatched" },
|
||
{ id: "in_progress", label: "In progress" },
|
||
{ 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 ?? [];
|
||
|
||
// 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"],
|
||
queryFn: api.channels,
|
||
enabled: needChannelName,
|
||
staleTime: 5 * 60_000,
|
||
});
|
||
const resolvedChannelName =
|
||
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 ? "Loading…" : "This channel");
|
||
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="glass-card rounded-xl">
|
||
<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">{channelChipLabel}</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={`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="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>
|
||
);
|
||
}
|