- Home: mode switcher moved to its own row (no longer crammed next to title on mobile), hide-watched simplified to text-only toggle - Home/History/Discovery: pagination buttons text-sm → text-xs, page counter text-sm → text-xs - Liked/Downloads/SearchResults: top-level gap-8 → gap-6 - Liked: refresh button px-4 py-2 text-sm → px-3 py-1.5 text-xs - Empty states: standardize to text-zinc-500 text-sm across Queue, ContinueWatching, History, Following, Discovery, Liked - Following: "Latest uploads" tab label → "Feed" - Home: remove -mt-3 hacks from mode description rows Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
145 lines
5.3 KiB
JavaScript
145 lines
5.3 KiB
JavaScript
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-6">
|
|
<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="flex flex-col gap-2">
|
|
{visibleVideos.map((v) => (
|
|
<VideoCard key={v.youtube_video_id} video={v} variant="list" />
|
|
))}
|
|
</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>
|
|
);
|
|
}
|