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-new.tsx | 204 ++++++++++++++++++++++++++++++------------------ 1 file changed, 130 insertions(+), 74 deletions(-) (limited to 'app/routes/band-new.tsx') 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 = {}; 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(); @@ -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([]); - const [pending, setPending] = useState>({}); + const [entries, setEntries] = useState([]); + + 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(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 (
@@ -83,11 +131,7 @@ export default function BandNew() {
- ({ id: p.id, role: p.roles.join(", ") || null })))} - /> +
@@ -149,61 +193,80 @@ export default function BandNew() {
))}
-
- {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="ロール名" - /> - )} - -
+ ))}
); })} @@ -211,14 +274,7 @@ export default function BandNew() { )} {available.length > 0 ? (