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,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -180,6 +181,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -214,6 +216,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
COALESCE(uv.downloaded, 0) AS is_downloaded,
@@ -261,6 +264,7 @@ def home_feed(
v.duration_seconds, v.published_at, v.tags, v.category,
c.id AS channel_id, c.name AS channel_name,
c.youtube_channel_id AS channel_youtube_id,
c.thumbnail_url AS channel_thumbnail_url,
COALESCE(uv.watched, 0) AS watched,
COALESCE(uv.watch_progress_seconds, 0) AS watch_progress_seconds,
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,
v.duration_seconds, v.published_at, v.tags, v.category,
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
JOIN channels c ON dq.channel_id = c.id
JOIN videos v ON v.channel_id = c.id

View File

@@ -30,17 +30,17 @@ function BottomNav({ newCount }) {
return (
<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)" }}
>
<div className="flex items-stretch h-16">
<div className="flex items-stretch h-14">
{tabs.map((tab) => (
<NavLink
key={tab.to}
to={tab.to}
end={tab.end}
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"
}`
}
@@ -51,16 +51,16 @@ function BottomNav({ newCount }) {
{isActive && (
<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}
</svg>
{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}
</span>
)}
</div>
<span className={`text-[10px] font-medium leading-none ${isActive ? "font-semibold" : ""}`}>
<span className="text-[9px] font-medium leading-none">
{tab.label}
</span>
</>
@@ -95,16 +95,16 @@ function DownloadIndicator() {
return (
<Link
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`}
>
<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" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</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 && (
<span className="text-zinc-500">+{active.length - 1}</span>
<span className="hidden sm:inline text-zinc-500">+{active.length - 1}</span>
)}
</Link>
);
@@ -207,27 +207,27 @@ export default function Layout() {
const newCount = useNewVideosCount();
return (
<div className="min-h-screen flex flex-col">
<div className="flex flex-col overflow-hidden" style={{ height: "100dvh" }}>
{/* Header */}
<header className="sticky top-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">
<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-3 h-12 sm:h-14 flex items-center gap-2 sm:gap-4">
{/* Logo */}
<button
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>
{/* Search */}
<div className="flex-1 max-w-2xl">
{/* Search — min-w-0 prevents it from overflowing on narrow screens */}
<div className="flex-1 min-w-0">
<SearchBar />
</div>
{/* Active downloads indicator */}
<DownloadIndicator />
{/* Nav */}
{/* Desktop nav */}
<nav className="hidden sm:flex items-center gap-0.5">
<NavItem to="/">Home</NavItem>
@@ -258,21 +258,27 @@ export default function Layout() {
<NavItem to="/settings">Settings</NavItem>
</nav>
{/* User */}
{/* User — hidden on mobile, sign out is in Settings */}
<button
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
</button>
</div>
</header>
{/* Page content */}
<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))" }}>
{/* Page content — only this area scrolls */}
<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 />
</div>
</main>
{/* Bottom nav — natural flex child, always visible on mobile */}
<BottomNav newCount={newCount} />
</div>
);

View File

@@ -378,7 +378,7 @@ export default function VideoCard({ video, size = "md", onDismiss, variant = "gr
</div>
{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, " ")}
</p>
)}

View File

@@ -6,6 +6,7 @@ import {
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
} from "../api";
import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 50;
@@ -305,7 +306,7 @@ export default function DiscoveryPage() {
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setChannelPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setChannelPage(p => p - 1); scrollToTop(); }}
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"
>
@@ -313,7 +314,7 @@ export default function DiscoveryPage() {
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {channelPage + 1}</span>
<button
onClick={() => { setChannelPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setChannelPage(p => p + 1); scrollToTop(); }}
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"
>
@@ -334,7 +335,7 @@ export default function DiscoveryPage() {
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setVideoPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setVideoPage(p => p - 1); scrollToTop(); }}
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"
>
@@ -342,7 +343,7 @@ export default function DiscoveryPage() {
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {videoPage + 1}</span>
<button
onClick={() => { setVideoPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setVideoPage(p => p + 1); scrollToTop(); }}
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"
>

View File

@@ -2,6 +2,7 @@ import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getHistory } from "../api";
import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 25;
@@ -40,7 +41,7 @@ export default function History() {
</div>
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
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"
>
@@ -48,7 +49,7 @@ export default function History() {
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
<button
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
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"
>

View File

@@ -2,6 +2,7 @@ import { useState, useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { homeFeed, surpriseMe, getSettings, updateSettings, getChannels, markChannelsSeen } from "../api";
import VideoCard from "../components/VideoCard";
import { scrollToTop } from "../utils/scroll";
const PAGE_SIZE = 25;
@@ -95,7 +96,7 @@ export default function Home() {
setShuffleKey(k => k + 1);
setPage(0);
setDismissed(new Set());
window.scrollTo({ top: 0, behavior: "smooth" });
scrollToTop();
};
return (
@@ -235,7 +236,7 @@ export default function Home() {
)}
<div className="flex items-center justify-center gap-3 pt-2">
<button
onClick={() => { setPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setPage(p => p - 1); scrollToTop(); }}
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"
>
@@ -243,7 +244,7 @@ export default function Home() {
</button>
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
<button
onClick={() => { setPage(p => p + 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
onClick={() => { setPage(p => p + 1); scrollToTop(); }}
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"
>

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 });
}