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:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user