diff options
Diffstat (limited to 'app/routes/band-new.tsx')
| -rw-r--r-- | app/routes/band-new.tsx | 219 |
1 files changed, 219 insertions, 0 deletions
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> + ); +} |
