From b8d24d292d99c8da285092ce923b5e2b546d8f45 Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@mosquit.one>
Date: Sat, 9 May 2026 00:27:19 +0900
Subject: Implement band/artist management with version history

Full CRUD for bands and artists: UUID + slug URLs, dynamic link
editor, band-artist associations with roles, per-edit revision
snapshots (message + IP). Add README and CLAUDE.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/band-new.tsx | 219 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 219 insertions(+)
 create mode 100644 app/routes/band-new.tsx

(limited to 'app/routes/band-new.tsx')

diff --git a/app/routes/band-new.tsx b/app/routes/band-new.tsx
new file mode 100644
index 0000000..b0325a3
--- /dev/null
+++ b/app/routes/band-new.tsx
@@ -0,0 +1,219 @@
+import { useState } from "react";
+import { Form, Link, redirect, useActionData, useLoaderData } from "react-router";
+import type { ActionFunctionArgs } from "react-router";
+import { createBand, getIpAddress, listArtists } from "~/lib/db.server";
+
+export function loader() {
+  return { artists: listArtists() };
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+  const fd = await request.formData();
+  const name = (fd.get("name") as string).trim();
+  const slug = (fd.get("slug") as string).trim();
+  const area = (fd.get("area") as string).trim() || null;
+  const message = (fd.get("message") as string).trim();
+  const links: { label: string; url: string }[] = JSON.parse(
+    (fd.get("links") as string) || "[]"
+  );
+  const artists: { id: string; role: string | null }[] = JSON.parse(
+    (fd.get("artists") as string) || "[]"
+  );
+
+  const errors: Record<string, string> = {};
+  if (!name) errors.name = "必須です";
+  if (!slug) errors.slug = "必須です";
+  if (!message) errors.message = "必須です";
+  if (Object.keys(errors).length > 0) return { errors };
+
+  const id = crypto.randomUUID();
+  try {
+    createBand({ id, slug, name, area, links, artists, message, ip_address: getIpAddress(request) });
+  } catch (e) {
+    if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) {
+      return { errors: { slug: "このslugは既に使用されています" } };
+    }
+    throw e;
+  }
+  return redirect(`/bands/of/${id}`);
+}
+
+function toSlug(s: string) {
+  return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+export default function BandNew() {
+  const { artists } = useLoaderData<typeof loader>();
+  const actionData = useActionData<typeof action>();
+  const errors = actionData?.errors ?? {};
+
+  const [name, setName] = useState("");
+  const [slug, setSlug] = useState("");
+  const [slugManual, setSlugManual] = useState(false);
+  const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
+  const [picked, setPicked] = useState<{ id: string; name: string; role: string }[]>([]);
+
+  const available = artists.filter((a) => !picked.some((p) => p.id === a.id));
+
+  return (
+    <main className="max-w-3xl mx-auto px-4 py-8">
+      <div className="flex items-center gap-3 mb-6">
+        <Link to="/" className="text-gray-400 hover:text-gray-200 transition-colors">←</Link>
+        <h1 className="text-xl font-semibold">New Band</h1>
+      </div>
+
+      <Form method="post" className="space-y-5">
+        <input type="hidden" name="links" value={JSON.stringify(links)} />
+        <input
+          type="hidden"
+          name="artists"
+          value={JSON.stringify(picked.map((p) => ({ id: p.id, role: p.role || null })))}
+        />
+
+        <div>
+          <label className="block text-sm font-medium text-gray-300 mb-1">
+            バンド名 <span className="text-red-400">*</span>
+          </label>
+          <input
+            name="name"
+            value={name}
+            onChange={(e) => {
+              setName(e.target.value);
+              if (!slugManual) setSlug(toSlug(e.target.value));
+            }}
+            className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+          />
+          {errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
+        </div>
+
+        <div>
+          <label className="block text-sm font-medium text-gray-300 mb-1">
+            Slug <span className="text-red-400">*</span>
+          </label>
+          <input
+            name="slug"
+            value={slug}
+            onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
+            className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm"
+          />
+          {errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
+        </div>
+
+        <div>
+          <label className="block text-sm font-medium text-gray-300 mb-1">活動拠点</label>
+          <input
+            name="area"
+            className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+          />
+        </div>
+
+        <div>
+          <label className="block text-sm font-medium text-gray-300 mb-2">リンク</label>
+          <div className="space-y-2">
+            {links.map((link, i) => (
+              <div key={i} className="flex gap-2">
+                <input
+                  value={link.label}
+                  onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, label: e.target.value } : l))}
+                  placeholder="ラベル (例: X)"
+                  className="w-28 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+                />
+                <input
+                  value={link.url}
+                  onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, url: e.target.value } : l))}
+                  placeholder="https://..."
+                  className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+                />
+                <button
+                  type="button"
+                  onClick={() => setLinks(links.filter((_, idx) => idx !== i))}
+                  className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+                >
+                  ×
+                </button>
+              </div>
+            ))}
+          </div>
+          <button
+            type="button"
+            onClick={() => setLinks([...links, { label: "", url: "" }])}
+            className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors"
+          >
+            + リンクを追加
+          </button>
+        </div>
+
+        <div>
+          <label className="block text-sm font-medium text-gray-300 mb-2">メンバー</label>
+          <div className="space-y-2 mb-2">
+            {picked.map((p, i) => (
+              <div key={p.id} className="flex gap-2 items-center">
+                <span className="text-gray-200 text-sm w-32 truncate">{p.name}</span>
+                <input
+                  value={p.role}
+                  onChange={(e) => setPicked(picked.map((a, idx) => idx === i ? { ...a, role: e.target.value } : a))}
+                  placeholder="パート (例: Guitar)"
+                  className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+                />
+                <button
+                  type="button"
+                  onClick={() => setPicked(picked.filter((_, idx) => idx !== i))}
+                  className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+                >
+                  ×
+                </button>
+              </div>
+            ))}
+          </div>
+          {available.length > 0 ? (
+            <select
+              onChange={(e) => {
+                const a = artists.find((x) => x.id === e.target.value);
+                if (a) { setPicked([...picked, { id: a.id, name: a.name, role: "" }]); e.target.value = ""; }
+              }}
+              defaultValue=""
+              className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-400 focus:outline-none focus:border-blue-500 text-sm"
+            >
+              <option value="">+ アーティストを追加...</option>
+              {available.map((a) => (
+                <option key={a.id} value={a.id}>{a.name}</option>
+              ))}
+            </select>
+          ) : artists.length === 0 ? (
+            <p className="text-gray-500 text-sm">
+              アーティストがいません。{" "}
+              <Link to="/artists/new" className="text-blue-400 hover:text-blue-300">先に作成</Link>
+            </p>
+          ) : null}
+        </div>
+
+        <div>
+          <label className="block text-sm font-medium text-gray-300 mb-1">
+            更新メッセージ <span className="text-red-400">*</span>
+          </label>
+          <input
+            name="message"
+            placeholder="例: 初回登録"
+            className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+          />
+          {errors.message && <p className="text-red-400 text-sm mt-1">{errors.message}</p>}
+        </div>
+
+        <div className="flex gap-3 pt-2">
+          <button
+            type="submit"
+            className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded font-medium transition-colors"
+          >
+            作成
+          </button>
+          <Link
+            to="/"
+            className="bg-gray-800 hover:bg-gray-700 text-gray-300 px-4 py-2 rounded transition-colors"
+          >
+            キャンセル
+          </Link>
+        </div>
+      </Form>
+    </main>
+  );
+}
-- 
cgit v1.2.3

