1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the toolbar chip row as its own single-select group, divided from the content-type chips; removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic). 2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration, Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'. Backend gains the missing directions (views_asc, title_desc, subscribers_asc, priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
607 lines
20 KiB
TypeScript
607 lines
20 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
Eye,
|
|
EyeOff,
|
|
GripVertical,
|
|
Pencil,
|
|
RefreshCw,
|
|
RotateCcw,
|
|
Share2,
|
|
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,
|
|
type SidebarLayout,
|
|
type WidgetId,
|
|
} from "../lib/sidebarLayout";
|
|
import { shareUrl } from "../lib/urlState";
|
|
import { notify } from "../lib/notifications";
|
|
|
|
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
|
const SORT_IDS = [
|
|
"newest",
|
|
"oldest",
|
|
"views",
|
|
"duration_desc",
|
|
"duration_asc",
|
|
"title",
|
|
"subscribers",
|
|
"priority",
|
|
"shuffle",
|
|
];
|
|
|
|
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
|
|
|
|
// Fresh shuffle token for the "surprise me" sort.
|
|
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
|
|
|
|
const DATE_PRESETS: { days: number; key: string }[] = [
|
|
{ days: 1, key: "24h" },
|
|
{ days: 7, key: "1week" },
|
|
{ days: 30, key: "1month" },
|
|
{ days: 180, key: "6months" },
|
|
{ days: 365, key: "1year" },
|
|
];
|
|
|
|
// Filter values owned by the sidebar (everything except the header search `q` and the
|
|
// `scope` mode, both preserved across a "Clear all").
|
|
const DEFAULT_SIDEBAR_FILTERS: Omit<FeedFilters, "q" | "scope"> = {
|
|
tags: [],
|
|
tagMode: "or",
|
|
sort: "newest",
|
|
includeNormal: true,
|
|
includeShorts: false,
|
|
includeLive: false,
|
|
show: "unwatched",
|
|
};
|
|
|
|
function TagChip({
|
|
tag,
|
|
count,
|
|
active,
|
|
onClick,
|
|
}: {
|
|
tag: Tag;
|
|
count: number;
|
|
active: boolean;
|
|
onClick: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
title={t("sidebar.channelCount", { count })}
|
|
className={`inline-flex items-center gap-1.5 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"
|
|
}`}
|
|
>
|
|
<span>{tag.name}</span>
|
|
<span
|
|
className={`tabular-nums text-[10px] leading-none px-1 py-0.5 rounded-full ${
|
|
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
|
|
}`}
|
|
>
|
|
{count}
|
|
</span>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function Sidebar({
|
|
filters,
|
|
setFilters,
|
|
layout,
|
|
setLayout,
|
|
}: {
|
|
filters: FeedFilters;
|
|
setFilters: (f: FeedFilters) => void;
|
|
layout: SidebarLayout;
|
|
setLayout: (l: SidebarLayout) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tagsQuery = useQuery({ queryKey: ["tags"], queryFn: api.tags });
|
|
const tags = tagsQuery.data ?? [];
|
|
|
|
// Live per-tag channel counts for the current filter context (scope, channel, date,
|
|
// search, watch state, other category's tags). Lets us show contextual counts and hide
|
|
// chips that no longer match anything. Keyed on filters so it refetches as they change.
|
|
const facetsQuery = useQuery({
|
|
queryKey: ["facets", filters],
|
|
queryFn: () => api.facets(filters),
|
|
staleTime: 30_000,
|
|
});
|
|
const facetsReady = !!facetsQuery.data;
|
|
const facetCounts = facetsQuery.data?.counts ?? {};
|
|
// Before facets load, fall back to the static global count so chips don't flash empty.
|
|
const chipCount = (tag: Tag): number =>
|
|
facetsReady ? facetCounts[String(tag.id)] ?? 0 : tag.channel_count;
|
|
// Visible chips: hide zero-count ones once facets are in, but always keep selected ones
|
|
// so an active filter can still be cleared. Sorted by count (largest first), then name —
|
|
// so scanning top→bottom the smallest counts end up at the very bottom.
|
|
const visibleChips = (list: Tag[]): Tag[] => {
|
|
const shown = facetsReady
|
|
? list.filter((tg) => chipCount(tg) > 0 || filters.tags.includes(tg.id))
|
|
: list;
|
|
return [...shown].sort(
|
|
(a, b) => chipCount(b) - chipCount(a) || a.name.localeCompare(b.name)
|
|
);
|
|
};
|
|
|
|
// 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
|
|
? t("sidebar.loading")
|
|
: t("sidebar.thisChannel"));
|
|
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;
|
|
|
|
async function shareView() {
|
|
try {
|
|
await navigator.clipboard.writeText(shareUrl(filters));
|
|
notify({ level: "success", message: t("sidebar.shareCopied") });
|
|
} catch {
|
|
notify({ level: "warning", message: t("sidebar.shareFailed") });
|
|
}
|
|
}
|
|
|
|
function clearAll() {
|
|
setCustomDates(false);
|
|
setFilters({ ...DEFAULT_SIDEBAR_FILTERS, q: filters.q, scope: filters.scope });
|
|
}
|
|
|
|
const available: Record<WidgetId, boolean> = {
|
|
date: 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 "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"
|
|
}`}
|
|
>
|
|
{t("sidebar.datePreset." + p.key)}
|
|
</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"
|
|
}`}
|
|
>
|
|
{t("sidebar.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">{t("sidebar.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">{t("sidebar.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"
|
|
>
|
|
{t("sidebar.clearDates")}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
case "language": {
|
|
const chips = visibleChips(languages);
|
|
return (
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{chips.map((tg) => (
|
|
<TagChip
|
|
key={tg.id}
|
|
tag={tg}
|
|
count={chipCount(tg)}
|
|
active={filters.tags.includes(tg.id)}
|
|
onClick={() => toggleTag(tg.id)}
|
|
/>
|
|
))}
|
|
{facetsReady && chips.length === 0 && (
|
|
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
case "topic": {
|
|
const chips = visibleChips(topics);
|
|
return (
|
|
<>
|
|
<div
|
|
className="flex items-center justify-between mb-1.5"
|
|
title={t("sidebar.tagModeTooltip")}
|
|
>
|
|
<span className="text-[10px] uppercase tracking-wide text-muted">
|
|
{t("sidebar.match")}
|
|
</span>
|
|
<div
|
|
className="flex items-center rounded-full border border-border bg-card p-0.5 text-[10px]"
|
|
role="group"
|
|
aria-label={t("sidebar.match")}
|
|
>
|
|
{(["or", "and"] as const).map((m) => (
|
|
<button
|
|
key={m}
|
|
onClick={() => setFilters({ ...filters, tagMode: m })}
|
|
aria-pressed={filters.tagMode === m}
|
|
className={`px-2 py-0.5 rounded-full transition ${
|
|
filters.tagMode === m
|
|
? "bg-accent text-accent-fg"
|
|
: "text-muted hover:text-fg"
|
|
}`}
|
|
>
|
|
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{chips.map((tg) => (
|
|
<TagChip
|
|
key={tg.id}
|
|
tag={tg}
|
|
count={chipCount(tg)}
|
|
active={filters.tags.includes(tg.id)}
|
|
onClick={() => toggleTag(tg.id)}
|
|
/>
|
|
))}
|
|
{facetsReady && chips.length === 0 && (
|
|
<span className="text-[11px] text-muted">{t("sidebar.noMatchingTags")}</span>
|
|
)}
|
|
</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">
|
|
{t("sidebar.filters")}
|
|
{activeCount > 0 && (
|
|
<span className="ml-1.5 text-xs font-medium text-accent">
|
|
{t("sidebar.activeCount", { count: activeCount })}
|
|
</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"
|
|
>
|
|
{t("sidebar.clearAll")}
|
|
</button>
|
|
)}
|
|
{!editing && (
|
|
<button
|
|
onClick={shareView}
|
|
title={t("sidebar.shareView")}
|
|
aria-label={t("sidebar.shareView")}
|
|
className="p-1.5 rounded-lg text-muted hover:text-fg hover:bg-card transition"
|
|
>
|
|
<Share2 className="w-4 h-4" />
|
|
</button>
|
|
)}
|
|
{editing && (
|
|
<button
|
|
onClick={() => setLayout({ ...DEFAULT_LAYOUT })}
|
|
title={t("sidebar.resetDefaults")}
|
|
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 ? t("sidebar.done") : t("sidebar.customize")}
|
|
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">{t("sidebar.editHint")}</div>
|
|
)}
|
|
|
|
{filters.channelId && !editing && (
|
|
<div className="glass-card rounded-xl">
|
|
<div className="px-3 pt-2 text-xs uppercase tracking-wide text-muted">
|
|
{t("sidebar.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={t("sidebar.widget." + 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 { t } = useTranslation();
|
|
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={t("sidebar.dragToReorder")}
|
|
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 ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
|
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 ? t("sidebar.expand") : t("sidebar.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>
|
|
);
|
|
}
|