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:
parent
a44bdf7fdc
commit
4921a95d09
7 changed files with 115 additions and 46 deletions
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue