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>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
5
frontend/src/utils/scroll.js
Normal file
5
frontend/src/utils/scroll.js
Normal 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 });
|
||||
}
|
||||
Reference in New Issue
Block a user