diff --git a/VERSION b/VERSION index 1d0ba9e..267577d 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.0 +0.4.1 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 9133f7b..7741586 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 { ArrowDown, ArrowUp, 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,43 @@ import PlayerModal from "./PlayerModal"; const PAGE = 60; +// 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" }, + { key: "includeLive", label: "sidebar.content.live" }, +] 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": @@ -166,11 +204,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 +227,107 @@ export default function Feed({
); - return
{t("feed.noMatches")}
; - } + + const { key: sortKey, dir: sortDir } = parseSort(filters.sort); + + const toolbar = ( +
+
+ {SHOW_IDS.map((id) => ( + + ))} +
+
+ + {countQuery.data + ? t("feed.videoCount", { + count: countQuery.data.count, + formattedCount: countQuery.data.count.toLocaleString(), + }) + : " "} + +
+ {t("feed.sortLabel")} + + {sortKey !== "shuffle" && ( + + )} + {sortKey === "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) => ( void; onState: (id: string, status: string) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const qc = useQueryClient(); + // Precise upload date shown inline after the relative time (e.g. "9 yr ago · 12 Jan 2017"). + const fullDate = (d: string | null | undefined) => + d + ? new Date(d).toLocaleDateString(i18n.language, { + year: "numeric", + month: "short", + day: "numeric", + }) + : ""; // Track the playing item by id (not a frozen index) so it survives the queue changing // under us — e.g. an item removed in another tab. The index is derived from the *live* // queue, so the N / M counter and prev/next neighbours stay correct without disrupting @@ -550,7 +559,10 @@ export default function PlayerModal({ {active.view_count != null && ( · {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })} )} - · {relativeTime(active.published_at)} + + · {relativeTime(active.published_at)} + {active.published_at && ` · ${fullDate(active.published_at)}`} + {active.duration_seconds != null && ( · {formatDuration(active.duration_seconds)} )} @@ -566,7 +578,11 @@ export default function PlayerModal({ {detail.data?.view_count != null && ( · {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })} )} - {detail.data?.published_at && · {relativeTime(detail.data.published_at)}} + {detail.data?.published_at && ( + + · {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)} + + )} {detail.data?.duration_seconds != null && ( · {formatDuration(detail.data.duration_seconds)} )} diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index 0bb9f7d..79c5894 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -214,10 +214,7 @@ export default function Sidebar({ } const available: Record = { - show: true, - sort: true, date: true, - content: true, language: languages.length > 0, topic: topics.length > 0, }; @@ -239,59 +236,6 @@ export default function Sidebar({ function widgetBody(id: WidgetId): React.ReactNode { switch (id) { - case "show": - return ( -
- {SHOW_IDS.map((id) => ( - - ))} -
- ); - case "sort": - return ( -
- - {filters.sort === "shuffle" && ( - - )} -
- ); case "date": return ( <> @@ -381,26 +325,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/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 597470c..da5db77 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -227,16 +227,29 @@ function VideoCard({ onChannelFilter?: (channelId: string, channelName: string) => void; onOpen?: (v: Video, startAt?: number | null) => void; }) { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const watched = video.status === "watched"; const meta = ( <> {video.view_count != null && ( <> - {formatViews(video.view_count)} {t("card.views")} ·{" "} + + {formatViews(video.view_count)} {t("card.views")} + {" "} + ·{" "} )} {relativeTime(video.published_at)} + {video.published_at && ( + <> + {" · "} + {new Date(video.published_at).toLocaleDateString(i18n.language, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )} ); const title = ( diff --git a/frontend/src/i18n/locales/de/feed.json b/frontend/src/i18n/locales/de/feed.json index 3a46ab8..9378c5e 100644 --- a/frontend/src/i18n/locales/de/feed.json +++ b/frontend/src/i18n/locales/de/feed.json @@ -8,6 +8,18 @@ "noMatches": "Keine Videos entsprechen diesen Filtern.", "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", diff --git a/frontend/src/i18n/locales/en/feed.json b/frontend/src/i18n/locales/en/feed.json index 8e30dda..4332daa 100644 --- a/frontend/src/i18n/locales/en/feed.json +++ b/frontend/src/i18n/locales/en/feed.json @@ -8,6 +8,18 @@ "noMatches": "No videos match these filters.", "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", diff --git a/frontend/src/i18n/locales/hu/feed.json b/frontend/src/i18n/locales/hu/feed.json index 2995492..fc6b100 100644 --- a/frontend/src/i18n/locales/hu/feed.json +++ b/frontend/src/i18n/locales/hu/feed.json @@ -8,6 +8,18 @@ "noMatches": "Egy videó sem felel meg ezeknek a szűrőknek.", "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", diff --git a/frontend/src/lib/releaseNotes.ts b/frontend/src/lib/releaseNotes.ts index 4f89cea..818e3b6 100644 --- a/frontend/src/lib/releaseNotes.ts +++ b/frontend/src/lib/releaseNotes.ts @@ -14,6 +14,16 @@ export interface ReleaseEntry { } export const RELEASE_NOTES: ReleaseEntry[] = [ + { + version: "0.4.1", + date: "2026-06-16", + summary: "Feed controls above the videos, and clearer view counts and dates.", + features: [ + "The view filter (Unwatched / In progress / All / …) and the content type (Normal / Shorts / Live) are now quick chips right above the videos.", + "Cleaner ordering: pick what to sort by, then flip ascending/descending with one arrow (and 'Most viewed' is now 'Popular').", + "Video cards and the player show the exact upload date next to '… ago', and the full view count on hover.", + ], + }, { version: "0.4.0", date: "2026-06-16", diff --git a/frontend/src/lib/sidebarLayout.ts b/frontend/src/lib/sidebarLayout.ts index a9c0676..bb4f922 100644 --- a/frontend/src/lib/sidebarLayout.ts +++ b/frontend/src/lib/sidebarLayout.ts @@ -2,22 +2,14 @@ // 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"; +// `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", - "sort", - "date", - "content", - "language", - "topic", -]; +export const ALL_WIDGETS: WidgetId[] = ["date", "language", "topic"]; export const WIDGET_TITLES: Record = { - show: "Show", - sort: "Sort", date: "Upload date", - content: "Content type", language: "Language", topic: "Topic", };