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