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