summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/routes/band-by-uuid.tsx8
-rw-r--r--app/routes/band-edit.tsx156
-rw-r--r--app/routes/band-new.tsx144
3 files changed, 183 insertions, 125 deletions
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() {
</h2>
<ul className="space-y-2">
{artists.map((a) => (
- <li key={a.artist_id} className="flex items-center gap-3">
+ <li key={a.artist_id} className="flex items-center gap-3 flex-wrap">
<Link
to={`/artists/of/${a.artist_id}`}
className="text-blue-400 hover:text-blue-300 transition-colors font-medium"
>
{a.artist_name}
</Link>
- {a.role && (
- <span className="text-gray-400 text-sm">{a.role}</span>
- )}
+ {a.role && a.role.split(", ").filter(Boolean).map((r, i) => (
+ <span key={i} className="text-gray-500 text-xs bg-gray-900 px-1.5 py-0.5 rounded">{r}</span>
+ ))}
</li>
))}
</ul>
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>
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<typeof loader>();
const actionData = useActionData<typeof action>();
@@ -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<PickedArtist[]>([]);
+ const [pending, setPending] = useState<Record<string, PendingRole>>({});
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 (
<main className="max-w-3xl mx-auto px-4 py-8">
<div className="flex items-center gap-3 mb-6">
@@ -71,7 +86,7 @@ export default function BandNew() {
<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>
@@ -81,10 +96,7 @@ export default function BandNew() {
<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>}
@@ -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) => (
- <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}
@@ -153,19 +163,13 @@ export default function BandNew() {
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>
<button
type="button"
- onClick={() => setLinks([...links, { label: DEFAULT_LINK_TYPE, url: "" }])}
+ onClick={() => setLinks([...links, { label: LINK_TYPES[0].value, url: "" }])}
className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors"
>
+ リンクを追加
@@ -174,43 +178,77 @@ export default function BandNew() {
<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 = artists.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>
) : artists.length === 0 ? (
<p className="text-gray-500 text-sm">
@@ -233,18 +271,8 @@ export default function BandNew() {
</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>
+ <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>