feat(web): 发现页改为「推荐」分区 +「探索」筛选,卡片带本地状态与订阅 / 搜索资源
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
9a154bc249
commit
740e2fe67d
9 files changed
+1422
-211
No files matched your search
@@ -1,215 +1,53 @@
|
||||
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 { useSearchParams } from "react-router";
|
||||
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 {
|
||||
type ExploreState,
|
||||
fromSearchParams,
|
||||
patch,
|
||||
toSearchParams,
|
||||
type View,
|
||||
} from "~/components/discover/discover-state.ts";
|
||||
import { ExploreView } from "~/components/discover/explore-view.tsx";
|
||||
import { RecommendView } from "~/components/discover/recommend-view.tsx";
|
||||
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
|
||||
// 发现(对照 MP 推荐页):bgm.tv 每日放送、豆瓣热门电影 / 剧集。点条目按名称 + 年份匹配 TMDB 作品,
|
||||
// 确定的直接进作品页(可订阅、搜资源),不确定的列候选让人选。
|
||||
// 发现(对照 MP 推荐 + 探索,docs/porting/moviepilot-discover.md):
|
||||
// 「推荐」按分区横排 TMDB / 豆瓣 / Bangumi 的热门与榜单,每区「查看全部」带参数切到「探索」;
|
||||
// 「探索」选来源后按类型、风格、地区、语言、年份、评分、排序筛选或搜关键词。状态都在 URL 里。
|
||||
// 卡片标本地状态;点开进作品,豆瓣 / bgm 条目先匹配 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 view: View = sp.get("view") === "explore" || sp.has("src") ? "explore" : "recommend";
|
||||
const explore = fromSearchParams(sp);
|
||||
const category = sp.get("cat") ?? "";
|
||||
|
||||
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 ?? []);
|
||||
const go = (next: ExploreState) => setSp(toSearchParams(next), { replace: false });
|
||||
|
||||
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>
|
||||
))}
|
||||
<PageHeader title="发现" description="TMDB、豆瓣、Bangumi 的热门与榜单;点开作品可订阅或搜索资源" />
|
||||
<Tabs
|
||||
value={view}
|
||||
onValueChange={(v) =>
|
||||
setSp(v === "explore" ? toSearchParams(explore) : new URLSearchParams(category ? { cat: category } : {}))
|
||||
}
|
||||
>
|
||||
<TabsList className="mb-5">
|
||||
<TabsTrigger value="recommend">推荐</TabsTrigger>
|
||||
<TabsTrigger value="explore">探索</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="没有条目" />
|
||||
{view === "recommend" ? (
|
||||
<RecommendView
|
||||
category={category}
|
||||
onCategory={(c) => setSp(new URLSearchParams(c ? { cat: c } : {}), { replace: true })}
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
<ExploreView s={explore} onPatch={(p) => go(patch(explore, p))} />
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user