From 11241b10f6f962102421885e1b0580a1b0df22b0 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 9 May 2026 00:56:11 +0900 Subject: Support multiple roles per member with free-text fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/routes/band-by-uuid.tsx | 8 +-- app/routes/band-edit.tsx | 156 ++++++++++++++++++++++++++------------------ app/routes/band-new.tsx | 144 ++++++++++++++++++++++++---------------- 3 files changed, 183 insertions(+), 125 deletions(-) (limited to 'app/routes') diff --git a/app/routes/band-by-uuid.tsx b/app/routes/band-by-uuid.tsx index 94a7d3c..9cb4d33 100644 --- a/app/routes/band-by-uuid.tsx +++ b/app/routes/band-by-uuid.tsx @@ -61,16 +61,16 @@ export default function BandDetail() { 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(); const actionData = useActionData(); @@ -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( + initArtists.map((a) => ({ + id: a.artist_id, + name: a.artist_name, + roles: a.role ? a.role.split(", ").filter(Boolean) : [], + })) + ); + const [pending, setPending] = useState>( + 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 (
- - ← - +

Edit Band

@@ -91,7 +108,7 @@ export default function BandEdit() { ({ id: p.id, role: p.role || null })))} + value={JSON.stringify(picked.map((p) => ({ id: p.id, role: p.roles.join(", ") || null })))} />
@@ -101,10 +118,7 @@ export default function BandEdit() { { - 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 &&

{errors.name}

} @@ -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) => ( - - ))} + {LINK_TYPES.map((t) => )} - +
))} @@ -196,43 +202,77 @@ export default function BandEdit() {
-
- {picked.map((p, i) => ( -
- {p.name} - - -
- ))} -
+ {picked.length > 0 && ( +
+ {picked.map((p) => { + const pend = pending[p.id] ?? DEFAULT_PENDING; + return ( +
+
+ {p.name} + +
+ {p.roles.length > 0 && ( +
+ {p.roles.map((r, ri) => ( + + {r} + + + ))} +
+ )} +
+ + {pend.type === "other" && ( + 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" + /> + )} + +
+
+ ); + })} +
+ )} {available.length > 0 && ( )}
@@ -250,18 +290,8 @@ export default function BandEdit() {
- - - キャンセル - + + キャンセル
diff --git a/app/routes/band-new.tsx b/app/routes/band-new.tsx index 86222a3..a15ce24 100644 --- a/app/routes/band-new.tsx +++ b/app/routes/band-new.tsx @@ -45,6 +45,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 BandNew() { const { artists } = useLoaderData(); const actionData = useActionData(); @@ -54,11 +58,22 @@ export default function BandNew() { const [slug, setSlug] = useState(""); const [slugManual, setSlugManual] = useState(false); const [links, setLinks] = useState<{ label: string; url: string }[]>([]); - const DEFAULT_LINK_TYPE = LINK_TYPES[0].value; - const [picked, setPicked] = useState<{ id: string; name: string; role: string }[]>([]); + const [picked, setPicked] = useState([]); + const [pending, setPending] = useState>({}); const available = artists.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 (
@@ -71,7 +86,7 @@ export default function BandNew() { ({ id: p.id, role: p.role || null })))} + value={JSON.stringify(picked.map((p) => ({ id: p.id, role: p.roles.join(", ") || null })))} />
@@ -81,10 +96,7 @@ export default function BandNew() { { - 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 &&

{errors.name}

} @@ -143,9 +155,7 @@ export default function BandNew() { 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) => ( - - ))} + {LINK_TYPES.map((t) => )} - +
))}
- - ))} - + {picked.length > 0 && ( +
+ {picked.map((p) => { + const pend = pending[p.id] ?? DEFAULT_PENDING; + return ( +
+
+ {p.name} + +
+ {p.roles.length > 0 && ( +
+ {p.roles.map((r, ri) => ( + + {r} + + + ))} +
+ )} +
+ + {pend.type === "other" && ( + 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" + /> + )} + +
+
+ ); + })} +
+ )} {available.length > 0 ? ( ) : artists.length === 0 ? (

@@ -233,18 +271,8 @@ export default function BandNew() {

- - - キャンセル - + + キャンセル
-- cgit v1.2.3