Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
194 lines
7.0 KiB
TypeScript
194 lines
7.0 KiB
TypeScript
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>
|
||
);
|
||
}
|