import { useEffect, useState } from "react"; import { data, Form, Link, redirect, useLoaderData, useNavigation, useRevalidator } from "react-router"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { createBand, getIpAddress, getParseJob, toSlug } from "~/lib/db.server"; import type { BandMatch } from "~/lib/claude.server"; export async function loader({ params }: LoaderFunctionArgs) { const job = getParseJob(params.id as string); if (!job) throw data("Not found", { status: 404 }); return { job }; } export async function action({ request, params }: ActionFunctionArgs) { const fd = await request.formData(); const toRegister: { name: string }[] = JSON.parse( (fd.get("toRegister") as string) || "[]" ); const ip = getIpAddress(request); const registered: { id: string; name: string }[] = []; const errors: string[] = []; for (const { name } of toRegister) { const id = crypto.randomUUID(); const baseSlug = toSlug(name) || id.slice(0, 8); let ok = false; for (let attempt = 0; attempt < 3 && !ok; attempt++) { const slug = attempt === 0 ? baseSlug : `${baseSlug}-${id.slice(0, 6)}`; try { createBand({ id, slug, name, area: null, description: null, status: "active", links: [], members: [], message: "ライブ情報から自動登録", ip_address: ip }); registered.push({ id, name }); ok = true; } catch (e) { if (e instanceof Error && e.message.includes("UNIQUE constraint failed")) continue; errors.push(`${name}: ${e instanceof Error ? e.message : "不明なエラー"}`); ok = true; } } } return redirect(`/bands/auto-register/jobs/${params.id as string}?registered=${registered.length}&errors=${errors.length}`); } export default function ParseJobPage() { const { job } = useLoaderData(); const revalidator = useRevalidator(); const nav = useNavigation(); const busy = nav.state !== "idle"; // read flash params from URL const [flashMsg, setFlashMsg] = useState(null); useEffect(() => { const sp = new URLSearchParams(window.location.search); const reg = sp.get("registered"); const err = sp.get("errors"); if (reg !== null) { const parts: string[] = []; if (Number(reg) > 0) parts.push(`${reg}件を登録しました`); if (Number(err) > 0) parts.push(`${err}件が失敗`); setFlashMsg(parts.join("、") || "変更なし"); // clean URL window.history.replaceState(null, "", window.location.pathname); } }, []); // poll while job is still running useEffect(() => { if (job.status !== "pending" && job.status !== "processing") return; const t = setTimeout(() => revalidator.revalidate(), 2000); return () => clearTimeout(t); }, [job.status, job.updated_at, revalidator]); const matches: BandMatch[] = job.status === "done" && job.result ? (JSON.parse(job.result) as BandMatch[]) : []; return (

解析ジョブ

{flashMsg && (

{flashMsg}

)}
{job.created_at.slice(0, 16).replace("T", " ")}
{job.input_text}
{(job.status === "pending" || job.status === "processing") && (
Claudeが解析中です…
)} {job.status === "error" && (

{job.error_message}

)} {job.status === "done" && matches.length > 0 && ( )}
); } function JobStatusBadge({ status }: { status: string }) { const map: Record = { pending: { label: "待機中", color: "#6b7280" }, processing: { label: "解析中", color: "#fbbf24" }, done: { label: "完了", color: "#34d399" }, error: { label: "エラー", color: "#f87171" }, }; const s = map[status] ?? { label: status, color: "#6b7280" }; return ( ● {s.label} ); } function ReviewSection({ matches, busy }: { matches: BandMatch[]; busy: boolean }) { type Sel = { include: boolean; name: string; treatAsNew: boolean }; const [sels, setSels] = useState>(() => { const init: Record = {}; for (let i = 0; i < matches.length; i++) { const m = matches[i]; init[i] = { include: m.confidence !== "exact", name: m.extracted, treatAsNew: m.confidence === "new" }; } return init; }); function update(i: number, patch: Partial) { setSels((prev) => ({ ...prev, [i]: { ...prev[i], ...patch } })); } const toRegister = matches .map((m, i) => ({ m, s: sels[i] })) .filter(({ s }) => s.include && s.treatAsNew) .map(({ s }) => ({ name: s.name })); return ( <>

解析結果

{matches.map((m, i) => { const s = sels[i]; return (
{(m.confidence === "new" || (m.confidence === "fuzzy" && s.treatAsNew)) && ( update(i, { include: e.target.checked })} /> )}
{s.include && s.treatAsNew ? update(i, { name: e.target.value })} /> : {m.extracted} }
{m.confidence === "exact" && (
登録済 {m.matched_id && {m.matched_name}}
)} {m.confidence === "fuzzy" && (
要確認 候補: {m.matched_name}
)} {m.confidence === "new" && (
新規
)}
); })}
新しいテキストを投入 バンド一覧へ
); }