From a09e0dda0c6129106ccccad5b71a33744744f945 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 13:38:47 +0200 Subject: [PATCH 01/11] fix(player): persist watched at end-of-playback (atomic upsert) The in-app player marks a video watched when it reaches the end, firing POST /videos/{id}/state at the same instant as a progress checkpoint. The progress endpoint deletes the 'new' video_states row near the end, so the concurrent state UPDATE matched 0 rows and raised StaleDataError -> 500. The watched status was never persisted (and Feed swallowed the error), so the video stayed in the unwatched feed even after a refresh. - set_video_state: atomic INSERT ... ON CONFLICT (uq_user_video) DO UPDATE for watched/hidden, immune to a concurrent delete; tolerate StaleDataError on the 'new' branch and in the progress endpoint. - PlayerModal: skip the redundant near-end progress checkpoint when we just auto-marked watched, removing the self-inflicted race. - Feed: drop the optimistic override if the server rejects the change, so a failed request can't leave a card phantom-hidden. --- backend/app/routes/feed.py | 54 ++++++++++++++++++------- frontend/src/components/Feed.tsx | 10 ++++- frontend/src/components/PlayerModal.tsx | 11 +++-- 3 files changed, 57 insertions(+), 18 deletions(-) diff --git a/backend/app/routes/feed.py b/backend/app/routes/feed.py index 03863a1..8db08b6 100644 --- a/backend/app/routes/feed.py +++ b/backend/app/routes/feed.py @@ -2,7 +2,9 @@ from datetime import date, datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import Select, and_, false, func, or_, select +from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.orm import Session, aliased +from sqlalchemy.orm.exc import StaleDataError from app import quota from app.auth import current_user @@ -387,29 +389,48 @@ def set_video_state( if db.get(Video, video_id) is None: raise HTTPException(status_code=404, detail="Unknown video") - row = db.execute( - select(VideoState).where( - VideoState.user_id == user.id, VideoState.video_id == video_id - ) - ).scalar_one_or_none() + now = datetime.now(timezone.utc) if status == "new": + # Un-marking: keep the row if it still holds a resume position (un-marking + # "watched" should restore the in-progress state, not wipe where the user left + # off); otherwise drop it. The in-app player fires this alongside a progress + # checkpoint that may DELETE the same row, so tolerate it vanishing under us. + row = db.execute( + select(VideoState).where( + VideoState.user_id == user.id, VideoState.video_id == video_id + ) + ).scalar_one_or_none() if row is not None: - # Keep the row if it still holds a resume position (un-marking "watched" - # should restore the in-progress state, not wipe where the user left off). if row.position_seconds: row.status = "new" row.watched_at = None else: db.delete(row) - db.commit() + try: + db.commit() + except StaleDataError: + db.rollback() return {"video_id": video_id, "status": "new"} - if row is None: - row = VideoState(user_id=user.id, video_id=video_id) - db.add(row) - row.status = status - row.watched_at = datetime.now(timezone.utc) if status == "watched" else row.watched_at + # watched / hidden: atomic upsert. The player marks "watched" at end-of-playback at + # the same instant a progress checkpoint may DELETE the in-progress row; a plain + # SELECT-then-UPDATE then raises StaleDataError ("0 rows matched") on that race. An + # INSERT … ON CONFLICT DO UPDATE keyed on uq_user_video is immune to it. + set_: dict = {"status": status, "updated_at": now} + if status == "watched": + set_["watched_at"] = now # hidden keeps any existing watched_at + stmt = ( + pg_insert(VideoState) + .values( + user_id=user.id, + video_id=video_id, + status=status, + watched_at=now if status == "watched" else None, + ) + .on_conflict_do_update(constraint="uq_user_video", set_=set_) + ) + db.execute(stmt) db.commit() return {"video_id": video_id, "status": status} @@ -455,7 +476,12 @@ def set_video_progress( else: row.position_seconds = 0 row.progress_updated_at = datetime.now(timezone.utc) - db.commit() + # A concurrent state change (e.g. the end-of-playback "watched" mark) may + # have removed/updated this row already — tolerate the lost race. + try: + db.commit() + except StaleDataError: + db.rollback() return {"video_id": video_id, "position_seconds": 0} if row is None: diff --git a/frontend/src/components/Feed.tsx b/frontend/src/components/Feed.tsx index ff4c09d..6b0e0f5 100644 --- a/frontend/src/components/Feed.tsx +++ b/frontend/src/components/Feed.tsx @@ -147,7 +147,15 @@ export default function Feed({ api .setState(id, status) .then(() => qc.invalidateQueries({ queryKey: ["feed"] })) - .catch(() => {}); + .catch(() => + // Server rejected the change — drop the optimistic override so the card + // reverts to the real (unchanged) state instead of staying phantom-hidden. + setOverrides((o) => { + const next = { ...o }; + delete next[id]; + return next; + }) + ); if (status === "hidden") { const v = loadedRef.current.find((x) => x.id === id); notify({ diff --git a/frontend/src/components/PlayerModal.tsx b/frontend/src/components/PlayerModal.tsx index 1d38fed..a97ebbd 100644 --- a/frontend/src/components/PlayerModal.tsx +++ b/frontend/src/components/PlayerModal.tsx @@ -339,12 +339,14 @@ export default function PlayerModal({ // Auto-watch only applies to the active item — not to other videos the player // navigated to via description links. - const maybeAutoWatch = (current: number, duration: number) => { - if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return; + const maybeAutoWatch = (current: number, duration: number): boolean => { + if (autoMarkedRef.current || duration <= 0 || currentIdRef.current !== id) return false; if (current > duration - FINISH_MARGIN) { autoMarkedRef.current = true; setWatched(true); + return true; } + return false; }; const persist = (): Promise | void => { const p = playerRef.current; @@ -352,7 +354,10 @@ export default function PlayerModal({ try { const cur = p.getCurrentTime(); const dur = typeof p.getDuration === "function" ? p.getDuration() : 0; - maybeAutoWatch(cur, dur); + // If we just auto-marked watched, skip the near-end progress checkpoint: it would + // only clear the resume position, and firing both at once needlessly races the + // watched write against the progress row it deletes. + if (maybeAutoWatch(cur, dur)) return; // Only checkpoint the feed video we opened with — a navigated-to video may not // be in this user's library, so the server would 404 on it. if (currentIdRef.current === id) { From ccf044ac2c7dbf4f342872158ed763fe85b33e98 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 13:55:43 +0200 Subject: [PATCH 02/11] feat(channels): YouTube link on channel name + done checkmarks - Channel name: middle-click opens the channel's YouTube page in a new tab (left-click still opens the in-app detail) plus an explicit external-link icon. - recent/full sync badges show a check icon when the state is reached. - New i18n key channels.row.openOnYouTube (HU/EN/DE). --- frontend/src/components/Channels.tsx | 41 +++++++++++++++++++--- frontend/src/i18n/locales/de/channels.json | 1 + frontend/src/i18n/locales/en/channels.json | 1 + frontend/src/i18n/locales/hu/channels.json | 1 + 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 3db9eb1..dc37a27 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -4,6 +4,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowDown, ArrowUp, + Check, + ExternalLink, Eye, EyeOff, History, @@ -361,10 +363,11 @@ function SyncBadge({ ok, label, hint }: { ok: boolean; label: string; hint?: str return ( + {ok && } {label} @@ -393,6 +396,9 @@ function ChannelRow({ onToggleTag: (tagId: number) => void; }) { const { t } = useTranslation(); + const ytUrl = c.handle + ? `https://www.youtube.com/@${c.handle.replace(/^@/, "")}` + : `https://www.youtube.com/channel/${c.id}`; return (
- +
+ + + + + + +
{t("channels.row.stored", { count: c.stored_videos, formatted: c.stored_videos.toLocaleString() })} {c.subscriber_count != null && · {t("channels.row.subs", { count: c.subscriber_count, formatted: c.subscriber_count.toLocaleString() })}} diff --git a/frontend/src/i18n/locales/de/channels.json b/frontend/src/i18n/locales/de/channels.json index 8638c94..f57b703 100644 --- a/frontend/src/i18n/locales/de/channels.json +++ b/frontend/src/i18n/locales/de/channels.json @@ -36,6 +36,7 @@ "row": { "stored": "{{formatted}} gespeichert", "subs": "{{formatted}} Abonnenten", + "openOnYouTube": "Auf YouTube öffnen", "priorityHint": "Deine Einstufung für diesen Kanal. Sortiere den Feed nach „Kanalpriorität“, um Kanäle mit höherer Priorität nach oben zu bringen.", "raisePriority": "Priorität erhöhen", "lowerPriority": "Priorität senken", diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index 9ecd280..0adc427 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -36,6 +36,7 @@ "row": { "stored": "{{formatted}} stored", "subs": "{{formatted}} subs", + "openOnYouTube": "Open on YouTube", "priorityHint": "Your ranking for this channel. Sort the feed by “Channel priority” to bring higher-priority channels to the top.", "raisePriority": "Raise priority", "lowerPriority": "Lower priority", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 30904eb..fc36ce7 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -36,6 +36,7 @@ "row": { "stored": "{{formatted}} tárolt", "subs": "{{formatted}} feliratkozó", + "openOnYouTube": "Megnyitás a YouTube-on", "priorityHint": "A te rangsorod ehhez a csatornához. Rendezd a hírfolyamot „Csatorna prioritás” szerint, hogy a magasabb prioritású csatornák felülre kerüljenek.", "raisePriority": "Prioritás növelése", "lowerPriority": "Prioritás csökkentése", From b96fe0caba1ffd68bdb7f4dd5c857079877cafa1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 13:55:43 +0200 Subject: [PATCH 03/11] feat(playlists): move the new-playlist input to the top of the rail --- frontend/src/components/Playlists.tsx | 30 +++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 93512f0..8bf346e 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -580,6 +580,21 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) {
)} +
{ + e.preventDefault(); + if (newName.trim()) createMut.mutate(newName.trim()); + }} + className="flex items-center gap-1.5 mb-3 pb-3 border-b border-border" + > + + setNewName(e.target.value)} + placeholder={t("playlists.newPlaylist")} + className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" + /> + {playlists.length === 0 && !listQuery.isLoading && (
{t("playlists.noneYet")}
)} @@ -618,21 +633,6 @@ export default function Playlists({ canWrite }: { canWrite: boolean }) { ))}
-
{ - e.preventDefault(); - if (newName.trim()) createMut.mutate(newName.trim()); - }} - className="flex items-center gap-1.5 mt-3 pt-3 border-t border-border" - > - - setNewName(e.target.value)} - placeholder={t("playlists.newPlaylist")} - className="flex-1 min-w-0 bg-card border border-border rounded-md px-2 py-1 text-xs outline-none focus:border-accent" - /> -
From e0590c2b6cfcf2c56fddeb55c9eb5f47fd66749d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 13:55:43 +0200 Subject: [PATCH 04/11] fix(stats): keep daily usage bars always visible Each day now has a full-height track behind a solid accent fill, so low-usage days no longer render as a near-invisible sliver that only stood out on hover. --- frontend/src/components/Stats.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/Stats.tsx b/frontend/src/components/Stats.tsx index f8dd9c3..46a776e 100644 --- a/frontend/src/components/Stats.tsx +++ b/frontend/src/components/Stats.tsx @@ -63,15 +63,21 @@ export default function Stats() { ) : (
{data.daily.map((d) => ( + // Each day gets a full-height track so the column is always visible + // (even on near-zero days); the accent fill renders the value on top.
+ > +
+
))}
)} From 7a5f52a89b794a1a409acda5aafbcfd2edc83e46 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 17 Jun 2026 14:28:29 +0200 Subject: [PATCH 05/11] feat(nav): group rail modules + move chrome controls into the sidebar - Split the rail into a content group (Feed/Channels/Playlists) and an admin group (Stats/Scheduler) separated by a divider (system group hidden for non-admins). - Move the language switcher, About and notification bell out of the top header into an icon cluster above Settings (horizontal expanded, vertical collapsed). Their popovers portal to and anchor right + above the button, escaping the nav's backdrop-filter. About is removed from the account popover (now in the cluster). - LanguageSwitcher/NotificationCenter gain a 'rail' variant for the above. - Also relocate the Toaster mount into the (now relative) content column. --- frontend/src/App.tsx | 14 ++- frontend/src/components/Header.tsx | 12 +-- frontend/src/components/LanguageSwitcher.tsx | 84 ++++++++++++++++- frontend/src/components/NavSidebar.tsx | 90 ++++++++++++------- .../src/components/NotificationCenter.tsx | 48 ++++++++-- 5 files changed, 194 insertions(+), 54 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7501839..35b2825 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -86,7 +86,7 @@ function loadInitialFilters(): FeedFilters { } export default function App() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(loadInitialFilters); const [view, setView] = useState<"grid" | "list">("grid"); @@ -235,14 +235,17 @@ export default function App() { page={page} setPage={setPage} onOpenAbout={() => setAboutOpen(true)} + onChangeLanguage={changeLanguage} + language={i18n.language as LangCode} + filters={filters} + setFilters={setFilters} /> -
+
{ setChannelFilter("needs_full"); setPage("channels"); @@ -298,6 +301,10 @@ export default function App() { )}
+ {/* Toasts rise from the bottom-left, by the notification bell in the nav rail. + Anchored inside the content column so they clear the sidebar automatically + (collapsed or expanded) without tracking its width. */} + {wizardOpen && meQuery.data && !meQuery.data.is_demo && ( setWizardOpen(false)} /> @@ -314,7 +321,6 @@ export default function App() { {notesOpen && ( setNotesOpen(false)} highlight={notesHighlight} /> )} - ); } diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx index 5afe782..72d498e 100644 --- a/frontend/src/components/Header.tsx +++ b/frontend/src/components/Header.tsx @@ -2,10 +2,7 @@ import { useTranslation } from "react-i18next"; import { Library, Search, User } from "lucide-react"; import type { FeedFilters, Me } from "../lib/api"; import type { Page } from "../lib/urlState"; -import { type LangCode } from "../i18n"; import SyncStatus from "./SyncStatus"; -import NotificationCenter from "./NotificationCenter"; -import LanguageSwitcher from "./LanguageSwitcher"; // Contextual top bar. Primary navigation + account now live in the left NavSidebar; the // header carries the global sync status, the feed's scope toggle + search (or the current @@ -15,17 +12,15 @@ export default function Header({ filters, setFilters, page, - onChangeLanguage, onGoToFullHistory, }: { me: Me; filters: FeedFilters; setFilters: (f: FeedFilters) => void; page: Page; - onChangeLanguage: (code: LangCode) => void; onGoToFullHistory: () => void; }) { - const { t, i18n } = useTranslation(); + const { t } = useTranslation(); return (
@@ -83,11 +78,6 @@ export default function Header({ : t("header.channelManager")} )} - -
- - -
); } diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 62e90cf..598f3d6 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -1,20 +1,31 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Check, Globe } from "lucide-react"; import { LANGUAGES, type LangCode } from "../i18n"; // Compact language picker (globe + current code). Presentational: the parent decides what // changing the language does (set it; persist server-side when signed in). +// +// Two variants: "header" (legacy inline dropdown, opens below) and "rail" (used in the left +// nav's bottom icon cluster — an icon-only button whose menu is portaled to and +// anchored to the right + above the button, escaping the nav's backdrop-filter which would +// otherwise trap an absolutely-positioned popover). export default function LanguageSwitcher({ value, onChange, align = "right", + variant = "header", }: { value: LangCode; onChange: (code: LangCode) => void; align?: "left" | "right"; + variant?: "header" | "rail"; }) { const [open, setOpen] = useState(false); const closeTimer = useRef | null>(null); + const btnRef = useRef(null); + const panelRef = useRef(null); + const [pos, setPos] = useState<{ left: number; bottom: number }>({ left: 0, bottom: 0 }); const current = LANGUAGES.find((l) => l.code === value) ?? LANGUAGES[0]; function openNow() { @@ -24,6 +35,77 @@ export default function LanguageSwitcher({ function closeSoon() { closeTimer.current = setTimeout(() => setOpen(false), 200); } + function toggleRail() { + if (!open) { + const r = btnRef.current?.getBoundingClientRect(); + if (r) setPos({ left: r.right + 8, bottom: window.innerHeight - r.bottom }); + } + setOpen((o) => !o); + } + + // Rail popover: dismiss on outside click / Escape (it's portaled, so a contains() check + // spans both the button and the floating panel). + useEffect(() => { + if (variant !== "rail" || !open) return; + function onDoc(e: MouseEvent) { + const target = e.target as Node; + if (panelRef.current?.contains(target) || btnRef.current?.contains(target)) return; + setOpen(false); + } + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); + } + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + }; + }, [variant, open]); + + const menu = ( +
+ {LANGUAGES.map((l) => ( + + ))} +
+ ); + + if (variant === "rail") { + return ( + <> + + {open && + createPortal( +
+ {menu} +
, + document.body + )} + + ); + } return (
diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index cb6d989..d9ce0fe 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -16,9 +16,12 @@ import { Tv, UserPlus, } from "lucide-react"; -import { api, type Me } from "../lib/api"; +import { api, type FeedFilters, type Me } from "../lib/api"; import type { Page } from "../lib/urlState"; +import { type LangCode } from "../i18n"; import AvatarImg from "./Avatar"; +import LanguageSwitcher from "./LanguageSwitcher"; +import NotificationCenter from "./NotificationCenter"; // Primary app navigation: a collapsible left rail with icon+label entries (Design C). The // modules used to hide under the avatar dropdown; they now live here. Collapsed, it becomes @@ -28,11 +31,19 @@ export default function NavSidebar({ page, setPage, onOpenAbout, + onChangeLanguage, + language, + filters, + setFilters, }: { me: Me; page: Page; setPage: (p: Page) => void; onOpenAbout: () => void; + onChangeLanguage: (code: LangCode) => void; + language: LangCode; + filters: FeedFilters; + setFilters: (f: FeedFilters) => void; }) { const { t } = useTranslation(); const [collapsed, setCollapsed] = useState( @@ -105,20 +116,40 @@ export default function NavSidebar({ } } - const items: { page: Page; icon: typeof Home; label: string }[] = [ + type NavItem = { page: Page; icon: typeof Home; label: string }; + // User-facing content modules vs. admin/system modules, separated by a divider in the rail. + const userItems: NavItem[] = [ { page: "feed", icon: Home, label: t("header.account.feed") }, { page: "channels", icon: Tv, label: t("header.account.channels") }, { page: "playlists", icon: ListVideo, label: t("header.account.playlists") }, ]; - if (me.role === "admin") { - items.push({ page: "stats", icon: BarChart3, label: t("header.account.stats") }); - items.push({ page: "scheduler", icon: Activity, label: t("header.account.scheduler") }); - } + const systemItems: NavItem[] = + me.role === "admin" + ? [ + { page: "stats", icon: BarChart3, label: t("header.account.stats") }, + { page: "scheduler", icon: Activity, label: t("header.account.scheduler") }, + ] + : []; const rowBase = "w-full flex items-center gap-3 rounded-lg px-2.5 py-2 text-sm transition"; const name = me.display_name ?? me.email.split("@")[0]; + const renderItem = ({ page: p, icon: Icon, label }: NavItem) => ( + + ); + return (