Compare commits

..

2 Commits

Author SHA1 Message Date
Mattias Tall
cb05b739a8 Mobile UX: fixed bottom nav, compact header, less cramped cards
- App-shell layout (height:100dvh, only main scrolls) so the bottom nav
  is a natural flex child and never disappears regardless of browser
  chrome show/hide behaviour
- Bottom nav reduced from h-16 to h-14, icons from 20px to 18px, labels
  from 10px to 9px — slimmer bar, still readable
- Header: min-w-0 on search prevents horizontal overflow; user/sign-out
  hidden on mobile (accessible via Settings); logo shortened to "YT" on
  mobile; px-3 / h-12 on mobile instead of px-4 / h-14
- Grid card descriptions hidden on mobile (hidden sm:block) — reduces
  height cramping in the 2-column feed
- scrollToTop() utility replaces window.scrollTo so pagination still
  scrolls to top within the new scroll container

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:30:23 +02:00
Mattias Tall
3e63281849 Fix channel avatars missing from all home-feed SQL modes
All inline SQL queries in the feed endpoint (chronological, random,
inbox, ranked scored CTE, and discovery injection) were missing
c.thumbnail_url AS channel_thumbnail_url — only _VIDEO_SELECT had it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 12:22:21 +02:00
7 changed files with 53 additions and 34 deletions

View File

@@ -148,6 +148,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category, v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name, c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id, c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -180,6 +181,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category, v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name, c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id, c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -214,6 +216,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category, v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name, c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id, c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -261,6 +264,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category, v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name, c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id, c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched, COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds, COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded, COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -315,7 +319,8 @@ def home_feed(
SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url, SELECT v.id, v.youtube_video_id, v.title, v.description, v.thumbnail_url,
v.duration_seconds, v.published_at, v.tags, v.category, v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name, c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url
FROM discovery_queue dq FROM discovery_queue dq
JOIN channels c ON dq.channel_id = c.id JOIN channels c ON dq.channel_id = c.id
JOIN videos v ON v.channel_id = c.id JOIN videos v ON v.channel_id = c.id

View File

@@ -30,17 +30,17 @@ function BottomNav({ newCount }) {
return ( return (
<nav <nav
className="sm:hidden fixed bottom-0 inset-x-0 z-50 bg-zinc-950/98 backdrop-blur border-t border-zinc-800" className="sm:hidden shrink-0 bg-zinc-950/98 backdrop-blur border-t border-zinc-800"
style={{ paddingBottom: "env(safe-area-inset-bottom)" }} style={{ paddingBottom: "env(safe-area-inset-bottom)" }}
> >
<div className="flex items-stretch h-16"> <div className="flex items-stretch h-14">
{tabs.map((tab) => ( {tabs.map((tab) => (
<NavLink <NavLink
key={tab.to} key={tab.to}
to={tab.to} to={tab.to}
end={tab.end} end={tab.end}
className={({ isActive }) => className={({ isActive }) =>
`relative flex-1 flex flex-col items-center justify-center gap-1 transition-colors ${ `relative flex-1 flex flex-col items-center justify-center gap-0.5 transition-colors ${
isActive ? "text-accent" : "text-zinc-500" isActive ? "text-accent" : "text-zinc-500"
}` }`
} }
@@ -51,16 +51,16 @@ function BottomNav({ newCount }) {
{isActive && ( {isActive && (
<span className="absolute -inset-2 rounded-xl bg-accent/10" /> <span className="absolute -inset-2 rounded-xl bg-accent/10" />
)} )}
<svg className="w-5 h-5 relative" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-[18px] h-[18px] relative" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{tab.icon} {tab.icon}
</svg> </svg>
{tab.badge > 0 && ( {tab.badge > 0 && (
<span className="absolute -top-1 -right-1.5 min-w-[14px] h-3.5 bg-accent text-black text-[9px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none"> <span className="absolute -top-1 -right-1.5 min-w-[13px] h-3 bg-accent text-black text-[8px] font-bold rounded-full flex items-center justify-center px-0.5 leading-none">
{tab.badge > 99 ? "99+" : tab.badge} {tab.badge > 99 ? "99+" : tab.badge}
</span> </span>
)} )}
</div> </div>
<span className={`text-[10px] font-medium leading-none ${isActive ? "font-semibold" : ""}`}> <span className="text-[9px] font-medium leading-none">
{tab.label} {tab.label}
</span> </span>
</> </>
@@ -95,16 +95,16 @@ function DownloadIndicator() {
return ( return (
<Link <Link
to="/downloads" to="/downloads"
className="flex items-center gap-2 px-2.5 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300 shrink-0" className="flex items-center gap-1.5 px-2 py-1 rounded-lg bg-zinc-800 hover:bg-zinc-700 transition-colors text-xs text-zinc-300 shrink-0"
title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`} title={`${active.length} download${active.length > 1 ? "s" : ""} in progress`}
> >
<svg className="w-3.5 h-3.5 animate-spin text-accent" fill="none" viewBox="0 0 24 24"> <svg className="w-3 h-3 animate-spin text-accent shrink-0" fill="none" viewBox="0 0 24 24">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" /> <circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" /> <path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg> </svg>
<span className="font-mono tabular-nums">{pct.toFixed(0)}%</span> <span className="font-mono tabular-nums text-[11px]">{pct.toFixed(0)}%</span>
{active.length > 1 && ( {active.length > 1 && (
<span className="text-zinc-500">+{active.length - 1}</span> <span className="hidden sm:inline text-zinc-500">+{active.length - 1}</span>
)} )}
</Link> </Link>
); );
@@ -207,27 +207,27 @@ export default function Layout() {
const newCount = useNewVideosCount(); const newCount = useNewVideosCount();
return ( return (
<div className="min-h-screen flex flex-col"> <div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
{/* Header */} {/* Header */}
<header className="sticky top-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800"> <header className="shrink-0 z-40 bg-zinc-950/90 backdrop-blur border-b border-zinc-800">
<div className="max-w-screen-xl mx-auto px-4 h-14 flex items-center gap-4"> <div className="max-w-screen-xl mx-auto px-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">
{/* Logo */} {/* Logo */}
<button <button
onClick={() => navigate("/")} onClick={() => navigate("/")}
className="font-display font-bold text-lg text-accent shrink-0" className="font-display font-bold text-base sm:text-lg text-accent shrink-0"
> >
YT Hub YT
</button> </button>
{/* Search */} {/* Search — min-w-0 prevents it from overflowing on narrow screens */}
<div className="flex-1 max-w-2xl"> <div className="flex-1 min-w-0">
<SearchBar /> <SearchBar />
</div> </div>
{/* Active downloads indicator */} {/* Active downloads indicator */}
<DownloadIndicator /> <DownloadIndicator />
{/* Nav */} {/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-0.5"> <nav className="hidden sm:flex items-center gap-0.5">
<NavItem to="/">Home</NavItem> <NavItem to="/">Home</NavItem>
@@ -258,21 +258,27 @@ export default function Layout() {
<NavItem to="/settings">Settings</NavItem> <NavItem to="/settings">Settings</NavItem>
</nav> </nav>
{/* User */} {/* User — hidden on mobile, sign out is in Settings */}
<button <button
onClick={logout} onClick={logout}
className="ml-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0" className="hidden sm:inline-flex ml-2 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0"
> >
{user?.username} · sign out {user?.username} · sign out
</button> </button>
</div> </div>
</header> </header>
{/* Page content */} {/* Page content — only this area scrolls */}
<main className="flex-1 max-w-screen-xl mx-auto w-full px-4 py-6 pb-28 sm:pb-6" style={{ paddingBottom: "calc(7rem + env(safe-area-inset-bottom, 0px))" }}> <main
data-scroll
className="flex-1 min-h-0 overflow-y-auto overscroll-contain"
>
<div className="max-w-screen-xl mx-auto w-full px-3 sm:px-4 py-4 sm:py-6">
<Outlet /> <Outlet />
</div>
</main> </main>
{/* Bottom nav — natural flex child, always visible on mobile */}
<BottomNav newCount={newCount} /> <BottomNav newCount={newCount} />
</div> </div>
); );

View File

@@ -378,7 +378,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
</div> </div>
{video.description && ( {video.description && (
<p className="text-[11px] leading-relaxed text-zinc-500 line-clamp-2 mt-0.5"> <p className="hidden sm:block text-[11px] leading-relaxed text-zinc-500 line-clamp-2 mt-0.5">
{video.description.replace(/\n+/g, " ")} {video.description.replace(/\n+/g, " ")}
</p> </p>
)} )}

View File

@@ -6,6 +6,7 @@ import {
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery, followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
} from "../api"; } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
@@ -305,7 +306,7 @@ export default function DiscoveryPage() {
</div> </div>
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">
<button <button
onClick={() => { setChannelPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
disabled={channelPage === 0} disabled={channelPage === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
@@ -313,7 +314,7 @@ export default function DiscoveryPage() {
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span> <span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span>
<button <button
onClick={() => { setChannelPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextChannelPage} disabled={!hasNextChannelPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
@@ -334,7 +335,7 @@ export default function DiscoveryPage() {
</div> </div>
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">
<button <button
onClick={() => { setVideoPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
disabled={videoPage === 0} disabled={videoPage === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
@@ -342,7 +343,7 @@ export default function DiscoveryPage() {
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span> <span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span>
<button <button
onClick={() => { setVideoPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextVideoPage} disabled={!hasNextVideoPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { getHistory } from "../api"; import { getHistory } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -40,7 +41,7 @@ export default function History() {
</div> </div>
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">
<button <button
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(p => p - 1); scrollToTop(); }}
disabled={page === 0} disabled={page === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
@@ -48,7 +49,7 @@ export default function History() {
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span> <span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
<button <button
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(p => p + 1); scrollToTop(); }}
disabled={!hasNext} disabled={!hasNext}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >

View File

@@ -2,6 +2,7 @@ import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { homeFeed, surpriseMe, getSettings, updateSettings, getChannels, markChannelsSeen } from "../api"; import { homeFeed, surpriseMe, getSettings, updateSettings, getChannels, markChannelsSeen } from "../api";
import VideoCard from "../components/VideoCard"; import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 25; const PAGE_SIZE = 25;
@@ -95,7 +96,7 @@ export default function Home() {
setShuffleKey(k => k + 1); setShuffleKey(k => k + 1);
setPage(0); setPage(0);
setDismissed(new Set()); setDismissed(new Set());
window.scrollTo({ top: 0, behavior: "smooth" }); scrollToTop();
}; };
return ( return (
@@ -235,7 +236,7 @@ export default function Home() {
)} )}
<div className="flex items-center justify-center gap-3 pt-2"> <div className="flex items-center justify-center gap-3 pt-2">
<button <button
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(p => p - 1); scrollToTop(); }}
disabled={page === 0} disabled={page === 0}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >
@@ -243,7 +244,7 @@ export default function Home() {
</button> </button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span> <span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
<button <button
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }} onClick={() => { setPage(p => p + 1); scrollToTop(); }}
disabled={!hasNextPage} disabled={!hasNextPage}
className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
> >

View File

@@ -0,0 +1,5 @@
export function scrollToTop(behavior = "smooth") {
const main = document.querySelector("main[data-scroll]");
if (main) main.scrollTo({ top: 0, behavior });
else window.scrollTo({ top: 0, behavior });
}