diff options
Diffstat (limited to 'app/routes/band-new.tsx')
| -rw-r--r-- | app/routes/band-new.tsx | 204 |
1 files changed, 130 insertions, 74 deletions
diff --git a/app/routes/band-new.tsx b/app/routes/band-new.tsx index 62be1d4..0b7e17f 100644 --- a/app/routes/band-new.tsx +++ b/app/routes/band-new.tsx @@ -1,7 +1,7 @@ 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"; +import { createBand, getIpAddress, listArtists, type MemberInput } from "~/lib/db.server"; import { ARTIST_ROLES, LINK_TYPES } from "~/lib/constants"; export function loader() { @@ -19,9 +19,7 @@ export async function action({ request }: ActionFunctionArgs) { 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 members: MemberInput[] = JSON.parse((fd.get("members") as string) || "[]"); const errors: Record<string, string> = {}; if (!name) errors.name = "必須です"; @@ -31,7 +29,7 @@ export async function action({ request }: ActionFunctionArgs) { const id = crypto.randomUUID(); try { - createBand({ id, slug, name, area, description, status, links, artists, message, ip_address: getIpAddress(request) }); + createBand({ id, slug, name, area, description, status, links, members, message, ip_address: getIpAddress(request) }); } catch (e) { if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) { return { errors: { slug: "このslugは既に使用されています" } }; @@ -45,9 +43,18 @@ 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: "" }; +type MemberEntry = { + key: string; + artist_id: string; + artist_name: string; + roles: string[]; + since: string; + until: string; + note: string; + pendingRole: { type: string; custom: string }; +}; + +const DEFAULT_PENDING = { type: ARTIST_ROLES[0], custom: "" }; export default function BandNew() { const { artists } = useLoaderData<typeof loader>(); @@ -58,22 +65,63 @@ export default function BandNew() { const [slug, setSlug] = useState(""); const [slugManual, setSlugManual] = useState(false); const [links, setLinks] = useState<{ label: string; url: string }[]>([]); - const [picked, setPicked] = useState<PickedArtist[]>([]); - const [pending, setPending] = useState<Record<string, PendingRole>>({}); + const [entries, setEntries] = useState<MemberEntry[]>([]); + + const usedArtistIds = new Set(entries.map((e) => e.artist_id)); + const available = artists.filter((a) => !usedArtistIds.has(a.id)); + + const artistIds = [...new Set(entries.map((e) => e.artist_id))]; - const available = artists.filter((a) => !picked.some((p) => p.id === a.id)); + function addArtist(artistId: string) { + const a = artists.find((x) => x.id === artistId); + if (!a) return; + setEntries((prev) => [ + ...prev, + { key: crypto.randomUUID(), artist_id: a.id, artist_name: a.name, roles: [], since: "", until: "", note: "", pendingRole: { ...DEFAULT_PENDING } }, + ]); + } + + function addPeriod(artistId: string) { + const ref = entries.find((e) => e.artist_id === artistId); + if (!ref) return; + setEntries((prev) => [ + ...prev, + { key: crypto.randomUUID(), artist_id: ref.artist_id, artist_name: ref.artist_name, roles: [], since: "", until: "", note: "", pendingRole: { ...DEFAULT_PENDING } }, + ]); + } - function addRole(artistId: string) { - const pend = pending[artistId] ?? DEFAULT_PENDING; - const role = pend.type === "other" ? pend.custom.trim() : pend.type; + function removeArtist(artistId: string) { + setEntries((prev) => prev.filter((e) => e.artist_id !== artistId)); + } + + function removeEntry(key: string) { + setEntries((prev) => prev.filter((e) => e.key !== key)); + } + + function updateEntry<K extends keyof MemberEntry>(key: string, field: K, value: MemberEntry[K]) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e)); + } + + function addRole(key: string) { + const entry = entries.find((e) => e.key === key); + if (!entry) return; + const role = entry.pendingRole.type === "other" ? entry.pendingRole.custom.trim() : entry.pendingRole.type; if (!role) return; - setPicked((prev) => prev.map((a) => a.id === artistId ? { ...a, roles: [...a.roles, role] } : a)); + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, roles: [...e.roles, role] } : e)); } - function removeRole(artistId: string, idx: number) { - setPicked((prev) => prev.map((a) => a.id === artistId ? { ...a, roles: a.roles.filter((_, i) => i !== idx) } : a)); + function removeRole(key: string, idx: number) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, roles: e.roles.filter((_, i) => i !== idx) } : e)); } + const serialized: MemberInput[] = entries.map((e) => ({ + artist_id: e.artist_id, + role: e.roles.join(", ") || null, + since: e.since, + until: e.until, + note: e.note, + })); + return ( <main> <div className="page-header"> @@ -83,11 +131,7 @@ export default function BandNew() { <Form method="post"> <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.roles.join(", ") || null })))} - /> + <input type="hidden" name="members" value={JSON.stringify(serialized)} /> <div> <label>バンド名 <span className="req">*</span></label> @@ -149,61 +193,80 @@ export default function BandNew() { </div> ))} </div> - <button - type="button" - className="btn-text" - onClick={() => setLinks([...links, { label: LINK_TYPES[0].value, url: "" }])} - > + <button type="button" className="btn-text" onClick={() => setLinks([...links, { label: LINK_TYPES[0].value, url: "" }])}> + リンクを追加 </button> </div> <div> <label>メンバー</label> - {picked.length > 0 && ( + {artistIds.length > 0 && ( <div className="members-form"> - {picked.map((p) => { - const pend = pending[p.id] ?? DEFAULT_PENDING; + {artistIds.map((artistId) => { + const group = entries.filter((e) => e.artist_id === artistId); return ( - <div key={p.id} className="member-card"> + <div key={artistId} className="member-group"> <div className="card-header"> - <span className="card-name">{p.name}</span> - <button - type="button" - className="btn-icon" - onClick={() => setPicked(picked.filter((a) => a.id !== p.id))} - > - 削除 - </button> + <span className="card-name">{group[0].artist_name}</span> + <button type="button" className="btn-text" onClick={() => addPeriod(artistId)}>+ 期間追加</button> + <button type="button" className="btn-icon" onClick={() => removeArtist(artistId)}>削除</button> </div> - {p.roles.length > 0 && ( - <div className="badges"> - {p.roles.map((r, ri) => ( - <span key={ri} className="badge"> - {r} - <button type="button" onClick={() => removeRole(p.id, ri)}>×</button> - </span> - ))} + {group.map((entry) => ( + <div key={entry.key} className="member-card"> + {group.length > 1 && ( + <div style={{ textAlign: "right" }}> + <button type="button" className="btn-icon" onClick={() => removeEntry(entry.key)}>×</button> + </div> + )} + {entry.roles.length > 0 && ( + <div className="badges"> + {entry.roles.map((r, ri) => ( + <span key={ri} className="badge"> + {r} + <button type="button" onClick={() => removeRole(entry.key, ri)}>×</button> + </span> + ))} + </div> + )} + <div className="role-row"> + <select + value={entry.pendingRole.type} + onChange={(e) => updateEntry(entry.key, "pendingRole", { ...entry.pendingRole, type: e.target.value })} + > + {ARTIST_ROLES.map((r) => <option key={r} value={r}>{r}</option>)} + <option value="other">その他...</option> + </select> + {entry.pendingRole.type === "other" && ( + <input + className="custom-input" + value={entry.pendingRole.custom} + onChange={(e) => updateEntry(entry.key, "pendingRole", { ...entry.pendingRole, custom: e.target.value })} + placeholder="ロール名" + /> + )} + <button type="button" className="btn-text" onClick={() => addRole(entry.key)}>+ 追加</button> + </div> + <div className="period-row"> + <input + value={entry.since} + onChange={(e) => updateEntry(entry.key, "since", e.target.value)} + placeholder="加入 (例: 2020-04)" + /> + <span className="period-sep">〜</span> + <input + value={entry.until} + onChange={(e) => updateEntry(entry.key, "until", e.target.value)} + placeholder="脱退 (空欄=在籍中)" + /> + <input + className="period-note" + value={entry.note} + onChange={(e) => updateEntry(entry.key, "note", e.target.value)} + placeholder="ノート" + /> + </div> </div> - )} - <div className="role-row"> - <select - value={pend.type} - onChange={(e) => setPending({ ...pending, [p.id]: { ...pend, type: e.target.value } })} - > - {ARTIST_ROLES.map((r) => <option key={r} value={r}>{r}</option>)} - <option value="other">その他...</option> - </select> - {pend.type === "other" && ( - <input - className="custom-input" - value={pend.custom} - onChange={(e) => setPending({ ...pending, [p.id]: { ...pend, custom: e.target.value } })} - placeholder="ロール名" - /> - )} - <button type="button" className="btn-text" onClick={() => addRole(p.id)}>+ 追加</button> - </div> + ))} </div> ); })} @@ -211,14 +274,7 @@ export default function BandNew() { )} {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, roles: [] }]); - setPending({ ...pending, [a.id]: { ...DEFAULT_PENDING } }); - e.target.value = ""; - } - }} + onChange={(e) => { if (e.target.value) { addArtist(e.target.value); e.target.value = ""; } }} defaultValue="" > <option value="">+ アーティストを追加...</option> |
