Merge: promote dev to prod
Some checks failed
CI / frontend (push) Failing after 38s
CI / backend (push) Failing after 34s

This commit is contained in:
npeter83 2026-06-16 03:18:58 +02:00
commit 4455cc9264
11 changed files with 231 additions and 112 deletions

View file

@ -1 +1 @@
0.4.0
0.4.1

View file

@ -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:

View file

@ -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<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":
@ -166,11 +204,10 @@ export default function Feed({
if (query.isLoading) return <div className="p-8 text-muted">{t("feed.loading")}</div>;
if (query.isError) return <div className="p-8 text-muted">{t("feed.loadError")}</div>;
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 (
<div className="p-8 grid place-items-center text-center">
<div className="max-w-sm">
<h2 className="text-lg font-semibold">{t("feed.emptyTitle")}</h2>
@ -190,20 +227,107 @@ export default function Feed({
</div>
</div>
);
return <div className="p-8 text-muted">{t("feed.noMatches")}</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 (
<button
key={c.key}
onClick={() => setFilters({ ...filters, [c.key]: !on })}
aria-pressed={on}
className={`text-xs px-3 py-1.5 rounded-full border transition ${
on
? "bg-accent text-accent-fg border-accent"
: "border-border text-muted hover:text-fg hover:border-accent"
}`}
>
{t(c.label)}
</button>
);
})}
</div>
<div className="flex items-center gap-2 border-t border-border pt-2.5">
<span className="text-sm text-muted">
{countQuery.data
? t("feed.videoCount", {
count: countQuery.data.count,
formattedCount: countQuery.data.count.toLocaleString(),
})
: " "}
</span>
<div className="flex-1" />
<span className="text-xs text-muted">{t("feed.sortLabel")}</span>
<select
value={sortKey}
onChange={(e) => {
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_KEYS.map((k) => (
<option key={k} value={k}>
{t("feed.sortKey." + k)}
</option>
))}
</select>
{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")}
aria-label={t("sidebar.reshuffle")}
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"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
</div>
</div>
);
return (
<div className="p-4">
<div className="pb-3 text-sm text-muted">
{countQuery.data
? t("feed.videoCount", {
count: countQuery.data.count,
formattedCount: countQuery.data.count.toLocaleString(),
})
: " "}
</div>
{view === "grid" ? (
{toolbar}
{items.length === 0 ? (
<div className="py-16 text-center text-muted">{t("feed.noMatches")}</div>
) : view === "grid" ? (
<div className="grid gap-4 grid-cols-[repeat(auto-fill,minmax(260px,1fr))]">
{items.map((v) => (
<VideoCard

View file

@ -201,8 +201,17 @@ export default function PlayerModal({
onClose: () => 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 && (
<span>· {t("player.views", { count: active.view_count, formattedCount: formatViews(active.view_count) })}</span>
)}
<span>· {relativeTime(active.published_at)}</span>
<span>
· {relativeTime(active.published_at)}
{active.published_at && ` · ${fullDate(active.published_at)}`}
</span>
{active.duration_seconds != null && (
<span>· {formatDuration(active.duration_seconds)}</span>
)}
@ -566,7 +578,11 @@ export default function PlayerModal({
{detail.data?.view_count != null && (
<span>· {t("player.views", { count: detail.data.view_count, formattedCount: formatViews(detail.data.view_count) })}</span>
)}
{detail.data?.published_at && <span>· {relativeTime(detail.data.published_at)}</span>}
{detail.data?.published_at && (
<span>
· {relativeTime(detail.data.published_at)} · {fullDate(detail.data.published_at)}
</span>
)}
{detail.data?.duration_seconds != null && (
<span>· {formatDuration(detail.data.duration_seconds)}</span>
)}

View file

@ -214,10 +214,7 @@ export default function Sidebar({
}
const available: Record<WidgetId, boolean> = {
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 (
<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 "sort":
return (
<div className="flex items-center gap-1.5">
<select
value={filters.sort}
onChange={(e) => {
const sort = e.target.value;
// Seed shuffle on selection so it lands on a fresh order, not the
// deterministic seed-0 one every time.
setFilters({
...filters,
sort,
seed: sort === "shuffle" ? rollSeed() : undefined,
});
}}
className="w-full 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)}
</option>
))}
</select>
{filters.sort === "shuffle" && (
<button
onClick={() => setFilters({ ...filters, seed: rollSeed() })}
title={t("sidebar.reshuffle")}
aria-label={t("sidebar.reshuffle")}
className="shrink-0 p-1.5 rounded-lg border border-border bg-card text-fg shadow-sm hover:border-accent hover:text-accent active:translate-y-px transition"
>
<RefreshCw className="w-4 h-4" />
</button>
)}
</div>
);
case "date":
return (
<>
@ -381,26 +325,6 @@ export default function Sidebar({
)}
</>
);
case "content":
return (
<>
<Toggle
label={t("sidebar.content.normal")}
checked={filters.includeNormal}
onChange={(v) => setFilters({ ...filters, includeNormal: v })}
/>
<Toggle
label={t("sidebar.content.shorts")}
checked={filters.includeShorts}
onChange={(v) => setFilters({ ...filters, includeShorts: v })}
/>
<Toggle
label={t("sidebar.content.live")}
checked={filters.includeLive}
onChange={(v) => setFilters({ ...filters, includeLive: v })}
/>
</>
);
case "language": {
const chips = visibleChips(languages);
return (

View file

@ -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")} ·{" "}
<span title={`${video.view_count.toLocaleString()} ${t("card.views")}`}>
{formatViews(video.view_count)} {t("card.views")}
</span>{" "}
·{" "}
</>
)}
{relativeTime(video.published_at)}
{video.published_at && (
<>
{" · "}
{new Date(video.published_at).toLocaleDateString(i18n.language, {
year: "numeric",
month: "short",
day: "numeric",
})}
</>
)}
</>
);
const title = (

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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",

View file

@ -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<WidgetId, string> = {
show: "Show",
sort: "Sort",
date: "Upload date",
content: "Content type",
language: "Language",
topic: "Topic",
};