- 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>
320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
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;
|
||
|
||
const FEED_MODES = [
|
||
{ value: "ranked", label: "For you", hint: "Ranked by your taste" },
|
||
{ value: "chronological", label: "New", hint: "Everything in date order" },
|
||
{ value: "random", label: "Explore", hint: "Random from discovery pool" },
|
||
{ value: "inbox", label: "Inbox", hint: "New from followed channels since last visit" },
|
||
];
|
||
|
||
export default function Home() {
|
||
const qc = useQueryClient();
|
||
const [surpriseResults, setSurpriseResults] = useState(null);
|
||
const [mode, setMode] = useState(() => localStorage.getItem("home-feed-mode") ?? "ranked");
|
||
const [page, setPage] = useState(0);
|
||
const [dismissed, setDismissed] = useState(new Set());
|
||
const [shuffleKey, setShuffleKey] = useState(0);
|
||
const [duration, setDuration] = useState("");
|
||
const [viewMode, setViewMode] = useState(() => localStorage.getItem("home-view-mode") ?? "grid");
|
||
|
||
const toggleViewMode = () => {
|
||
const next = viewMode === "grid" ? "list" : "grid";
|
||
localStorage.setItem("home-view-mode", next);
|
||
setViewMode(next);
|
||
};
|
||
|
||
const { data: userSettings } = useQuery({
|
||
queryKey: ["settings"],
|
||
queryFn: () => getSettings().then(r => r.data),
|
||
staleTime: 60_000,
|
||
});
|
||
|
||
const { data: channels = [] } = useQuery({
|
||
queryKey: ["channels"],
|
||
queryFn: () => getChannels().then(r => r.data),
|
||
staleTime: 60_000,
|
||
});
|
||
const inboxCount = channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
||
|
||
const markSeenMut = useMutation({
|
||
mutationFn: () => markChannelsSeen(),
|
||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||
});
|
||
const hideWatched = userSettings?.hide_watched_from_feed ?? false;
|
||
|
||
const handleHideWatchedToggle = () => {
|
||
const next = !hideWatched;
|
||
updateSettings({ hide_watched_from_feed: next });
|
||
qc.setQueryData(["settings"], old => old ? { ...old, hide_watched_from_feed: next } : old);
|
||
};
|
||
|
||
const { data: feedData = [], isLoading: loadingFeed } = useQuery({
|
||
queryKey: ["home-feed", mode, page, hideWatched, duration, mode === "random" ? shuffleKey : 0],
|
||
queryFn: () => homeFeed(page, PAGE_SIZE, mode, duration).then((r) => r.data),
|
||
staleTime: 10 * 60_000,
|
||
placeholderData: (prev) => prev,
|
||
});
|
||
|
||
const surpriseMut = useMutation({
|
||
mutationFn: () => surpriseMe().then((r) => r.data),
|
||
onSuccess: (data) => setSurpriseResults(data),
|
||
});
|
||
|
||
const visibleFeed = useMemo(
|
||
() => feedData.filter(v => !dismissed.has(v.youtube_video_id)),
|
||
[feedData, dismissed],
|
||
);
|
||
|
||
const hasFollowing = channels.length > 0 || feedData.length > 0 || page > 0;
|
||
const hasNextPage = mode === "ranked"
|
||
? feedData.filter(v => !v.is_recommended).length === PAGE_SIZE
|
||
: feedData.length === PAGE_SIZE;
|
||
|
||
const handleDismiss = (video) =>
|
||
setDismissed(prev => new Set([...prev, video.youtube_video_id]));
|
||
|
||
const handleModeChange = (newMode) => {
|
||
localStorage.setItem("home-feed-mode", newMode);
|
||
setMode(newMode);
|
||
setPage(0);
|
||
setDismissed(new Set());
|
||
};
|
||
|
||
const handleDurationChange = (d) => {
|
||
setDuration(prev => prev === d ? "" : d);
|
||
setPage(0);
|
||
setDismissed(new Set());
|
||
};
|
||
|
||
const handleReshuffle = () => {
|
||
setShuffleKey(k => k + 1);
|
||
setPage(0);
|
||
setDismissed(new Set());
|
||
scrollToTop();
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col gap-10">
|
||
{loadingFeed ? (
|
||
<div className="flex items-center justify-center py-24">
|
||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||
</div>
|
||
) : hasFollowing ? (
|
||
<section className="flex flex-col gap-6">
|
||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||
<h2 className="font-display font-semibold text-xl text-zinc-200">Home</h2>
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
onClick={toggleViewMode}
|
||
title={viewMode === "grid" ? "Switch to list view" : "Switch to grid view"}
|
||
className="flex items-center justify-center w-8 h-8 rounded-lg text-zinc-500 hover:text-zinc-300 hover:bg-zinc-800 transition-colors border border-zinc-800"
|
||
>
|
||
{viewMode === "grid" ? (
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||
</svg>
|
||
) : (
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 5a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1V5zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1V5zM4 15a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1H5a1 1 0 01-1-1v-4zm10 0a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
<button
|
||
onClick={handleHideWatchedToggle}
|
||
title={hideWatched ? "Showing unwatched only" : "Showing all videos"}
|
||
className={[
|
||
"flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium transition-colors border",
|
||
hideWatched
|
||
? "bg-accent/10 text-accent border-accent/30"
|
||
: "text-zinc-500 border-zinc-800 hover:text-zinc-300 hover:border-zinc-700",
|
||
].join(" ")}
|
||
>
|
||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
{hideWatched ? (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
|
||
) : (
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
||
)}
|
||
</svg>
|
||
{hideWatched ? "Unwatched" : "All"}
|
||
</button>
|
||
<div className="flex items-center gap-1 bg-zinc-900 rounded-xl p-1">
|
||
{FEED_MODES.map(m => (
|
||
<button
|
||
key={m.value}
|
||
onClick={() => handleModeChange(m.value)}
|
||
title={m.hint}
|
||
className={[
|
||
"relative px-3 py-1.5 rounded-lg text-sm font-medium transition-colors",
|
||
mode === m.value
|
||
? "bg-zinc-700 text-zinc-100"
|
||
: "text-zinc-500 hover:text-zinc-300",
|
||
].join(" ")}
|
||
>
|
||
{m.label}
|
||
{m.value === "inbox" && inboxCount > 0 && (
|
||
<span className="absolute -top-1 -right-1 min-w-[16px] h-4 bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none">
|
||
{inboxCount > 99 ? "99+" : inboxCount}
|
||
</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Duration filter */}
|
||
<div className="flex items-center gap-1.5 -mt-3">
|
||
{[["short", "< 10 min"], ["medium", "10–30 min"], ["long", "30+ min"]].map(([val, label]) => (
|
||
<button
|
||
key={val}
|
||
onClick={() => handleDurationChange(val)}
|
||
className={[
|
||
"px-2.5 py-1 rounded-full text-xs font-medium transition-colors",
|
||
duration === val
|
||
? "bg-zinc-700 text-zinc-100"
|
||
: "text-zinc-600 hover:text-zinc-400",
|
||
].join(" ")}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{mode === "inbox" && (
|
||
<div className="flex items-center justify-between -mt-3">
|
||
<p className="text-xs text-zinc-600">Unwatched videos from followed channels since your last visit.</p>
|
||
<button
|
||
onClick={() => markSeenMut.mutate()}
|
||
disabled={markSeenMut.isPending}
|
||
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4 disabled:opacity-50"
|
||
>
|
||
Mark all read
|
||
</button>
|
||
</div>
|
||
)}
|
||
{mode === "chronological" && (
|
||
<p className="text-xs text-zinc-600 -mt-3">All videos from channels you follow, newest first.</p>
|
||
)}
|
||
{mode === "random" && (
|
||
<div className="flex items-center justify-between -mt-3">
|
||
<p className="text-xs text-zinc-600">Random from your discovery pool — no weighting, no ranking.</p>
|
||
<button
|
||
onClick={handleReshuffle}
|
||
className="flex items-center gap-1.5 text-xs text-zinc-500 hover:text-zinc-300 transition-colors shrink-0 ml-4"
|
||
>
|
||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||
</svg>
|
||
Reshuffle
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{visibleFeed.length === 0 && !loadingFeed ? (
|
||
<p className="text-center text-zinc-500 text-sm py-12">
|
||
{mode === "inbox" ? "You're all caught up — no new videos since your last visit." : "Nothing to show here."}
|
||
</p>
|
||
) : (
|
||
<div className={viewMode === "list" ? "flex flex-col gap-2" : "grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"}>
|
||
{visibleFeed.map((v) => (
|
||
<VideoCard
|
||
key={v.youtube_video_id}
|
||
video={v}
|
||
variant={viewMode}
|
||
onDismiss={v.is_recommended ? handleDismiss : undefined}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
<div className="flex items-center justify-center gap-3 pt-2">
|
||
<button
|
||
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"
|
||
>
|
||
← Prev
|
||
</button>
|
||
<span className="text-zinc-500 text-sm tabular-nums">Page {page + 1}</span>
|
||
<button
|
||
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"
|
||
>
|
||
Next →
|
||
</button>
|
||
</div>
|
||
</section>
|
||
) : (
|
||
<section className="flex flex-col items-center py-16 gap-4 text-center">
|
||
<p className="text-zinc-400 text-sm max-w-sm">
|
||
Follow some channels to get a personalised feed. Or let us pick something.
|
||
</p>
|
||
<button
|
||
onClick={() => surpriseMut.mutate()}
|
||
disabled={surpriseMut.isPending}
|
||
className="bg-accent text-black font-display font-bold text-lg px-8 py-4 rounded-2xl hover:scale-105 active:scale-95 transition-all disabled:opacity-60 shadow-lg shadow-accent/20"
|
||
>
|
||
<span className="flex items-center gap-2">
|
||
<span className="text-2xl">✦</span>
|
||
{surpriseMut.isPending ? "Picking something…" : "Surprise Me"}
|
||
</span>
|
||
</button>
|
||
{surpriseResults && (
|
||
<div className="mt-6 w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||
{surpriseResults.map((v) => (
|
||
<VideoCard key={v.youtube_video_id || v.video_id} video={{
|
||
youtube_video_id: v.youtube_video_id,
|
||
title: v.title,
|
||
thumbnail_url: v.thumbnail_url,
|
||
duration_seconds: v.duration_seconds,
|
||
channel_name: v.channel_name,
|
||
is_downloaded: v.downloaded,
|
||
is_watched: v.watched,
|
||
}} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
|
||
{/* Surprise Me — footer */}
|
||
{hasFollowing && (
|
||
<section className="flex flex-col items-center gap-3 py-4 border-t border-zinc-800/60">
|
||
<p className="text-zinc-600 text-xs">Want something random?</p>
|
||
<button
|
||
onClick={() => surpriseMut.mutate()}
|
||
disabled={surpriseMut.isPending}
|
||
className="flex items-center gap-2 px-5 py-2 rounded-full bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||
>
|
||
<span>✦</span>
|
||
{surpriseMut.isPending ? "Picking…" : "Surprise Me"}
|
||
</button>
|
||
{surpriseResults && (
|
||
<div className="mt-4 w-full grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||
{surpriseResults.map((v) => (
|
||
<VideoCard key={v.youtube_video_id || v.video_id} video={{
|
||
youtube_video_id: v.youtube_video_id,
|
||
title: v.title,
|
||
thumbnail_url: v.thumbnail_url,
|
||
duration_seconds: v.duration_seconds,
|
||
channel_name: v.channel_name,
|
||
is_downloaded: v.downloaded,
|
||
is_watched: v.watched,
|
||
}} />
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|