Files
UHD_Media_Tool/apps/web/app/components/discover/use-discover-actions.tsx
T

194 lines
7.0 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 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>
);
}