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";
import { qk } from "../lib/queryKeys";
import {
ArrowDown,
ArrowUp,
ArrowLeft,
Columns2,
Grid3x3,
@@ -34,12 +32,14 @@ import {
SORT_KEYS,
type SortKey,
} from "../lib/feedSort";
import { type SortOption } from "../lib/columnSort";
import { FEED_VIEWS, type FeedView } from "../lib/feedView";
import { clearedFilters, useActiveFilters } from "../lib/useActiveFilters";
import ActiveFilterChips from "./ActiveFilterChips";
import { LoadingState, StateMessage } from "./QueryState";
import { PageToolbar } from "./PageShell";
import { useWizard } from "./WizardProvider";
import SortControl from "./SortControl";
import ViewSwitcher from "./ViewSwitcher";
import VirtualFeed from "./VirtualFeed";
// 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);
// 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 = (
<div className="pb-3">
@@ -615,58 +627,33 @@ export default function Feed({
</span>
<div className="ml-auto flex items-center gap-2">
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
const key = e.target.value as SortKey;
const dir = DIRECTIONLESS.includes(key)
? "desc"
: SORT_DEFAULT_DIR[key as Exclude<SortKey, "shuffle" | "relevance">];
<SortControl
options={sortOptions}
sort={{ key: sortKey, dir: sortDir }}
onChange={(next) => {
if (!next) return; // the feed is always sorted — no "no sort" option is offered
setFilters({
...filters,
sort: buildSort(key, dir),
seed: key === "shuffle" ? rollSeed() : undefined,
sort: buildSort(next.key as SortKey, next.dir),
seed: next.key === "shuffle" ? rollSeed() : undefined,
});
}}
aria-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"
>
{/* Relevance only ranks meaningfully when there's a search term — offer it then. */}
{(filters.q.trim() ? (["relevance", ...sortKeys] as SortKey[]) : sortKeys).map((k) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{!DIRECTIONLESS.includes(sortKey) && (
<button
onClick={() =>
setFilters({
...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>
)}
label={t("feed.sortLabel")}
ascLabel={t("feed.dirAsc")}
descLabel={t("feed.dirDesc")}
trailing={
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>
) : undefined
}
/>
<span className="mx-0.5 h-5 w-px bg-border" aria-hidden="true" />
<ViewSwitcher
value={view}