Compare commits
2 Commits
b3c288a590
...
cb05b739a8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb05b739a8 | ||
|
|
3e63281849 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
<Outlet />
|
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>
|
</main>
|
||||||
|
|
||||||
|
{/* Bottom nav — natural flex child, always visible on mobile */}
|
||||||
<BottomNav newCount={newCount} />
|
<BottomNav newCount={newCount} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
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