2026-06-11 17:39:24 +02:00
|
|
|
|
import { useState } from "react";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
import { useQuery } from "@tanstack/react-query";
|
2026-06-11 03:47:51 +02:00
|
|
|
|
import { X } from "lucide-react";
|
2026-06-11 02:19:47 +02:00
|
|
|
|
import { api, type FeedFilters, type Tag } from "../lib/api";
|
|
|
|
|
|
|
2026-06-11 17:39:24 +02:00
|
|
|
|
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",
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
|
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" },
|
2026-06-11 04:00:17 +02:00
|
|
|
|
{ id: "title", label: "Name (A–Z)" },
|
|
|
|
|
|
{ id: "subscribers", label: "Channel subscribers" },
|
2026-06-11 02:19:47 +02:00
|
|
|
|
{ id: "shuffle", label: "Surprise me" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
const SHOWS = [
|
|
|
|
|
|
{ id: "unwatched", label: "Unwatched" },
|
|
|
|
|
|
{ id: "all", label: "All" },
|
|
|
|
|
|
{ id: "watched", label: "Watched" },
|
|
|
|
|
|
{ id: "saved", label: "Saved" },
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
|
{ id: "hidden", label: "Hidden" },
|
2026-06-11 02:19:47 +02:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function TagChip({
|
|
|
|
|
|
tag,
|
|
|
|
|
|
active,
|
|
|
|
|
|
onClick,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
tag: Tag;
|
|
|
|
|
|
active: boolean;
|
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={onClick}
|
fix: address reader-UI feedback (shorts, search, tags, login, UX)
- Shorts: confirm via youtube.com/shorts/<id> probe (SOCS cookie bypasses the
consent redirect) instead of a 60s heuristic; concurrent probing, shorts_probed
flag, scheduled refinement (migration 0005)
- Search: match title + channel name only (descriptions caused noisy results)
- Faceted tag filtering: AND across categories (language AND topic narrows),
OR within a category; any/all toggle applies to topics
- Language detection: majority vote over individual titles (fixes misdetections
like multipoleguy -> English; drops bogus Polish/Romanian)
- Login: drop forced consent so returning sign-in is quick (select_account)
- Feed cards: clickable channel name (opens channel), persistent saved badge,
undo toast on hide, Hidden view to restore; tag chips show counts in tooltip
2026-06-11 03:07:49 +02:00
|
|
|
|
title={`${tag.channel_count} channel${tag.channel_count === 1 ? "" : "s"}`}
|
2026-06-11 03:28:45 +02:00
|
|
|
|
className={`text-xs px-2.5 py-1 rounded-full border shadow-sm hover:shadow active:translate-y-px transition ${
|
2026-06-11 02:19:47 +02:00
|
|
|
|
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,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
filters: FeedFilters;
|
|
|
|
|
|
setFilters: (f: FeedFilters) => 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");
|
2026-06-11 17:39:24 +02:00
|
|
|
|
const [customDates, setCustomDates] = useState(false);
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
|
|
function toggleTag(id: number) {
|
|
|
|
|
|
const has = filters.tags.includes(id);
|
|
|
|
|
|
setFilters({
|
|
|
|
|
|
...filters,
|
|
|
|
|
|
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-11 17:39:24 +02:00
|
|
|
|
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,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2026-06-11 02:19:47 +02:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<aside className="w-64 shrink-0 border-r border-border bg-surface/40 overflow-y-auto p-4 space-y-5 hidden md:block">
|
2026-06-11 17:39:24 +02:00
|
|
|
|
<div className="flex items-center justify-between -mb-1">
|
|
|
|
|
|
<div className="text-sm font-semibold">
|
|
|
|
|
|
Filters
|
|
|
|
|
|
{activeCount > 0 && (
|
|
|
|
|
|
<span className="ml-1.5 text-xs font-medium text-accent">{activeCount} active</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button
|
|
|
|
|
|
onClick={clearAll}
|
|
|
|
|
|
disabled={!active}
|
|
|
|
|
|
className="text-xs text-muted enabled:hover:text-accent disabled:opacity-40 transition"
|
|
|
|
|
|
>
|
|
|
|
|
|
Clear all
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-11 03:47:51 +02:00
|
|
|
|
{filters.channelId && (
|
|
|
|
|
|
<Section title="Channel">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-06-11 02:19:47 +02:00
|
|
|
|
<Section title="Show">
|
|
|
|
|
|
<div className="grid grid-cols-2 gap-1.5">
|
|
|
|
|
|
{SHOWS.map((s) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={s.id}
|
|
|
|
|
|
onClick={() => setFilters({ ...filters, show: s.id })}
|
2026-06-11 03:28:45 +02:00
|
|
|
|
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
|
2026-06-11 02:19:47 +02:00
|
|
|
|
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>
|
|
|
|
|
|
|
2026-06-11 04:00:17 +02:00
|
|
|
|
<Section title="Upload date">
|
2026-06-11 17:39:24 +02:00
|
|
|
|
<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>
|
2026-06-11 04:00:17 +02:00
|
|
|
|
</div>
|
2026-06-11 17:39:24 +02:00
|
|
|
|
|
|
|
|
|
|
{(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>
|
|
|
|
|
|
)}
|
2026-06-11 04:00:17 +02:00
|
|
|
|
</Section>
|
|
|
|
|
|
|
2026-06-11 03:47:51 +02:00
|
|
|
|
<Section title="Content type">
|
|
|
|
|
|
<Toggle
|
|
|
|
|
|
label="Normal"
|
|
|
|
|
|
checked={filters.includeNormal}
|
|
|
|
|
|
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
|
|
|
|
|
|
/>
|
2026-06-11 02:19:47 +02:00
|
|
|
|
<Toggle
|
2026-06-11 03:47:51 +02:00
|
|
|
|
label="Shorts"
|
2026-06-11 02:19:47 +02:00
|
|
|
|
checked={filters.includeShorts}
|
|
|
|
|
|
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Toggle
|
2026-06-11 03:47:51 +02:00
|
|
|
|
label="Live / Upcoming"
|
2026-06-11 02:19:47 +02:00
|
|
|
|
checked={filters.includeLive}
|
|
|
|
|
|
onChange={(v) => setFilters({ ...filters, includeLive: v })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
|
|
|
|
|
|
{languages.length > 0 && (
|
|
|
|
|
|
<Section title="Language">
|
|
|
|
|
|
<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>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{topics.length > 0 && (
|
|
|
|
|
|
<Section
|
|
|
|
|
|
title="Topic"
|
|
|
|
|
|
right={
|
|
|
|
|
|
<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 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>
|
|
|
|
|
|
</Section>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function Section({
|
|
|
|
|
|
title,
|
|
|
|
|
|
right,
|
|
|
|
|
|
children,
|
|
|
|
|
|
}: {
|
|
|
|
|
|
title: string;
|
|
|
|
|
|
right?: React.ReactNode;
|
|
|
|
|
|
children: React.ReactNode;
|
|
|
|
|
|
}) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
|
<div className="text-xs uppercase tracking-wide text-muted">{title}</div>
|
|
|
|
|
|
{right}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{children}
|
|
|
|
|
|
</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>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|