feat(feed): filter by year — per-year count facet + year control (E8·S2)

Add a Year filter to the feed rail: quick chips for recent years plus a
'More…' popover listing all years with per-year video counts, sortable by
year or count. Selecting a year narrows the feed to that whole calendar
year (a published_after/before range applied in the api layer) and is
mutually exclusive with the Upload-date control.

Backend: new /api/feed/years endpoint + feed_years() — one grouped query
that honours every filter EXCEPT the publish-date narrowing (dropped), so
the year list stays a live drill-down and switching years never zeroes the
others. FE keys the list on the date-stripped filters, so picking a year
re-queries the feed but not the list.

Wires year through FeedFilters, the filterParams/URL (?year) round-trip,
the active-filter chip row, and the panel layout. i18n HU+EN.

Tests: feed_years count query (date narrowing ignored, other filters
honoured); dateFilter year-clearing; urlState year round-trip.
This commit is contained in:
2026-08-25 02:55:12 +02:00
parent 2c843e549b
commit edcea87ea5
16 changed files with 373 additions and 9 deletions
@@ -151,6 +151,7 @@ export default function DateFilterSelect({
...filters,
dateFrom: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
}
className={dateInputCls}
@@ -169,6 +170,7 @@ export default function DateFilterSelect({
...filters,
dateTo: e.target.value || undefined,
maxAgeDays: undefined,
year: undefined,
})
}
className={dateInputCls}
+4
View File
@@ -15,6 +15,7 @@ import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import DateFilterSelect from "./DateFilterSelect";
import YearFilter from "./YearFilter";
import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
@@ -145,6 +146,7 @@ export default function Sidebar({
const available: Record<string, boolean> = {
date: true,
year: true,
channel: true,
language: languages.length > 0,
topic: topics.length > 0,
@@ -159,6 +161,8 @@ export default function Sidebar({
return (
<DateFilterSelect filters={filters} setFilters={setFilters} presets={DATE_PRESETS} />
);
case "year":
return <YearFilter filters={filters} setFilters={setFilters} />;
case "language":
return (
<Combobox
+196
View File
@@ -0,0 +1,196 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { ChevronDown } from "lucide-react";
import { qk } from "../lib/queryKeys";
import { api, type FeedFilters } from "../lib/api";
import { PopoverItem, PopoverSurface, useAnchoredPopover } from "./AnchoredPopover";
import { filterChipClass } from "./FilterChipGroup";
import CountBadge from "./CountBadge";
// The feed rail's "Year" control (item 8 / E8·S2): quick chips for the most recent years plus a
// "More…" popover listing ALL years with per-year counts, sortable by year or count. Selecting a year
// filters the feed to that calendar year (a whole-year published_after/before range, applied in the
// api layer); it's a single-select toggle (click the active year to clear it) and mutually exclusive
// with the Upload-date control — picking a year clears any maxAge/custom range, and vice versa.
//
// The per-year counts come from /feed/years, which drops the publish-date narrowing itself, so the
// list is keyed on the date-stripped filters: picking a year re-queries the FEED but never this list
// (its numbers wouldn't change) — the same drill-down principle as the tag facets dropping their
// own category.
const QUICK = 6; // recent-year chips shown before the "More…" overflow
export default function YearFilter({
filters,
setFilters,
}: {
filters: FeedFilters;
setFilters: (f: FeedFilters) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const [sortByCount, setSortByCount] = useState(false);
const facetFilters: FeedFilters = {
...filters,
year: undefined,
maxAgeDays: undefined,
dateFrom: undefined,
dateTo: undefined,
};
const yearsQuery = useQuery({
queryKey: qk.feedYears(facetFilters),
queryFn: () => api.feedYears(facetFilters),
staleTime: 30_000,
placeholderData: keepPreviousData,
});
const years = yearsQuery.data?.years ?? [];
const selected = filters.year;
// A year pick and the Upload-date control are mutually exclusive publish-date narrowings.
const pick = (year?: number) =>
setFilters({ ...filters, year, maxAgeDays: undefined, dateFrom: undefined, dateTo: undefined });
// Quick chips = the most recent years, plus the selected year if it falls outside that window (so the
// active pick is always visible), re-sorted newest-first.
const quick = useMemo(() => {
const top = years.slice(0, QUICK);
if (selected != null && !top.some((y) => y.year === selected)) {
const sel = years.find((y) => y.year === selected) ?? { year: selected, count: 0 };
return [...top, sel].sort((a, b) => b.year - a.year);
}
return top;
}, [years, selected]);
const sorted = useMemo(
() =>
[...years].sort((a, b) =>
sortByCount ? b.count - a.count || b.year - a.year : b.year - a.year,
),
[years, sortByCount],
);
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
// Honest states: a first-load / errored fetch must not read as "no years" (an empty chip row).
if (years.length === 0) {
if (yearsQuery.isLoading) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="status" aria-live="polite">
{t("common.loading")}
</p>
);
}
if (yearsQuery.isError) {
return (
<p className="text-muted px-1 py-1.5 text-xs" role="alert">
{t("common.loadError")}
</p>
);
}
return <p className="text-muted px-1 py-1.5 text-xs">{t("sidebar.year.none")}</p>;
}
return (
<div className="flex flex-wrap gap-1.5">
{quick.map((y) => {
const on = selected === y.year;
return (
<button
key={y.year}
type="button"
aria-pressed={on}
onClick={() => pick(on ? undefined : y.year)}
className={filterChipClass}
>
<span className="tabular-nums">{y.year}</span>
{y.count > 0 && <CountBadge count={y.count} active={on} />}
</button>
);
})}
{years.length > quick.length && (
<>
<button
type="button"
ref={pop.refs.setReference}
{...pop.getReferenceProps()}
aria-label={t("sidebar.year.moreLabel")}
className={filterChipClass}
>
<span>{t("sidebar.year.more")}</span>
<ChevronDown className="text-muted h-3.5 w-3.5" aria-hidden="true" />
</button>
<PopoverSurface
popover={pop}
ariaLabel={t("sidebar.year.moreLabel")}
className="glass-menu min-w-44 rounded-xl p-1.5 shadow-lg animate-[popIn_0.16s_ease]"
>
<div className="mb-1 flex items-center gap-2 border-b border-border px-1 pb-2">
<span className="text-2xs text-muted">{t("sidebar.year.sortBy")}</span>
<div className="ml-auto flex gap-1">
{(
[
["sortYear", false],
["sortCount", true],
] as const
).map(([key, v]) => (
<button
key={key}
type="button"
aria-pressed={sortByCount === v}
onClick={() => setSortByCount(v)}
className={`text-2xs rounded-md border px-2 py-0.5 transition ${
sortByCount === v
? "border-accent bg-accent/15 text-accent"
: "border-border text-muted hover:border-accent/50 hover:text-fg"
}`}
>
{t("sidebar.year." + key)}
</button>
))}
</div>
</div>
<div
className="max-h-64 overflow-y-auto no-scrollbar"
role="group"
aria-label={t("sidebar.year.moreLabel")}
>
{sorted.map((y) => {
const on = selected === y.year;
return (
<PopoverItem
key={y.year}
role="menuitemradio"
checked={on}
onSelect={() => {
pick(on ? undefined : y.year);
setOpen(false);
}}
label={String(y.year)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition ${
on ? "text-fg" : "text-muted"
}`}
>
<span
className={`h-1.5 w-1.5 shrink-0 rounded-full ${on ? "bg-accent" : "bg-transparent"}`}
/>
<span className="flex-1 tabular-nums">{y.year}</span>
<span className="text-2xs text-muted shrink-0 tabular-nums">{y.count}</span>
</PopoverItem>
);
})}
</div>
</PopoverSurface>
</>
)}
</div>
);
}