From 11241b10f6f962102421885e1b0580a1b0df22b0 Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@mosquit.one>
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 <noreply@anthropic.com>
---
 app/routes/band-edit.tsx | 156 ++++++++++++++++++++++++++++-------------------
 1 file changed, 93 insertions(+), 63 deletions(-)

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

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>
-- 
cgit v1.2.3

