summaryrefslogtreecommitdiff
path: root/app/routes/band-edit.tsx
diff options
context:
space:
mode:
authoryyamashita <yyamashita@mosquit.one>2026-05-09 00:27:19 +0900
committeryyamashita <yyamashita@mosquit.one>2026-05-09 00:27:19 +0900
commitb8d24d292d99c8da285092ce923b5e2b546d8f45 (patch)
treec8cde36d7a109dd8eb75b62a6aefd81e80d1f5ee /app/routes/band-edit.tsx
parent859e6d8ed530daac1180c7b03182d9389be084dc (diff)
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>
Diffstat (limited to 'app/routes/band-edit.tsx')
-rw-r--r--app/routes/band-edit.tsx236
1 files changed, 236 insertions, 0 deletions
diff --git a/app/routes/band-edit.tsx b/app/routes/band-edit.tsx
new file mode 100644
index 0000000..70e1d60
--- /dev/null
+++ b/app/routes/band-edit.tsx
@@ -0,0 +1,236 @@
+import { useState } from "react";
+import { data, Form, Link, redirect, useActionData, useLoaderData } from "react-router";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
+import {
+ getBandArtists,
+ getBandById,
+ getBandLinks,
+ getIpAddress,
+ listArtists,
+ updateBand,
+} from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const band = getBandById(params.uuid!);
+ if (!band) throw data("Not found", { status: 404 });
+ const links = getBandLinks(band.id);
+ const bandArtists = getBandArtists(band.id);
+ const allArtists = listArtists();
+ return { band, links, bandArtists, allArtists };
+}
+
+export async function action({ params, request }: ActionFunctionArgs) {
+ const band = getBandById(params.uuid!);
+ if (!band) throw data("Not found", { status: 404 });
+
+ 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 };
+
+ try {
+ updateBand(band.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/${band.id}`);
+}
+
+function toSlug(s: string) {
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+export default function BandEdit() {
+ const { band, links: initLinks, bandArtists: initArtists, allArtists } = useLoaderData<typeof loader>();
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [name, setName] = useState(band.name);
+ const [slug, setSlug] = useState(band.slug);
+ const [slugManual, setSlugManual] = useState(true);
+ const [links, setLinks] = useState(initLinks.map((l) => ({ label: l.label, url: l.url })));
+ const [picked, setPicked] = useState(
+ initArtists.map((a) => ({ id: a.artist_id, name: a.artist_name, role: a.role ?? "" }))
+ );
+
+ const available = allArtists.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={`/bands/of/${band.id}`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ ←
+ </Link>
+ <h1 className="text-xl font-semibold">Edit 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"
+ defaultValue={band.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 = allArtists.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>
+ )}
+ </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={`/bands/of/${band.id}`}
+ className="bg-gray-800 hover:bg-gray-700 text-gray-300 px-4 py-2 rounded transition-colors"
+ >
+ キャンセル
+ </Link>
+ </div>
+ </Form>
+ </main>
+ );
+}