Merge improvement/feed-toolbar-show-sort: Show chips + key/direction sort

This commit is contained in:
npeter83 2026-06-16 02:46:26 +02:00
commit 26eb0df9de
7 changed files with 115 additions and 46 deletions

View file

@ -275,10 +275,13 @@ SORTS = {
"newest": Video.published_at.desc().nulls_last(), "newest": Video.published_at.desc().nulls_last(),
"oldest": Video.published_at.asc().nulls_last(), "oldest": Video.published_at.asc().nulls_last(),
"views": Video.view_count.desc().nulls_last(), "views": Video.view_count.desc().nulls_last(),
"views_asc": Video.view_count.asc().nulls_last(),
"duration_desc": Video.duration_seconds.desc().nulls_last(), "duration_desc": Video.duration_seconds.desc().nulls_last(),
"duration_asc": Video.duration_seconds.asc().nulls_last(), "duration_asc": Video.duration_seconds.asc().nulls_last(),
"title": func.lower(Video.title).asc().nulls_last(), "title": func.lower(Video.title).asc().nulls_last(),
"title_desc": func.lower(Video.title).desc().nulls_last(),
"subscribers": Channel.subscriber_count.desc().nulls_last(), "subscribers": Channel.subscriber_count.desc().nulls_last(),
"subscribers_asc": Channel.subscriber_count.asc().nulls_last(),
# Your per-channel priority (set in the channel manager), newest first within a tier. # Your per-channel priority (set in the channel manager), newest first within a tier.
# coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row. # coalesce keeps it null-safe in "all" scope where unsubscribed channels have no row.
"priority": func.coalesce(Subscription.priority, 0).desc(), "priority": func.coalesce(Subscription.priority, 0).desc(),
@ -296,9 +299,10 @@ def get_feed(
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict: ) -> dict:
query, _status = _filtered_query(db, user, **params) query, _status = _filtered_query(db, user, **params)
if sort == "priority": if sort in ("priority", "priority_asc"):
prio = func.coalesce(Subscription.priority, 0)
query = query.order_by( query = query.order_by(
func.coalesce(Subscription.priority, 0).desc(), prio.asc() if sort == "priority_asc" else prio.desc(),
Video.published_at.desc().nulls_last(), Video.published_at.desc().nulls_last(),
) )
else: else:

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useInfiniteQuery, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { api, type FeedFilters, type Video } from "../lib/api";
import i18n from "../i18n"; import i18n from "../i18n";
import { notify } from "../lib/notifications"; import { notify } from "../lib/notifications";
@ -10,18 +10,9 @@ import PlayerModal from "./PlayerModal";
const PAGE = 60; const PAGE = 60;
// Sort + content-type live in the feed toolbar (above the cards), not the filter sidebar. // The "Show" view filter, content-type filter and ordering live in the feed toolbar
const SORT_IDS = [ // (above the cards), not the filter sidebar.
"newest", const SHOW_IDS = ["unwatched", "in_progress", "all", "watched", "hidden"];
"oldest",
"views",
"duration_desc",
"duration_asc",
"title",
"subscribers",
"priority",
"shuffle",
];
const CONTENT = [ const CONTENT = [
{ key: "includeNormal", label: "sidebar.content.normal" }, { key: "includeNormal", label: "sidebar.content.normal" },
{ key: "includeShorts", label: "sidebar.content.shorts" }, { key: "includeShorts", label: "sidebar.content.shorts" },
@ -29,6 +20,33 @@ const CONTENT = [
] as const; ] as const;
const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); 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 { function matchesView(status: string, show: string): boolean {
switch (show) { switch (show) {
case "hidden": case "hidden":
@ -210,9 +228,26 @@ export default function Feed({
</div> </div>
); );
const { key: sortKey, dir: sortDir } = parseSort(filters.sort);
const toolbar = ( const toolbar = (
<div className="pb-3"> <div className="pb-3">
<div className="flex items-center gap-1.5 flex-wrap mb-2.5"> <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) => { {CONTENT.map((c) => {
const on = filters[c.key]; const on = filters[c.key];
return ( return (
@ -243,20 +278,37 @@ export default function Feed({
<div className="flex-1" /> <div className="flex-1" />
<span className="text-xs text-muted">{t("feed.sortLabel")}</span> <span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select <select
value={filters.sort} value={sortKey}
onChange={(e) => { onChange={(e) => {
const sort = e.target.value; const key = e.target.value as SortKey;
setFilters({ ...filters, sort, seed: sort === "shuffle" ? rollSeed() : undefined }); 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" className="bg-card border border-border rounded-lg px-2 py-1.5 text-sm outline-none focus:border-accent"
> >
{SORT_IDS.map((id) => ( {SORT_KEYS.map((k) => (
<option key={id} value={id}> <option key={k} value={k}>
{t("sidebar.sort." + id)} {t("feed.sortKey." + k)}
</option> </option>
))} ))}
</select> </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 <button
onClick={() => setFilters({ ...filters, seed: rollSeed() })} onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")} title={t("sidebar.reshuffle")}

View file

@ -214,7 +214,6 @@ export default function Sidebar({
} }
const available: Record<WidgetId, boolean> = { const available: Record<WidgetId, boolean> = {
show: true,
date: true, date: true,
language: languages.length > 0, language: languages.length > 0,
topic: topics.length > 0, topic: topics.length > 0,
@ -237,24 +236,6 @@ export default function Sidebar({
function widgetBody(id: WidgetId): React.ReactNode { function widgetBody(id: WidgetId): React.ReactNode {
switch (id) { 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": case "date":
return ( return (
<> <>

View file

@ -9,6 +9,17 @@
"videoCount_one": "{{formattedCount}} Video", "videoCount_one": "{{formattedCount}} Video",
"videoCount_other": "{{formattedCount}} Videos", "videoCount_other": "{{formattedCount}} Videos",
"sortLabel": "Sortierung", "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…", "loadingMore": "Mehr wird geladen…",
"hiddenNamed": "Ausgeblendet: „{{title}}”", "hiddenNamed": "Ausgeblendet: „{{title}}”",
"hidden": "Video ausgeblendet", "hidden": "Video ausgeblendet",

View file

@ -9,6 +9,17 @@
"videoCount_one": "{{formattedCount}} video", "videoCount_one": "{{formattedCount}} video",
"videoCount_other": "{{formattedCount}} videos", "videoCount_other": "{{formattedCount}} videos",
"sortLabel": "Sort", "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…", "loadingMore": "Loading more…",
"hiddenNamed": "Hidden “{{title}}”", "hiddenNamed": "Hidden “{{title}}”",
"hidden": "Video hidden", "hidden": "Video hidden",

View file

@ -9,6 +9,17 @@
"videoCount_one": "{{formattedCount}} videó", "videoCount_one": "{{formattedCount}} videó",
"videoCount_other": "{{formattedCount}} videó", "videoCount_other": "{{formattedCount}} videó",
"sortLabel": "Rendezés", "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…", "loadingMore": "Továbbiak betöltése…",
"hiddenNamed": "Elrejtve: „{{title}}”", "hiddenNamed": "Elrejtve: „{{title}}”",
"hidden": "Videó elrejtve", "hidden": "Videó elrejtve",

View file

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