From d96b47ae5d60941300a7edf551b7ca7e5e52f1ac Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 02:34:13 +0200 Subject: [PATCH 1/6] =?UTF-8?q?feat(feed):=201D=20=E2=80=94=20content-type?= =?UTF-8?q?=20chips=20+=20sort=20in=20a=20toolbar=20above=20the=20cards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move content type and ordering out of the filter sidebar into a feed toolbar (approved proposal 2): a prominent content-type chip row (Normal/Shorts/Live toggle chips) with the sort control + count + reshuffle in a quieter row beneath it. The toolbar renders even on 'no matches' so content-type can be turned back on after it filtered everything out. The sidebar keeps Show / Upload date / Language / Topic; 'sort' and 'content' widgets removed from the layout (normalizeLayout drops them from any saved layout). New feed.sortLabel string (HU/EN/DE). --- frontend/src/components/Feed.tsx | 104 +++++++++++++++++++++---- frontend/src/components/Sidebar.tsx | 57 -------------- frontend/src/i18n/locales/de/feed.json | 1 + frontend/src/i18n/locales/en/feed.json | 1 + frontend/src/i18n/locales/hu/feed.json | 1 + frontend/src/lib/sidebarLayout.ts | 15 +--- 6 files changed, 95 insertions(+), 84 deletions(-) diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index 9133f7b..eac7b72 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -1,6 +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 { api, type FeedFilters, type Video } from "../lib/api"; import i18n from "../i18n"; import { notify } from "../lib/notifications"; @@ -9,6 +10,25 @@ 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", +]; +const CONTENT = [ + { key: "includeNormal", label: "sidebar.content.normal" }, + { key: "includeShorts", label: "sidebar.content.shorts" }, + { key: "includeLive", label: "sidebar.content.live" }, +] as const; +const rollSeed = () => Math.floor(Math.random() * 1_000_000_000); + function matchesView(status: string, show: string): boolean { switch (show) { case "hidden": @@ -166,11 +186,10 @@ export default function Feed({ if (query.isLoading) return
{t("feed.loading")}
; if (query.isError) return
{t("feed.loadError")}
; - if (items.length === 0) { - // No YouTube connection and looking at their own (empty) subscriptions: offer both - // connecting YouTube and just browsing the shared library (no read scope needed). - if (!canRead && filters.scope === "my") - return ( + // Onboarding empty state (no YouTube + own subscriptions): a dedicated full-page prompt, + // with no toolbar (content-type/sort would be meaningless here). + if (items.length === 0 && !canRead && filters.scope === "my") + return (

{t("feed.emptyTitle")}

@@ -190,20 +209,73 @@ export default function Feed({
); - return
{t("feed.noMatches")}
; - } + + const toolbar = ( +
+
+ {CONTENT.map((c) => { + const on = filters[c.key]; + return ( + + ); + })} +
+
+ + {countQuery.data + ? t("feed.videoCount", { + count: countQuery.data.count, + formattedCount: countQuery.data.count.toLocaleString(), + }) + : " "} + +
+ {t("feed.sortLabel")} + + {filters.sort === "shuffle" && ( + + )} +
+
+ ); return (
-
- {countQuery.data - ? t("feed.videoCount", { - count: countQuery.data.count, - formattedCount: countQuery.data.count.toLocaleString(), - }) - : " "} -
- {view === "grid" ? ( + {toolbar} + {items.length === 0 ? ( +
{t("feed.noMatches")}
+ ) : view === "grid" ? (
{items.map((v) => ( = { show: true, - sort: true, date: true, - content: true, language: languages.length > 0, topic: topics.length > 0, }; @@ -257,41 +255,6 @@ export default function Sidebar({ ))}
); - case "sort": - return ( -
- - {filters.sort === "shuffle" && ( - - )} -
- ); case "date": return ( <> @@ -381,26 +344,6 @@ export default function Sidebar({ )} ); - case "content": - return ( - <> - setFilters({ ...filters, includeNormal: v })} - /> - setFilters({ ...filters, includeShorts: v })} - /> - setFilters({ ...filters, includeLive: v })} - /> - - ); case "language": { const chips = visibleChips(languages); return ( diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json index 3a46ab8..582fcac 100644 --- a/frontend/src/i18n/locales/de/feed.json +++ b/frontend/src/i18n/locales/de/feed.json @@ -8,6 +8,7 @@ "noMatches": "Keine Videos entsprechen diesen Filtern.", "videoCount_one": "{{formattedCount}} Video", "videoCount_other": "{{formattedCount}} Videos", + "sortLabel": "Sortierung", "loadingMore": "Mehr wird geladen…", "hiddenNamed": "Ausgeblendet: „{{title}}”", "hidden": "Video ausgeblendet", diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 8e30dda..3991cf5 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -8,6 +8,7 @@ "noMatches": "No videos match these filters.", "videoCount_one": "{{formattedCount}} video", "videoCount_other": "{{formattedCount}} videos", + "sortLabel": "Sort", "loadingMore": "Loading more…", "hiddenNamed": "Hidden “{{title}}”", "hidden": "Video hidden", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 2995492..62d94e0 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -8,6 +8,7 @@ "noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.", "videoCount_one": "{{formattedCount}} videó", "videoCount_other": "{{formattedCount}} videó", + "sortLabel": "Rendezés", "loadingMore": "Továbbiak betöltése…", "hiddenNamed": "Elrejtve: „{{title}}”", "hidden": "Videó elrejtve", diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index a9c0676..29ea1ef 100644 --- a/frontend/src/lib/sidebarLayout.ts +++ b/frontend/src/lib/sidebarLayout.ts @@ -2,22 +2,15 @@ // collapsed, and which are hidden. Persisted to localStorage and the server-side // `preferences` blob so it follows the account. -export type WidgetId = "show" | "sort" | "date" | "content" | "language" | "topic"; +// `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"; -export const ALL_WIDGETS: WidgetId[] = [ - "show", - "sort", - "date", - "content", - "language", - "topic", -]; +export const ALL_WIDGETS: WidgetId[] = ["show", "date", "language", "topic"]; export const WIDGET_TITLES: Record = { show: "Show", - sort: "Sort", date: "Upload date", - content: "Content type", language: "Language", topic: "Topic", }; From 4921a95d0963361471cba37586bba971f4ca5f86 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 16 Jun 2026 02:46:26 +0200 Subject: [PATCH 2/6] 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. --- backend/app/routes/feed.py | 8 ++- frontend/src/components/Feed.tsx | 92 ++++++++++++++++++++------ frontend/src/components/Sidebar.tsx | 19 ------ frontend/src/i18n/locales/de/feed.json | 11 +++ frontend/src/i18n/locales/en/feed.json | 11 +++ frontend/src/i18n/locales/hu/feed.json | 11 +++ frontend/src/lib/sidebarLayout.ts | 9 ++- 7 files changed, 115 insertions(+), 46 deletions(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 0ef733b..03863a1 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -275,10 +275,13 @@ SORTS = { "newest": Video.published_at.desc().nulls_last(), "oldest": Video.published_at.asc().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_asc": Video.duration_seconds.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_asc": Channel.subscriber_count.asc().nulls_last(), # 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. "priority": func.coalesce(Subscription.priority, 0).desc(), @@ -296,9 +299,10 @@ def get_feed( db: Session = Depends(get_db), ) -> dict: 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( - func.coalesce(Subscription.priority, 0).desc(), + prio.asc() if sort == "priority_asc" else prio.desc(), Video.published_at.desc().nulls_last(), ) else: diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index eac7b72..7741586 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -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, { 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, "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({
); + const { key: sortKey, dir: sortDir } = parseSort(filters.sort); + const toolbar = (
+ {SHOW_IDS.map((id) => ( + + ))} +