summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/app.css305
-rw-r--r--app/lib/db.server.ts175
-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
9 files changed, 584 insertions, 458 deletions
diff --git a/app/app.css b/app/app.css
index f7c2b1c..1a24300 100644
--- a/app/app.css
+++ b/app/app.css
@@ -1,243 +1,148 @@
-*, *::before, *::after { box-sizing: border-box; }
-
-body {
- margin: 0;
- background: #030712;
- color: #e5e7eb;
- font-family: ui-sans-serif, system-ui, sans-serif;
- line-height: 1.5;
- -webkit-font-smoothing: antialiased;
-}
+/* ── Reset / base ── */
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+html { font-family: system-ui, sans-serif; background: #0f1117; color: #d1d5db; }
a { color: inherit; text-decoration: none; }
-p { margin: 0; }
-h1, h2 { margin: 0; }
-ol, ul { list-style: none; padding: 0; margin: 0; }
-hr { border: none; border-top: 1px solid #1f2937; margin: 1.5rem 0; }
-section { margin-bottom: 1.5rem; }
-pre { background: #111827; border-radius: 6px; padding: 1rem; overflow-x: auto; font-size: .875rem; margin-top: 1rem; }
-
-/* ── Nav ── */
-
-nav { border-bottom: 1px solid #1f2937; }
-nav > div {
- max-width: 48rem;
- margin: 0 auto;
- padding: .75rem 1rem;
- display: flex;
- align-items: center;
- gap: 1.5rem;
+a:hover { text-decoration: underline; }
+
+input, select, textarea, button {
+ font: inherit;
+ background: #1f2937;
+ color: #f3f4f6;
+ border: 1px solid #374151;
+ border-radius: 4px;
+ padding: .375rem .625rem;
}
-nav a { font-size: .875rem; color: #9ca3af; }
-nav a:hover { color: #f9fafb; }
-nav .logo { font-size: 1rem; font-weight: 700; color: #fff; letter-spacing: -.025em; }
+input:focus, select:focus, textarea:focus { outline: 1px solid #6366f1; }
+button { cursor: pointer; }
-/* ── Main ── */
+/* ── Layout ── */
-main {
- max-width: 48rem;
- margin: 0 auto;
- padding: 2rem 1rem;
-}
+.nav { background: #111827; border-bottom: 1px solid #1f2937; padding: .75rem 1.5rem; display: flex; gap: 1.5rem; align-items: center; }
+.nav-brand { font-weight: 700; color: #f9fafb; font-size: 1.1rem; }
+.nav a { font-size: .875rem; color: #9ca3af; }
+.nav a:hover { color: #f3f4f6; text-decoration: none; }
-h1 { font-size: 1.5rem; font-weight: 700; }
-h2 {
- font-size: .6875rem;
- font-weight: 600;
- color: #6b7280;
- text-transform: uppercase;
- letter-spacing: .06em;
- margin-bottom: .75rem;
-}
+main { max-width: 760px; margin: 2rem auto; padding: 0 1.5rem; }
-/* ── Page header (← Title) ── */
+/* ── Page header ── */
-.page-header {
- display: flex;
- align-items: center;
- gap: .75rem;
- margin-bottom: 1.5rem;
-}
-.page-header h1 { font-size: 1.25rem; font-weight: 600; }
-.back { color: #6b7280; }
-.back:hover { color: #e5e7eb; }
+.page-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1.5rem; }
+.page-header h1 { font-size: 1.5rem; font-weight: 700; color: #f9fafb; }
+.back { color: #6b7280; font-size: 1.25rem; text-decoration: none; }
+.back:hover { color: #9ca3af; }
/* ── Detail header ── */
-.detail-header {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 1rem;
- margin-bottom: 1.5rem;
-}
-.detail-info { flex: 1; min-width: 0; }
-.detail-subtitle { font-size: .875rem; color: #9ca3af; display: flex; gap: .75rem; margin-top: .25rem; }
-.detail-desc { font-size: .875rem; color: #d1d5db; line-height: 1.6; margin-top: .75rem; }
-.detail-actions { display: flex; align-items: center; gap: .75rem; flex-shrink: 0; }
-.detail-actions .history { font-size: .875rem; color: #9ca3af; }
-.detail-actions .history:hover { color: #e5e7eb; }
-.detail-actions .edit {
- font-size: .875rem;
- background: #1f2937;
- color: #e5e7eb;
- padding: .375rem .75rem;
- border-radius: 4px;
-}
-.detail-actions .edit:hover { background: #374151; }
+.detail-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; margin-bottom: 2rem; }
+.detail-info h1 { font-size: 1.75rem; font-weight: 700; color: #f9fafb; }
+.detail-subtitle { display: flex; gap: .75rem; color: #6b7280; font-size: .875rem; margin-top: .25rem; }
+.detail-desc { color: #9ca3af; font-size: .875rem; margin-top: .5rem; white-space: pre-wrap; }
+.detail-actions { display: flex; gap: .5rem; flex-shrink: 0; }
-/* ── Home band list ── */
+/* ── Sections ── */
-.band-list li {
- display: flex;
- justify-content: space-between;
- align-items: flex-start;
- gap: 1.5rem;
- padding: 1rem 0;
- border-bottom: 1px solid #0f172a;
-}
-.band-list li:last-child { border-bottom: none; }
-.band-list .name-col { min-width: 0; }
-.band-list .name-col a { font-weight: 500; }
-.band-list .name-col a:hover { color: #fff; }
-.band-list .desc {
- font-size: .875rem;
- color: #6b7280;
- margin-top: .125rem;
- overflow: hidden;
- display: -webkit-box;
- -webkit-box-orient: vertical;
- -webkit-line-clamp: 1;
-}
-.band-list .info { font-size: .875rem; color: #6b7280; flex-shrink: 0; display: flex; gap: 1rem; padding-top: .125rem; }
+section { margin-bottom: 2rem; }
+section h2 { font-size: 1rem; font-weight: 600; color: #9ca3af; text-transform: uppercase; letter-spacing: .05em; margin-bottom: .75rem; }
-/* ── Detail lists ── */
+/* ── Member list (detail view) ── */
.member-list { display: flex; flex-direction: column; gap: .5rem; }
-.member-list li { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
+.member-list li { display: flex; flex-direction: column; gap: .25rem; }
+.member-main { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.member-list a { color: #60a5fa; font-weight: 500; }
.member-list a:hover { color: #93c5fd; }
-.link-list { display: flex; flex-direction: column; gap: .375rem; }
-.link-list a { font-size: .875rem; color: #60a5fa; }
-.link-list a:hover { color: #93c5fd; }
+/* ── Period display (detail view) ── */
-/* ── Badge ── */
-
-.badge {
- display: inline-flex;
- align-items: center;
- gap: .25rem;
- background: #111827;
- color: #9ca3af;
- font-size: .75rem;
- padding: .125rem .5rem;
- border-radius: 3px;
-}
-.badge button { all: unset; cursor: pointer; color: #6b7280; line-height: 1; }
-.badge button:hover { color: #f87171; }
+.period-list { list-style: none; display: flex; flex-direction: column; gap: .125rem; padding-left: .75rem; border-left: 2px solid #374151; margin-top: .125rem; }
+.period-item { display: flex; gap: .5rem; align-items: baseline; font-size: .8rem; }
+.period-range { color: #9ca3af; }
+.period-note { color: #6b7280; }
-/* ── Meta footer ── */
+/* ── Link list ── */
-.meta {
- font-family: ui-monospace, monospace;
- font-size: .75rem;
- color: #374151;
- display: flex;
- flex-direction: column;
- gap: .25rem;
-}
-.meta a:hover { color: #9ca3af; }
-.meta .updated { font-family: ui-sans-serif, system-ui, sans-serif; color: #6b7280; margin-top: .5rem; }
+.link-list { display: flex; flex-direction: column; gap: .375rem; }
+.link-list a { color: #60a5fa; font-size: .875rem; }
+.link-list a:hover { color: #93c5fd; }
-/* ── Forms ── */
+/* ── Band list / home ── */
-form { display: flex; flex-direction: column; gap: 1.25rem; }
-label { display: block; font-size: .875rem; color: #d1d5db; margin-bottom: .25rem; }
-.req { color: #f87171; }
-.muted { font-size: .875rem; color: #6b7280; }
-.muted a { color: #60a5fa; }
-.muted a:hover { color: #93c5fd; }
+.band-list { display: flex; flex-direction: column; gap: .5rem; }
+.band-item { display: flex; align-items: center; gap: .75rem; padding: .75rem; background: #111827; border-radius: 6px; }
+.band-item a { font-weight: 500; color: #f3f4f6; flex: 1; }
+.band-item a:hover { color: #60a5fa; text-decoration: none; }
-input, textarea {
- display: block;
- width: 100%;
- background: #1f2937;
- border: 1px solid #374151;
- border-radius: 4px;
- padding: .5rem .75rem;
- color: #f3f4f6;
- font: inherit;
- font-size: .875rem;
-}
-input[type="hidden"] { display: none; }
-select {
- background: #1f2937;
- border: 1px solid #374151;
- border-radius: 4px;
- padding: .5rem .75rem;
- color: #f3f4f6;
- font: inherit;
- font-size: .875rem;
- width: auto;
-}
-input:focus, textarea:focus, select:focus { outline: none; border-color: #3b82f6; }
-textarea { resize: vertical; }
-input.mono { font-family: ui-monospace, monospace; }
+/* ── Meta ── */
+
+.meta { font-size: .75rem; color: #6b7280; display: flex; flex-direction: column; gap: .25rem; }
+.meta a { color: #6b7280; }
+.meta a:hover { color: #9ca3af; }
+.updated { color: #6b7280; }
+.muted { color: #6b7280; font-size: .875rem; }
/* ── Buttons ── */
-button {
- display: inline-flex;
- align-items: center;
- padding: .5rem 1rem;
- border: none;
- border-radius: 4px;
- font: inherit;
- font-size: .875rem;
- cursor: pointer;
- background: #1f2937;
- color: #d1d5db;
-}
-button:hover { background: #374151; }
-button[type="submit"] { background: #2563eb; color: #fff; font-weight: 500; }
-button[type="submit"]:hover { background: #3b82f6; }
-
-.btn {
- display: inline-flex;
- align-items: center;
- padding: .5rem 1rem;
- border-radius: 4px;
- font-size: .875rem;
- background: #1f2937;
- color: #d1d5db;
-}
-.btn:hover { background: #374151; }
+.btn { display: inline-flex; align-items: center; padding: .375rem .875rem; border-radius: 4px; font-size: .875rem; background: #1f2937; border: 1px solid #374151; color: #d1d5db; text-decoration: none; }
+.btn:hover { background: #374151; text-decoration: none; }
+button[type="submit"], .btn-primary { background: #4f46e5; border-color: #4f46e5; color: #fff; padding: .375rem .875rem; border-radius: 4px; }
+button[type="submit"]:hover, .btn-primary:hover { background: #4338ca; }
+.btn-text { background: none; border: none; color: #60a5fa; font-size: .8rem; padding: .25rem .125rem; }
+.btn-text:hover { color: #93c5fd; }
+.btn-icon { background: none; border: none; color: #6b7280; font-size: .875rem; padding: .25rem .375rem; }
+.btn-icon:hover { color: #f3f4f6; }
+.edit, .history { font-size: .875rem; color: #6b7280; padding: .375rem .75rem; border: 1px solid #374151; border-radius: 4px; }
+.edit:hover, .history:hover { color: #d1d5db; border-color: #6b7280; text-decoration: none; }
+
+/* ── Badges ── */
+
+.badge { display: inline-flex; align-items: center; gap: .25rem; background: #1e3a5f; color: #93c5fd; font-size: .75rem; padding: .125rem .5rem; border-radius: 9999px; }
+.badge button { background: none; border: none; color: #60a5fa; font-size: .7rem; padding: 0; line-height: 1; }
+.badge button:hover { color: #f87171; }
-.btn-text { background: none; padding: 0; color: #60a5fa; font-size: .875rem; }
-.btn-text:hover { background: none; color: #93c5fd; }
+/* ── Form ── */
-.btn-icon { background: none; padding: .125rem .5rem; color: #6b7280; }
-.btn-icon:hover { background: none; color: #f87171; }
+form { display: flex; flex-direction: column; gap: 1rem; }
+form > div { display: flex; flex-direction: column; gap: .375rem; }
+form label { font-size: .875rem; color: #9ca3af; }
+form input, form select, form textarea { width: 100%; }
+.req { color: #f87171; }
+.error { font-size: .75rem; color: #f87171; }
+.mono { font-family: monospace; }
-/* ── Form components ── */
+/* ── Actions row ── */
-.actions { display: flex; gap: .75rem; padding-top: .5rem; }
-.error { color: #f87171; font-size: .875rem; margin-top: .25rem; }
+.actions { display: flex; gap: .75rem; align-items: center; padding-top: .5rem; }
-.links-form { display: flex; flex-direction: column; gap: .5rem; }
+/* ── Links form ── */
+
+.links-form { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .75rem; }
.link-row { display: flex; gap: .5rem; align-items: center; }
.link-row input { width: auto; flex: 1; min-width: 0; }
.link-row .label-input { flex: 0 0 7rem; }
.link-row select { width: 9rem; flex-shrink: 0; }
-.members-form { display: flex; flex-direction: column; gap: .5rem; margin-bottom: .75rem; }
-.member-card { background: #111827; border-radius: 6px; padding: .75rem; }
-.member-card .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: .5rem; }
-.member-card .card-name { font-size: .875rem; font-weight: 500; color: #e5e7eb; }
+/* ── Members form (edit/new) ── */
+
+.members-form { display: flex; flex-direction: column; gap: .75rem; margin-bottom: .75rem; }
+
+.member-group { background: #111827; border-radius: 6px; overflow: hidden; }
+.member-group > .card-header { display: flex; justify-content: space-between; align-items: center; padding: .625rem .75rem; border-bottom: 1px solid #1f2937; }
+
+.member-card { padding: .75rem; border-top: 1px solid #1f2937; }
+.member-card:first-of-type { border-top: none; }
.member-card .badges { display: flex; flex-wrap: wrap; gap: .375rem; margin-bottom: .5rem; }
-.member-card .role-row { display: flex; gap: .5rem; align-items: center; }
+.member-card .role-row { display: flex; gap: .5rem; align-items: center; margin-bottom: .5rem; }
.member-card .role-row .custom-input { width: 8rem; flex: none; }
+.card-name { font-size: .875rem; font-weight: 500; color: #e5e7eb; }
+
+/* ── Period row (edit/new form) ── */
+
+.period-row { display: flex; gap: .5rem; align-items: center; margin-top: .5rem; }
+.period-row input { flex: 1; min-width: 0; font-size: .8rem; }
+.period-row .period-note { flex: 1.5; }
+.period-sep { color: #6b7280; flex-shrink: 0; font-size: .875rem; }
/* ── Revision list ── */
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index e35bba6..cb51028 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -47,6 +47,7 @@ function initSchema(db: Database.Database) {
order_index INTEGER NOT NULL DEFAULT 0
);
+ -- legacy table kept for migration only
CREATE TABLE IF NOT EXISTS band_artists (
band_id TEXT NOT NULL REFERENCES bands(id) ON DELETE CASCADE,
artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
@@ -55,6 +56,17 @@ function initSchema(db: Database.Database) {
PRIMARY KEY (band_id, artist_id)
);
+ CREATE TABLE IF NOT EXISTS members (
+ id TEXT PRIMARY KEY,
+ band_id TEXT NOT NULL REFERENCES bands(id) ON DELETE CASCADE,
+ artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
+ role TEXT,
+ since TEXT NOT NULL DEFAULT '',
+ until TEXT NOT NULL DEFAULT '',
+ note TEXT NOT NULL DEFAULT '',
+ order_index INTEGER NOT NULL DEFAULT 0
+ );
+
CREATE TABLE IF NOT EXISTS band_revisions (
id TEXT PRIMARY KEY,
band_id TEXT NOT NULL REFERENCES bands(id) ON DELETE CASCADE,
@@ -79,16 +91,35 @@ function initSchema(db: Database.Database) {
// migrations
try { db.exec("ALTER TABLE bands ADD COLUMN description TEXT"); } catch { /* already exists */ }
try { db.exec("ALTER TABLE bands ADD COLUMN status TEXT NOT NULL DEFAULT 'active'"); } catch { /* already exists */ }
+ try { db.exec("ALTER TABLE band_artists ADD COLUMN periods TEXT"); } catch { /* already exists */ }
+
+ // one-time data migration: band_artists → members
+ try {
+ const mc = (db.prepare("SELECT COUNT(*) as c FROM members").get() as { c: number }).c;
+ if (mc === 0) {
+ const rows = db.prepare("SELECT * FROM band_artists").all() as {
+ band_id: string; artist_id: string; role: string | null; order_index: number;
+ }[];
+ const insert = db.prepare(
+ "INSERT INTO members (id, band_id, artist_id, role, since, until, note, order_index) VALUES (?, ?, ?, ?, '', '', '', ?)"
+ );
+ for (const r of rows) {
+ insert.run(crypto.randomUUID(), r.band_id, r.artist_id, r.role, r.order_index);
+ }
+ }
+ } catch { /* band_artists might not have data */ }
db.exec(`
CREATE INDEX IF NOT EXISTS idx_artist_links_artist_id ON artist_links(artist_id);
- CREATE INDEX IF NOT EXISTS idx_band_artists_band_id ON band_artists(band_id);
- CREATE INDEX IF NOT EXISTS idx_band_artists_artist_id ON band_artists(artist_id);
+ CREATE INDEX IF NOT EXISTS idx_members_band_id ON members(band_id);
+ CREATE INDEX IF NOT EXISTS idx_members_artist_id ON members(artist_id);
CREATE INDEX IF NOT EXISTS idx_band_revisions_band_id ON band_revisions(band_id);
CREATE INDEX IF NOT EXISTS idx_artist_revisions_artist_id ON artist_revisions(artist_id);
`);
}
+// ── Interfaces ────────────────────────────────────────────────────────────────
+
export interface Band {
id: string;
slug: string;
@@ -122,23 +153,35 @@ export interface ArtistLink {
order_index: number;
}
-export interface BandArtistRow {
+export interface MemberRaw {
+ id: string;
band_id: string;
artist_id: string;
role: string | null;
+ since: string;
+ until: string;
+ note: string;
order_index: number;
+}
+
+export interface BandMemberRow extends MemberRaw {
artist_name: string;
artist_slug: string;
}
-export interface ArtistBandRow {
- band_id: string;
- artist_id: string;
- role: string | null;
+export interface ArtistMemberRow extends MemberRaw {
band_name: string;
band_slug: string;
}
+export interface MemberInput {
+ artist_id: string;
+ role: string | null;
+ since: string;
+ until: string;
+ note: string;
+}
+
export interface BandRevision {
id: string;
band_id: string;
@@ -194,16 +237,16 @@ export function getBandLinks(bandId: string): BandLink[] {
.all(bandId) as BandLink[];
}
-export function getBandArtists(bandId: string): BandArtistRow[] {
+export function getBandMembers(bandId: string): BandMemberRow[] {
return getDb()
.prepare(
- `SELECT ba.*, a.name AS artist_name, a.slug AS artist_slug
- FROM band_artists ba
- JOIN artists a ON a.id = ba.artist_id
- WHERE ba.band_id = ?
- ORDER BY ba.order_index`
+ `SELECT m.*, a.name AS artist_name, a.slug AS artist_slug
+ FROM members m
+ JOIN artists a ON a.id = m.artist_id
+ WHERE m.band_id = ?
+ ORDER BY m.order_index`
)
- .all(bandId) as BandArtistRow[];
+ .all(bandId) as BandMemberRow[];
}
export function getBandRevisions(bandId: string): BandRevision[] {
@@ -220,7 +263,7 @@ export interface CreateBandInput {
description: string | null;
status: string;
links: { label: string; url: string }[];
- artists: { id: string; role: string | null }[];
+ members: MemberInput[];
message: string;
ip_address: string;
}
@@ -236,25 +279,17 @@ export function createBand(input: CreateBandInput): Band {
"INSERT INTO band_links (id, band_id, label, url, order_index) VALUES (?, ?, ?, ?, ?)"
).run(crypto.randomUUID(), input.id, link.label, link.url, i);
});
- input.artists.forEach((artist, i) => {
+ input.members.forEach((m, i) => {
db.prepare(
- "INSERT INTO band_artists (band_id, artist_id, role, order_index) VALUES (?, ?, ?, ?)"
- ).run(input.id, artist.id, artist.role, i);
+ "INSERT INTO members (id, band_id, artist_id, role, since, until, note, order_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), input.id, m.artist_id, m.role, m.since, m.until, m.note, i);
});
const band = getBandById(input.id)!;
const links = getBandLinks(input.id);
- const artists = getBandArtists(input.id);
- const snapshot = JSON.stringify({
- name: band.name,
- area: band.area,
- description: band.description,
- status: band.status,
- links: links.map((l) => ({ label: l.label, url: l.url })),
- artists: artists.map((a) => ({ id: a.artist_id, name: a.artist_name, role: a.role })),
- });
+ const members = getBandMembers(input.id);
db.prepare(
"INSERT INTO band_revisions (id, band_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
- ).run(crypto.randomUUID(), input.id, snapshot, input.message, input.ip_address);
+ ).run(crypto.randomUUID(), input.id, buildSnapshot(band, links, members), input.message, input.ip_address);
return band;
})() as Band;
}
@@ -266,7 +301,7 @@ export interface UpdateBandInput {
description: string | null;
status: string;
links: { label: string; url: string }[];
- artists: { id: string; role: string | null }[];
+ members: MemberInput[];
message: string;
ip_address: string;
}
@@ -283,29 +318,39 @@ export function updateBand(id: string, input: UpdateBandInput): void {
"INSERT INTO band_links (id, band_id, label, url, order_index) VALUES (?, ?, ?, ?, ?)"
).run(crypto.randomUUID(), id, link.label, link.url, i);
});
- db.prepare("DELETE FROM band_artists WHERE band_id = ?").run(id);
- input.artists.forEach((artist, i) => {
+ db.prepare("DELETE FROM members WHERE band_id = ?").run(id);
+ input.members.forEach((m, i) => {
db.prepare(
- "INSERT INTO band_artists (band_id, artist_id, role, order_index) VALUES (?, ?, ?, ?)"
- ).run(id, artist.id, artist.role, i);
+ "INSERT INTO members (id, band_id, artist_id, role, since, until, note, order_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), id, m.artist_id, m.role, m.since, m.until, m.note, i);
});
const band = getBandById(id)!;
const links = getBandLinks(id);
- const artists = getBandArtists(id);
- const snapshot = JSON.stringify({
- name: band.name,
- area: band.area,
- description: band.description,
- status: band.status,
- links: links.map((l) => ({ label: l.label, url: l.url })),
- artists: artists.map((a) => ({ id: a.artist_id, name: a.artist_name, role: a.role })),
- });
+ const members = getBandMembers(id);
db.prepare(
"INSERT INTO band_revisions (id, band_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
- ).run(crypto.randomUUID(), id, snapshot, input.message, input.ip_address);
+ ).run(crypto.randomUUID(), id, buildSnapshot(band, links, members), input.message, input.ip_address);
})();
}
+function buildSnapshot(band: Band, links: BandLink[], members: BandMemberRow[]): string {
+ return JSON.stringify({
+ name: band.name,
+ area: band.area,
+ description: band.description,
+ status: band.status,
+ links: links.map((l) => ({ label: l.label, url: l.url })),
+ members: members.map((m) => ({
+ artist_id: m.artist_id,
+ artist_name: m.artist_name,
+ role: m.role,
+ since: m.since,
+ until: m.until,
+ note: m.note,
+ })),
+ });
+}
+
// ── Artist queries ────────────────────────────────────────────────────────────
export function listArtists(): Artist[] {
@@ -326,16 +371,16 @@ export function getArtistLinks(artistId: string): ArtistLink[] {
.all(artistId) as ArtistLink[];
}
-export function getArtistBands(artistId: string): ArtistBandRow[] {
+export function getArtistMembers(artistId: string): ArtistMemberRow[] {
return getDb()
.prepare(
- `SELECT ba.*, b.name AS band_name, b.slug AS band_slug
- FROM band_artists ba
- JOIN bands b ON b.id = ba.band_id
- WHERE ba.artist_id = ?
- ORDER BY b.name`
+ `SELECT m.*, b.name AS band_name, b.slug AS band_slug
+ FROM members m
+ JOIN bands b ON b.id = m.band_id
+ WHERE m.artist_id = ?
+ ORDER BY b.name, m.order_index`
)
- .all(artistId) as ArtistBandRow[];
+ .all(artistId) as ArtistMemberRow[];
}
export function getArtistRevisions(artistId: string): ArtistRevision[] {
@@ -411,21 +456,14 @@ export function updateArtist(id: string, input: UpdateArtistInput): void {
// ── Export / Import ───────────────────────────────────────────────────────────
-interface BandArtistRaw {
- band_id: string;
- artist_id: string;
- role: string | null;
- order_index: number;
-}
-
export interface DbExport {
- version: 1;
+ version: 3;
exported_at: string;
bands: Band[];
band_links: BandLink[];
artists: Artist[];
artist_links: ArtistLink[];
- band_artists: BandArtistRaw[];
+ members: MemberRaw[];
band_revisions: BandRevision[];
artist_revisions: ArtistRevision[];
}
@@ -433,13 +471,13 @@ export interface DbExport {
export function exportDb(): DbExport {
const db = getDb();
return {
- version: 1,
+ version: 3,
exported_at: new Date().toISOString(),
bands: db.prepare("SELECT * FROM bands").all() as Band[],
band_links: db.prepare("SELECT * FROM band_links ORDER BY band_id, order_index").all() as BandLink[],
artists: db.prepare("SELECT * FROM artists").all() as Artist[],
artist_links: db.prepare("SELECT * FROM artist_links ORDER BY artist_id, order_index").all() as ArtistLink[],
- band_artists: db.prepare("SELECT * FROM band_artists ORDER BY band_id, order_index").all() as BandArtistRaw[],
+ members: db.prepare("SELECT * FROM members ORDER BY band_id, order_index").all() as MemberRaw[],
band_revisions: db.prepare("SELECT * FROM band_revisions ORDER BY created_at").all() as BandRevision[],
artist_revisions: db.prepare("SELECT * FROM artist_revisions ORDER BY created_at").all() as ArtistRevision[],
};
@@ -450,15 +488,16 @@ export interface ImportResult {
artists: number;
band_links: number;
artist_links: number;
- band_artists: number;
+ members: number;
band_revisions: number;
artist_revisions: number;
}
export function importDb(data: DbExport): ImportResult {
- if (data.version !== 1) throw new Error("Unsupported export version");
+ if (data.version !== 3) throw new Error("Unsupported export version");
const db = getDb();
return db.transaction(() => {
+ db.prepare("DELETE FROM members").run();
db.prepare("DELETE FROM band_artists").run();
db.prepare("DELETE FROM band_revisions").run();
db.prepare("DELETE FROM artist_revisions").run();
@@ -495,11 +534,11 @@ export function importDb(data: DbExport): ImportResult {
insertArtistLink.run(l.id, l.artist_id, l.label, l.url, l.order_index);
}
- const insertBandArtist = db.prepare(
- "INSERT INTO band_artists (band_id, artist_id, role, order_index) VALUES (?, ?, ?, ?)"
+ const insertMember = db.prepare(
+ "INSERT INTO members (id, band_id, artist_id, role, since, until, note, order_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
);
- for (const ba of data.band_artists) {
- insertBandArtist.run(ba.band_id, ba.artist_id, ba.role, ba.order_index);
+ for (const m of data.members) {
+ insertMember.run(m.id, m.band_id, m.artist_id, m.role, m.since, m.until, m.note, m.order_index);
}
const insertBandRev = db.prepare(
@@ -521,7 +560,7 @@ export function importDb(data: DbExport): ImportResult {
artists: data.artists.length,
band_links: data.band_links.length,
artist_links: data.artist_links.length,
- band_artists: data.band_artists.length,
+ members: data.members.length,
band_revisions: data.band_revisions.length,
artist_revisions: data.artist_revisions.length,
};
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>