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:
inputnoise
2026-05-25 20:09:04 +02:00
commit 1827dd6c4e
63 changed files with 14480 additions and 0 deletions

View 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>
);
}