summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/api-bands.tsx4
-rw-r--r--app/routes/api-import.tsx4
-rw-r--r--app/routes/artist-by-uuid.tsx53
-rw-r--r--app/routes/band-by-uuid.tsx63
-rw-r--r--app/routes/band-edit.tsx229
-rw-r--r--app/routes/band-history.tsx5
-rw-r--r--app/routes/band-new.tsx204
7 files changed, 372 insertions, 190 deletions
diff --git a/app/routes/api-bands.tsx b/app/routes/api-bands.tsx
index 64a9269..68efdeb 100644
--- a/app/routes/api-bands.tsx
+++ b/app/routes/api-bands.tsx
@@ -1,5 +1,5 @@
import type { ActionFunctionArgs } from "react-router";
-import { createBand, getIpAddress, listBands, toSlug } from "~/lib/db.server";
+import { createBand, getIpAddress, listBands, toSlug, type MemberInput } from "~/lib/db.server";
export function loader() {
return Response.json(listBands());
@@ -33,7 +33,7 @@ export async function action({ request }: ActionFunctionArgs) {
description: (body.description as string) || null,
status: (body.status as string) || "active",
links: (body.links as { label: string; url: string }[]) || [],
- artists: (body.artists as { id: string; role: string | null }[]) || [],
+ members: (body.members as MemberInput[]) || [],
message: (body.message as string) || "API import",
ip_address: getIpAddress(request),
});
diff --git a/app/routes/api-import.tsx b/app/routes/api-import.tsx
index e28a7db..ca81fc7 100644
--- a/app/routes/api-import.tsx
+++ b/app/routes/api-import.tsx
@@ -13,8 +13,8 @@ export async function action({ request }: ActionFunctionArgs) {
return Response.json({ error: "Invalid JSON body" }, { status: 400 });
}
- if (!data || data.version !== 1) {
- return Response.json({ error: "Invalid or unsupported export format (expected version 1)" }, { status: 400 });
+ if (!data || data.version !== 3) {
+ return Response.json({ error: "Invalid or unsupported export format (expected version 3)" }, { status: 400 });
}
try {
diff --git a/app/routes/artist-by-uuid.tsx b/app/routes/artist-by-uuid.tsx
index 6eb06a7..a65525b 100644
--- a/app/routes/artist-by-uuid.tsx
+++ b/app/routes/artist-by-uuid.tsx
@@ -1,18 +1,29 @@
import { data, Link, useLoaderData } from "react-router";
import type { LoaderFunctionArgs } from "react-router";
-import { getArtistBands, getArtistById, getArtistLinks, getArtistRevisions } from "~/lib/db.server";
+import { getArtistById, getArtistLinks, getArtistMembers, getArtistRevisions, type ArtistMemberRow } from "~/lib/db.server";
export async function loader({ params }: LoaderFunctionArgs) {
const artist = getArtistById(params.uuid!);
if (!artist) throw data("Not found", { status: 404 });
const links = getArtistLinks(artist.id);
- const bands = getArtistBands(artist.id);
+ const memberships = getArtistMembers(artist.id);
const revisions = getArtistRevisions(artist.id);
- return { artist, links, bands, latest: revisions[0] ?? null };
+ return { artist, links, memberships, latest: revisions[0] ?? null };
+}
+
+function periodLabel(m: ArtistMemberRow): string | null {
+ if (!m.since && !m.until) return null;
+ const from = m.since || "?";
+ const to = m.until || "現在";
+ return `${from} 〜 ${to}`;
}
export default function ArtistDetail() {
- const { artist, links, bands, latest } = useLoaderData<typeof loader>();
+ const { artist, links, memberships, latest } = useLoaderData<typeof loader>();
+
+ // group by band_id
+ const bandIds = [...new Set(memberships.map((m) => m.band_id))];
+
return (
<main>
<div className="detail-header">
@@ -23,16 +34,36 @@ export default function ArtistDetail() {
</div>
</div>
- {bands.length > 0 && (
+ {bandIds.length > 0 && (
<section>
<h2>バンド</h2>
<ul className="member-list">
- {bands.map((b) => (
- <li key={b.band_id}>
- <Link to={`/bands/of/${b.band_id}`}>{b.band_name}</Link>
- {b.role && <span className="muted">{b.role}</span>}
- </li>
- ))}
+ {bandIds.map((bandId) => {
+ const group = memberships.filter((m) => m.band_id === bandId);
+ const first = group[0];
+ return (
+ <li key={bandId}>
+ <div className="member-main">
+ <Link to={`/bands/of/${bandId}`}>{first.band_name}</Link>
+ {first.role && <span className="muted">{first.role}</span>}
+ </div>
+ {group.some((m) => m.since || m.until || m.note) && (
+ <ul className="period-list">
+ {group.map((m) => {
+ const label = periodLabel(m);
+ if (!label && !m.note) return null;
+ return (
+ <li key={m.id} className="period-item">
+ {label && <span className="period-range">{label}</span>}
+ {m.note && <span className="period-note">{m.note}</span>}
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </li>
+ );
+ })}
</ul>
</section>
)}
diff --git a/app/routes/band-by-uuid.tsx b/app/routes/band-by-uuid.tsx
index 99a8b5c..2335e14 100644
--- a/app/routes/band-by-uuid.tsx
+++ b/app/routes/band-by-uuid.tsx
@@ -3,8 +3,9 @@ import type { LoaderFunctionArgs } from "react-router";
import {
getBandById,
getBandLinks,
- getBandArtists,
+ getBandMembers,
getBandRevisions,
+ type BandMemberRow,
} from "~/lib/db.server";
import { LINK_TYPE_LABEL } from "~/lib/constants";
@@ -12,9 +13,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 artists = getBandArtists(band.id);
+ const members = getBandMembers(band.id);
const revisions = getBandRevisions(band.id);
- return { band, links, artists, latest: revisions[0] ?? null };
+ return { band, links, members, latest: revisions[0] ?? null };
}
const STATUS_LABEL: Record<string, string> = {
@@ -23,8 +24,19 @@ const STATUS_LABEL: Record<string, string> = {
disbanded: "解散",
};
+function periodLabel(m: BandMemberRow): string | null {
+ if (!m.since && !m.until) return null;
+ const from = m.since || "?";
+ const to = m.until || "現在";
+ return `${from} 〜 ${to}`;
+}
+
export default function BandDetail() {
- const { band, links, artists, latest } = useLoaderData<typeof loader>();
+ const { band, links, members, latest } = useLoaderData<typeof loader>();
+
+ // group members by artist_id for display
+ const artistIds = [...new Set(members.map((m) => m.artist_id))];
+
return (
<main>
<div className="detail-header">
@@ -44,18 +56,43 @@ export default function BandDetail() {
</div>
</div>
- {artists.length > 0 && (
+ {artistIds.length > 0 && (
<section>
<h2>メンバー</h2>
<ul className="member-list">
- {artists.map((a) => (
- <li key={a.artist_id}>
- <Link to={`/artists/of/${a.artist_id}`}>{a.artist_name}</Link>
- {a.role && a.role.split(", ").filter(Boolean).map((r, i) => (
- <span key={i} className="badge">{r}</span>
- ))}
- </li>
- ))}
+ {artistIds.map((artistId) => {
+ const group = members.filter((m) => m.artist_id === artistId);
+ const first = group[0];
+ return (
+ <li key={artistId}>
+ <div className="member-main">
+ <Link to={`/artists/of/${artistId}`}>{first.artist_name}</Link>
+ {group.flatMap((m) =>
+ m.role ? m.role.split(", ").filter(Boolean).map((r, i) => (
+ <span key={`${m.id}-${i}`} className="badge">{r}</span>
+ )) : []
+ ).filter((_, i, arr) => {
+ // deduplicate role badges across periods (keep unique labels)
+ return true;
+ })}
+ </div>
+ {group.some((m) => m.since || m.until || m.note) && (
+ <ul className="period-list">
+ {group.map((m) => {
+ const label = periodLabel(m);
+ if (!label && !m.note) return null;
+ return (
+ <li key={m.id} className="period-item">
+ {label && <span className="period-range">{label}</span>}
+ {m.note && <span className="period-note">{m.note}</span>}
+ </li>
+ );
+ })}
+ </ul>
+ )}
+ </li>
+ );
+ })}
</ul>
</section>
)}
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<string, string> = {};
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<typeof loader>();
+ const { band, links: initLinks, members: initMembers, allArtists } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
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<PickedArtist[]>(
- initArtists.map((a) => ({
- id: a.artist_id,
- name: a.artist_name,
- roles: a.role ? a.role.split(", ").filter(Boolean) : [],
+ const [entries, setEntries] = useState<MemberEntry[]>(
+ 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<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));
+ 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<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(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 (
<main>
<div className="page-header">
@@ -105,11 +154,7 @@ export default function BandEdit() {
<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>
@@ -171,61 +216,80 @@ export default function BandEdit() {
</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>
);
})}
@@ -233,14 +297,7 @@ export default function BandEdit() {
)}
{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, 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>
diff --git a/app/routes/band-history.tsx b/app/routes/band-history.tsx
index 954fd52..1fa2854 100644
--- a/app/routes/band-history.tsx
+++ b/app/routes/band-history.tsx
@@ -23,8 +23,9 @@ export default function BandHistory() {
) : (
<ol className="rev-list">
{revisions.map((rev, i) => {
- let snap: { name?: string; area?: string; links?: unknown[]; artists?: unknown[] } = {};
+ let snap: { name?: string; area?: string; links?: unknown[]; members?: unknown[]; artists?: unknown[] } = {};
try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ }
+ const memberCount = snap.members?.length ?? snap.artists?.length ?? 0;
return (
<li key={rev.id} className="rev">
<div className="rev-header">
@@ -37,7 +38,7 @@ export default function BandHistory() {
<div className="rev-snap">
<p>名前: {snap.name ?? "—"}</p>
{snap.area && <p>拠点: {snap.area}</p>}
- <p>リンク: {snap.links?.length ?? 0}件 / メンバー: {snap.artists?.length ?? 0}人</p>
+ <p>リンク: {snap.links?.length ?? 0}件 / メンバー: {memberCount}件</p>
</div>
</li>
);
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>