feat(feed): Show chips in the toolbar + key+direction sort

1) Move the Show view filter (Unwatched/In progress/All/Watched/Hidden) up into the
   toolbar chip row as its own single-select group, divided from the content-type chips;
   removed the 'show' sidebar widget (sidebar now = Upload date / Language / Topic).
2) Rework ordering like the Playlists page: a sort-key dropdown (Date, Popular, Duration,
   Name, Channel subscribers, Channel priority, Surprise me) + a single asc/desc arrow
   toggle, instead of separate directional entries. 'Most viewed' is now 'Popular'.
   Backend gains the missing directions (views_asc, title_desc, subscribers_asc,
   priority_asc); the frontend maps (key, dir) -> the backend sort string, so FeedFilters
   is unchanged. Trilingual (feed.sortKey.*, feed.dirAsc/dirDesc). Feed.tsx normalized to LF.
This commit is contained in:
npeter83 2026-06-16 02:46:26 +02:00
parent 4973dcb09d
commit f72eee2583
7 changed files with 115 additions and 46 deletions

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import { ArrowDown, ArrowUp, RefreshCw } from "lucide-react";
import { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n";
import { notify } from "../lib/notifications";
@ -10,18 +10,9 @@ import PlayerModal from "./PlayerModal";
const PAGE = 60;
// Sort + content-type live in the feed toolbar (above the cards), not the filter sidebar.
const SORT_IDS = [
"newest",
"oldest",
"views",
"duration_desc",
"duration_asc",
"title",
"subscribers",
"priority",
"shuffle",
];
// The "Show" view filter, content-type filter and ordering live in the feed toolbar
// (above the cards), not the filter sidebar.
const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
const CONTENT = [
{ key: "includeNormal", label: "sidebar.content.normal" },
{ key: "includeShorts", label: "sidebar.content.shorts" },
@ -29,6 +20,33 @@ const CONTENT = [
] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000);
// Ordering = a key + direction (like the Playlists page), mapped to the backend sort strings
// (which encode both). One entry per concept; a single arrow flips the direction.
type SortKey = "date" | "popular" | "duration" | "title" | "subscribers" | "priority" | "shuffle";
const SORT_KEYS: SortKey[] = ["date", "popular", "duration", "title", "subscribers", "priority", "shuffle"];
const SORT_MAP: Record<Exclude<SortKey, "shuffle">, { asc: string; desc: string }> = {
date: { desc: "newest", asc: "oldest" },
popular: { desc: "views", asc: "views_asc" },
duration: { desc: "duration_desc", asc: "duration_asc" },
title: { asc: "title", desc: "title_desc" },
subscribers: { desc: "subscribers", asc: "subscribers_asc" },
priority: { desc: "priority", asc: "priority_asc" },
};
const SORT_DEFAULT_DIR: Record<Exclude<SortKey, "shuffle">, "asc" | "desc"> = {
date: "desc", popular: "desc", duration: "desc", title: "asc", subscribers: "desc", priority: "desc",
};
function parseSort(s: string): { key: SortKey; dir: "asc" | "desc" } {
if (s === "shuffle") return { key: "shuffle", dir: "desc" };
for (const k of Object.keys(SORT_MAP) as (keyof typeof SORT_MAP)[]) {
if (SORT_MAP[k].asc === s) return { key: k, dir: "asc" };
if (SORT_MAP[k].desc === s) return { key: k, dir: "desc" };
}
return { key: "date", dir: "desc" };
}
function buildSort(key: SortKey, dir: "asc" | "desc"): string {
return key === "shuffle" ? "shuffle" : SORT_MAP[key][dir];
}
function matchesView(status: string, show: string): boolean {
switch (show) {
case "hidden":
@ -210,9 +228,26 @@ export default function Feed({
</div>
);
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
const toolbar = (
<div className="pb-3">
<div className="flex items-center gap-1.5 flex-wrap mb-2.5">
{SHOW_IDS.map((id) => (
<button
key={id}
onClick={() => setFilters({ ...filters, show: id })}
aria-pressed={filters.show === id}
className={`text-xs px-3 py-1.5 rounded-full border transition ${
filters.show === id
? "bg-accent text-accent-fg border-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{t("sidebar.show." + id)}
</button>
))}
<span className="mx-1 h-5 w-px bg-border" aria-hidden="true" />
{CONTENT.map((c) => {
const on = filters[c.key];
return (
@ -243,20 +278,37 @@ export default function Feed({
<div className="flex-1" />
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select
value={filters.sort}
value={sortKey}
onChange={(e) => {
const sort = e.target.value;
setFilters({ ...filters, sort, seed: sort === "shuffle" ? rollSeed() : undefined });
const key = e.target.value as SortKey;
const dir = key === "shuffle" ? "desc" : SORT_DEFAULT_DIR[key];
setFilters({
...filters,
sort: buildSort(key, dir),
seed: key === "shuffle" ? rollSeed() : undefined,
});
}}
className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
>
{SORT_IDS.map((id) => (
<option key={id} value={id}>
{t("sidebar.sort." + id)}
{SORT_KEYS.map((k) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{filters.sort === "shuffle" && (
{sortKey !== "shuffle" && (
<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")}

View file

@ -214,7 +214,6 @@ export default function Sidebar({
}
const available: Record<WidgetId, boolean> = {
show: true,
date: true,
language: languages.length > 0,
topic: topics.length > 0,
@ -237,24 +236,6 @@ export default function Sidebar({
function widgetBody(id: WidgetId): React.ReactNode {
switch (id) {
case "show":
return (
<div className="grid grid-cols-2 gap-1.5">
{SHOW_IDS.map((id) => (
<button
key={id}
onClick={() => setFilters({ ...filters, show: id })}
className={`text-xs py-1.5 rounded-lg border shadow-sm active:translate-y-px transition ${
filters.show === id
? "bg-accent text-accent-fg border-accent"
: "bg-card border-border hover:border-accent"
}`}
>
{t("sidebar.show." + id)}
</button>
))}
</div>
);
case "date":
return (
<>

View file

@ -9,6 +9,17 @@
"videoCount_one": "{{formattedCount}} Video",
"videoCount_other": "{{formattedCount}} Videos",
"sortLabel": "Sortierung",
"dirAsc": "Aufsteigend",
"dirDesc": "Absteigend",
"sortKey": {
"date": "Datum",
"popular": "Beliebt",
"duration": "Dauer",
"title": "Name",
"subscribers": "Kanal-Abonnenten",
"priority": "Kanal-Priorität",
"shuffle": "Überraschung"
},
"loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”",
"hidden": "Video ausgeblendet",

View file

@ -9,6 +9,17 @@
"videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort",
"dirAsc": "Ascending",
"dirDesc": "Descending",
"sortKey": {
"date": "Date",
"popular": "Popular",
"duration": "Duration",
"title": "Name",
"subscribers": "Channel subscribers",
"priority": "Channel priority",
"shuffle": "Surprise me"
},
"loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”",
"hidden": "Video hidden",

View file

@ -9,6 +9,17 @@
"videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés",
"dirAsc": "Növekvő",
"dirDesc": "Csökkenő",
"sortKey": {
"date": "Dátum",
"popular": "Népszerű",
"duration": "Hossz",
"title": "Név",
"subscribers": "Csatorna feliratkozók",
"priority": "Csatorna prioritás",
"shuffle": "Meglepetés"
},
"loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”",
"hidden": "Videó elrejtve",

View file

@ -2,14 +2,13 @@
// collapsed, and which are hidden. Persisted to localStorage and the server-side
// `preferences` blob so it follows the account.
// `sort` and `content` moved to the feed toolbar (above the cards); they are no longer
// sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
export type WidgetId = "show" | "date" | "language" | "topic";
// `show`, `sort` and `content` moved to the feed toolbar (above the cards); they are no
// longer sidebar widgets. normalizeLayout drops them from any persisted layout automatically.
export type WidgetId = "date" | "language" | "topic";
export const ALL_WIDGETS: WidgetId[] = ["show", "date", "language", "topic"];
export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic"];
export const WIDGET_TITLES: Record<WidgetId, string> = {
show: "Show",
date: "Upload date",
language: "Language",
topic: "Topic",