diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 18:57:59 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-15 18:57:59 +0900 |
| commit | 59a9f4984c3acd29bc746a78e30849f50fa555ee (patch) | |
| tree | b9dab024c0c5d1d68e023b8f49eb0716b97965bd /app/routes/band-auto-register-job.tsx | |
| parent | a100fb28f92fa7c5d2cf701d118c4c7b898c9bf2 (diff) | |
Add Claude-powered band auto-registration from live info text
Extracts band/artist names from pasted live info via Claude (API or CLI
adapter), matches against existing bands, and lets the user review and
bulk-register new ones through a polling job queue. Also adds a
livehouse events import script and recent-bands/artists home page
sections.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/band-auto-register-job.tsx')
| -rw-r--r-- | app/routes/band-auto-register-job.tsx | 211 |
1 files changed, 211 insertions, 0 deletions
diff --git a/app/routes/band-auto-register-job.tsx b/app/routes/band-auto-register-job.tsx new file mode 100644 index 0000000..31f8ba4 --- /dev/null +++ b/app/routes/band-auto-register-job.tsx @@ -0,0 +1,211 @@ +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<typeof loader>(); + const revalidator = useRevalidator(); + const nav = useNavigation(); + const busy = nav.state !== "idle"; + + // read flash params from URL + const [flashMsg, setFlashMsg] = useState<string | null>(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 ( + <main> + <div className="page-header"> + <Link to="/bands/auto-register" className="back">←</Link> + <h1>解析ジョブ</h1> + </div> + + {flashMsg && ( + <p style={{ color: "#34d399", marginBottom: "1rem" }}>{flashMsg}</p> + )} + + <div className="job-meta"> + <JobStatusBadge status={job.status} /> + <span className="muted" style={{ fontSize: ".8rem" }}> + {job.created_at.slice(0, 16).replace("T", " ")} + </span> + </div> + + <div style={{ background: "#111827", borderRadius: 6, padding: ".75rem 1rem", marginTop: ".75rem", fontSize: ".8rem", color: "#6b7280", whiteSpace: "pre-wrap", maxHeight: "8rem", overflow: "auto" }}> + {job.input_text} + </div> + + {(job.status === "pending" || job.status === "processing") && ( + <div style={{ marginTop: "2rem", display: "flex", alignItems: "center", gap: ".75rem" }}> + <span className="spinner" /> + <span className="muted">Claudeが解析中です…</span> + </div> + )} + + {job.status === "error" && ( + <p className="error" style={{ marginTop: "1rem" }}>{job.error_message}</p> + )} + + {job.status === "done" && matches.length > 0 && ( + <ReviewSection matches={matches} busy={busy} /> + )} + </main> + ); +} + +function JobStatusBadge({ status }: { status: string }) { + const map: Record<string, { label: string; color: string }> = { + 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 ( + <span style={{ color: s.color, fontWeight: 600, fontSize: ".875rem" }}> + ● {s.label} + </span> + ); +} + +function ReviewSection({ matches, busy }: { matches: BandMatch[]; busy: boolean }) { + type Sel = { include: boolean; name: string; treatAsNew: boolean }; + const [sels, setSels] = useState<Record<number, Sel>>(() => { + const init: Record<number, Sel> = {}; + 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<Sel>) { + 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 ( + <> + <section style={{ marginTop: "1.5rem" }}> + <h2>解析結果</h2> + <div className="auto-register-list"> + {matches.map((m, i) => { + const s = sels[i]; + return ( + <div key={i} className={`auto-register-row conf-${m.confidence}`}> + <div className="ar-left"> + {(m.confidence === "new" || (m.confidence === "fuzzy" && s.treatAsNew)) && ( + <input type="checkbox" checked={s.include} onChange={(e) => update(i, { include: e.target.checked })} /> + )} + </div> + <div className="ar-body"> + <div className="ar-name"> + {s.include && s.treatAsNew + ? <input className="ar-name-input" value={s.name} onChange={(e) => update(i, { name: e.target.value })} /> + : <span>{m.extracted}</span> + } + </div> + {m.confidence === "exact" && ( + <div className="ar-status ar-exact"> + <span className="ar-badge">登録済</span> + {m.matched_id && <Link to={`/bands/of/${m.matched_id}`} className="ar-link">{m.matched_name}</Link>} + </div> + )} + {m.confidence === "fuzzy" && ( + <div className="ar-status ar-fuzzy"> + <span className="ar-badge">要確認</span> + <span className="ar-candidate">候補: {m.matched_name}</span> + <button type="button" className="btn-text" onClick={() => update(i, { treatAsNew: !s.treatAsNew, include: !s.treatAsNew })}> + {s.treatAsNew ? "既存バンドとして扱う" : "新規として登録する"} + </button> + </div> + )} + {m.confidence === "new" && ( + <div className="ar-status ar-new"> + <span className="ar-badge">新規</span> + </div> + )} + </div> + </div> + ); + })} + </div> + </section> + + <Form method="post"> + <input type="hidden" name="toRegister" value={JSON.stringify(toRegister)} /> + <div className="actions" style={{ marginTop: "1rem" }}> + <button type="submit" disabled={busy || toRegister.length === 0}> + {busy ? "登録中..." : `${toRegister.length}件を登録する`} + </button> + <Link to="/bands/auto-register" className="btn">新しいテキストを投入</Link> + <Link to="/bands" className="btn">バンド一覧へ</Link> + </div> + </Form> + </> + ); +} |
