Files
UHD_Media_Tool/apps/web/app/routes/console/discover.tsx
T
2026-10-02 22:36:26 +08:00

216 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { type DiscoverItem, type DiscoverMatchResult, tmdbImageUrl } from "@umt/contracts";
import { ExternalLink, LoaderCircle, Search, Star } from "lucide-react";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useBangumiCalendar, useDiscoverMatch, useDoubanHot } from "~/api/discover.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { errorMessage } from "~/lib/api.ts";
// 发现(对照 MP 推荐页):bgm.tv 每日放送、豆瓣热门电影 / 剧集。点条目按名称 + 年份匹配 TMDB 作品,
// 确定的直接进作品页(可订阅、搜资源),不确定的列候选让人选。
export const meta = () => [{ title: "发现 · UHD 媒体自动化控制台" }];
type View = "anime" | "movie" | "tv";
const VIEWS: { value: View; label: string }[] = [
{ value: "anime", label: "番剧放送" },
{ value: "movie", label: "热门电影" },
{ value: "tv", label: "热门剧集" },
];
/** bgm 周一 = 1 … 周日 = 7 */
const todayWeekday = () => ((new Date().getDay() + 6) % 7) + 1;
export default function DiscoverPage() {
const [sp, setSp] = useSearchParams();
const view = (VIEWS.some((v) => v.value === sp.get("view")) ? sp.get("view") : "anime") as View;
const [day, setDay] = useState(todayWeekday);
const cal = useBangumiCalendar(view === "anime");
const hot = useDoubanHot(view === "tv" ? "tv" : "movie", view !== "anime");
const match = useDiscoverMatch();
const navigate = useNavigate();
const [picking, setPicking] = useState<{ item: DiscoverItem; result: DiscoverMatchResult } | null>(null);
const [busyId, setBusyId] = useState<string | null>(null);
const open = async (it: DiscoverItem) => {
setBusyId(`${it.source}:${it.id}`);
try {
const r = await match.mutateAsync(it);
if (r.match) navigate(consolePath("media", "tmdb", r.match.type, r.match.id));
else setPicking({ item: it, result: r });
} catch (err) {
toast.error(errorMessage(err, "匹配 TMDB 失败"));
} finally {
setBusyId(null);
}
};
const q = view === "anime" ? cal : hot;
const items =
view === "anime" ? (cal.data?.days.find((d) => d.weekday === day)?.items ?? []) : (hot.data?.items ?? []);
return (
<>
<PageHeader title="发现" description="bgm.tv 每日放送与豆瓣热门;点开匹配到 TMDB 作品后可订阅或搜资源" />
<Tabs value={view} onValueChange={(v) => setSp(new URLSearchParams({ view: v }), { replace: true })}>
<TabsList className="mb-4">
{VIEWS.map((v) => (
<TabsTrigger key={v.value} value={v.value}>
{v.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{view === "anime" && cal.data ? (
<div className="mb-4 flex flex-wrap gap-1.5">
{cal.data.days.map((d) => (
<Button
key={d.weekday}
size="sm"
variant={d.weekday === day ? "default" : "outline"}
onClick={() => setDay(d.weekday)}
>
{d.label}
<span className="text-micro opacity-70">{d.items.length}</span>
</Button>
))}
</div>
) : null}
{q.isLoading ? (
<LoadingState rows={4} />
) : q.error ? (
<ErrorState error={q.error} onRetry={() => void q.refetch()} />
) : items.length === 0 ? (
<EmptyState title="没有条目" />
) : (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{items.map((it) => (
<li key={`${it.source}:${it.id}`}>
<ItemCard item={it} busy={busyId === `${it.source}:${it.id}`} onOpen={() => void open(it)} />
</li>
))}
</ul>
)}
{picking ? <PickDialog {...picking} onClose={() => setPicking(null)} /> : null}
</>
);
}
function ItemCard({ item, busy, onOpen }: { item: DiscoverItem; busy: boolean; onOpen: () => void }) {
const [failed, setFailed] = useState(false);
return (
<button
type="button"
onClick={onOpen}
disabled={busy}
className="group flex w-full min-w-0 flex-col overflow-hidden rounded-lg border border-border/70 text-left transition-colors hover:border-foreground/40 disabled:opacity-70"
>
<div className="relative aspect-[2/3] w-full bg-muted">
{item.cover && !failed ? (
<img
src={item.cover}
alt=""
loading="lazy"
referrerPolicy="no-referrer"
onError={() => setFailed(true)}
className="h-full w-full object-cover"
/>
) : null}
{busy ? (
<span className="absolute inset-0 grid place-items-center bg-background/60">
<LoaderCircle className="h-5 w-5 animate-spin" />
</span>
) : null}
</div>
<div className="min-w-0 px-2 py-1.5">
<p className="truncate text-caption font-medium text-foreground" title={item.title}>
{item.title}
</p>
<p className="flex items-center gap-2 text-meta text-muted-foreground">
<span>{item.date ?? item.year ?? "—"}</span>
{item.rating ? (
<span className="inline-flex items-center gap-0.5">
<Star className="h-3 w-3" />
{item.rating.toFixed(1)}
</span>
) : null}
</p>
</div>
</button>
);
}
function PickDialog({
item,
result,
onClose,
}: {
item: DiscoverItem;
result: DiscoverMatchResult;
onClose: () => void;
}) {
return (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>选择 TMDB 作品</DialogTitle>
<DialogDescription>
「{item.title}」{item.year ? `(${item.year})` : ""}没能自动确定对应的 TMDB 作品。
</DialogDescription>
</DialogHeader>
{result.candidates.length ? (
<ul className="grid gap-2">
{result.candidates.map((c) => (
<li key={`${c.type}:${c.id}`}>
<Link
to={consolePath("media", "tmdb", c.type, c.id)}
onClick={onClose}
className="flex items-center gap-3 rounded-lg border border-border/70 p-2 hover:border-foreground/40 hover:bg-accent"
>
<span className="h-14 w-10 shrink-0 overflow-hidden rounded-tight bg-muted">
{c.posterPath ? (
<img
src={tmdbImageUrl(c.posterPath, "w92") ?? ""}
alt=""
className="h-full w-full object-cover"
/>
) : null}
</span>
<span className="min-w-0">
<span className="block truncate text-caption font-medium">{c.title}</span>
<span className="text-meta text-muted-foreground">
{c.type === "movie" ? "电影" : "剧集"} · {c.year ?? "—"}
</span>
</span>
</Link>
</li>
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">TMDB 上没有找到相近的作品。</p>
)}
<div className="flex flex-wrap gap-2">
<Button asChild variant="outline" size="sm">
<Link to={`${consolePath("search")}?q=${encodeURIComponent(item.title)}`} onClick={onClose}>
<Search />
按名称搜索
</Link>
</Button>
<Button asChild variant="ghost" size="sm">
<a href={item.url} target="_blank" rel="noreferrer">
<ExternalLink />
{item.source === "bangumi" ? "bgm.tv" : "豆瓣"}
</a>
</Button>
</div>
</DialogContent>
</Dialog>
);
}