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>
This commit is contained in:
65
frontend/src/App.jsx
Normal file
65
frontend/src/App.jsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate, useParams } from "react-router-dom";
|
||||
import { AuthProvider, useAuth } from "./hooks/useAuth";
|
||||
import Layout from "./components/Layout";
|
||||
import Home from "./pages/Home";
|
||||
import SearchResults from "./pages/SearchResults";
|
||||
import ChannelPage from "./pages/Channel";
|
||||
import DownloadsPage from "./pages/Downloads";
|
||||
import DiscoveryPage from "./pages/Discovery";
|
||||
import LoginPage from "./pages/Login";
|
||||
import WatchPage from "./pages/Watch";
|
||||
|
||||
function WatchWrapper() {
|
||||
const { youtubeVideoId } = useParams();
|
||||
return <WatchPage key={youtubeVideoId} />;
|
||||
}
|
||||
import FollowingPage from "./pages/Following";
|
||||
import LikedPage from "./pages/Liked";
|
||||
import SettingsPage from "./pages/Settings";
|
||||
import ContinueWatchingPage from "./pages/ContinueWatching";
|
||||
import QueuePage from "./pages/Queue";
|
||||
import HistoryPage from "./pages/History";
|
||||
import StatsPage from "./pages/Stats";
|
||||
import CollectionsPage from "./pages/Collections";
|
||||
|
||||
function RequireAuth({ children }) {
|
||||
const { user, loading } = useAuth();
|
||||
if (loading) return <div className="flex items-center justify-center h-screen text-zinc-500">Loading…</div>;
|
||||
if (!user) return <Navigate to="/login" replace />;
|
||||
return children;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<Layout />
|
||||
</RequireAuth>
|
||||
}
|
||||
>
|
||||
<Route index element={<Home />} />
|
||||
<Route path="search" element={<SearchResults />} />
|
||||
<Route path="channels/:id" element={<ChannelPage />} />
|
||||
<Route path="downloads" element={<DownloadsPage />} />
|
||||
<Route path="following" element={<FollowingPage />} />
|
||||
<Route path="discovery" element={<DiscoveryPage />} />
|
||||
<Route path="liked" element={<LikedPage />} />
|
||||
<Route path="continue-watching" element={<ContinueWatchingPage />} />
|
||||
<Route path="queue" element={<QueuePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="history" element={<HistoryPage />} />
|
||||
<Route path="stats" element={<StatsPage />} />
|
||||
<Route path="collections" element={<CollectionsPage />} />
|
||||
<Route path="watch/:youtubeVideoId" element={<WatchWrapper />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
135
frontend/src/api/index.js
Normal file
135
frontend/src/api/index.js
Normal file
@@ -0,0 +1,135 @@
|
||||
import axios from "axios";
|
||||
|
||||
const api = axios.create({ baseURL: "/api" });
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res,
|
||||
(err) => {
|
||||
if (err.response?.status === 401) {
|
||||
localStorage.removeItem("token");
|
||||
window.location.href = "/login";
|
||||
}
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
// Auth
|
||||
export const login = (username, password) => {
|
||||
const form = new FormData();
|
||||
form.append("username", username);
|
||||
form.append("password", password);
|
||||
return api.post("/auth/login", form);
|
||||
};
|
||||
export const register = (data) => api.post("/auth/register", data);
|
||||
export const getMe = () => api.get("/auth/me");
|
||||
|
||||
// Search
|
||||
export const search = (q, live = false) =>
|
||||
api.get("/search", { params: { q, live } });
|
||||
export const getSearchHistory = () => api.get("/search/history");
|
||||
|
||||
// Channels
|
||||
export const getChannels = () => api.get("/channels");
|
||||
export const getChannelFeed = (offset = 0) => api.get("/channels/feed", { params: { offset, limit: 24 } });
|
||||
export const getChannel = (id) => api.get(`/channels/${id}`);
|
||||
export const syncAllChannels = () => api.post("/channels/sync-all");
|
||||
export const getChannelVideos = (id) => api.get(`/channels/${id}/videos`);
|
||||
export const followChannel = (id) => api.post(`/channels/${id}/follow`);
|
||||
export const unfollowChannel = (id) => api.delete(`/channels/${id}/follow`);
|
||||
export const indexChannel = (id) => api.post(`/channels/${id}/index`);
|
||||
export const followChannelByUrl = (data) => api.post("/channels/follow-by-url", data);
|
||||
export const setChannelAutoDownload = (id, value) => api.patch(`/channels/${id}/auto-download`, { auto_download: value });
|
||||
export const markChannelsSeen = () => api.post("/channels/mark-seen");
|
||||
export const followBulk = (handles) => api.post("/channels/follow-bulk", { handles });
|
||||
export const updateChannelNotes = (id, notes) => api.patch(`/channels/${id}/notes`, { notes });
|
||||
export const muteChannel = (id) => api.post(`/channels/${id}/mute`);
|
||||
export const unmuteChannel = (id) => api.delete(`/channels/${id}/mute`);
|
||||
export const getChannelGroups = () => api.get("/channels/groups");
|
||||
export const createChannelGroup = (name) => api.post("/channels/groups", { name });
|
||||
export const deleteChannelGroup = (id) => api.delete(`/channels/groups/${id}`);
|
||||
export const renameChannelGroup = (id, name) => api.patch(`/channels/groups/${id}`, { name });
|
||||
export const addChannelToGroup = (groupId, channelId) => api.post(`/channels/groups/${groupId}/channels/${channelId}`);
|
||||
export const removeChannelFromGroup = (groupId, channelId) => api.delete(`/channels/groups/${groupId}/channels/${channelId}`);
|
||||
export const bulkChannelAction = (channel_ids, action) => api.post("/channels/bulk-action", { channel_ids, action });
|
||||
|
||||
// Videos
|
||||
export const homeFeed = (page = 0, limit = 25, mode = "ranked", duration = "") =>
|
||||
api.get("/videos/home-feed", { params: { offset: page * limit, limit, mode, ...(duration ? { duration } : {}) } });
|
||||
export const continueWatching = () => api.get("/videos/continue-watching");
|
||||
export const longVideos = () => api.get("/videos/long");
|
||||
export const surpriseMe = () => api.get("/videos/surprise");
|
||||
export const getVideo = (id) => api.get(`/videos/${id}`);
|
||||
export const getVideoByYtId = (ytId) => api.get(`/videos/by-yt/${ytId}`);
|
||||
export const updateProgress = (id, data) => api.patch(`/videos/${id}/progress`, data);
|
||||
export const toggleQueue = (id) => api.post(`/videos/${id}/queue`);
|
||||
export const getQueue = () => api.get("/videos/queue");
|
||||
export const toggleLike = (id) => api.post(`/videos/${id}/like`);
|
||||
export const getLikedVideos = () => api.get("/videos/liked");
|
||||
export const rateVideo = (id, rating) => api.post(`/videos/${id}/rate`, { rating });
|
||||
export const getRelatedVideos = (videoId, mode = "weighted") => api.get(`/videos/${videoId}/related`, { params: { mode } });
|
||||
export const getHistory = (page = 0, limit = 25, channel_id = null) =>
|
||||
api.get("/videos/history", { params: { offset: page * limit, limit, ...(channel_id ? { channel_id } : {}) } });
|
||||
export const getBookmarks = (videoId) => api.get(`/videos/${videoId}/bookmarks`);
|
||||
export const createBookmark = (videoId, data) => api.post(`/videos/${videoId}/bookmarks`, data);
|
||||
export const updateBookmark = (videoId, bookmarkId, data) => api.patch(`/videos/${videoId}/bookmarks/${bookmarkId}`, data);
|
||||
export const deleteBookmark = (videoId, bookmarkId) => api.delete(`/videos/${videoId}/bookmarks/${bookmarkId}`);
|
||||
export const importChapters = (videoId) => api.post(`/videos/${videoId}/bookmarks/import-chapters`);
|
||||
export const clearChapters = (videoId) => api.delete(`/videos/${videoId}/bookmarks/clear-chapters`);
|
||||
|
||||
// Downloads
|
||||
export const createDownload = (youtube_video_id, quality) =>
|
||||
api.post("/downloads", { youtube_video_id, ...(quality ? { quality } : {}) });
|
||||
export const getDownloads = () => api.get("/downloads");
|
||||
export const getDownload = (id) => api.get(`/downloads/${id}`);
|
||||
export const deleteDownload = (id) => api.delete(`/downloads/${id}`);
|
||||
export const deleteAllDownloads = () => api.delete("/downloads/all");
|
||||
export const restoreDownload = (id) => api.post(`/downloads/${id}/restore`);
|
||||
export const downloadChannel = (channelId) => api.post(`/downloads/channel/${channelId}`);
|
||||
export const downloadFollowing = () => api.post("/downloads/following");
|
||||
|
||||
// Export
|
||||
export const exportData = () => api.get("/export", { responseType: "blob" });
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => api.get("/settings");
|
||||
export const updateSettings = (data) => api.patch("/settings", data);
|
||||
|
||||
// Discovery
|
||||
export const getDiscovery = (offset = 0, limit = 50) =>
|
||||
api.get("/discovery", { params: { offset, limit } });
|
||||
export const dismissDiscoveryVideo = (youtubeVideoId) =>
|
||||
api.post(`/discovery/videos/${youtubeVideoId}/dismiss`);
|
||||
export const getDiscoveryVideos = (offset = 0, limit = 50) =>
|
||||
api.get("/discovery/videos", { params: { offset, limit } });
|
||||
export const followDiscovery = (channelId) =>
|
||||
api.post(`/discovery/${channelId}/follow`);
|
||||
export const dismissDiscovery = (channelId) =>
|
||||
api.post(`/discovery/${channelId}/dismiss`);
|
||||
export const refreshDiscovery = () => api.post("/discovery/refresh");
|
||||
export const getCommunityShelf = () => api.get("/discovery/community");
|
||||
|
||||
// Stats
|
||||
export const getStats = () => api.get("/stats");
|
||||
|
||||
// Admin
|
||||
export const getAdminUsers = () => api.get("/admin/users");
|
||||
export const deleteAdminUser = (id) => api.delete(`/admin/users/${id}`);
|
||||
export const getAdminConfig = () => api.get("/admin/config");
|
||||
export const updateAdminConfig = (data) => api.patch("/admin/config", data);
|
||||
|
||||
// Collections
|
||||
export const getCollections = () => api.get("/collections");
|
||||
export const createCollection = (name) => api.post("/collections", { name });
|
||||
export const renameCollection = (id, name) => api.patch(`/collections/${id}`, { name });
|
||||
export const deleteCollection = (id) => api.delete(`/collections/${id}`);
|
||||
export const getCollectionVideos = (id) => api.get(`/collections/${id}/videos`);
|
||||
export const addToCollection = (collectionId, videoId) => api.post(`/collections/${collectionId}/videos`, { video_id: videoId });
|
||||
export const removeFromCollection = (collectionId, videoId) => api.delete(`/collections/${collectionId}/videos/${videoId}`);
|
||||
79
frontend/src/components/ChannelCard.jsx
Normal file
79
frontend/src/components/ChannelCard.jsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { followChannel, unfollowChannel } from "../api";
|
||||
|
||||
function formatSubs(n) {
|
||||
if (!n) return null;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
export default function ChannelCard({ channel }) {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const channelId = channel.local_channel_id ?? channel.id;
|
||||
const isFollowed = channel.is_followed ?? (channel.status === "followed");
|
||||
|
||||
const followMut = useMutation({
|
||||
mutationFn: () => isFollowed ? unfollowChannel(channelId) : followChannel(channelId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
const subs = formatSubs(channel.subscriber_count);
|
||||
const meta = [
|
||||
subs && `${subs} subscribers`,
|
||||
channel.video_count > 0 && `${channel.video_count} videos`,
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
return (
|
||||
<div
|
||||
className="bg-zinc-900 rounded-xl p-4 flex gap-4 cursor-pointer hover:bg-zinc-800 transition-colors"
|
||||
onClick={() => channelId && navigate(`/channels/${channelId}`)}
|
||||
>
|
||||
{/* Avatar */}
|
||||
{channel.thumbnail_url ? (
|
||||
<img
|
||||
src={channel.thumbnail_url}
|
||||
alt={channel.name}
|
||||
className="w-16 h-16 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-700 flex items-center justify-center text-2xl font-display font-bold text-zinc-400 shrink-0">
|
||||
{channel.name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="flex-1 min-w-0 flex flex-col gap-1 justify-center">
|
||||
<p className="font-semibold text-zinc-100 truncate">{channel.name}</p>
|
||||
{meta && (
|
||||
<p className="text-xs text-zinc-500">{meta}</p>
|
||||
)}
|
||||
{channel.description && (
|
||||
<p className="text-xs text-zinc-400 line-clamp-2 leading-relaxed mt-0.5">
|
||||
{channel.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Follow button */}
|
||||
{channelId && (
|
||||
<div className="shrink-0 flex items-center">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); followMut.mutate(); }}
|
||||
disabled={followMut.isPending}
|
||||
className={`text-xs font-medium px-4 py-2 rounded-lg transition-colors ${
|
||||
isFollowed || followMut.isSuccess
|
||||
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
||||
: "bg-accent text-black hover:bg-yellow-300"
|
||||
}`}
|
||||
>
|
||||
{isFollowed || followMut.isSuccess ? "Following" : "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
frontend/src/components/Layout.jsx
Normal file
209
frontend/src/components/Layout.jsx
Normal file
@@ -0,0 +1,209 @@
|
||||
import { Outlet, NavLink, useNavigate, Link, useLocation } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
import SearchBar from "./SearchBar";
|
||||
import { getDownloads, getChannels } from "../api";
|
||||
|
||||
function DownloadIndicator() {
|
||||
const { data } = useQuery({
|
||||
queryKey: ["downloads"],
|
||||
queryFn: () => getDownloads().then((r) => r.data),
|
||||
refetchInterval: (query) => {
|
||||
const active = (query.state.data ?? []).some(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
return active ? 1500 : 10_000;
|
||||
},
|
||||
});
|
||||
|
||||
const active = (data ?? []).filter(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
if (!active.length) return null;
|
||||
|
||||
const top = active[0];
|
||||
const pct = top.progress_percent ?? 0;
|
||||
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
{active.length > 1 && (
|
||||
<span className="text-zinc-500">+{active.length - 1}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function NavItem({ to, children, badge }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`relative px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
|
||||
isActive
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
|
||||
}`
|
||||
}
|
||||
>
|
||||
{children}
|
||||
{badge > 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">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function DropItem({ to, children, badge }) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`relative flex items-center justify-between gap-4 px-3 py-2 text-sm rounded-lg mx-1 transition-colors ${
|
||||
isActive
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span>{children}</span>
|
||||
{badge > 0 && (
|
||||
<span className="min-w-[18px] h-[18px] bg-accent text-black text-[10px] font-bold rounded-full flex items-center justify-center px-1 leading-none shrink-0">
|
||||
{badge > 99 ? "99+" : badge}
|
||||
</span>
|
||||
)}
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
function NavDropdown({ label, paths, children }) {
|
||||
const location = useLocation();
|
||||
const isGroupActive = paths.some(p => location.pathname.startsWith(p));
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<button
|
||||
className={`flex items-center gap-1 px-3 py-1.5 rounded-md text-sm font-medium transition-colors select-none ${
|
||||
isGroupActive
|
||||
? "bg-zinc-800 text-zinc-100"
|
||||
: "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800/60"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
<svg className="w-3 h-3 opacity-50 transition-transform duration-150 group-hover:rotate-180" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* invisible bridge so mouse can move from button to panel without gap */}
|
||||
<div className="absolute top-full left-0 right-0 h-2 bg-transparent" />
|
||||
|
||||
<div className="absolute top-[calc(100%+2px)] right-0 z-50
|
||||
invisible opacity-0 group-hover:visible group-hover:opacity-100
|
||||
transition-all duration-100 ease-out">
|
||||
<div className="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl py-1.5 min-w-[180px]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NavDivider() {
|
||||
return <div className="my-1 mx-3 h-px bg-zinc-800" />;
|
||||
}
|
||||
|
||||
function useNewVideosCount() {
|
||||
const { data: channels = [] } = useQuery({
|
||||
queryKey: ["channels"],
|
||||
queryFn: () => getChannels().then((r) => r.data),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
return channels.reduce((sum, c) => sum + (c.new_count ?? 0), 0);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const { user, logout } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const newCount = useNewVideosCount();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* 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">
|
||||
{/* Logo */}
|
||||
<button
|
||||
onClick={() => navigate("/")}
|
||||
className="font-display font-bold text-lg text-accent shrink-0"
|
||||
>
|
||||
YT Hub
|
||||
</button>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex-1 max-w-2xl">
|
||||
<SearchBar />
|
||||
</div>
|
||||
|
||||
{/* Active downloads indicator */}
|
||||
<DownloadIndicator />
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="hidden sm:flex items-center gap-0.5">
|
||||
<NavItem to="/">Home</NavItem>
|
||||
|
||||
<NavDropdown
|
||||
label="Watch"
|
||||
paths={["/continue-watching", "/liked", "/queue", "/history"]}
|
||||
>
|
||||
<DropItem to="/continue-watching">Continue watching</DropItem>
|
||||
<DropItem to="/liked">Liked videos</DropItem>
|
||||
<DropItem to="/queue">Watch Later</DropItem>
|
||||
<DropItem to="/collections">Collections</DropItem>
|
||||
<NavDivider />
|
||||
<DropItem to="/history">History</DropItem>
|
||||
</NavDropdown>
|
||||
|
||||
<NavDropdown
|
||||
label="Library"
|
||||
paths={["/following", "/downloads", "/discovery", "/stats"]}
|
||||
>
|
||||
<DropItem to="/following" badge={newCount}>Following</DropItem>
|
||||
<NavDivider />
|
||||
<DropItem to="/discovery">Discover</DropItem>
|
||||
<DropItem to="/downloads">Downloads</DropItem>
|
||||
<NavDivider />
|
||||
<DropItem to="/stats">Stats</DropItem>
|
||||
</NavDropdown>
|
||||
|
||||
<NavItem to="/settings">Settings</NavItem>
|
||||
</nav>
|
||||
|
||||
{/* User */}
|
||||
<button
|
||||
onClick={logout}
|
||||
className="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">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
141
frontend/src/components/SearchBar.jsx
Normal file
141
frontend/src/components/SearchBar.jsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { search, getSearchHistory } from "../api";
|
||||
|
||||
function MagnifyIcon() {
|
||||
return (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SearchBar() {
|
||||
const navigate = useNavigate();
|
||||
const [params] = useSearchParams();
|
||||
const [input, setInput] = useState(params.get("q") || "");
|
||||
const [debouncedQ, setDebouncedQ] = useState("");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const ref = useRef(null);
|
||||
|
||||
// Debounce for inline preview
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebouncedQ(input.trim()), 350);
|
||||
return () => clearTimeout(t);
|
||||
}, [input]);
|
||||
|
||||
// Inline local-only preview results
|
||||
const { data: preview } = useQuery({
|
||||
queryKey: ["search-preview", debouncedQ],
|
||||
queryFn: () => search(debouncedQ, false),
|
||||
enabled: debouncedQ.length >= 2,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Recent searches (shown when focused + empty)
|
||||
const { data: historyData } = useQuery({
|
||||
queryKey: ["search-history"],
|
||||
queryFn: () => getSearchHistory().then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
enabled: focused,
|
||||
});
|
||||
|
||||
const previewVideos = preview?.data?.videos?.slice(0, 5) ?? [];
|
||||
const recentQueries = historyData?.queries ?? [];
|
||||
|
||||
// Close dropdown on outside click
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (ref.current && !ref.current.contains(e.target)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", handler);
|
||||
return () => document.removeEventListener("mousedown", handler);
|
||||
}, []);
|
||||
|
||||
const submit = (q = input) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) return;
|
||||
setOpen(false);
|
||||
navigate(`/search?q=${encodeURIComponent(trimmed)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative w-full">
|
||||
<div className="flex items-center bg-zinc-900 border border-zinc-700 rounded-lg px-3 gap-2 focus-within:border-zinc-500 transition-colors">
|
||||
<span className="text-zinc-500">
|
||||
<MagnifyIcon />
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setOpen(true);
|
||||
}}
|
||||
onFocus={() => { setFocused(true); if (input.length >= 2) setOpen(true); else setOpen(true); }}
|
||||
onBlur={() => setFocused(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") submit();
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
}}
|
||||
placeholder="Search videos, channels…"
|
||||
className="flex-1 bg-transparent py-2 text-sm text-zinc-100 placeholder-zinc-500 outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent searches (empty input, focused) */}
|
||||
{open && !debouncedQ && recentQueries.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50">
|
||||
<p className="px-3 pt-2.5 pb-1 text-[10px] text-zinc-600 uppercase tracking-wider font-medium">Recent</p>
|
||||
{recentQueries.map((q) => (
|
||||
<button
|
||||
key={q}
|
||||
onMouseDown={() => { setOpen(false); navigate(`/search?q=${encodeURIComponent(q)}`); }}
|
||||
className="w-full flex items-center gap-2.5 px-3 py-2 hover:bg-zinc-800 text-left transition-colors"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5 text-zinc-600 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm text-zinc-300 truncate">{q}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline preview dropdown */}
|
||||
{open && previewVideos.length > 0 && (
|
||||
<div className="absolute top-full left-0 right-0 mt-1 bg-zinc-900 border border-zinc-700 rounded-lg shadow-2xl overflow-hidden z-50">
|
||||
{previewVideos.map((v) => (
|
||||
<button
|
||||
key={v.youtube_video_id}
|
||||
onMouseDown={() => { setOpen(false); navigate(`/watch/${v.youtube_video_id}`); }}
|
||||
className="w-full flex items-center gap-3 px-3 py-2 hover:bg-zinc-800 text-left transition-colors"
|
||||
>
|
||||
{v.thumbnail_url && (
|
||||
<img
|
||||
src={v.thumbnail_url}
|
||||
alt=""
|
||||
className="w-16 h-9 object-cover rounded flex-shrink-0"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-zinc-100 truncate">{v.title}</p>
|
||||
<p className="text-xs text-zinc-500 truncate">{v.channel_name}</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onMouseDown={() => submit()}
|
||||
className="w-full flex items-center gap-2 px-3 py-2.5 text-sm text-accent hover:bg-zinc-800 border-t border-zinc-800 transition-colors"
|
||||
>
|
||||
<MagnifyIcon />
|
||||
Search YouTube for "{input}"
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
frontend/src/components/Shelf.jsx
Normal file
29
frontend/src/components/Shelf.jsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import VideoCard from "./VideoCard";
|
||||
|
||||
export default function Shelf({ title, videos, action, emptyText }) {
|
||||
if (!videos?.length && emptyText) {
|
||||
return (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-lg text-zinc-300 mb-3">{title}</h2>
|
||||
<p className="text-sm text-zinc-600">{emptyText}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
if (!videos?.length) return null;
|
||||
|
||||
return (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display font-semibold text-lg text-zinc-300">{title}</h2>
|
||||
{action}
|
||||
</div>
|
||||
<div className="flex gap-4 overflow-x-auto pb-2 -mx-1 px-1">
|
||||
{videos.map((v) => (
|
||||
<div key={v.youtube_video_id || v.id} className="w-64 shrink-0">
|
||||
<VideoCard video={v} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
22
frontend/src/components/SortPicker.jsx
Normal file
22
frontend/src/components/SortPicker.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
export default function SortPicker({ value, onChange, options }) {
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<svg
|
||||
className="absolute left-2.5 w-3 h-3 text-zinc-500 pointer-events-none"
|
||||
fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
|
||||
</svg>
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="pl-7 pr-3 py-1.5 bg-zinc-800 border border-zinc-700/60 rounded-lg text-xs text-zinc-300 focus:outline-none focus:border-zinc-500 cursor-pointer"
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
365
frontend/src/components/VideoCard.jsx
Normal file
365
frontend/src/components/VideoCard.jsx
Normal file
@@ -0,0 +1,365 @@
|
||||
import clsx from "clsx";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createDownload, toggleQueue, toggleLike, dismissDiscovery, getSettings } from "../api";
|
||||
|
||||
function formatDuration(secs) {
|
||||
if (!secs) return null;
|
||||
const h = Math.floor(secs / 3600);
|
||||
const m = Math.floor((secs % 3600) / 60);
|
||||
const s = secs % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return null;
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
function IconBtn({ onClick, title, active, pending, children }) {
|
||||
return (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onClick(e); }}
|
||||
title={title}
|
||||
className={clsx(
|
||||
"flex items-center justify-center w-6 h-6 rounded-full transition-all duration-150",
|
||||
active
|
||||
? "text-accent"
|
||||
: "text-zinc-600 hover:text-zinc-200",
|
||||
pending && "opacity-60 cursor-default",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadIcon({ active }) {
|
||||
return active ? (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 20h14v-2H5v2z" />
|
||||
</svg>
|
||||
) : (
|
||||
<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 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function HeartIcon({ active }) {
|
||||
return active ? (
|
||||
<svg className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
) : (
|
||||
<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.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueIcon({ active }) {
|
||||
return (
|
||||
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2.5 : 2}
|
||||
d="M4 6h16M4 11h16M4 16h10" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={active ? 2.5 : 2}
|
||||
d="M16 18l4-2.5-4-2.5v5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ThumbnailBlock({ video, isWatched, duration, calmMode, onDismiss, className }) {
|
||||
return (
|
||||
<div className={clsx("relative bg-zinc-800 overflow-hidden", className)}>
|
||||
{video.thumbnail_url ? (
|
||||
<img
|
||||
src={video.thumbnail_url}
|
||||
alt={video.title}
|
||||
className={clsx(
|
||||
"w-full h-full object-cover transition-all duration-300",
|
||||
"group-hover:scale-[1.03]",
|
||||
isWatched && "opacity-50",
|
||||
)}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss */}
|
||||
{video.is_recommended && !calmMode && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDismiss?.(); }}
|
||||
title="Not interested"
|
||||
className="absolute top-2 right-2 z-10 w-5 h-5 rounded-full bg-black/60 text-zinc-400 hover:text-white hover:bg-black flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<svg className="w-2.5 h-2.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{duration && (
|
||||
<span className="absolute bottom-2 right-2 bg-black/75 text-white text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono tabular-nums">
|
||||
{duration}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Resolution */}
|
||||
{video.download_resolution && (
|
||||
<span className="absolute bottom-2 left-2 text-[10px] font-medium px-1.5 py-0.5 rounded-md font-mono text-accent bg-black/75">
|
||||
{video.download_resolution}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Watched dot */}
|
||||
{isWatched && (
|
||||
<span className="absolute top-2 left-2 w-2 h-2 rounded-full bg-accent shadow-[0_0_6px_rgba(var(--color-accent-rgb),0.8)]" />
|
||||
)}
|
||||
|
||||
{/* Play hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center">
|
||||
<div className="w-11 h-11 rounded-full bg-white/15 backdrop-blur-sm flex items-center justify-center border border-white/20">
|
||||
<svg className="w-5 h-5 text-white ml-0.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
{video.watch_progress_seconds > 0 && video.duration_seconds > 0 && (
|
||||
<div className="absolute bottom-0 inset-x-0 h-[3px] bg-white/10">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${Math.min(video.watch_progress_seconds / video.duration_seconds, 1) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoCard({ video, size = "md", onDismiss, variant = "grid" }) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: appSettings } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => getSettings().then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
const calmMode = appSettings?.calm_mode ?? false;
|
||||
|
||||
const channels = qc.getQueryData(["channels"]) ?? [];
|
||||
const channelMeta = channels.find(c => c.id === video.channel_id) ?? null;
|
||||
const channelNote = channelMeta?.notes || null;
|
||||
const isDormant = channelMeta?.last_published_at
|
||||
? (Date.now() - new Date(channelMeta.last_published_at)) / (1000 * 60 * 60 * 24) > 180
|
||||
: false;
|
||||
|
||||
const internalId = video.id ?? video.local_video_id ?? null;
|
||||
const isDownloaded = video.is_downloaded;
|
||||
const isWatched = video.is_watched;
|
||||
const duration = formatDuration(video.duration_seconds);
|
||||
const date = formatDate(video.published_at);
|
||||
|
||||
const [downloaded, setDownloaded] = useState(isDownloaded);
|
||||
const [queued, setQueued] = useState(video.queued ?? false);
|
||||
const [liked, setLiked] = useState(video.liked ?? false);
|
||||
|
||||
const dlMut = useMutation({
|
||||
mutationFn: () => createDownload(video.youtube_video_id),
|
||||
onSuccess: () => { setDownloaded(true); qc.invalidateQueries({ queryKey: ["downloads"] }); },
|
||||
});
|
||||
const qMut = useMutation({
|
||||
mutationFn: () => toggleQueue(internalId),
|
||||
onSuccess: (res) => { setQueued(res.data.queued); qc.invalidateQueries({ queryKey: ["videos"] }); },
|
||||
});
|
||||
const likeMut = useMutation({
|
||||
mutationFn: () => toggleLike(internalId),
|
||||
onSuccess: (res) => { setLiked(res.data.liked); qc.invalidateQueries({ queryKey: ["liked-videos"] }); },
|
||||
});
|
||||
const dismissMut = useMutation({
|
||||
mutationFn: () => dismissDiscovery(video.channel_id),
|
||||
onSuccess: () => onDismiss?.(video),
|
||||
});
|
||||
|
||||
const actions = (
|
||||
<div className="flex items-center gap-0.5" onClick={(e) => e.stopPropagation()}>
|
||||
<IconBtn onClick={() => navigate(`/watch/${video.youtube_video_id}`)} title="Watch">
|
||||
<svg className="w-3.5 h-3.5 ml-px" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
onClick={() => { if (!downloaded && !dlMut.isPending) dlMut.mutate(); }}
|
||||
title={downloaded ? "Downloaded" : dlMut.isPending ? "Downloading…" : "Download"}
|
||||
active={downloaded}
|
||||
pending={dlMut.isPending}
|
||||
>
|
||||
{dlMut.isPending ? (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : <DownloadIcon active={downloaded} />}
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
onClick={() => { if (internalId && !qMut.isPending) qMut.mutate(); }}
|
||||
title={!internalId ? "Save video first to queue it" : queued ? "Remove from Watch Later" : "Watch Later"}
|
||||
active={queued}
|
||||
pending={qMut.isPending}
|
||||
>
|
||||
<QueueIcon active={queued} />
|
||||
</IconBtn>
|
||||
|
||||
<IconBtn
|
||||
onClick={() => { if (internalId && !likeMut.isPending) likeMut.mutate(); }}
|
||||
title={!internalId ? "Watch video first to like it" : liked ? "Unlike" : "Like"}
|
||||
active={liked}
|
||||
pending={likeMut.isPending}
|
||||
>
|
||||
<HeartIcon active={liked} />
|
||||
</IconBtn>
|
||||
|
||||
{(queued || downloaded) && (
|
||||
<span className="ml-1 text-[10px] text-zinc-700 font-medium">
|
||||
{queued ? "later" : "saved"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── List variant ─────────────────────────────────────────────────────────
|
||||
if (variant === "list") {
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||
className="group flex gap-5 px-3 py-3.5 rounded-2xl cursor-pointer hover:bg-zinc-800/50 transition-colors duration-150"
|
||||
>
|
||||
<ThumbnailBlock
|
||||
video={video}
|
||||
isWatched={isWatched}
|
||||
duration={duration}
|
||||
calmMode={calmMode}
|
||||
onDismiss={() => dismissMut.mutate()}
|
||||
className="w-56 sm:w-72 aspect-video rounded-xl shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col min-w-0 flex-1 py-0.5 gap-2">
|
||||
{/* Title */}
|
||||
<h3 className="font-semibold text-[15px] leading-snug text-zinc-50 line-clamp-2">
|
||||
{video.title}
|
||||
</h3>
|
||||
|
||||
{/* Channel · date · badges */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[12px] text-zinc-400 truncate">{video.channel_name}</span>
|
||||
{date && <span className="text-zinc-700 text-[12px]">·</span>}
|
||||
{date && <span className="text-[12px] text-zinc-600 shrink-0">{date}</span>}
|
||||
{video.is_recommended && !calmMode && (
|
||||
<span className="text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||
Discover
|
||||
</span>
|
||||
)}
|
||||
{isDormant && !calmMode && (
|
||||
<span title="No uploads in 6+ months" className="text-[10px] text-zinc-700 tracking-widest">zzz</span>
|
||||
)}
|
||||
{channelNote && (
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{video.description ? (
|
||||
<p className="text-[12px] leading-relaxed text-zinc-500 line-clamp-3 flex-1">
|
||||
{video.description.replace(/\n+/g, " ")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex-1" />
|
||||
)}
|
||||
|
||||
{/* Actions — fade in on hover */}
|
||||
<div className="opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 mt-auto">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Grid variant ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/watch/${video.youtube_video_id}`)}
|
||||
className={clsx(
|
||||
"group relative flex flex-col cursor-pointer rounded-2xl overflow-hidden",
|
||||
"bg-zinc-900 hover:bg-zinc-800/80 transition-colors duration-150",
|
||||
size === "sm" && "text-xs",
|
||||
)}
|
||||
>
|
||||
<ThumbnailBlock
|
||||
video={video}
|
||||
isWatched={isWatched}
|
||||
duration={duration}
|
||||
calmMode={calmMode}
|
||||
onDismiss={() => dismissMut.mutate()}
|
||||
className="aspect-video"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 p-3 flex-1">
|
||||
{/* Title */}
|
||||
<p className="font-medium text-[13px] leading-snug text-zinc-50 line-clamp-2">
|
||||
{video.title}
|
||||
</p>
|
||||
|
||||
{/* Channel + date */}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-[11px] text-zinc-500 truncate">{video.channel_name}</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
{channelNote && (
|
||||
<span title={channelNote} className="text-zinc-700 hover:text-zinc-400 transition-colors cursor-default">
|
||||
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M3 5a2 2 0 012-2h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2zm2 0v14h14V5zm2 3h10v2H7zm0 4h7v2H7z"/>
|
||||
</svg>
|
||||
</span>
|
||||
)}
|
||||
{isDormant && !calmMode && (
|
||||
<span title="No uploads in 6+ months" className="text-[9px] text-zinc-700 tracking-widest">zzz</span>
|
||||
)}
|
||||
{date && <span className="text-[11px] text-zinc-700">{date}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badges */}
|
||||
{(video.is_recommended && !calmMode) && (
|
||||
<span className="self-start text-[10px] font-semibold tracking-wide text-indigo-400 bg-indigo-950/60 px-1.5 py-0.5 rounded-full">
|
||||
Discover
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Actions — fade in on hover */}
|
||||
<div className="mt-auto pt-1.5 opacity-0 group-hover:opacity-100 pointer-events-none group-hover:pointer-events-auto transition-opacity duration-150 border-t border-zinc-800/80">
|
||||
{actions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
277
frontend/src/components/VideoPlayer.jsx
Normal file
277
frontend/src/components/VideoPlayer.jsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getVideoByYtId, updateProgress, createDownload, followChannelByUrl, getDownload } from "../api";
|
||||
|
||||
function formatDuration(s) {
|
||||
if (!s) return "";
|
||||
const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), sec = s % 60;
|
||||
if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
||||
return `${m}:${String(sec).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function YoutubeEmbed({ youtubeId, startAt, onTimeUpdate }) {
|
||||
useEffect(() => {
|
||||
const handler = (e) => {
|
||||
if (e.origin !== "https://www.youtube.com") return;
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
if (data.event === "infoDelivery" && data.info?.currentTime != null) {
|
||||
onTimeUpdate(Math.floor(data.info.currentTime));
|
||||
}
|
||||
} catch {}
|
||||
};
|
||||
window.addEventListener("message", handler);
|
||||
return () => window.removeEventListener("message", handler);
|
||||
}, [onTimeUpdate]);
|
||||
|
||||
const start = startAt > 10 ? startAt : 0;
|
||||
const src = `https://www.youtube.com/embed/${youtubeId}?autoplay=1&rel=0&modestbranding=1&enablejsapi=1&start=${start}&origin=${encodeURIComponent(window.location.origin)}`;
|
||||
|
||||
return (
|
||||
<iframe
|
||||
src={src}
|
||||
className="w-full aspect-video rounded-lg bg-black"
|
||||
allow="autoplay; fullscreen; picture-in-picture"
|
||||
allowFullScreen
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LocalVideo({ src, startAt, onTimeUpdate }) {
|
||||
const ref = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current && startAt > 10) {
|
||||
ref.current.currentTime = startAt;
|
||||
}
|
||||
}, []); // only on mount
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={ref}
|
||||
src={src}
|
||||
controls
|
||||
autoPlay
|
||||
className="w-full aspect-video rounded-lg bg-black"
|
||||
onTimeUpdate={() => {
|
||||
if (ref.current) onTimeUpdate(Math.floor(ref.current.currentTime));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadProgress({ pct, status }) {
|
||||
const label = status === "pending" ? "Queued…" : `Downloading ${pct.toFixed(0)}%`;
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 h-1.5 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-zinc-500 shrink-0 font-mono w-28">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function VideoPlayer() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const qc = useQueryClient();
|
||||
const youtubeId = params.get("play");
|
||||
const urlTitle = params.get("pt");
|
||||
const urlChannel = params.get("pc");
|
||||
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [downloadId, setDownloadId] = useState(null);
|
||||
const [switchedToLocal, setSwitchedToLocal] = useState(false);
|
||||
const saveTimerRef = useRef(null);
|
||||
const initiatedRef = useRef(null); // track which video we triggered download for
|
||||
|
||||
// ── Video metadata ────────────────────────────────────────────────────────
|
||||
const { data: video, refetch: refetchVideo } = useQuery({
|
||||
queryKey: ["video-play", youtubeId],
|
||||
queryFn: () =>
|
||||
getVideoByYtId(youtubeId)
|
||||
.then((r) => r.data)
|
||||
.catch((err) => (err.response?.status === 404 ? null : Promise.reject(err))),
|
||||
enabled: !!youtubeId,
|
||||
staleTime: 0,
|
||||
});
|
||||
|
||||
// ── Download polling ──────────────────────────────────────────────────────
|
||||
const { data: dlStatus } = useQuery({
|
||||
queryKey: ["download-status", downloadId],
|
||||
queryFn: () => getDownload(downloadId).then((r) => r.data),
|
||||
enabled: !!downloadId,
|
||||
refetchInterval: (query) => {
|
||||
const s = query.state.data?.status;
|
||||
return s === "complete" || s === "failed" ? false : 1500;
|
||||
},
|
||||
});
|
||||
|
||||
// When download finishes, re-fetch video to get local_file_url and auto-switch
|
||||
useEffect(() => {
|
||||
if (dlStatus?.status === "complete" && !switchedToLocal) {
|
||||
refetchVideo().then(({ data }) => {
|
||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
||||
});
|
||||
}
|
||||
}, [dlStatus?.status, switchedToLocal, refetchVideo]);
|
||||
|
||||
// ── Trigger download on open ──────────────────────────────────────────────
|
||||
const downloadMut = useMutation({
|
||||
mutationFn: (ytId) => createDownload(ytId),
|
||||
onSuccess: (res) => {
|
||||
const dl = res.data;
|
||||
setDownloadId(dl.id);
|
||||
// If it came back complete already (was pre-downloaded), just switch now
|
||||
if (dl.status === "complete") {
|
||||
refetchVideo().then(({ data }) => {
|
||||
if (data?.local_file_url) setSwitchedToLocal(true);
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!youtubeId || initiatedRef.current === youtubeId) return;
|
||||
initiatedRef.current = youtubeId;
|
||||
setSwitchedToLocal(false);
|
||||
setCurrentTime(0);
|
||||
setDownloadId(null);
|
||||
// Small delay so the modal renders before the fetch starts
|
||||
const t = setTimeout(() => downloadMut.mutate(youtubeId), 200);
|
||||
return () => clearTimeout(t);
|
||||
}, [youtubeId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// ── Progress saving ───────────────────────────────────────────────────────
|
||||
const followMut = useMutation({
|
||||
mutationFn: () => followChannelByUrl({ youtube_channel_id: video?.channel_youtube_id }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["channels"] }),
|
||||
});
|
||||
|
||||
const handleTimeUpdate = useCallback((secs) => {
|
||||
setCurrentTime(secs);
|
||||
clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
if (video?.id) {
|
||||
const duration = video.duration_seconds ?? 0;
|
||||
const watched = duration > 0 && secs >= duration * 0.9;
|
||||
updateProgress(video.id, { watch_progress_seconds: secs, watched });
|
||||
}
|
||||
}, 10_000);
|
||||
}, [video]);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setParams((p) => { p.delete("play"); p.delete("pt"); p.delete("pc"); return p; });
|
||||
setSwitchedToLocal(false);
|
||||
clearTimeout(saveTimerRef.current);
|
||||
}, [setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e) => { if (e.key === "Escape") close(); };
|
||||
document.addEventListener("keydown", handler);
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [close]);
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimerRef.current), []);
|
||||
|
||||
if (!youtubeId) return null;
|
||||
|
||||
const title = video?.title ?? urlTitle ?? youtubeId;
|
||||
const channelName = video?.channel_name ?? urlChannel;
|
||||
const startAt = video?.watch_progress_seconds ?? 0;
|
||||
const isDownloading = dlStatus && (dlStatus.status === "pending" || dlStatus.status === "downloading");
|
||||
const localUrl = switchedToLocal ? video?.local_file_url : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/85 backdrop-blur-sm p-4"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) close(); }}
|
||||
>
|
||||
<div className="relative w-full max-w-4xl flex flex-col gap-3 max-h-[95vh] overflow-y-auto">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h2 className="font-display font-semibold text-white text-lg leading-snug line-clamp-2">
|
||||
{title}
|
||||
</h2>
|
||||
{channelName && (
|
||||
<p className="text-sm text-zinc-400 mt-0.5">{channelName}</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={close} className="shrink-0 text-zinc-400 hover:text-white transition-colors p-1">
|
||||
<CloseIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player — local file once ready, YouTube embed while downloading */}
|
||||
{localUrl ? (
|
||||
<LocalVideo src={localUrl} startAt={currentTime || startAt} onTimeUpdate={handleTimeUpdate} />
|
||||
) : (
|
||||
<YoutubeEmbed youtubeId={youtubeId} startAt={startAt} onTimeUpdate={handleTimeUpdate} />
|
||||
)}
|
||||
|
||||
{/* Download progress bar (shows while downloading, disappears when done) */}
|
||||
{isDownloading && (
|
||||
<DownloadProgress
|
||||
pct={dlStatus.progress_percent ?? 0}
|
||||
status={dlStatus.status}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status / source indicator */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{localUrl ? (
|
||||
<span className="text-xs text-accent font-medium">▶ Playing local file</span>
|
||||
) : isDownloading ? (
|
||||
<span className="text-xs text-zinc-500">Watching on YouTube · switching to local when ready</span>
|
||||
) : dlStatus?.status === "failed" ? (
|
||||
<span className="text-xs text-red-400">Download failed — watching on YouTube</span>
|
||||
) : null}
|
||||
|
||||
{/* Follow channel */}
|
||||
{video?.channel_youtube_id && (
|
||||
<button
|
||||
onClick={() => followMut.mutate()}
|
||||
disabled={followMut.isPending || followMut.isSuccess}
|
||||
className="ml-auto text-xs font-medium px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{followMut.isSuccess ? "Following ✓" : "Follow channel"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Duration */}
|
||||
{video?.duration_seconds && (
|
||||
<span className="text-xs text-zinc-600 font-mono">
|
||||
{formatDuration(currentTime || startAt)} / {formatDuration(video.duration_seconds)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
{video?.description && (
|
||||
<details>
|
||||
<summary className="text-xs text-zinc-500 cursor-pointer hover:text-zinc-300 transition-colors select-none">
|
||||
Description
|
||||
</summary>
|
||||
<p className="text-sm text-zinc-400 mt-2 whitespace-pre-line leading-relaxed max-h-40 overflow-y-auto">
|
||||
{video.description}
|
||||
</p>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
frontend/src/hooks/useAuth.jsx
Normal file
50
frontend/src/hooks/useAuth.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useState, useEffect, createContext, useContext } from "react";
|
||||
import { getMe, login as apiLogin, register as apiRegister } from "../api";
|
||||
|
||||
const AuthContext = createContext(null);
|
||||
|
||||
export function AuthProvider({ children }) {
|
||||
const [user, setUser] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem("token");
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getMe()
|
||||
.then((res) => setUser(res.data))
|
||||
.catch(() => localStorage.removeItem("token"))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const login = async (username, password) => {
|
||||
const res = await apiLogin(username, password);
|
||||
localStorage.setItem("token", res.data.access_token);
|
||||
const me = await getMe();
|
||||
setUser(me.data);
|
||||
};
|
||||
|
||||
const register = async (username, email, password) => {
|
||||
const res = await apiRegister({ username, email, password });
|
||||
localStorage.setItem("token", res.data.access_token);
|
||||
const me = await getMe();
|
||||
setUser(me.data);
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem("token");
|
||||
setUser(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, loading, login, register, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
return useContext(AuthContext);
|
||||
}
|
||||
25
frontend/src/hooks/usePlayer.js
Normal file
25
frontend/src/hooks/usePlayer.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
export function usePlayer() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
|
||||
const play = (youtubeVideoId, meta = {}) => {
|
||||
setParams((p) => {
|
||||
p.set("play", youtubeVideoId);
|
||||
if (meta.title) p.set("pt", meta.title);
|
||||
if (meta.channel_name) p.set("pc", meta.channel_name);
|
||||
return p;
|
||||
});
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
setParams((p) => {
|
||||
p.delete("play");
|
||||
p.delete("pt");
|
||||
p.delete("pc");
|
||||
return p;
|
||||
});
|
||||
};
|
||||
|
||||
return { play, close, currentId: params.get("play") };
|
||||
}
|
||||
27
frontend/src/index.css
Normal file
27
frontend/src/index.css
Normal file
@@ -0,0 +1,27 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #3f3f46 transparent;
|
||||
}
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #3f3f46;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.line-clamp-2 {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
22
frontend/src/main.jsx
Normal file
22
frontend/src/main.jsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 1000 * 60 * 5,
|
||||
retry: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
162
frontend/src/pages/Channel.jsx
Normal file
162
frontend/src/pages/Channel.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getChannel, getChannelVideos, followChannel, unfollowChannel, indexChannel, downloadChannel } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import SortPicker from "../components/SortPicker";
|
||||
|
||||
function formatSubs(n) {
|
||||
if (!n) return null;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
const VIDEO_SORTS = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "oldest", label: "Oldest" },
|
||||
{ value: "title", label: "Title A–Z" },
|
||||
{ value: "unwatched", label: "Unwatched first" },
|
||||
];
|
||||
|
||||
function sortVideos(items, sort) {
|
||||
const arr = [...items];
|
||||
if (sort === "oldest") return arr.sort((a, b) => new Date(a.published_at ?? 0) - new Date(b.published_at ?? 0));
|
||||
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
||||
if (sort === "unwatched") return arr.sort((a, b) => Number(a.is_watched) - Number(b.is_watched));
|
||||
return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
|
||||
}
|
||||
|
||||
export default function ChannelPage() {
|
||||
const { id } = useParams();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: channel, isLoading: loadingChannel } = useQuery({
|
||||
queryKey: ["channel", id],
|
||||
queryFn: () => getChannel(id).then((r) => r.data),
|
||||
});
|
||||
|
||||
const { data: videos, isLoading: loadingVideos } = useQuery({
|
||||
queryKey: ["channel-videos", id],
|
||||
queryFn: () => getChannelVideos(id).then((r) => r.data),
|
||||
});
|
||||
|
||||
const followMut = useMutation({
|
||||
mutationFn: () =>
|
||||
channel?.status === "followed" ? unfollowChannel(id) : followChannel(id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["channel", id] });
|
||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||
},
|
||||
});
|
||||
|
||||
const indexMut = useMutation({
|
||||
mutationFn: () => indexChannel(id),
|
||||
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["channel-videos", id] }), 4000),
|
||||
});
|
||||
|
||||
const [dlResult, setDlResult] = useState(null);
|
||||
const [videoSort, setVideoSort] = useState("newest");
|
||||
const dlMut = useMutation({
|
||||
mutationFn: () => downloadChannel(id),
|
||||
onSuccess: (res) => {
|
||||
setDlResult(res.data.queued);
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
},
|
||||
});
|
||||
|
||||
if (loadingChannel) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!channel) return <p className="text-zinc-500">Channel not found.</p>;
|
||||
|
||||
const isFollowed = channel.status === "followed";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
{/* Channel header */}
|
||||
<div className="flex items-start gap-5">
|
||||
{channel.thumbnail_url ? (
|
||||
<img
|
||||
src={channel.thumbnail_url}
|
||||
alt={channel.name}
|
||||
className="w-20 h-20 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 rounded-full bg-zinc-800 flex items-center justify-center text-3xl font-display font-bold text-zinc-400 shrink-0">
|
||||
{channel.name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">{channel.name}</h1>
|
||||
<p className="text-sm text-zinc-500 mt-1">
|
||||
{[
|
||||
formatSubs(channel.subscriber_count) && `${formatSubs(channel.subscriber_count)} subscribers`,
|
||||
`${channel.video_count} videos indexed`,
|
||||
].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
{channel.description && (
|
||||
<p className="text-sm text-zinc-400 mt-2 line-clamp-2">{channel.description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0 flex-wrap justify-end">
|
||||
{dlResult != null && (
|
||||
<span className="text-sm text-accent font-mono">
|
||||
{dlResult === 0 ? "Already up to date" : `${dlResult} queued`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => dlMut.mutate()}
|
||||
disabled={dlMut.isPending}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-accent text-black hover:bg-yellow-300 transition-colors disabled:opacity-60 flex items-center gap-2"
|
||||
>
|
||||
{dlMut.isPending ? "Queuing…" : "Download all"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => indexMut.mutate()}
|
||||
disabled={indexMut.isPending || indexMut.isSuccess}
|
||||
className="text-sm font-medium px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{indexMut.isPending ? "Indexing…" : indexMut.isSuccess ? "Done ✓" : "Re-index"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => followMut.mutate()}
|
||||
disabled={followMut.isPending}
|
||||
className={`text-sm font-medium px-4 py-2 rounded-lg transition-colors ${
|
||||
isFollowed
|
||||
? "bg-zinc-700 text-zinc-300 hover:bg-zinc-600"
|
||||
: "bg-zinc-800 text-zinc-300 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{isFollowed ? "Following" : "Follow"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video grid */}
|
||||
{loadingVideos ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : videos?.length ? (
|
||||
<>
|
||||
<div className="flex justify-end">
|
||||
<SortPicker value={videoSort} onChange={setVideoSort} options={VIDEO_SORTS} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{sortVideos(videos, videoSort).map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={{ ...v, channel_name: channel.name }} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-zinc-500 text-sm">No videos indexed yet. Hit Re-index to fetch them.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
221
frontend/src/pages/Collections.jsx
Normal file
221
frontend/src/pages/Collections.jsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
getCollections, createCollection, renameCollection, deleteCollection,
|
||||
getCollectionVideos, removeFromCollection,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
function formatDate(d) {
|
||||
if (!d) return "";
|
||||
return new Date(d).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
|
||||
}
|
||||
|
||||
function ThumbnailMosaic({ thumbnails }) {
|
||||
const t = thumbnails.slice(0, 4);
|
||||
if (t.length === 0) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<svg className="w-8 h-8" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M4 6h16v2H4zm0 5h16v2H4zm0 5h16v2H4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (t.length === 1) return <img src={t[0]} className="w-full h-full object-cover" alt="" />;
|
||||
return (
|
||||
<div className="grid grid-cols-2 w-full h-full gap-px">
|
||||
{t.map((src, i) => (
|
||||
<img key={i} src={src} className="w-full h-full object-cover" alt="" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CollectionCard({ col, onOpen, onRename, onDelete }) {
|
||||
const [confirming, setConfirming] = useState(false);
|
||||
return (
|
||||
<div
|
||||
onClick={() => onOpen(col)}
|
||||
className="group bg-zinc-900 rounded-2xl overflow-hidden cursor-pointer hover:bg-zinc-800/80 transition-colors"
|
||||
>
|
||||
<div className="aspect-video bg-zinc-800 overflow-hidden">
|
||||
<ThumbnailMosaic thumbnails={col.thumbnails} />
|
||||
</div>
|
||||
<div className="p-3 flex flex-col gap-1">
|
||||
<p className="font-medium text-sm text-zinc-100 truncate">{col.name}</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-zinc-500">{col.video_count} video{col.video_count !== 1 ? "s" : ""}</span>
|
||||
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => onRename(col)}
|
||||
className="p-1 text-zinc-600 hover:text-zinc-300 transition-colors"
|
||||
title="Rename"
|
||||
>
|
||||
<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="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
</svg>
|
||||
</button>
|
||||
{confirming ? (
|
||||
<button onClick={() => { onDelete(col.id); setConfirming(false); }} className="text-[11px] text-red-400 hover:text-red-300 px-1">confirm</button>
|
||||
) : (
|
||||
<button onClick={() => setConfirming(true)} className="p-1 text-zinc-600 hover:text-red-400 transition-colors" title="Delete">
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CollectionsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(null); // collection being viewed
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [renaming, setRenaming] = useState(null); // {id, name}
|
||||
|
||||
const { data: collections = [], isLoading } = useQuery({
|
||||
queryKey: ["collections"],
|
||||
queryFn: () => getCollections().then(r => r.data),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const { data: openData } = useQuery({
|
||||
queryKey: ["collection-videos", open?.id],
|
||||
queryFn: () => getCollectionVideos(open.id).then(r => r.data),
|
||||
enabled: !!open,
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (name) => createCollection(name),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["collections"] }); setCreating(false); setNewName(""); },
|
||||
});
|
||||
|
||||
const renameMut = useMutation({
|
||||
mutationFn: ({ id, name }) => renameCollection(id, name),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["collections"] }); setRenaming(null); },
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id) => deleteCollection(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["collections"] }); if (open) setOpen(null); },
|
||||
});
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: ({ videoId }) => removeFromCollection(open.id, videoId),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["collection-videos", open?.id] }),
|
||||
});
|
||||
|
||||
if (open) {
|
||||
const videos = openData?.videos ?? [];
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setOpen(null)} className="text-zinc-500 hover:text-zinc-200 transition-colors">
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">{open.name}</h1>
|
||||
<span className="text-zinc-600 text-sm">{videos.length} video{videos.length !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm py-12 text-center">No videos in this collection yet.<br/>Add them from the video watch page.</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{videos.map(v => (
|
||||
<div key={v.youtube_video_id} className="flex items-start gap-3 group">
|
||||
<div className="flex-1 min-w-0">
|
||||
<VideoCard video={v} variant="list" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeMut.mutate({ videoId: v.id })}
|
||||
title="Remove from collection"
|
||||
className="shrink-0 mt-4 p-1.5 rounded-lg text-zinc-700 hover:text-red-400 hover:bg-zinc-800 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Collections</h1>
|
||||
<button
|
||||
onClick={() => setCreating(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
New collection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{creating && (
|
||||
<div className="flex items-center gap-3 p-4 bg-zinc-900 rounded-2xl">
|
||||
<input
|
||||
autoFocus
|
||||
value={newName}
|
||||
onChange={e => setNewName(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === "Enter" && newName.trim()) createMut.mutate(newName.trim()); if (e.key === "Escape") { setCreating(false); setNewName(""); } }}
|
||||
placeholder="Collection name…"
|
||||
className="flex-1 bg-zinc-800 text-zinc-100 text-sm rounded-lg px-3 py-2 focus:outline-none border border-zinc-700 focus:border-zinc-500"
|
||||
/>
|
||||
<button onClick={() => { if (newName.trim()) createMut.mutate(newName.trim()); }} disabled={!newName.trim() || createMut.isPending} className="px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium disabled:opacity-50">Create</button>
|
||||
<button onClick={() => { setCreating(false); setNewName(""); }} className="px-3 py-2 text-zinc-500 hover:text-zinc-300 text-sm">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{renaming && (
|
||||
<div className="flex items-center gap-3 p-4 bg-zinc-900 rounded-2xl">
|
||||
<input
|
||||
autoFocus
|
||||
value={renaming.name}
|
||||
onChange={e => setRenaming(r => ({ ...r, name: e.target.value }))}
|
||||
onKeyDown={e => { if (e.key === "Enter") renameMut.mutate(renaming); if (e.key === "Escape") setRenaming(null); }}
|
||||
className="flex-1 bg-zinc-800 text-zinc-100 text-sm rounded-lg px-3 py-2 focus:outline-none border border-zinc-700 focus:border-zinc-500"
|
||||
/>
|
||||
<button onClick={() => renameMut.mutate(renaming)} disabled={renameMut.isPending} className="px-4 py-2 rounded-lg bg-accent text-black text-sm font-medium disabled:opacity-50">Save</button>
|
||||
<button onClick={() => setRenaming(null)} className="px-3 py-2 text-zinc-500 hover:text-zinc-300 text-sm">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-7 h-7 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : collections.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm text-center py-16">No collections yet. Create one to organise your videos.</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{collections.map(col => (
|
||||
<CollectionCard
|
||||
key={col.id}
|
||||
col={col}
|
||||
onOpen={setOpen}
|
||||
onRename={col => setRenaming({ id: col.id, name: col.name })}
|
||||
onDelete={id => deleteMut.mutate(id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
frontend/src/pages/ContinueWatching.jsx
Normal file
52
frontend/src/pages/ContinueWatching.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { continueWatching } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
export default function ContinueWatchingPage() {
|
||||
const { data: videos = [], isLoading } = useQuery({
|
||||
queryKey: ["continue-watching"],
|
||||
queryFn: () => continueWatching().then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Continue Watching</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-zinc-600" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Nothing in progress</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Videos you've started but not finished will appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} in progress</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{videos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={{
|
||||
...v,
|
||||
is_watched: false,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
356
frontend/src/pages/Discovery.jsx
Normal file
356
frontend/src/pages/Discovery.jsx
Normal file
@@ -0,0 +1,356 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getDiscovery, getDiscoveryVideos,
|
||||
followDiscovery, dismissDiscovery, dismissDiscoveryVideo, refreshDiscovery,
|
||||
} from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
const SOURCE_LABELS = {
|
||||
search: "Based on your channels",
|
||||
graph: "Related to channels you follow",
|
||||
community: "Popular with other users",
|
||||
category: "Similar category",
|
||||
liked: "Related to videos you liked",
|
||||
};
|
||||
|
||||
function formatSubs(n) {
|
||||
if (!n) return null;
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function avatarColor(name) {
|
||||
if (!name) return "#52525b";
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
|
||||
return `hsl(${h}, 55%, 42%)`;
|
||||
}
|
||||
|
||||
function ChannelCard({ item }) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [gone, setGone] = useState(false);
|
||||
|
||||
const followMut = useMutation({
|
||||
mutationFn: () => followDiscovery(item.channel_id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["channels"] });
|
||||
setTimeout(() => {
|
||||
setGone(true);
|
||||
qc.invalidateQueries({ queryKey: ["discovery"] });
|
||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
||||
}, 600);
|
||||
},
|
||||
});
|
||||
|
||||
const dismissMut = useMutation({
|
||||
mutationFn: () => dismissDiscovery(item.channel_id),
|
||||
onSuccess: () => {
|
||||
setTimeout(() => {
|
||||
setGone(true);
|
||||
qc.invalidateQueries({ queryKey: ["discovery"] });
|
||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
||||
}, 300);
|
||||
},
|
||||
});
|
||||
|
||||
const [featured, ...rest] = item.preview_videos ?? [];
|
||||
const subs = formatSubs(item.subscriber_count);
|
||||
const busy = followMut.isPending || dismissMut.isPending;
|
||||
|
||||
if (gone) return null;
|
||||
|
||||
return (
|
||||
<div className={`rounded-xl border border-zinc-800 bg-zinc-900 overflow-hidden flex flex-col transition-opacity duration-300 ${
|
||||
followMut.isSuccess || dismissMut.isSuccess ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||
}`}>
|
||||
{/* Featured thumbnail */}
|
||||
{featured ? (
|
||||
<div
|
||||
className="relative aspect-video bg-zinc-800 overflow-hidden cursor-pointer group/thumb"
|
||||
onClick={() => navigate(`/channels/${item.channel_id}`)}
|
||||
>
|
||||
<img
|
||||
src={featured.thumbnail_url}
|
||||
alt={featured.title}
|
||||
className="w-full h-full object-cover group-hover/thumb:scale-105 transition-transform duration-300"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-zinc-900/80 to-transparent" />
|
||||
<div className="absolute bottom-2 left-3">
|
||||
{item.thumbnail_url ? (
|
||||
<img src={item.thumbnail_url} alt={item.name}
|
||||
className="w-8 h-8 rounded-full object-cover ring-2 ring-zinc-900" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full flex items-center justify-center font-bold text-white text-sm ring-2 ring-zinc-900"
|
||||
style={{ backgroundColor: avatarColor(item.name) }}>
|
||||
{item.name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="h-20 cursor-pointer flex items-end px-3 pb-2"
|
||||
style={{ background: `linear-gradient(135deg, ${avatarColor(item.name)}44, ${avatarColor(item.name)}22)` }}
|
||||
onClick={() => navigate(`/channels/${item.channel_id}`)}
|
||||
>
|
||||
{item.thumbnail_url ? (
|
||||
<img src={item.thumbnail_url} alt={item.name}
|
||||
className="w-10 h-10 rounded-full object-cover ring-2 ring-zinc-900" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white text-base ring-2 ring-zinc-900"
|
||||
style={{ backgroundColor: avatarColor(item.name) }}>
|
||||
{item.name?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3 flex flex-col gap-2 flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<button
|
||||
onClick={() => navigate(`/channels/${item.channel_id}`)}
|
||||
className="font-semibold text-sm text-zinc-100 hover:text-white text-left leading-tight line-clamp-1"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
<p className="text-xs text-zinc-500 mt-0.5 truncate">
|
||||
{[subs && `${subs} subscribers`, SOURCE_LABELS[item.source] ?? "Recommended"].filter(Boolean).join(" · ")}
|
||||
</p>
|
||||
</div>
|
||||
{!followMut.isSuccess && (
|
||||
<button
|
||||
onClick={() => dismissMut.mutate()}
|
||||
disabled={busy}
|
||||
title="Not interested"
|
||||
className="p-1 rounded-md text-zinc-600 hover:text-zinc-300 hover:bg-zinc-800 transition-colors disabled:opacity-40 shrink-0"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{rest.length > 0 && (
|
||||
<ul className="flex flex-col gap-0.5">
|
||||
{rest.slice(0, 2).map((v, i) => (
|
||||
<li key={i} className="text-xs text-zinc-500 truncate">· {v.title}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{!featured && item.description && (
|
||||
<p className="text-xs text-zinc-500 line-clamp-2">{item.description}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-auto pt-1">
|
||||
{followMut.isSuccess ? (
|
||||
<p className="text-xs text-accent font-medium text-center py-1">Following ✓</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => followMut.mutate()}
|
||||
disabled={busy}
|
||||
className="w-full py-1.5 rounded-lg bg-accent text-black text-xs font-semibold hover:bg-yellow-300 transition-colors disabled:opacity-50"
|
||||
>
|
||||
Follow
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tab({ active, onClick, children, count }) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={[
|
||||
"px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2",
|
||||
active
|
||||
? "border-accent text-zinc-100"
|
||||
: "border-transparent text-zinc-500 hover:text-zinc-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{children}
|
||||
{count > 0 && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded-full ${active ? "bg-accent/20 text-accent" : "bg-zinc-800 text-zinc-500"}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DiscoveryPage() {
|
||||
const qc = useQueryClient();
|
||||
const [tab, setTab] = useState("channels");
|
||||
const [channelPage, setChannelPage] = useState(0);
|
||||
const [videoPage, setVideoPage] = useState(0);
|
||||
const [dismissedVideos, setDismissedVideos] = useState(new Set());
|
||||
|
||||
const { data: channels = [], isLoading: loadingChannels } = useQuery({
|
||||
queryKey: ["discovery", channelPage],
|
||||
queryFn: () => getDiscovery(channelPage * PAGE_SIZE, PAGE_SIZE).then((r) => r.data),
|
||||
staleTime: 0,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const { data: videos = [], isLoading: loadingVideos } = useQuery({
|
||||
queryKey: ["discovery-videos", videoPage],
|
||||
queryFn: () => getDiscoveryVideos(videoPage * PAGE_SIZE, PAGE_SIZE).then((r) => r.data),
|
||||
staleTime: 0,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const refreshMut = useMutation({
|
||||
mutationFn: refreshDiscovery,
|
||||
onSuccess: () => setTimeout(() => {
|
||||
qc.invalidateQueries({ queryKey: ["discovery"] });
|
||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
||||
}, 8000),
|
||||
});
|
||||
|
||||
const handleDismissVideo = (video) => {
|
||||
setDismissedVideos(prev => new Set([...prev, video.youtube_video_id]));
|
||||
dismissDiscoveryVideo(video.youtube_video_id).then(() => {
|
||||
qc.invalidateQueries({ queryKey: ["discovery-videos"] });
|
||||
});
|
||||
};
|
||||
|
||||
const visibleVideos = videos.filter(v => !dismissedVideos.has(v.youtube_video_id));
|
||||
const isEmpty = tab === "channels" ? channels.length === 0 : visibleVideos.length === 0;
|
||||
const isLoading = tab === "channels" ? loadingChannels : loadingVideos;
|
||||
const hasNextChannelPage = channels.length === PAGE_SIZE;
|
||||
const hasNextVideoPage = videos.length === PAGE_SIZE;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Discover</h1>
|
||||
<button
|
||||
onClick={() => refreshMut.mutate()}
|
||||
disabled={refreshMut.isPending}
|
||||
className="flex items-center gap-2 text-sm font-medium px-4 py-2 bg-zinc-800 text-zinc-300 rounded-lg hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{refreshMut.isPending && (
|
||||
<svg className="w-3.5 h-3.5 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
)}
|
||||
{refreshMut.isPending ? "Searching…" : "Find more"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{refreshMut.isSuccess && !refreshMut.isPending && (
|
||||
<div className="px-4 py-3 rounded-xl bg-zinc-800/60 border border-zinc-700/50 text-sm text-zinc-300">
|
||||
Searching YouTube for new channels — results will appear in a few seconds.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex items-center gap-1 border-b border-zinc-800">
|
||||
<Tab active={tab === "channels"} onClick={() => setTab("channels")} count={0}>
|
||||
Channels
|
||||
</Tab>
|
||||
<Tab active={tab === "videos"} onClick={() => setTab("videos")} count={0}>
|
||||
Videos
|
||||
</Tab>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : isEmpty ? (
|
||||
<div className="flex flex-col items-center gap-4 py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Nothing here yet</p>
|
||||
<p className="text-zinc-500 text-sm mt-1 max-w-xs">
|
||||
Follow a few channels first, then hit "Find more" to discover similar ones.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refreshMut.mutate()}
|
||||
disabled={refreshMut.isPending}
|
||||
className="mt-2 px-6 py-2.5 rounded-xl bg-accent text-black font-semibold text-sm hover:bg-yellow-300 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{refreshMut.isPending ? "Searching…" : "Find channels"}
|
||||
</button>
|
||||
</div>
|
||||
) : tab === "channels" ? (
|
||||
<>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{channels.map((item) => (
|
||||
<ChannelCard key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setChannelPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
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"
|
||||
>
|
||||
← Prev
|
||||
</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" }); }}
|
||||
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"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{visibleVideos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={{ ...v, is_recommended: true }}
|
||||
onDismiss={handleDismissVideo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-center gap-3 pt-2">
|
||||
<button
|
||||
onClick={() => { setVideoPage(p => p - 1); window.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
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"
|
||||
>
|
||||
← Prev
|
||||
</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" }); }}
|
||||
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"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
311
frontend/src/pages/Downloads.jsx
Normal file
311
frontend/src/pages/Downloads.jsx
Normal file
@@ -0,0 +1,311 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getDownloads, deleteDownload, deleteAllDownloads, restoreDownload } from "../api";
|
||||
import SortPicker from "../components/SortPicker";
|
||||
|
||||
const HISTORY_SORTS = [
|
||||
{ value: "newest", label: "Newest" },
|
||||
{ value: "oldest", label: "Oldest" },
|
||||
{ value: "title", label: "Title A–Z" },
|
||||
{ value: "status", label: "Status" },
|
||||
];
|
||||
|
||||
function sortHistory(items, sort) {
|
||||
const arr = [...items];
|
||||
if (sort === "oldest") return arr.sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
|
||||
if (sort === "title") return arr.sort((a, b) => (a.video_title ?? "").localeCompare(b.video_title ?? ""));
|
||||
if (sort === "status") return arr.sort((a, b) => a.status.localeCompare(b.status));
|
||||
return arr.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
|
||||
}
|
||||
|
||||
const STATUS_COLORS = {
|
||||
pending: "text-zinc-400",
|
||||
downloading: "text-accent",
|
||||
complete: "text-green-400",
|
||||
failed: "text-red-400",
|
||||
};
|
||||
|
||||
const STATUS_LABELS = {
|
||||
pending: "Pending",
|
||||
downloading: "Downloading",
|
||||
complete: "Complete",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
function ProgressBar({ pct }) {
|
||||
return (
|
||||
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden mt-1.5">
|
||||
<div
|
||||
className="h-full bg-accent rounded-full transition-all duration-300"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function daysRemaining(pendingDeleteAt) {
|
||||
const diff = new Date(pendingDeleteAt) - Date.now();
|
||||
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
|
||||
return Math.max(0, days);
|
||||
}
|
||||
|
||||
export default function DownloadsPage() {
|
||||
const [historySort, setHistorySort] = useState("newest");
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: downloads, isLoading } = useQuery({
|
||||
queryKey: ["downloads"],
|
||||
queryFn: () => getDownloads().then((r) => r.data),
|
||||
refetchInterval: (query) => {
|
||||
const active = query.state.data?.some(
|
||||
(d) => d.status === "pending" || d.status === "downloading"
|
||||
);
|
||||
return active ? 2000 : false;
|
||||
},
|
||||
});
|
||||
|
||||
const clearAllMut = useMutation({
|
||||
mutationFn: deleteAllDownloads,
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
setConfirmClear(false);
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const active = downloads?.filter((d) => ["pending", "downloading"].includes(d.status)) ?? [];
|
||||
const trash = downloads?.filter((d) => d.pending_delete_at) ?? [];
|
||||
const history = useMemo(
|
||||
() => sortHistory(
|
||||
downloads?.filter((d) => !["pending", "downloading"].includes(d.status) && !d.pending_delete_at) ?? [],
|
||||
historySort,
|
||||
),
|
||||
[downloads, historySort],
|
||||
);
|
||||
|
||||
const hasRemovable = history.length > 0 || trash.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Downloads</h1>
|
||||
{hasRemovable && (
|
||||
confirmClear ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-zinc-400">Remove all history and trash?</span>
|
||||
<button
|
||||
onClick={() => clearAllMut.mutate()}
|
||||
disabled={clearAllMut.isPending}
|
||||
className="px-3 py-1.5 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-500 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
Yes, remove all
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmClear(false)}
|
||||
className="px-3 py-1.5 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirmClear(true)}
|
||||
className="text-sm text-zinc-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
Remove all
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{active.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-base text-zinc-400 mb-3">Active</h2>
|
||||
<div className="flex flex-col gap-3">
|
||||
{active.map((d) => (
|
||||
<DownloadRow key={d.id} download={d} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{trash.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-base text-zinc-400 mb-1">Trash</h2>
|
||||
<p className="text-xs text-zinc-600 mb-3">Watched downloads — auto-deleted when the timer runs out.</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{trash.map((d) => (
|
||||
<TrashRow key={d.id} download={d} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{history.length > 0 && (
|
||||
<section>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="font-display font-semibold text-base text-zinc-400">History</h2>
|
||||
<SortPicker value={historySort} onChange={setHistorySort} options={HISTORY_SORTS} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{history.map((d) => (
|
||||
<DownloadRow key={d.id} download={d} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!downloads?.length && (
|
||||
<p className="text-zinc-500 text-sm">No downloads yet. Find a video and hit Download.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashRow({ download: d }) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const restoreMut = useMutation({
|
||||
mutationFn: () => restoreDownload(d.id),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["downloads"] }),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => deleteDownload(d.id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
qc.invalidateQueries({ queryKey: ["video-play", d.youtube_video_id] });
|
||||
},
|
||||
});
|
||||
|
||||
const days = daysRemaining(d.pending_delete_at);
|
||||
const urgentColor = days <= 1 ? "text-red-400" : days <= 3 ? "text-amber-400" : "text-zinc-500";
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900/60 border border-zinc-800 rounded-xl p-4 flex items-start gap-4">
|
||||
<div
|
||||
className="cursor-pointer shrink-0"
|
||||
onClick={() => navigate(`/watch/${d.youtube_video_id}`)}
|
||||
>
|
||||
{d.video_thumbnail_url ? (
|
||||
<img src={d.video_thumbnail_url} alt="" className="w-20 h-11 object-cover rounded-lg opacity-60" />
|
||||
) : (
|
||||
<div className="w-20 h-11 rounded-lg bg-zinc-800" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className="text-sm font-medium text-zinc-400 truncate cursor-pointer hover:text-zinc-200 transition-colors"
|
||||
onClick={() => navigate(`/watch/${d.youtube_video_id}`)}
|
||||
>
|
||||
{d.video_title}
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${urgentColor}`}>
|
||||
{days === 0 ? "Deletes today" : `Deletes in ${days} day${days !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => restoreMut.mutate()}
|
||||
disabled={restoreMut.isPending}
|
||||
title="Keep this file"
|
||||
className="px-2.5 py-1.5 rounded-lg text-xs font-medium text-zinc-300 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
Keep
|
||||
</button>
|
||||
<button
|
||||
onClick={() => deleteMut.mutate()}
|
||||
disabled={deleteMut.isPending}
|
||||
title="Delete now"
|
||||
className="p-1.5 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-900/20 disabled:opacity-40 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DownloadRow({ download: d }) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: () => deleteDownload(d.id),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ["downloads"] });
|
||||
qc.invalidateQueries({ queryKey: ["video-play", d.youtube_video_id] });
|
||||
},
|
||||
});
|
||||
|
||||
const isActive = d.status === "pending" || d.status === "downloading";
|
||||
const canWatch = d.status === "complete" && d.youtube_video_id;
|
||||
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-xl p-4 flex items-start gap-4">
|
||||
<div
|
||||
className={canWatch ? "cursor-pointer shrink-0" : "shrink-0"}
|
||||
onClick={canWatch ? () => navigate(`/watch/${d.youtube_video_id}`) : undefined}
|
||||
>
|
||||
{d.video_thumbnail_url ? (
|
||||
<img
|
||||
src={d.video_thumbnail_url}
|
||||
alt=""
|
||||
className="w-20 h-11 object-cover rounded-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-11 rounded-lg bg-zinc-800" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium text-zinc-100 truncate ${canWatch ? "cursor-pointer hover:text-white" : ""}`}
|
||||
onClick={canWatch ? () => navigate(`/watch/${d.youtube_video_id}`) : undefined}
|
||||
>
|
||||
{d.video_title}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs font-medium ${STATUS_COLORS[d.status]}`}>
|
||||
{STATUS_LABELS[d.status]}
|
||||
</span>
|
||||
{d.status === "downloading" && (
|
||||
<span className="text-xs text-zinc-500">{d.progress_percent.toFixed(0)}%</span>
|
||||
)}
|
||||
</div>
|
||||
{d.status === "downloading" && <ProgressBar pct={d.progress_percent} />}
|
||||
{d.error_message && (
|
||||
<p className="text-xs text-red-400 mt-1 truncate">{d.error_message}</p>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => deleteMut.mutate()}
|
||||
disabled={deleteMut.isPending}
|
||||
title={isActive ? "Cancel download" : "Delete file and record"}
|
||||
className="shrink-0 p-1.5 rounded-lg text-zinc-600 hover:text-red-400 hover:bg-red-900/20 transition-colors disabled:opacity-40"
|
||||
>
|
||||
{isActive ? (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1027
frontend/src/pages/Following.jsx
Normal file
1027
frontend/src/pages/Following.jsx
Normal file
File diff suppressed because it is too large
Load Diff
62
frontend/src/pages/History.jsx
Normal file
62
frontend/src/pages/History.jsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getHistory } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
export default function History() {
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const { data: videos = [], isLoading } = useQuery({
|
||||
queryKey: ["history", page],
|
||||
queryFn: () => getHistory(page, PAGE_SIZE).then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
placeholderData: (prev) => prev,
|
||||
});
|
||||
|
||||
const hasNext = videos.length === PAGE_SIZE;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-screen-xl mx-auto">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="font-display font-bold text-2xl text-white">Watch History</h1>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<p className="text-zinc-400 text-sm">No watch history yet. Start watching some videos!</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{videos.map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} />
|
||||
))}
|
||||
</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={!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"
|
||||
>
|
||||
Next →
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
318
frontend/src/pages/Home.jsx
Normal file
318
frontend/src/pages/Home.jsx
Normal file
@@ -0,0 +1,318 @@
|
||||
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", "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); 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>
|
||||
);
|
||||
}
|
||||
99
frontend/src/pages/Liked.jsx
Normal file
99
frontend/src/pages/Liked.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useState, useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getLikedVideos, refreshDiscovery } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import SortPicker from "../components/SortPicker";
|
||||
|
||||
const LIKED_SORTS = [
|
||||
{ value: "liked_at", label: "Liked recently" },
|
||||
{ value: "newest", label: "Published newest" },
|
||||
{ value: "channel", label: "Channel A–Z" },
|
||||
{ value: "title", label: "Title A–Z" },
|
||||
];
|
||||
|
||||
function sortLiked(items, sort) {
|
||||
const arr = [...items];
|
||||
if (sort === "newest") return arr.sort((a, b) => new Date(b.published_at ?? 0) - new Date(a.published_at ?? 0));
|
||||
if (sort === "channel") return arr.sort((a, b) => (a.channel_name ?? "").localeCompare(b.channel_name ?? ""));
|
||||
if (sort === "title") return arr.sort((a, b) => (a.title ?? "").localeCompare(b.title ?? ""));
|
||||
return arr; // liked_at = backend order
|
||||
}
|
||||
|
||||
export default function LikedPage() {
|
||||
const qc = useQueryClient();
|
||||
const [sort, setSort] = useState("liked_at");
|
||||
|
||||
const { data: videos = [], isLoading } = useQuery({
|
||||
queryKey: ["liked-videos"],
|
||||
queryFn: () => getLikedVideos().then((r) => r.data),
|
||||
});
|
||||
|
||||
const refreshMut = useMutation({
|
||||
mutationFn: refreshDiscovery,
|
||||
onSuccess: () => setTimeout(() => qc.invalidateQueries({ queryKey: ["discovery"] }), 3000),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||
<div>
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Liked videos</h1>
|
||||
<p className="text-sm text-zinc-500 mt-1">
|
||||
{videos.length} video{videos.length !== 1 ? "s" : ""} · used to power discovery
|
||||
</p>
|
||||
</div>
|
||||
<SortPicker value={sort} onChange={setSort} options={LIKED_SORTS} />
|
||||
<button
|
||||
onClick={() => refreshMut.mutate()}
|
||||
disabled={refreshMut.isPending || refreshMut.isSuccess}
|
||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors disabled:opacity-60"
|
||||
>
|
||||
{refreshMut.isPending ? (
|
||||
<>
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
Running…
|
||||
</>
|
||||
) : refreshMut.isSuccess ? "Discovery updated ✓" : (
|
||||
<>
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||
d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Refresh discovery
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{videos.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-3 py-20 text-center">
|
||||
<svg className="w-12 h-12 text-zinc-700" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<p className="text-zinc-400 font-medium">No liked videos yet</p>
|
||||
<p className="text-zinc-600 text-sm max-w-xs">
|
||||
Hit the heart on any video. Liked videos teach the discovery engine what you enjoy.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{sortLiked(videos, sort).map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
96
frontend/src/pages/Login.jsx
Normal file
96
frontend/src/pages/Login.jsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, register } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState("login");
|
||||
const [form, setForm] = useState({ username: "", email: "", password: "" });
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setLoading(true);
|
||||
try {
|
||||
if (mode === "login") {
|
||||
await login(form.username, form.password);
|
||||
} else {
|
||||
await register(form.username, form.email, form.password);
|
||||
}
|
||||
navigate("/");
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.detail || "Something went wrong");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-zinc-950 px-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<h1 className="font-display font-bold text-3xl text-accent mb-2 text-center">YT Hub</h1>
|
||||
<p className="text-zinc-500 text-sm text-center mb-8">Your personal YouTube command center</p>
|
||||
|
||||
<div className="bg-zinc-900 rounded-2xl p-6 border border-zinc-800">
|
||||
<div className="flex gap-1 mb-6 bg-zinc-800 rounded-lg p-1">
|
||||
{["login", "register"].map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`flex-1 py-1.5 text-sm font-medium rounded-md capitalize transition-colors ${
|
||||
mode === m ? "bg-zinc-700 text-zinc-100" : "text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Username"
|
||||
value={form.username}
|
||||
onChange={set("username")}
|
||||
required
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 transition-colors"
|
||||
/>
|
||||
{mode === "register" && (
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={form.email}
|
||||
onChange={set("email")}
|
||||
required
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Password"
|
||||
value={form.password}
|
||||
onChange={set("password")}
|
||||
required
|
||||
className="bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2.5 text-sm text-zinc-100 placeholder-zinc-500 outline-none focus:border-zinc-500 transition-colors"
|
||||
/>
|
||||
|
||||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="mt-2 bg-accent text-black font-semibold py-2.5 rounded-lg hover:bg-accent-light transition-colors disabled:opacity-60"
|
||||
>
|
||||
{loading ? "…" : mode === "login" ? "Sign in" : "Create account"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
54
frontend/src/pages/Queue.jsx
Normal file
54
frontend/src/pages/Queue.jsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { getQueue, toggleQueue } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
|
||||
export default function QueuePage() {
|
||||
const qc = useQueryClient();
|
||||
const { data: videos = [], isLoading } = useQuery({
|
||||
queryKey: ["queue"],
|
||||
queryFn: () => getQueue().then((r) => r.data),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Watch Later</h1>
|
||||
|
||||
{isLoading ? (
|
||||
<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>
|
||||
) : videos.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-4 py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-zinc-800 flex items-center justify-center">
|
||||
<svg className="w-7 h-7 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5}
|
||||
d="M4 6h16M4 11h16M4 16h10m6-1l-4 2.5 4 2.5V15z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-zinc-300 font-medium">Queue is empty</p>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
Hit the queue icon on any video to save it for later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-zinc-500 -mt-2">{videos.length} video{videos.length !== 1 ? "s" : ""} saved</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{videos.map((v) => (
|
||||
<VideoCard
|
||||
key={v.youtube_video_id}
|
||||
video={v}
|
||||
onRemoveFromQueue={() => {
|
||||
toggleQueue(v.id).then(() => qc.invalidateQueries({ queryKey: ["queue"] }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
frontend/src/pages/SearchResults.jsx
Normal file
144
frontend/src/pages/SearchResults.jsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { search } from "../api";
|
||||
import VideoCard from "../components/VideoCard";
|
||||
import ChannelCard from "../components/ChannelCard";
|
||||
|
||||
function Badge({ label }) {
|
||||
return (
|
||||
<span className="text-xs bg-zinc-800 text-zinc-400 px-2 py-0.5 rounded-full">{label}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<div className="w-8 h-8 border-2 border-accent border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export default function SearchResults() {
|
||||
const [params] = useSearchParams();
|
||||
const q = params.get("q") || "";
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
// Reset pagination when query changes
|
||||
useEffect(() => { setVisibleCount(PAGE_SIZE); }, [q]);
|
||||
|
||||
// Local search — fast, appears immediately
|
||||
const localQuery = useQuery({
|
||||
queryKey: ["search", q, false],
|
||||
queryFn: () => search(q, false).then((r) => r.data),
|
||||
enabled: q.length > 0,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
// Live search — always runs in parallel, merges YouTube results in when ready
|
||||
const liveQuery = useQuery({
|
||||
queryKey: ["search", q, true],
|
||||
queryFn: () => search(q, true).then((r) => r.data),
|
||||
enabled: q.length > 0,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
// Show live data when available (superset of local), fall back to local while waiting
|
||||
const data = liveQuery.data ?? localQuery.data;
|
||||
const isLoading = localQuery.isLoading;
|
||||
const isLiveLoading = liveQuery.isLoading && !liveQuery.isError;
|
||||
const source = data?.source;
|
||||
|
||||
if (!q) {
|
||||
return <p className="text-zinc-500 text-sm">Type something in the search bar above.</p>;
|
||||
}
|
||||
|
||||
if (isLoading) return <Spinner />;
|
||||
|
||||
const videos = data?.videos ?? [];
|
||||
const allChannels = data?.channels ?? [];
|
||||
// Cap channels shown — when they're synthesized from 40 video results it's too many
|
||||
const channels = allChannels.slice(0, 6);
|
||||
const visibleVideos = videos.slice(0, visibleCount);
|
||||
const hasMore = visibleCount < videos.length;
|
||||
|
||||
// Show channels first only when they're the primary result (few videos or FTS hit)
|
||||
const channelsFirst = channels.length > 0 && (videos.length === 0 || source === "local");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<h1 className="font-display font-semibold text-xl text-zinc-100">
|
||||
Results for <span className="text-accent">"{q}"</span>
|
||||
</h1>
|
||||
{source === "live" && <Badge label="Live from YouTube" />}
|
||||
{source === "local" && <Badge label="Local library" />}
|
||||
{source === "mixed" && <Badge label="Local + YouTube" />}
|
||||
{isLiveLoading && (
|
||||
<span className="flex items-center gap-1.5 text-xs text-zinc-500">
|
||||
<svg className="w-3 h-3 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"/>
|
||||
</svg>
|
||||
searching YouTube…
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Channels first when they're the primary result */}
|
||||
{channelsFirst && channels.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-xs text-zinc-400 uppercase tracking-wide mb-3">Channels</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{channels.map((c) => (
|
||||
<ChannelCard key={c.youtube_channel_id} channel={c} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* Video results */}
|
||||
{videos.length > 0 ? (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-xs text-zinc-400 uppercase tracking-wide mb-3">
|
||||
Videos
|
||||
<span className="ml-2 text-zinc-600 font-normal normal-case">
|
||||
{hasMore ? `${visibleCount} of ${videos.length}` : videos.length}
|
||||
</span>
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{visibleVideos.map((v) => (
|
||||
<VideoCard key={v.youtube_video_id} video={v} />
|
||||
))}
|
||||
</div>
|
||||
{hasMore && (
|
||||
<button
|
||||
onClick={() => setVisibleCount((n) => n + PAGE_SIZE)}
|
||||
className="mt-6 w-full py-2.5 rounded-xl bg-zinc-800 text-zinc-300 text-sm font-medium hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Show more ({videos.length - visibleCount} remaining)
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
!isLoading && !channels.length && (
|
||||
<p className="text-zinc-500 text-sm">No results found for "{q}".</p>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Channels after videos when videos dominate */}
|
||||
{!channelsFirst && channels.length > 0 && (
|
||||
<section>
|
||||
<h2 className="font-display font-semibold text-xs text-zinc-400 uppercase tracking-wide mb-3">Channels</h2>
|
||||
<div className="flex flex-col gap-2">
|
||||
{channels.map((c) => (
|
||||
<ChannelCard key={c.youtube_channel_id} channel={c} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
433
frontend/src/pages/Settings.jsx
Normal file
433
frontend/src/pages/Settings.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { getSettings, updateSettings, exportData, getAdminUsers, deleteAdminUser, getAdminConfig, updateAdminConfig } from "../api";
|
||||
import { useAuth } from "../hooks/useAuth";
|
||||
|
||||
const REGION_OPTIONS = [
|
||||
{ value: "US", label: "United States" },
|
||||
{ value: "SE", label: "Sweden" },
|
||||
{ value: "GB", label: "United Kingdom" },
|
||||
{ value: "DE", label: "Germany" },
|
||||
{ value: "JP", label: "Japan" },
|
||||
{ value: "FR", label: "France" },
|
||||
{ value: "CA", label: "Canada" },
|
||||
{ value: "AU", label: "Australia" },
|
||||
{ value: "BR", label: "Brazil" },
|
||||
{ value: "IN", label: "India" },
|
||||
{ value: "KR", label: "South Korea" },
|
||||
{ value: "MX", label: "Mexico" },
|
||||
];
|
||||
|
||||
const BROWSER_OPTIONS = [
|
||||
{ value: "", label: "Disabled" },
|
||||
{ value: "chrome", label: "Chrome" },
|
||||
{ value: "chromium", label: "Chromium" },
|
||||
{ value: "firefox", label: "Firefox" },
|
||||
{ value: "brave", label: "Brave" },
|
||||
{ value: "edge", label: "Edge" },
|
||||
];
|
||||
|
||||
const QUALITY_OPTIONS = [
|
||||
{ value: "best", label: "Highest available", hint: "Let yt-dlp pick the best it can get" },
|
||||
{ value: "2160p", label: "4K — 2160p", hint: "Ultra HD, very large files" },
|
||||
{ value: "1440p", label: "2K — 1440p", hint: "Quad HD" },
|
||||
{ value: "1080p", label: "1080p — Full HD", hint: "Recommended for most people" },
|
||||
{ value: "720p", label: "720p — HD", hint: "Good quality, smaller files" },
|
||||
{ value: "480p", label: "480p — SD", hint: "Fast downloads, lower quality" },
|
||||
{ value: "360p", label: "360p", hint: "Minimal bandwidth" },
|
||||
{ value: "240p", label: "240p", hint: "Very low quality" },
|
||||
{ value: "144p", label: "144p", hint: "Lowest quality" },
|
||||
];
|
||||
|
||||
function Section({ title, children }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display font-semibold text-base text-zinc-400 uppercase tracking-wide text-xs">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="bg-zinc-900 rounded-2xl divide-y divide-zinc-800">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, hint, children }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-6 px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-zinc-200">{label}</p>
|
||||
{hint && <p className="text-xs text-zinc-500 mt-0.5">{hint}</p>}
|
||||
</div>
|
||||
<div className="shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RegionPicker({ value, onChange }) {
|
||||
const selected = new Set((value || "US,SE").split(",").map((r) => r.trim()).filter(Boolean));
|
||||
|
||||
const toggle = (code) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(code)) {
|
||||
next.delete(code);
|
||||
} else {
|
||||
next.add(code);
|
||||
}
|
||||
if (next.size === 0) return; // always keep at least one
|
||||
onChange([...next].join(","));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{REGION_OPTIONS.map((r) => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => toggle(r.value)}
|
||||
className={`px-3 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
selected.has(r.value)
|
||||
? "bg-accent text-black"
|
||||
: "bg-zinc-800 text-zinc-400 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Toggle({ value, onChange }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => onChange(!value)}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
value ? "bg-accent" : "bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
value ? "translate-x-6" : "translate-x-1"
|
||||
}`} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function AdminSection() {
|
||||
const qc = useQueryClient();
|
||||
const [confirming, setConfirming] = useState(null);
|
||||
|
||||
const { data: users = [] } = useQuery({
|
||||
queryKey: ["admin-users"],
|
||||
queryFn: () => getAdminUsers().then(r => r.data),
|
||||
});
|
||||
|
||||
const { data: adminConfig } = useQuery({
|
||||
queryKey: ["admin-config"],
|
||||
queryFn: () => getAdminConfig().then(r => r.data),
|
||||
});
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: (id) => deleteAdminUser(id),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ["admin-users"] }); setConfirming(null); },
|
||||
});
|
||||
|
||||
const configMut = useMutation({
|
||||
mutationFn: (data) => updateAdminConfig(data),
|
||||
onSuccess: (res) => qc.setQueryData(["admin-config"], res.data),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<h2 className="font-display font-semibold text-base text-zinc-400 uppercase tracking-wide text-xs">Admin</h2>
|
||||
<div className="bg-zinc-900 rounded-2xl divide-y divide-zinc-800">
|
||||
<div className="flex items-center justify-between gap-6 px-5 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Open registration</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Allow anyone with the URL to create an account.</p>
|
||||
</div>
|
||||
<Toggle
|
||||
value={adminConfig?.allow_registration ?? true}
|
||||
onChange={(v) => configMut.mutate({ allow_registration: v })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-zinc-200">Users ({users.length})</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{users.map(u => (
|
||||
<div key={u.id} className="flex items-center justify-between gap-3 py-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span className="text-sm text-zinc-200 truncate">{u.username}</span>
|
||||
{u.is_admin && (
|
||||
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/15 text-accent">admin</span>
|
||||
)}
|
||||
<span className="text-xs text-zinc-600 truncate">{u.email}</span>
|
||||
</div>
|
||||
{!u.is_admin && (
|
||||
confirming === u.id ? (
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button
|
||||
onClick={() => deleteMut.mutate(u.id)}
|
||||
className="text-xs text-red-400 hover:text-red-300 px-2 py-1 rounded-lg bg-red-950/40 transition-colors"
|
||||
>
|
||||
confirm delete
|
||||
</button>
|
||||
<button onClick={() => setConfirming(null)} className="text-xs text-zinc-500 hover:text-zinc-300">cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setConfirming(u.id)}
|
||||
className="shrink-0 text-zinc-700 hover:text-red-400 transition-colors p-1"
|
||||
title="Delete user"
|
||||
>
|
||||
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
|
||||
</svg>
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user } = useAuth();
|
||||
const qc = useQueryClient();
|
||||
|
||||
const { data: s, isLoading } = useQuery({
|
||||
queryKey: ["settings"],
|
||||
queryFn: () => getSettings().then((r) => r.data),
|
||||
});
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (patch) => updateSettings(patch),
|
||||
onSuccess: (res) => qc.setQueryData(["settings"], res.data),
|
||||
});
|
||||
|
||||
const set = (patch) => mut.mutate(patch);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-2xl">
|
||||
<h1 className="font-display font-bold text-2xl text-zinc-100">Settings</h1>
|
||||
|
||||
{/* Discovery */}
|
||||
<Section title="Discovery">
|
||||
<div className="px-5 py-4 flex flex-col gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Trending regions</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">
|
||||
Pull trending content from these regions to find new channels.
|
||||
</p>
|
||||
</div>
|
||||
<RegionPicker
|
||||
value={s?.discovery_regions ?? "US,SE"}
|
||||
onChange={(v) => set({ discovery_regions: v })}
|
||||
/>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* YouTube authentication */}
|
||||
<Section title="YouTube authentication">
|
||||
<Row
|
||||
label="Browser cookies"
|
||||
hint="Pass cookies from your browser to bypass bot detection. You must be signed in to YouTube in that browser."
|
||||
>
|
||||
<select
|
||||
value={s?.cookies_browser ?? ""}
|
||||
onChange={(e) => set({ cookies_browser: e.target.value })}
|
||||
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{BROWSER_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Download quality */}
|
||||
<Section title="Download quality">
|
||||
<Row
|
||||
label="Preferred quality"
|
||||
hint="Applied to all new downloads. Existing files are unaffected."
|
||||
>
|
||||
<select
|
||||
value={s?.preferred_quality ?? "best"}
|
||||
onChange={(e) => set({ preferred_quality: e.target.value })}
|
||||
className="bg-zinc-800 text-zinc-200 text-sm rounded-lg px-3 py-1.5 border border-zinc-700 focus:outline-none focus:border-accent"
|
||||
>
|
||||
{QUALITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>{o.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</Row>
|
||||
<Row
|
||||
label="Max concurrent downloads"
|
||||
hint="How many videos download at the same time."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range" min={1} max={5} step={1}
|
||||
value={s?.max_concurrent_downloads ?? 3}
|
||||
onChange={(e) => set({ max_concurrent_downloads: Number(e.target.value) })}
|
||||
className="w-28 accent-yellow-400"
|
||||
/>
|
||||
<span className="text-sm text-zinc-300 w-4 text-center tabular-nums">
|
||||
{s?.max_concurrent_downloads ?? 3}
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
<Row
|
||||
label="Auto-download on sync"
|
||||
hint="When syncing followed channels, automatically download new videos."
|
||||
>
|
||||
<Toggle
|
||||
value={s?.auto_download_on_sync ?? false}
|
||||
onChange={(v) => set({ auto_download_on_sync: v })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Feed */}
|
||||
<Section title="Feed">
|
||||
<Row
|
||||
label="Hide watched videos"
|
||||
hint="Remove already-watched videos from your home feed."
|
||||
>
|
||||
<Toggle
|
||||
value={s?.hide_watched_from_feed ?? false}
|
||||
onChange={(v) => set({ hide_watched_from_feed: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Calm mode"
|
||||
hint="Hide recommendation badges, discovery labels, and subscriber counts across the app."
|
||||
>
|
||||
<Toggle
|
||||
value={s?.calm_mode ?? false}
|
||||
onChange={(v) => set({ calm_mode: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Hide subscriber counts"
|
||||
hint="Stop showing subscriber numbers on channel pages and cards."
|
||||
>
|
||||
<Toggle
|
||||
value={s?.hide_subscriber_counts ?? false}
|
||||
onChange={(v) => set({ hide_subscriber_counts: v })}
|
||||
/>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Feed tuning */}
|
||||
<Section title="Feed tuning">
|
||||
<div className="px-5 py-3 pb-1">
|
||||
<p className="text-xs text-zinc-500">
|
||||
Adjust how the ranked feed weighs each signal. 5 is the default; 0 disables that dimension entirely.
|
||||
</p>
|
||||
</div>
|
||||
{[
|
||||
{ key: "feed_weight_recency", label: "Recency", hint: "How much newer videos are boosted over older ones" },
|
||||
{ key: "feed_weight_affinity", label: "Taste match", hint: "How much videos matching your watch/search habits are boosted" },
|
||||
{ key: "feed_weight_channel", label: "Channel loyalty", hint: "How much channels you watch and like more are boosted" },
|
||||
].map(({ key, label, hint }) => (
|
||||
<Row key={key} label={label} hint={hint}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[10px] text-zinc-600 w-3 text-right tabular-nums">0</span>
|
||||
<input
|
||||
type="range" min={0} max={10} step={0.5}
|
||||
value={s?.[key] ?? 5}
|
||||
onChange={(e) => set({ [key]: Number(e.target.value) })}
|
||||
className="w-28 accent-yellow-400"
|
||||
/>
|
||||
<span className="text-[10px] text-zinc-600 w-3 tabular-nums">10</span>
|
||||
<span className="text-sm text-zinc-300 w-6 text-center tabular-nums font-mono">
|
||||
{(s?.[key] ?? 5).toFixed(1).replace(".0", "")}
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* Watching */}
|
||||
<Section title="Watching">
|
||||
<Row
|
||||
label="Theater mode"
|
||||
hint="Expand the video player to full width by default on every video page."
|
||||
>
|
||||
<Toggle
|
||||
value={s?.theater_mode ?? false}
|
||||
onChange={(v) => set({ theater_mode: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Autoplay next video"
|
||||
hint="Automatically play the next channel video when the current one ends."
|
||||
>
|
||||
<Toggle
|
||||
value={s?.autoplay_enabled ?? false}
|
||||
onChange={(v) => set({ autoplay_enabled: v })}
|
||||
/>
|
||||
</Row>
|
||||
<Row
|
||||
label="Mark as watched at"
|
||||
hint="How far through a video before it's considered watched."
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="range" min={50} max={100} step={5}
|
||||
value={s?.mark_watched_at_percent ?? 90}
|
||||
onChange={(e) => set({ mark_watched_at_percent: Number(e.target.value) })}
|
||||
className="w-28 accent-yellow-400"
|
||||
/>
|
||||
<span className="text-sm text-zinc-300 w-8 text-center tabular-nums">
|
||||
{s?.mark_watched_at_percent ?? 90}%
|
||||
</span>
|
||||
</div>
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
{/* Data */}
|
||||
<Section title="Data">
|
||||
<div className="px-5 py-4 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-zinc-200">Export my data</p>
|
||||
<p className="text-xs text-zinc-500 mt-0.5">Download your watch history, ratings, likes, bookmarks, and queue as JSON.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const res = await exportData();
|
||||
const url = URL.createObjectURL(res.data);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `ythub-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}}
|
||||
className="shrink-0 px-4 py-2 rounded-lg bg-zinc-800 text-zinc-300 text-sm hover:bg-zinc-700 transition-colors"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{user?.is_admin && <AdminSection />}
|
||||
|
||||
{mut.isPending && (
|
||||
<p className="text-xs text-zinc-500 text-center">Saving…</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
197
frontend/src/pages/Stats.jsx
Normal file
197
frontend/src/pages/Stats.jsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getStats } from "../api";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
function fmt(seconds) {
|
||||
if (!seconds) return "0m";
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
if (h === 0) return `${m}m`;
|
||||
if (m === 0) return `${h}h`;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function StatCard({ label, value, sub }) {
|
||||
return (
|
||||
<div className="bg-zinc-900 rounded-2xl p-4 flex flex-col gap-1">
|
||||
<p className="text-[11px] text-zinc-500 uppercase tracking-wider font-medium">{label}</p>
|
||||
<p className="text-2xl font-bold text-white font-mono leading-none">{value}</p>
|
||||
{sub && <p className="text-xs text-zinc-600 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Stats() {
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["stats"],
|
||||
queryFn: () => getStats().then(r => r.data),
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
if (!data) return null;
|
||||
|
||||
const maxSeconds = Math.max(...(data.top_channels.map(c => c.watch_seconds || 0)), 1);
|
||||
const today = new Date();
|
||||
const days = Array.from({ length: 30 }, (_, i) => {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - (29 - i));
|
||||
return d.toISOString().slice(0, 10);
|
||||
});
|
||||
const dailyMap = Object.fromEntries((data.daily || []).map(d => [d.date, d]));
|
||||
const maxDayCount = Math.max(...days.map(d => dailyMap[d]?.count || 0), 1);
|
||||
|
||||
const maxCatCount = Math.max(...(data.top_categories || []).map(c => c.watch_count || 0), 1);
|
||||
const topTags = (data.taste_profile || []).slice(0, 12);
|
||||
const maxTagScore = Math.max(...topTags.map(t => t.score || 0), 1);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-4xl mx-auto">
|
||||
<h1 className="font-display font-bold text-2xl text-white">Stats</h1>
|
||||
|
||||
{/* Top numbers */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard label="Total watched" value={data.total_watched.toLocaleString()} sub={fmt(data.total_watch_seconds) + " total"} />
|
||||
<StatCard label="This week" value={data.this_week.count} sub={fmt(data.this_week.seconds)} />
|
||||
<StatCard label="This month" value={data.this_month.count} sub={fmt(data.this_month.seconds)} />
|
||||
<StatCard label="Total liked" value={(data.total_liked || 0).toLocaleString()} sub="videos" />
|
||||
</div>
|
||||
|
||||
{/* Engagement row */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<StatCard
|
||||
label="Avg completion"
|
||||
value={`${data.avg_completion_percent ?? 0}%`}
|
||||
sub="of videos you start"
|
||||
/>
|
||||
<StatCard
|
||||
label="Finished"
|
||||
value={(data.finished_count || 0).toLocaleString()}
|
||||
sub="watched ≥90%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Bailed early"
|
||||
value={(data.bailed_count || 0).toLocaleString()}
|
||||
sub="left before 20%"
|
||||
/>
|
||||
<StatCard
|
||||
label="Rewatched"
|
||||
value={(data.rewatched_videos || 0).toLocaleString()}
|
||||
sub={`${data.total_rewatches || 0} total rewatches`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Activity chart */}
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Activity — last 30 days</h2>
|
||||
<div className="flex items-end gap-0.5 h-16">
|
||||
{days.map(date => {
|
||||
const entry = dailyMap[date];
|
||||
const count = entry?.count || 0;
|
||||
const pct = count / maxDayCount;
|
||||
return (
|
||||
<div
|
||||
key={date}
|
||||
title={`${date}: ${count} video${count !== 1 ? "s" : ""}`}
|
||||
className="flex-1 rounded-sm transition-all"
|
||||
style={{
|
||||
height: count === 0 ? "2px" : `${Math.max(pct * 100, 6)}%`,
|
||||
backgroundColor: count === 0 ? "#27272a" : `hsl(50,95%,${30 + pct * 30}%)`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px] text-zinc-600">
|
||||
<span>30 days ago</span><span>Today</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||
{/* Top channels */}
|
||||
{data.top_channels.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Top channels by watch time</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{data.top_channels.map(ch => {
|
||||
const pct = (ch.watch_seconds || 0) / maxSeconds;
|
||||
return (
|
||||
<div key={ch.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<Link to={`/channels/${ch.id}`} className="text-zinc-200 hover:text-white transition-colors truncate text-[13px]">{ch.name}</Link>
|
||||
<span className="text-zinc-500 text-[11px] shrink-0 ml-2 font-mono">{ch.watch_count} · {fmt(ch.watch_seconds)}</span>
|
||||
</div>
|
||||
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-accent/60 rounded-full" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top categories */}
|
||||
{data.top_categories?.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Top categories</h2>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{data.top_categories.map(cat => {
|
||||
const pct = cat.watch_count / maxCatCount;
|
||||
const comp = cat.avg_completion ? Math.round(cat.avg_completion) : null;
|
||||
return (
|
||||
<div key={cat.category} className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] text-zinc-200 truncate">{cat.category}</span>
|
||||
<span className="text-[11px] text-zinc-500 shrink-0 ml-2 font-mono">
|
||||
{cat.watch_count}{comp !== null ? ` · ${comp}% avg` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div className="h-full bg-indigo-500/60 rounded-full" style={{ width: `${pct * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Taste profile */}
|
||||
{topTags.length > 0 && (
|
||||
<div className="bg-zinc-900 rounded-2xl p-5 flex flex-col gap-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<h2 className="text-xs font-semibold text-zinc-500 uppercase tracking-wider">Taste profile</h2>
|
||||
<p className="text-[11px] text-zinc-600">built from your watches, likes and bookmarks</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{topTags.map(t => {
|
||||
const intensity = t.score / maxTagScore;
|
||||
return (
|
||||
<span
|
||||
key={t.tag}
|
||||
title={`score: ${t.score.toFixed(1)}`}
|
||||
className="px-3 py-1 rounded-full text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: `rgba(250,204,21,${0.08 + intensity * 0.18})`,
|
||||
color: `hsl(50,95%,${55 + intensity * 20}%)`,
|
||||
fontSize: `${11 + intensity * 4}px`,
|
||||
}}
|
||||
>
|
||||
{t.tag}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1127
frontend/src/pages/Watch.jsx
Normal file
1127
frontend/src/pages/Watch.jsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user