diff options
| author | yyamashita <yyamashita@mosquit.one> | 2026-05-09 00:56:11 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@mosquit.one> | 2026-05-09 00:56:11 +0900 |
| commit | 11241b10f6f962102421885e1b0580a1b0df22b0 (patch) | |
| tree | d341df87363f8dbcd982e068f5528f4926a1384d /app/routes/band-edit.tsx | |
| parent | b8548d029760ecfa59cafedd23899a91e6120b5f (diff) | |
Support multiple roles per member with free-text fallback
- Role field stores comma-separated values ("Guitar, Vocal")
- Form: tag-based multi-role editor per member; predefined select
+ "その他..." option reveals a free-text input for custom roles
- Band detail: render each role as an inline tag (split on ", ")
- band-edit: initializes roles from existing comma-separated data
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.tsx | 156 |
1 files changed, 93 insertions, 63 deletions
diff --git a/app/routes/band-edit.tsx b/app/routes/band-edit.tsx index 50b8bd4..0a98bd6 100644 --- a/app/routes/band-edit.tsx +++ b/app/routes/band-edit.tsx @@ -59,6 +59,10 @@ function toSlug(s: string) { return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w-ヿ一-鿿--]/g, "").replace(/^-+|-+$/g, ""); } +type PickedArtist = { id: string; name: string; roles: string[] }; +type PendingRole = { type: string; custom: string }; +const DEFAULT_PENDING: PendingRole = { type: ARTIST_ROLES[0], custom: "" }; + export default function BandEdit() { const { band, links: initLinks, bandArtists: initArtists, allArtists } = useLoaderData<typeof loader>(); const actionData = useActionData<typeof action>(); @@ -68,21 +72,34 @@ export default function BandEdit() { 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 [picked, setPicked] = useState<PickedArtist[]>( + initArtists.map((a) => ({ + id: a.artist_id, + name: a.artist_name, + roles: a.role ? a.role.split(", ").filter(Boolean) : [], + })) + ); + const [pending, setPending] = useState<Record<string, PendingRole>>( + Object.fromEntries(initArtists.map((a) => [a.artist_id, { ...DEFAULT_PENDING }])) ); const available = allArtists.filter((a) => !picked.some((p) => p.id === a.id)); + function addRole(artistId: string) { + const pend = pending[artistId] ?? DEFAULT_PENDING; + const role = pend.type === "other" ? pend.custom.trim() : pend.type; + if (!role) return; + setPicked((prev) => prev.map((a) => a.id === artistId ? { ...a, roles: [...a.roles, role] } : a)); + } + + function removeRole(artistId: string, idx: number) { + setPicked((prev) => prev.map((a) => a.id === artistId ? { ...a, roles: a.roles.filter((_, i) => i !== idx) } : a)); + } + 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> + <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> @@ -91,7 +108,7 @@ export default function BandEdit() { <input type="hidden" name="artists" - value={JSON.stringify(picked.map((p) => ({ id: p.id, role: p.role || null })))} + value={JSON.stringify(picked.map((p) => ({ id: p.id, role: p.roles.join(", ") || null })))} /> <div> @@ -101,10 +118,7 @@ export default function BandEdit() { <input name="name" value={name} - onChange={(e) => { - setName(e.target.value); - if (!slugManual) setSlug(toSlug(e.target.value)); - }} + 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>} @@ -165,9 +179,7 @@ export default function BandEdit() { onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, label: e.target.value } : l))} className="w-36 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm" > - {LINK_TYPES.map((t) => ( - <option key={t.value} value={t.value}>{t.label}</option> - ))} + {LINK_TYPES.map((t) => <option key={t.value} value={t.value}>{t.label}</option>)} </select> <input value={link.url} @@ -175,13 +187,7 @@ export default function BandEdit() { 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> + <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> @@ -196,43 +202,77 @@ export default function BandEdit() { <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> - <select - value={p.role} - onChange={(e) => setPicked(picked.map((a, idx) => idx === i ? { ...a, role: e.target.value } : a))} - 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" - > - <option value="">— パートを選択 —</option> - {ARTIST_ROLES.map((r) => ( - <option key={r} value={r}>{r}</option> - ))} - </select> - <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> + {picked.length > 0 && ( + <div className="space-y-2 mb-3"> + {picked.map((p) => { + const pend = pending[p.id] ?? DEFAULT_PENDING; + return ( + <div key={p.id} className="bg-gray-900 rounded-lg p-3"> + <div className="flex items-center justify-between mb-2"> + <span className="text-gray-200 text-sm font-medium">{p.name}</span> + <button + type="button" + onClick={() => setPicked(picked.filter((a) => a.id !== p.id))} + className="text-gray-500 hover:text-red-400 text-xs transition-colors" + > + 削除 + </button> + </div> + {p.roles.length > 0 && ( + <div className="flex flex-wrap gap-1.5 mb-2"> + {p.roles.map((r, ri) => ( + <span key={ri} className="inline-flex items-center gap-1 bg-gray-800 text-gray-300 text-xs px-2 py-0.5 rounded"> + {r} + <button type="button" onClick={() => removeRole(p.id, ri)} className="text-gray-500 hover:text-red-400 leading-none">×</button> + </span> + ))} + </div> + )} + <div className="flex gap-2 items-center"> + <select + value={pend.type} + onChange={(e) => setPending({ ...pending, [p.id]: { ...pend, type: e.target.value } })} + className="bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-gray-300 text-sm focus:outline-none focus:border-blue-500" + > + {ARTIST_ROLES.map((r) => <option key={r} value={r}>{r}</option>)} + <option value="other">その他...</option> + </select> + {pend.type === "other" && ( + <input + value={pend.custom} + onChange={(e) => setPending({ ...pending, [p.id]: { ...pend, custom: e.target.value } })} + placeholder="ロール名" + className="bg-gray-800 border border-gray-700 rounded px-2 py-1.5 text-gray-300 text-sm focus:outline-none focus:border-blue-500 w-32" + /> + )} + <button + type="button" + onClick={() => addRole(p.id)} + className="text-blue-400 hover:text-blue-300 text-sm transition-colors" + > + + 追加 + </button> + </div> + </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 = ""; } + if (a) { + setPicked([...picked, { id: a.id, name: a.name, roles: [] }]); + setPending({ ...pending, [a.id]: { ...DEFAULT_PENDING } }); + 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> - ))} + {available.map((a) => <option key={a.id} value={a.id}>{a.name}</option>)} </select> )} </div> @@ -250,18 +290,8 @@ export default function BandEdit() { </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> + <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> |
