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
@@ -0,0 +1,193 @@
|
||||
import { type DiscoverItem, type DiscoverMatchCandidate, tmdbImageUrl } from "@umt/contracts";
|
||||
import { ExternalLink, Search } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDiscoverMatch } from "~/api/discover.ts";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { useReleaseSearchLauncher } from "~/components/search/search-sections.tsx";
|
||||
import { SubscriptionFormDialog } from "~/components/subscriptions/subscription-form.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import type { CardAction } from "./discover-card.tsx";
|
||||
import { itemHref } from "./discover-state.ts";
|
||||
|
||||
// 卡片动作:进作品 / 订阅 / 搜索资源。都要先知道对应的作品:本地作品或 TMDB 条目直接用;
|
||||
// 豆瓣 / bgm 条目先按名称 + 年份匹配 TMDB(服务端记住确定的匹配),不确定时列候选让人选,选中后继续原来的动作。
|
||||
|
||||
interface Target {
|
||||
title: string;
|
||||
year: number | null;
|
||||
type: "movie" | "tv";
|
||||
tmdbId: string;
|
||||
entityId: string | null;
|
||||
}
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = { douban: "豆瓣", bangumi: "bgm.tv", tmdb: "TMDB" };
|
||||
|
||||
export function useDiscoverActions() {
|
||||
const navigate = useNavigate();
|
||||
const match = useDiscoverMatch();
|
||||
const launcher = useReleaseSearchLauncher();
|
||||
const [busy, setBusy] = useState<{ key: string; action: CardAction } | null>(null);
|
||||
const [picking, setPicking] = useState<{
|
||||
item: DiscoverItem;
|
||||
action: CardAction;
|
||||
candidates: DiscoverMatchCandidate[];
|
||||
} | null>(null);
|
||||
const [subscribing, setSubscribing] = useState<Target | null>(null);
|
||||
|
||||
const run = (action: CardAction, t: Target) => {
|
||||
if (action === "open") {
|
||||
navigate(t.entityId ? consolePath("library", t.entityId) : consolePath("media", "tmdb", t.type, t.tmdbId));
|
||||
} else if (action === "subscribe") setSubscribing(t);
|
||||
else launcher.open({ title: t.title, ref: t.entityId ? `local:${t.entityId}` : `tmdb:${t.type}:${t.tmdbId}` });
|
||||
};
|
||||
|
||||
const act = async (item: DiscoverItem, action: CardAction) => {
|
||||
const known = item.tmdb;
|
||||
if (action === "open") {
|
||||
const href = itemHref(item);
|
||||
if (href) return navigate(href);
|
||||
}
|
||||
if (known) {
|
||||
return run(action, {
|
||||
title: item.title,
|
||||
year: item.year,
|
||||
type: known.type,
|
||||
tmdbId: known.id,
|
||||
entityId: item.local?.entityId ?? null,
|
||||
});
|
||||
}
|
||||
setBusy({ key: `${item.source}:${item.id}`, action });
|
||||
try {
|
||||
const r = await match.mutateAsync(item);
|
||||
if (r.match) run(action, { ...r.match, tmdbId: r.match.id, entityId: null });
|
||||
else setPicking({ item, action, candidates: r.candidates });
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err, "匹配 TMDB 失败"));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
const dialogs: ReactNode = (
|
||||
<>
|
||||
{picking ? (
|
||||
<PickDialog
|
||||
item={picking.item}
|
||||
candidates={picking.candidates}
|
||||
onPick={(c) => {
|
||||
setPicking(null);
|
||||
run(picking.action, { ...c, tmdbId: c.id, entityId: null });
|
||||
}}
|
||||
onClose={() => setPicking(null)}
|
||||
/>
|
||||
) : null}
|
||||
{subscribing ? (
|
||||
<SubscriptionFormDialog
|
||||
initialTarget={
|
||||
subscribing.entityId
|
||||
? {
|
||||
kind: "entity",
|
||||
id: subscribing.entityId,
|
||||
title: subscribing.title,
|
||||
year: subscribing.year,
|
||||
mediaKind: subscribing.type === "movie" ? "movie" : "series",
|
||||
}
|
||||
: {
|
||||
kind: "tmdb",
|
||||
entityType: subscribing.type,
|
||||
externalId: subscribing.tmdbId,
|
||||
title: subscribing.title,
|
||||
year: subscribing.year,
|
||||
mediaKind: subscribing.type === "movie" ? "movie" : "series",
|
||||
}
|
||||
}
|
||||
onClose={() => setSubscribing(null)}
|
||||
onCreated={() => toast.success(`已订阅「${subscribing.title}」`)}
|
||||
/>
|
||||
) : null}
|
||||
{launcher.dialog}
|
||||
</>
|
||||
);
|
||||
|
||||
return {
|
||||
act,
|
||||
busyOf: (item: DiscoverItem): CardAction | null =>
|
||||
busy && busy.key === `${item.source}:${item.id}` ? busy.action : null,
|
||||
dialogs,
|
||||
};
|
||||
}
|
||||
|
||||
function PickDialog({
|
||||
item,
|
||||
candidates,
|
||||
onPick,
|
||||
onClose,
|
||||
}: {
|
||||
item: DiscoverItem;
|
||||
candidates: DiscoverMatchCandidate[];
|
||||
onPick: (c: DiscoverMatchCandidate) => void;
|
||||
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>
|
||||
{candidates.length ? (
|
||||
<ul className="grid gap-2">
|
||||
{candidates.map((c) => (
|
||||
<li key={`${c.type}:${c.id}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPick(c)}
|
||||
className="flex w-full cursor-pointer items-center gap-3 rounded-lg border border-border/70 p-2 text-left 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=""
|
||||
loading="lazy"
|
||||
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>
|
||||
</button>
|
||||
</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 />
|
||||
{SOURCE_LABEL[item.source] ?? item.source}
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user