From 184e6947707ecdf07dfa3a5cbc6e51cf9440e93a Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sun, 10 May 2026 00:21:04 +0900 Subject: Add members table with membership period and note support Replace band_artists + member_periods with a single members table (id, band_id, artist_id, role, since, until, note, order_index). Each row represents one membership period, so rejoining artists get multiple rows. Existing band_artists data is auto-migrated on startup. Export format bumped to version 3. Co-Authored-By: Claude Sonnet 4.6 --- app/routes/band-edit.tsx | 229 +++++++++++++++++++++++++++++------------------ 1 file changed, 143 insertions(+), 86 deletions(-) (limited to 'app/routes/band-edit.tsx') diff --git a/app/routes/band-edit.tsx b/app/routes/band-edit.tsx index 2e29277..ffba70f 100644 --- a/app/routes/band-edit.tsx +++ b/app/routes/band-edit.tsx @@ -2,12 +2,13 @@ import { useState } from "react"; import { data, Form, Link, redirect, useActionData, useLoaderData } from "react-router"; import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; import { - getBandArtists, getBandById, getBandLinks, + getBandMembers, getIpAddress, listArtists, updateBand, + type MemberInput, } from "~/lib/db.server"; import { ARTIST_ROLES, LINK_TYPES } from "~/lib/constants"; @@ -15,9 +16,9 @@ export async function loader({ params }: LoaderFunctionArgs) { const band = getBandById(params.uuid!); if (!band) throw data("Not found", { status: 404 }); const links = getBandLinks(band.id); - const bandArtists = getBandArtists(band.id); + const members = getBandMembers(band.id); const allArtists = listArtists(); - return { band, links, bandArtists, allArtists }; + return { band, links, members, allArtists }; } export async function action({ params, request }: ActionFunctionArgs) { @@ -31,12 +32,8 @@ export async function action({ params, request }: ActionFunctionArgs) { const description = (fd.get("description") as string).trim() || null; const status = (fd.get("status") as string) || "active"; const message = (fd.get("message") as string).trim(); - 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 links: { label: string; url: string }[] = JSON.parse((fd.get("links") as string) || "[]"); + const members: MemberInput[] = JSON.parse((fd.get("members") as string) || "[]"); const errors: Record = {}; if (!name) errors.name = "必須です"; @@ -45,7 +42,7 @@ export async function action({ params, request }: ActionFunctionArgs) { if (Object.keys(errors).length > 0) return { errors }; try { - updateBand(band.id, { slug, name, area, description, status, links, artists, message, ip_address: getIpAddress(request) }); + updateBand(band.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は既に使用されています" } }; @@ -59,12 +56,21 @@ 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 BandEdit() { - const { band, links: initLinks, bandArtists: initArtists, allArtists } = useLoaderData(); + const { band, links: initLinks, members: initMembers, allArtists } = useLoaderData(); const actionData = useActionData(); const errors = actionData?.errors ?? {}; @@ -72,30 +78,73 @@ 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, - roles: a.role ? a.role.split(", ").filter(Boolean) : [], + const [entries, setEntries] = useState( + initMembers.map((m) => ({ + key: crypto.randomUUID(), + artist_id: m.artist_id, + artist_name: m.artist_name, + roles: m.role ? m.role.split(", ").filter(Boolean) : [], + since: m.since, + until: m.until, + note: m.note, + pendingRole: { ...DEFAULT_PENDING }, })) ); - 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)); + const usedArtistIds = new Set(entries.map((e) => e.artist_id)); + const available = allArtists.filter((a) => !usedArtistIds.has(a.id)); + const artistIds = [...new Set(entries.map((e) => e.artist_id))]; + + function addArtist(artistId: string) { + const a = allArtists.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 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(key: string, field: K, value: MemberEntry[K]) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e)); + } - function addRole(artistId: string) { - const pend = pending[artistId] ?? DEFAULT_PENDING; - const role = pend.type === "other" ? pend.custom.trim() : pend.type; + 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 (
@@ -105,11 +154,7 @@ export default function BandEdit() {
- ({ id: p.id, role: p.roles.join(", ") || null })))} - /> +
@@ -171,61 +216,80 @@ export default function BandEdit() {
))}
-
- {picked.length > 0 && ( + {artistIds.length > 0 && (
- {picked.map((p) => { - const pend = pending[p.id] ?? DEFAULT_PENDING; + {artistIds.map((artistId) => { + const group = entries.filter((e) => e.artist_id === artistId); return ( -
+
- {p.name} - + {group[0].artist_name} + +
- {p.roles.length > 0 && ( -
- {p.roles.map((r, ri) => ( - - {r} - - - ))} + {group.map((entry) => ( +
+ {group.length > 1 && ( +
+ +
+ )} + {entry.roles.length > 0 && ( +
+ {entry.roles.map((r, ri) => ( + + {r} + + + ))} +
+ )} +
+ + {entry.pendingRole.type === "other" && ( + updateEntry(entry.key, "pendingRole", { ...entry.pendingRole, custom: e.target.value })} + placeholder="ロール名" + /> + )} + +
+
+ updateEntry(entry.key, "since", e.target.value)} + placeholder="加入 (例: 2020-04)" + /> + + updateEntry(entry.key, "until", e.target.value)} + placeholder="脱退 (空欄=在籍中)" + /> + updateEntry(entry.key, "note", e.target.value)} + placeholder="ノート" + /> +
- )} -
- - {pend.type === "other" && ( - setPending({ ...pending, [p.id]: { ...pend, custom: e.target.value } })} - placeholder="ロール名" - /> - )} - -
+ ))}
); })} @@ -233,14 +297,7 @@ export default function BandEdit() { )} {available.length > 0 && (