Files
youclonedl/frontend/src/pages/Home.jsx
inputnoise 1827dd6c4e Initial commit — YT Hub
Self-hosted personal YouTube management app.
FastAPI + SQLite backend, React + Vite + Tailwind frontend.
Dockerfiles and compose included for Portainer deployment.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-25 20:09:04 +02:00

319 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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";
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());
window.scrollTo({ top: 0, behavior: "smooth" });
};
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", "1030 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); window.scrollTo({ top: 0, behavior: "smooth" }); }}
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); window.scrollTo({ top: 0, behavior: "smooth" }); }}
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>
);
}