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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user