summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/app.css39
-rw-r--r--app/lib/claude.server.ts125
-rw-r--r--app/lib/db.server.ts79
-rw-r--r--app/lib/worker.server.ts32
-rw-r--r--app/root.tsx1
-rw-r--r--app/routes.ts2
-rw-r--r--app/routes/band-auto-register-job.tsx211
-rw-r--r--app/routes/band-auto-register.tsx88
-rw-r--r--app/routes/band-index.tsx1
-rw-r--r--app/routes/home.tsx108
10 files changed, 683 insertions, 3 deletions
diff --git a/app/app.css b/app/app.css
index 90dc85f..9125f90 100644
--- a/app/app.css
+++ b/app/app.css
@@ -163,6 +163,45 @@ form input, form select, form textarea { width: 100%; }
.entry-band { font-weight: 500; color: #e5e7eb; }
.entry-note { font-size: .8rem; color: #6b7280; margin-top: .125rem; }
+/* ── Job list ── */
+
+.job-list { display: flex; flex-direction: column; gap: .25rem; }
+.job-row { display: flex; align-items: baseline; gap: .75rem; padding: .5rem .75rem; background: #111827; border-radius: 4px; text-decoration: none; }
+.job-row:hover { background: #1f2937; text-decoration: none; }
+.job-status { flex-shrink: 0; font-size: .75rem; font-weight: 600; width: 4rem; }
+.job-preview { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #d1d5db; font-size: .875rem; }
+.job-time { flex-shrink: 0; font-size: .75rem; color: #6b7280; font-variant-numeric: tabular-nums; }
+.job-meta { display: flex; align-items: center; gap: 1rem; margin-bottom: .5rem; }
+
+/* ── Spinner ── */
+
+@keyframes spin { to { transform: rotate(360deg); } }
+.spinner { display: inline-block; width: 1rem; height: 1rem; border: 2px solid #374151; border-top-color: #6366f1; border-radius: 50%; animation: spin .8s linear infinite; flex-shrink: 0; }
+
+/* ── Auto-register ── */
+
+.auto-register-list { display: flex; flex-direction: column; gap: .5rem; }
+
+.auto-register-row { display: flex; gap: .75rem; align-items: flex-start; padding: .75rem; border-radius: 6px; border-left: 3px solid #374151; background: #111827; }
+.auto-register-row.conf-exact { border-left-color: #374151; opacity: .7; }
+.auto-register-row.conf-fuzzy { border-left-color: #d97706; }
+.auto-register-row.conf-new { border-left-color: #4f46e5; }
+
+.ar-left { width: 1.25rem; padding-top: .125rem; flex-shrink: 0; }
+.ar-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: .35rem; }
+.ar-name { font-weight: 500; color: #e5e7eb; }
+.ar-name-input { font-size: .9rem; width: 100%; background: #1f2937; }
+
+.ar-status { display: flex; align-items: center; gap: .5rem; flex-wrap: wrap; font-size: .8rem; }
+.ar-badge { display: inline-block; padding: .1rem .45rem; border-radius: 9999px; font-size: .7rem; font-weight: 600; }
+.ar-exact .ar-badge { background: #1f2937; color: #6b7280; }
+.ar-fuzzy .ar-badge { background: #451a03; color: #fbbf24; }
+.ar-new .ar-badge { background: #1e1b4b; color: #818cf8; }
+
+.ar-link { color: #60a5fa; font-size: .8rem; }
+.ar-link:hover { color: #93c5fd; }
+.ar-candidate { color: #9ca3af; }
+
/* ── Entry form rows (new/edit) ── */
.entry-row { display: flex; gap: .5rem; align-items: center; margin-bottom: .375rem; }
diff --git a/app/lib/claude.server.ts b/app/lib/claude.server.ts
new file mode 100644
index 0000000..4dcce6e
--- /dev/null
+++ b/app/lib/claude.server.ts
@@ -0,0 +1,125 @@
+import { spawn } from "child_process";
+import Anthropic from "@anthropic-ai/sdk";
+import type { Band } from "./db.server";
+
+export interface BandMatch {
+ extracted: string;
+ matched_id: string | null;
+ matched_name: string | null;
+ confidence: "exact" | "fuzzy" | "new";
+}
+
+// ── Shared logic ──────────────────────────────────────────────────────────────
+
+function buildPrompt(liveText: string, existingBands: Band[]): string {
+ const bandList = existingBands.map((b) => ` ${b.id}\t${b.name}`).join("\n");
+ return `ライブ情報テキストからバンド・アーティスト名を抽出し、既存バンドリストと照合してください。
+
+## ライブ情報テキスト
+${liveText}
+
+## 既存バンドリスト(UUID\t名前)
+${bandList || " (なし)"}
+
+## タスク
+1. ライブ情報からバンド・アーティスト名をすべて抽出する
+2. 各名前を既存バンドリストと照合する(表記揺れ・全角半角・英語/日本語表記の違いなどを考慮)
+3. 以下のJSON配列のみを返す(説明文不要):
+
+[
+ {
+ "extracted": "抽出した名前(元テキストのまま)",
+ "matched_id": "一致した既存バンドのUUID(なければnull)",
+ "matched_name": "一致した既存バンドの名前(なければnull)",
+ "confidence": "exact | fuzzy | new"
+ }
+]
+
+confidenceの定義:
+- "exact": 完全一致または明らかに同一
+- "fuzzy": 表記揺れの可能性が高い(人間の確認が必要)
+- "new": 既存リストに存在しない新しいバンド`;
+}
+
+function parseOutput(text: string): BandMatch[] {
+ const jsonMatch = text.match(/\[[\s\S]*\]/);
+ if (!jsonMatch) throw new Error("予期しない出力形式です");
+ return JSON.parse(jsonMatch[0]) as BandMatch[];
+}
+
+// ── Adapter interface ─────────────────────────────────────────────────────────
+
+interface BandMatchAdapter {
+ parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]>;
+}
+
+// ── Anthropic API adapter ─────────────────────────────────────────────────────
+
+class AnthropicAPIAdapter implements BandMatchAdapter {
+ async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> {
+ const client = new Anthropic();
+ const response = await client.messages.create({
+ model: "claude-sonnet-4-6",
+ max_tokens: 2048,
+ messages: [{ role: "user", content: buildPrompt(liveText, existingBands) }],
+ });
+ const text = response.content[0].type === "text" ? response.content[0].text : "";
+ return parseOutput(text);
+ }
+}
+
+// ── Claude Code CLI adapter ───────────────────────────────────────────────────
+
+class ClaudeCodeCLIAdapter implements BandMatchAdapter {
+ async parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> {
+ const text = await this.runCLI(buildPrompt(liveText, existingBands));
+ return parseOutput(text);
+ }
+
+ private runCLI(prompt: string): Promise<string> {
+ return new Promise((resolve, reject) => {
+ const proc = spawn(
+ "claude",
+ ["--print", "--output-format", "json", "--no-session-persistence", "--model", "sonnet"],
+ { stdio: ["pipe", "pipe", "pipe"] }
+ );
+
+ let stdout = "";
+ let stderr = "";
+ proc.stdout.on("data", (d: Buffer) => { stdout += d.toString(); });
+ proc.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
+
+ proc.on("error", (err) => reject(new Error(`claude CLI 起動失敗: ${err.message}`)));
+ proc.on("close", (code) => {
+ if (code !== 0) {
+ reject(new Error(`claude CLI がコード ${code} で終了: ${stderr.trim()}`));
+ return;
+ }
+ try {
+ // --output-format json wraps result in { "result": "..." }
+ const json = JSON.parse(stdout) as { result?: string };
+ resolve(json.result ?? stdout);
+ } catch {
+ resolve(stdout);
+ }
+ });
+
+ proc.stdin.write(prompt);
+ proc.stdin.end();
+ });
+ }
+}
+
+// ── Factory ───────────────────────────────────────────────────────────────────
+
+function getAdapter(): BandMatchAdapter {
+ const type = (process.env.CLAUDE_ADAPTER ?? "api").toLowerCase();
+ if (type === "cli") return new ClaudeCodeCLIAdapter();
+ return new AnthropicAPIAdapter();
+}
+
+// ── Public API (unchanged for callers) ───────────────────────────────────────
+
+export function parseLiveInfoBands(liveText: string, existingBands: Band[]): Promise<BandMatch[]> {
+ return getAdapter().parseLiveInfoBands(liveText, existingBands);
+}
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index c4b11b4..37d819b 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -142,7 +142,22 @@ function initSchema(db: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_artist_revisions_artist_id ON artist_revisions(artist_id);
CREATE INDEX IF NOT EXISTS idx_list_entries_list_id ON list_entries(list_id);
CREATE INDEX IF NOT EXISTS idx_list_revisions_list_id ON list_revisions(list_id);
+
+ CREATE TABLE IF NOT EXISTS parse_jobs (
+ id TEXT PRIMARY KEY,
+ status TEXT NOT NULL DEFAULT 'pending',
+ input_text TEXT NOT NULL,
+ result TEXT,
+ error_message TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_parse_jobs_status ON parse_jobs(status, created_at);
`);
+
+ // reset stuck jobs from previous server run
+ db.prepare("UPDATE parse_jobs SET status = 'pending', updated_at = datetime('now') WHERE status = 'processing'").run();
}
// ── Interfaces ────────────────────────────────────────────────────────────────
@@ -330,6 +345,14 @@ export function listBands(): Band[] {
return getDb().prepare("SELECT * FROM bands ORDER BY slug").all() as Band[];
}
+export function listRecentBands(limit = 8): Band[] {
+ return getDb().prepare("SELECT * FROM bands ORDER BY created_at DESC LIMIT ?").all(limit) as Band[];
+}
+
+export function listRecentArtists(limit = 8): Artist[] {
+ return getDb().prepare("SELECT * FROM artists ORDER BY created_at DESC LIMIT ?").all(limit) as Artist[];
+}
+
export function getBandById(id: string): Band | null {
return getDb().prepare("SELECT * FROM bands WHERE id = ?").get(id) as Band | null;
}
@@ -796,3 +819,59 @@ export function importDb(data: DbExport): ImportResult {
};
})() as ImportResult;
}
+
+// ── Parse job queue ───────────────────────────────────────────────────────────
+
+export interface ParseJob {
+ id: string;
+ status: "pending" | "processing" | "done" | "error";
+ input_text: string;
+ result: string | null;
+ error_message: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export function createParseJob(inputText: string): ParseJob {
+ const id = crypto.randomUUID();
+ getDb().prepare(
+ "INSERT INTO parse_jobs (id, input_text) VALUES (?, ?)"
+ ).run(id, inputText);
+ return getDb().prepare("SELECT * FROM parse_jobs WHERE id = ?").get(id) as ParseJob;
+}
+
+export function getParseJob(id: string): ParseJob | null {
+ return getDb().prepare("SELECT * FROM parse_jobs WHERE id = ?").get(id) as ParseJob | null;
+}
+
+export function listRecentParseJobs(limit = 20): ParseJob[] {
+ return getDb()
+ .prepare("SELECT * FROM parse_jobs ORDER BY created_at DESC LIMIT ?")
+ .all(limit) as ParseJob[];
+}
+
+export function claimNextParseJob(): ParseJob | null {
+ const db = getDb();
+ return db.transaction(() => {
+ const job = db.prepare(
+ "SELECT * FROM parse_jobs WHERE status = 'pending' ORDER BY created_at LIMIT 1"
+ ).get() as ParseJob | null;
+ if (!job) return null;
+ db.prepare(
+ "UPDATE parse_jobs SET status = 'processing', updated_at = datetime('now') WHERE id = ?"
+ ).run(job.id);
+ return { ...job, status: "processing" as const };
+ })();
+}
+
+export function completeParseJob(id: string, result: string): void {
+ getDb().prepare(
+ "UPDATE parse_jobs SET status = 'done', result = ?, updated_at = datetime('now') WHERE id = ?"
+ ).run(result, id);
+}
+
+export function failParseJob(id: string, errorMessage: string): void {
+ getDb().prepare(
+ "UPDATE parse_jobs SET status = 'error', error_message = ?, updated_at = datetime('now') WHERE id = ?"
+ ).run(errorMessage, id);
+}
diff --git a/app/lib/worker.server.ts b/app/lib/worker.server.ts
new file mode 100644
index 0000000..f71ea40
--- /dev/null
+++ b/app/lib/worker.server.ts
@@ -0,0 +1,32 @@
+import { claimNextParseJob, completeParseJob, failParseJob, listBands } from "./db.server";
+import { parseLiveInfoBands } from "./claude.server";
+
+const POLL_INTERVAL_MS = 2000;
+
+let started = false;
+
+function startWorker() {
+ if (started) return;
+ started = true;
+
+ let busy = false;
+
+ setInterval(async () => {
+ if (busy) return;
+ const job = claimNextParseJob();
+ if (!job) return;
+
+ busy = true;
+ try {
+ const bands = listBands();
+ const matches = await parseLiveInfoBands(job.input_text, bands);
+ completeParseJob(job.id, JSON.stringify(matches));
+ } catch (e) {
+ failParseJob(job.id, e instanceof Error ? e.message : "不明なエラー");
+ } finally {
+ busy = false;
+ }
+ }, POLL_INTERVAL_MS);
+}
+
+startWorker();
diff --git a/app/root.tsx b/app/root.tsx
index 15cc68b..a60527e 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -10,6 +10,7 @@ import {
import type { Route } from "./+types/root";
import "./app.css";
+import "./lib/worker.server";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
diff --git a/app/routes.ts b/app/routes.ts
index 0a2c028..6125a73 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -8,6 +8,8 @@ export default [
route("/api/import", "routes/api-import.tsx"),
route("/bands", "routes/band-index.tsx"),
route("/bands/new", "routes/band-new.tsx"),
+ route("/bands/auto-register", "routes/band-auto-register.tsx"),
+ route("/bands/auto-register/jobs/:id", "routes/band-auto-register-job.tsx"),
route("/bands/of/:uuid", "routes/band-by-uuid.tsx"),
route("/bands/named/:slug", "routes/band-by-slug.tsx"),
route("/bands/of/:uuid/edit", "routes/band-edit.tsx"),
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>
+ </>
+ );
+}
diff --git a/app/routes/band-auto-register.tsx b/app/routes/band-auto-register.tsx
new file mode 100644
index 0000000..227908d
--- /dev/null
+++ b/app/routes/band-auto-register.tsx
@@ -0,0 +1,88 @@
+import { Form, Link, redirect, useLoaderData, useNavigation } from "react-router";
+import type { ActionFunctionArgs } from "react-router";
+import { createParseJob, getIpAddress, listRecentParseJobs } from "~/lib/db.server";
+
+export function loader() {
+ return { jobs: listRecentParseJobs(10) };
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ const fd = await request.formData();
+ const liveText = (fd.get("liveText") as string | null)?.trim() ?? "";
+ if (!liveText) return { error: "テキストを入力してください" };
+ const job = createParseJob(liveText);
+ void getIpAddress(request); // unused but kept for symmetry
+ return redirect(`/bands/auto-register/jobs/${job.id}`);
+}
+
+const STATUS_LABEL: Record<string, string> = {
+ pending: "待機中",
+ processing: "解析中",
+ done: "完了",
+ error: "エラー",
+};
+
+const STATUS_COLOR: Record<string, string> = {
+ pending: "#6b7280",
+ processing: "#fbbf24",
+ done: "#34d399",
+ error: "#f87171",
+};
+
+export default function BandAutoRegister() {
+ const { jobs } = useLoaderData<typeof loader>();
+ const nav = useNavigation();
+ const busy = nav.state !== "idle";
+
+ return (
+ <main>
+ <div className="page-header">
+ <Link to="/bands" className="back">←</Link>
+ <h1>ライブ情報から自動登録</h1>
+ </div>
+
+ <Form method="post">
+ <div>
+ <label>ライブ情報テキスト <span className="req">*</span></label>
+ <textarea
+ name="liveText"
+ rows={10}
+ placeholder={"出演バンド・アーティストの情報をペーストしてください。\n\n例:\n2024.08.10 @ 渋谷CLUB QUATTRO\nopen 17:30 / start 18:00\n\nband1 / Band Two / バンド三"}
+ />
+ </div>
+ <div className="actions">
+ <button type="submit" disabled={busy}>
+ {busy ? "送信中..." : "キューに追加する"}
+ </button>
+ </div>
+ </Form>
+
+ {jobs.length > 0 && (
+ <section style={{ marginTop: "2rem" }}>
+ <h2>最近のジョブ</h2>
+ <div className="job-list">
+ {jobs.map((job) => (
+ <Link
+ key={job.id}
+ to={`/bands/auto-register/jobs/${job.id}`}
+ className="job-row"
+ >
+ <span
+ className="job-status"
+ style={{ color: STATUS_COLOR[job.status] ?? "#6b7280" }}
+ >
+ {STATUS_LABEL[job.status] ?? job.status}
+ </span>
+ <span className="job-preview">
+ {job.input_text.slice(0, 60).replace(/\n/g, " ")}
+ {job.input_text.length > 60 ? "…" : ""}
+ </span>
+ <span className="job-time">{job.created_at.slice(0, 16).replace("T", " ")}</span>
+ </Link>
+ ))}
+ </div>
+ </section>
+ )}
+ </main>
+ );
+}
diff --git a/app/routes/band-index.tsx b/app/routes/band-index.tsx
index 0b47da5..bed6df9 100644
--- a/app/routes/band-index.tsx
+++ b/app/routes/band-index.tsx
@@ -11,6 +11,7 @@ export default function BandIndex() {
<main>
<div className="page-header">
<h1>Bands</h1>
+ <Link to="/bands/auto-register" className="btn" style={{ fontSize: ".8rem" }}>ライブから一括登録</Link>
<Link to="/bands/new">+ Band</Link>
</div>
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 9795ba8..591585d 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,9 +1,111 @@
-import { redirect } from "react-router";
+import { Link, useLoaderData } from "react-router";
+import { listBands, listArtists, listBandLists, listRecentBands, listRecentArtists } from "~/lib/db.server";
export function loader() {
- return redirect("/bands");
+ return {
+ bandCount: listBands().length,
+ artistCount: listArtists().length,
+ listCount: listBandLists().length,
+ recentBands: listRecentBands(8),
+ recentArtists: listRecentArtists(8),
+ };
}
export default function Home() {
- return null;
+ const { bandCount, artistCount, listCount, recentBands, recentArtists } = useLoaderData<typeof loader>();
+ return (
+ <main>
+ <div style={{ marginBottom: "2.5rem" }}>
+ <h1 style={{ fontSize: "2rem", fontWeight: 700, color: "#f9fafb", marginBottom: ".5rem" }}>
+ whois.band
+ </h1>
+ <p className="muted">バンドとアーティストの情報管理サイト</p>
+ </div>
+
+ <div style={{ display: "flex", flexDirection: "column", gap: "1px", background: "#1f2937", border: "1px solid #1f2937", borderRadius: "6px", overflow: "hidden", marginBottom: "2.5rem" }}>
+ <SectionRow to="/bands" label="Bands" count={bandCount} newTo="/bands/new" />
+ <SectionRow to="/artists" label="Artists" count={artistCount} newTo="/artists/new" />
+ <SectionRow to="/lists" label="Lists" count={listCount} newTo="/lists/new" />
+ </div>
+
+ <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "2rem" }}>
+ <RecentSection title="最近追加されたバンド" viewAllTo="/bands">
+ {recentBands.length === 0 ? (
+ <p className="muted" style={{ fontSize: ".875rem" }}>まだありません</p>
+ ) : (
+ <ul className="band-list">
+ {recentBands.map((band) => (
+ <li key={band.id}>
+ <Link to={`/bands/of/${band.id}`}>{band.name}</Link>
+ {band.status === "hiatus" && (
+ <span className="muted" style={{ fontSize: ".75rem" }}>活動休止</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ )}
+ </RecentSection>
+
+ <RecentSection title="最近追加されたアーティスト" viewAllTo="/artists">
+ {recentArtists.length === 0 ? (
+ <p className="muted" style={{ fontSize: ".875rem" }}>まだありません</p>
+ ) : (
+ <ul className="band-list">
+ {recentArtists.map((artist) => (
+ <li key={artist.id}>
+ <Link to={`/artists/of/${artist.id}`}>{artist.name}</Link>
+ </li>
+ ))}
+ </ul>
+ )}
+ </RecentSection>
+ </div>
+ </main>
+ );
+}
+
+function SectionRow({
+ to,
+ label,
+ count,
+ newTo,
+}: {
+ to: string;
+ label: string;
+ count: number;
+ newTo: string;
+}) {
+ return (
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: ".875rem 1.25rem", background: "#0f1117" }}>
+ <div style={{ display: "flex", alignItems: "baseline", gap: "1rem" }}>
+ <Link to={to} style={{ fontWeight: 600, fontSize: "1rem", color: "#e5e7eb" }}>
+ {label}
+ </Link>
+ <span className="muted" style={{ fontSize: ".8rem" }}>{count}件</span>
+ </div>
+ <Link to={newTo} className="btn" style={{ fontSize: ".8rem" }}>
+ + 追加
+ </Link>
+ </div>
+ );
+}
+
+function RecentSection({
+ title,
+ viewAllTo,
+ children,
+}: {
+ title: string;
+ viewAllTo: string;
+ children: React.ReactNode;
+}) {
+ return (
+ <section style={{ marginBottom: 0 }}>
+ <div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: ".75rem" }}>
+ <h2 style={{ margin: 0 }}>{title}</h2>
+ <Link to={viewAllTo} className="muted" style={{ fontSize: ".75rem" }}>すべて見る</Link>
+ </div>
+ {children}
+ </section>
+ );
}