refactor(feed): drive the feed sort toolbar through the shared SortControl (R13 S1)

The feed's bespoke select + asc/desc button is now the generic SortControl,
fed a SortOption[] built from the feed vocabulary (relevance offered only with
a query; shuffle/relevance directionless). The feedSort wire codec
(parseSort/buildSort) stays as the server-boundary translation, and the
reshuffle button rides SortControl's `trailing` slot so shuffle keeps its
re-seed affordance. Behaviour-equivalent; ArrowUp/ArrowDown imports dropped.
This commit is contained in:
2026-08-13 23:28:47 +02:00
parent 7d9da80795
commit 440bdafa74
+37 -50
View File
@@ -8,8 +8,6 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { import {
ArrowDown,
ArrowUp,
ArrowLeft, ArrowLeft,
Columns2, Columns2,
Grid3x3, Grid3x3,
@@ -34,12 +32,14 @@ import {
SORT_KEYS, SORT_KEYS,
type SortKey, type SortKey,
} from "../lib/feedSort"; } from "../lib/feedSort";
import { type SortOption } from "../lib/columnSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView"; import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters"; import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips"; import ActiveFilterChips from "./ActiveFilterChips";
import { LoadingState, StateMessage } from "./QueryState"; import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell"; import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider"; import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import ViewSwitcher from "./ViewSwitcher"; import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed"; import VirtualFeed from "./VirtualFeed";
// Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened. // Lazy: the in-app YouTube player (IFrame API) loads only when a video is first opened.
@@ -547,6 +547,18 @@ export default function Feed({
); );
const { key: sortKey, dir: sortDir } = parseSort(filters.sort); const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
// The feed's sort vocabulary as source-agnostic options for the shared SortControl. Relevance only
// ranks meaningfully with a search term (offered then); shuffle/relevance are directionless.
const sortOptions: SortOption[] = (
filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys
).map((k) => ({
key: k,
label: t("feed.sortKey." + k),
directionless: DIRECTIONLESS.includes(k),
defaultDir: DIRECTIONLESS.includes(k)
? undefined
: SORT_DEFAULT_DIR[k as Exclude<SortKey, "shuffle" | "relevance">],
}));
const toolbar = ( const toolbar = (
<div className="pb-3"> <div className="pb-3">
@@ -615,58 +627,33 @@ export default function Feed({
</span> </span>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span> <span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select <SortControl
value={sortKey} options={sortOptions}
onChange={(e) => { sort={{ key: sortKey, dir: sortDir }}
const key = e.target.value as SortKey; onChange={(next) => {
const dir = DIRECTIONLESS.includes(key) if (!next) return; // the feed is always sorted — no "no sort" option is offered
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
setFilters({ setFilters({
...filters, ...filters,
sort: buildSort(key, dir), sort: buildSort(next.key as SortKey, next.dir),
seed: key === "shuffle" ? rollSeed() : undefined, seed: next.key === "shuffle" ? rollSeed() : undefined,
}); });
}} }}
aria-label={t("feed.sortLabel")} label={t("feed.sortLabel")}
className="bg-surface border border-border rounded-lg px-2 py-1.5 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40" ascLabel={t("feed.dirAsc")}
> descLabel={t("feed.dirDesc")}
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */} trailing={
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => ( sortKey === "shuffle" ? (
<option key={k} value={k}> <button
{t("feed.sortKey." + k)} onClick={() => setFilters({ ...filters, seed: rollSeed() })}
</option> title={t("sidebar.reshuffle")}
))} aria-label={t("sidebar.reshuffle")}
</select> className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
{!DIRECTIONLESS.includes(sortKey) && ( >
<button <RefreshCw className="w-4 h-4" />
onClick={() => </button>
setFilters({ ) : undefined
...filters, }
sort: buildSort(sortKey, sortDir === "asc" ? "desc" : "asc"), />
})
}
title={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
aria-label={sortDir === "asc" ? t("feed.dirAsc") : t("feed.dirDesc")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
{sortDir === "asc" ? (
<ArrowUp className="w-4 h-4" />
) : (
<ArrowDown className="w-4 h-4" />
)}
</button>
)}
{sortKey === "shuffle" && (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" /> <span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher <ViewSwitcher
value={view} value={view}