summaryrefslogtreecommitdiff
path: root/app/routes/band-auto-register-job.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/band-auto-register-job.tsx')
-rw-r--r--app/routes/band-auto-register-job.tsx211
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>
+ </>
+ );
+}