summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <yyamashita@mosquit.one>2026-05-11 00:06:52 +0900
committeryyamashita <yyamashita@mosquit.one>2026-05-11 00:06:52 +0900
commite9e576abd9d6c6030aa4bb290e869890831488ad (patch)
treeec521f62ddffda13c30f5c964e01b9daa1b52851
parent609dc6a3769d85e1cc4a8f06af58165be86b598c (diff)
Add lists feature (band recommendation lists with history)
New lists, list_entries, list_revisions tables; full CRUD routes under /lists; nav link in root. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--app/app.css14
-rw-r--r--app/lib/db.server.ts150
-rw-r--r--app/root.tsx1
-rw-r--r--app/routes.ts6
-rw-r--r--app/routes/list-by-slug.tsx13
-rw-r--r--app/routes/list-by-uuid.tsx54
-rw-r--r--app/routes/list-edit.tsx137
-rw-r--r--app/routes/list-history.tsx48
-rw-r--r--app/routes/list-index.tsx36
-rw-r--r--app/routes/list-new.tsx133
-rw-r--r--scripts/seed-mosquitone.ts137
11 files changed, 729 insertions, 0 deletions
diff --git a/app/app.css b/app/app.css
index 1129e5a..90dc85f 100644
--- a/app/app.css
+++ b/app/app.css
@@ -154,3 +154,17 @@ form input, form select, form textarea { width: 100%; }
.rev-time { font-size: .75rem; color: #6b7280; margin-top: .25rem; }
.rev-latest { font-size: .75rem; color: #60a5fa; flex-shrink: 0; }
.rev-snap { font-size: .75rem; color: #9ca3af; margin-top: .75rem; display: flex; flex-direction: column; gap: .125rem; }
+
+
+/* ── List entries (detail view) ── */
+
+.entry-list { display: flex; flex-direction: column; gap: .5rem; list-style: none; }
+.entry-list li { padding: .375rem 0; border-bottom: 1px solid #1f2937; }
+.entry-band { font-weight: 500; color: #e5e7eb; }
+.entry-note { font-size: .8rem; color: #6b7280; margin-top: .125rem; }
+
+/* ── Entry form rows (new/edit) ── */
+
+.entry-row { display: flex; gap: .5rem; align-items: center; margin-bottom: .375rem; }
+.entry-row .band-input { flex: 0 0 10rem; }
+.entry-row .note-input { flex: 1; min-width: 0; }
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index ab32126..c4b11b4 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -86,6 +86,31 @@ function initSchema(db: Database.Database) {
);
CREATE INDEX IF NOT EXISTS idx_band_links_band_id ON band_links(band_id);
+
+ CREATE TABLE IF NOT EXISTS lists (
+ id TEXT PRIMARY KEY,
+ slug TEXT UNIQUE NOT NULL,
+ title TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS list_entries (
+ id TEXT PRIMARY KEY,
+ list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
+ band_name TEXT NOT NULL,
+ note TEXT NOT NULL DEFAULT '',
+ order_index INTEGER NOT NULL DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS list_revisions (
+ id TEXT PRIMARY KEY,
+ list_id TEXT NOT NULL REFERENCES lists(id) ON DELETE CASCADE,
+ snapshot TEXT NOT NULL,
+ message TEXT NOT NULL,
+ ip_address TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
`);
// migrations
@@ -115,6 +140,8 @@ function initSchema(db: Database.Database) {
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);
+ CREATE INDEX IF NOT EXISTS idx_list_entries_list_id ON list_entries(list_id);
+ CREATE INDEX IF NOT EXISTS idx_list_revisions_list_id ON list_revisions(list_id);
`);
}
@@ -534,6 +561,129 @@ export function updateArtist(id: string, input: UpdateArtistInput): void {
})();
}
+// ── List queries ──────────────────────────────────────────────────────────────
+
+export interface BandList {
+ id: string;
+ slug: string;
+ title: string;
+ description: string;
+ created_at: string;
+}
+
+export interface ListEntry {
+ id: string;
+ list_id: string;
+ band_name: string;
+ note: string;
+ order_index: number;
+}
+
+export interface ListRevision {
+ id: string;
+ list_id: string;
+ snapshot: string;
+ message: string;
+ ip_address: string;
+ created_at: string;
+}
+
+export interface ListEntryInput {
+ band_name: string;
+ note: string;
+}
+
+export interface CreateListInput {
+ id: string;
+ slug: string;
+ title: string;
+ description: string;
+ entries: ListEntryInput[];
+ message: string;
+ ip_address: string;
+}
+
+export interface UpdateListInput {
+ slug: string;
+ title: string;
+ description: string;
+ entries: ListEntryInput[];
+ message: string;
+ ip_address: string;
+}
+
+export function listBandLists(): BandList[] {
+ return getDb().prepare("SELECT * FROM lists ORDER BY created_at DESC").all() as BandList[];
+}
+
+export function getBandListById(id: string): BandList | null {
+ return getDb().prepare("SELECT * FROM lists WHERE id = ?").get(id) as BandList | null;
+}
+
+export function getBandListBySlug(slug: string): BandList | null {
+ return getDb().prepare("SELECT * FROM lists WHERE slug = ?").get(slug) as BandList | null;
+}
+
+export function getListEntries(listId: string): ListEntry[] {
+ return getDb()
+ .prepare("SELECT * FROM list_entries WHERE list_id = ? ORDER BY order_index")
+ .all(listId) as ListEntry[];
+}
+
+export function getListRevisions(listId: string): ListRevision[] {
+ return getDb()
+ .prepare("SELECT * FROM list_revisions WHERE list_id = ? ORDER BY created_at DESC")
+ .all(listId) as ListRevision[];
+}
+
+export function createBandList(input: CreateListInput): BandList {
+ const db = getDb();
+ return db.transaction(() => {
+ db.prepare("INSERT INTO lists (id, slug, title, description) VALUES (?, ?, ?, ?)").run(
+ input.id, input.slug, input.title, input.description
+ );
+ input.entries.forEach((e, i) => {
+ db.prepare(
+ "INSERT INTO list_entries (id, list_id, band_name, note, order_index) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), input.id, e.band_name, e.note, i);
+ });
+ const list = getBandListById(input.id)!;
+ const entries = getListEntries(input.id);
+ db.prepare(
+ "INSERT INTO list_revisions (id, list_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), input.id, buildListSnapshot(list, entries), input.message, input.ip_address);
+ return list;
+ })() as BandList;
+}
+
+export function updateBandList(id: string, input: UpdateListInput): void {
+ const db = getDb();
+ db.transaction(() => {
+ db.prepare("UPDATE lists SET slug = ?, title = ?, description = ? WHERE id = ?").run(
+ input.slug, input.title, input.description, id
+ );
+ db.prepare("DELETE FROM list_entries WHERE list_id = ?").run(id);
+ input.entries.forEach((e, i) => {
+ db.prepare(
+ "INSERT INTO list_entries (id, list_id, band_name, note, order_index) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), id, e.band_name, e.note, i);
+ });
+ const list = getBandListById(id)!;
+ const entries = getListEntries(id);
+ db.prepare(
+ "INSERT INTO list_revisions (id, list_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), id, buildListSnapshot(list, entries), input.message, input.ip_address);
+ })();
+}
+
+function buildListSnapshot(list: BandList, entries: ListEntry[]): string {
+ return JSON.stringify({
+ title: list.title,
+ description: list.description,
+ entries: entries.map((e) => ({ band_name: e.band_name, note: e.note })),
+ });
+}
+
// ── Export / Import ───────────────────────────────────────────────────────────
export interface DbExport {
diff --git a/app/root.tsx b/app/root.tsx
index 26bfb48..242ffe2 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -51,6 +51,7 @@ export default function App() {
<Link to="/" className="logo">whois.band</Link>
<Link to="/bands/new">+ Band</Link>
<Link to="/artists/new">+ Artist</Link>
+ <Link to="/lists">Lists</Link>
</div>
</nav>
<Outlet />
diff --git a/app/routes.ts b/app/routes.ts
index 1d8d60b..b02a70a 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -16,4 +16,10 @@ export default [
route("/artists/named/:slug", "routes/artist-by-slug.tsx"),
route("/artists/of/:uuid/edit", "routes/artist-edit.tsx"),
route("/artists/of/:uuid/history", "routes/artist-history.tsx"),
+ route("/lists", "routes/list-index.tsx"),
+ route("/lists/new", "routes/list-new.tsx"),
+ route("/lists/of/:uuid", "routes/list-by-uuid.tsx"),
+ route("/lists/named/:slug", "routes/list-by-slug.tsx"),
+ route("/lists/of/:uuid/edit", "routes/list-edit.tsx"),
+ route("/lists/of/:uuid/history", "routes/list-history.tsx"),
] satisfies RouteConfig;
diff --git a/app/routes/list-by-slug.tsx b/app/routes/list-by-slug.tsx
new file mode 100644
index 0000000..902c74e
--- /dev/null
+++ b/app/routes/list-by-slug.tsx
@@ -0,0 +1,13 @@
+import { data, redirect } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getBandListBySlug } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const list = getBandListBySlug(params.slug!);
+ if (!list) throw data("Not found", { status: 404 });
+ return redirect(`/lists/of/${list.id}`);
+}
+
+export default function ListBySlug() {
+ return null;
+}
diff --git a/app/routes/list-by-uuid.tsx b/app/routes/list-by-uuid.tsx
new file mode 100644
index 0000000..f8c7380
--- /dev/null
+++ b/app/routes/list-by-uuid.tsx
@@ -0,0 +1,54 @@
+import { data, Link, useLoaderData } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getBandListById, getListEntries, getListRevisions } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const list = getBandListById(params.uuid!);
+ if (!list) throw data("Not found", { status: 404 });
+ const entries = getListEntries(list.id);
+ const revisions = getListRevisions(list.id);
+ return { list, entries, latest: revisions[0] ?? null };
+}
+
+export default function ListDetail() {
+ const { list, entries, latest } = useLoaderData<typeof loader>();
+
+ return (
+ <main>
+ <div className="detail-header">
+ <div className="detail-info">
+ <h1>{list.title}</h1>
+ {list.description && <p className="detail-desc">{list.description}</p>}
+ </div>
+ <div className="detail-actions">
+ <Link to={`/lists/of/${list.id}/history`} className="history">履歴</Link>
+ <Link to={`/lists/of/${list.id}/edit`} className="edit">編集</Link>
+ </div>
+ </div>
+
+ {entries.length === 0 ? (
+ <p className="muted">エントリがありません。</p>
+ ) : (
+ <ul className="entry-list">
+ {entries.map((entry) => (
+ <li key={entry.id}>
+ <div className="entry-band">{entry.band_name}</div>
+ {entry.note && <div className="entry-note">{entry.note}</div>}
+ </li>
+ ))}
+ </ul>
+ )}
+
+ <hr />
+ <div className="meta">
+ <p>/lists/of/{list.id}</p>
+ <p>
+ <Link to={`/lists/named/${list.slug}`}>/lists/named/{list.slug}</Link>
+ </p>
+ {latest && (
+ <p className="updated">最終更新: {latest.created_at} — {latest.message}</p>
+ )}
+ </div>
+ </main>
+ );
+}
diff --git a/app/routes/list-edit.tsx b/app/routes/list-edit.tsx
new file mode 100644
index 0000000..5d47737
--- /dev/null
+++ b/app/routes/list-edit.tsx
@@ -0,0 +1,137 @@
+import { useState } from "react";
+import { data, Form, Link, redirect, useActionData, useLoaderData } from "react-router";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
+import {
+ getBandListById,
+ getListEntries,
+ getIpAddress,
+ updateBandList,
+ type ListEntryInput,
+} from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const list = getBandListById(params.uuid!);
+ if (!list) throw data("Not found", { status: 404 });
+ const entries = getListEntries(list.id);
+ return { list, entries };
+}
+
+export async function action({ params, request }: ActionFunctionArgs) {
+ const list = getBandListById(params.uuid!);
+ if (!list) throw data("Not found", { status: 404 });
+
+ const fd = await request.formData();
+ const title = (fd.get("title") as string).trim();
+ const slug = (fd.get("slug") as string).trim();
+ const description = (fd.get("description") as string).trim();
+ const message = (fd.get("message") as string).trim();
+ const entries: ListEntryInput[] = JSON.parse((fd.get("entries") as string) || "[]");
+
+ const errors: Record<string, string> = {};
+ if (!title) errors.title = "必須です";
+ if (!slug) errors.slug = "必須です";
+ if (!message) errors.message = "必須です";
+ if (Object.keys(errors).length > 0) return { errors };
+
+ try {
+ updateBandList(list.id, { slug, title, description, entries, message, ip_address: getIpAddress(request) });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: lists.slug")) {
+ return { errors: { slug: "このslugは既に使用されています" } };
+ }
+ throw e;
+ }
+ return redirect(`/lists/of/${list.id}`);
+}
+
+type EntryRow = { key: string; band_name: string; note: string };
+
+export default function ListEdit() {
+ const { list, entries: initEntries } = useLoaderData<typeof loader>();
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [title, setTitle] = useState(list.title);
+ const [slug, setSlug] = useState(list.slug);
+ const [description, setDescription] = useState(list.description);
+ const [entries, setEntries] = useState<EntryRow[]>(
+ initEntries.map((e) => ({ key: crypto.randomUUID(), band_name: e.band_name, note: e.note }))
+ );
+
+ function addEntry() {
+ setEntries((prev) => [...prev, { key: crypto.randomUUID(), band_name: "", note: "" }]);
+ }
+
+ function removeEntry(key: string) {
+ setEntries((prev) => prev.filter((e) => e.key !== key));
+ }
+
+ function updateEntry(key: string, field: "band_name" | "note", value: string) {
+ setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e));
+ }
+
+ return (
+ <main>
+ <div className="page-header">
+ <Link to={`/lists/of/${list.id}`} className="back">←</Link>
+ <h1>{list.title} — 編集</h1>
+ </div>
+
+ <Form method="post">
+ <input type="hidden" name="entries" value={JSON.stringify(entries.map(({ band_name, note }) => ({ band_name, note })))} />
+
+ <div>
+ <label>タイトル <span className="req">*</span></label>
+ <input name="title" value={title} onChange={(e) => setTitle(e.target.value)} />
+ {errors.title && <p className="error">{errors.title}</p>}
+ </div>
+
+ <div>
+ <label>Slug <span className="req">*</span></label>
+ <input name="slug" value={slug} onChange={(e) => setSlug(e.target.value)} className="mono" />
+ {errors.slug && <p className="error">{errors.slug}</p>}
+ </div>
+
+ <div>
+ <label>説明</label>
+ <input name="description" value={description} onChange={(e) => setDescription(e.target.value)} />
+ </div>
+
+ <div>
+ <label>エントリ</label>
+ <div>
+ {entries.map((entry) => (
+ <div key={entry.key} className="entry-row">
+ <input
+ className="band-input"
+ value={entry.band_name}
+ onChange={(e) => updateEntry(entry.key, "band_name", e.target.value)}
+ placeholder="バンド名"
+ />
+ <input
+ className="note-input"
+ value={entry.note}
+ onChange={(e) => updateEntry(entry.key, "note", e.target.value)}
+ placeholder="メモ"
+ />
+ <button type="button" className="btn-icon" onClick={() => removeEntry(entry.key)}>×</button>
+ </div>
+ ))}
+ </div>
+ <button type="button" className="btn-text" onClick={addEntry}>+ エントリを追加</button>
+ </div>
+
+ <div>
+ <label>更新メッセージ <span className="req">*</span></label>
+ <input name="message" placeholder="例: エントリ追加" />
+ {errors.message && <p className="error">{errors.message}</p>}
+ </div>
+
+ <div className="actions">
+ <button type="submit">保存</button>
+ <Link to={`/lists/of/${list.id}`} className="btn">キャンセル</Link>
+ </div>
+ </Form>
+ </main>
+ );
+}
diff --git a/app/routes/list-history.tsx b/app/routes/list-history.tsx
new file mode 100644
index 0000000..c47614b
--- /dev/null
+++ b/app/routes/list-history.tsx
@@ -0,0 +1,48 @@
+import { data, Link, useLoaderData } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getBandListById, getListRevisions } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const list = getBandListById(params.uuid!);
+ if (!list) throw data("Not found", { status: 404 });
+ const revisions = getListRevisions(list.id);
+ return { list, revisions };
+}
+
+export default function ListHistory() {
+ const { list, revisions } = useLoaderData<typeof loader>();
+ return (
+ <main>
+ <div className="page-header">
+ <Link to={`/lists/of/${list.id}`} className="back">←</Link>
+ <h1>{list.title} — 編集履歴</h1>
+ </div>
+
+ {revisions.length === 0 ? (
+ <p className="muted">履歴がありません。</p>
+ ) : (
+ <ol className="rev-list">
+ {revisions.map((rev, i) => {
+ let snap: { title?: string; entries?: unknown[] } = {};
+ try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ }
+ return (
+ <li key={rev.id} className="rev">
+ <div className="rev-header">
+ <div className="rev-main">
+ <p className="rev-message">{rev.message}</p>
+ <p className="rev-time">{rev.created_at} · {rev.ip_address}</p>
+ </div>
+ {i === 0 && <span className="rev-latest">最新</span>}
+ </div>
+ <div className="rev-snap">
+ <p>タイトル: {snap.title ?? "—"}</p>
+ <p>エントリ数: {snap.entries?.length ?? 0}件</p>
+ </div>
+ </li>
+ );
+ })}
+ </ol>
+ )}
+ </main>
+ );
+}
diff --git a/app/routes/list-index.tsx b/app/routes/list-index.tsx
new file mode 100644
index 0000000..7995068
--- /dev/null
+++ b/app/routes/list-index.tsx
@@ -0,0 +1,36 @@
+import { Link, useLoaderData } from "react-router";
+import { listBandLists } from "~/lib/db.server";
+
+export function loader() {
+ return { lists: listBandLists() };
+}
+
+export default function ListIndex() {
+ const { lists } = useLoaderData<typeof loader>();
+ return (
+ <main>
+ <div className="page-header">
+ <h1>Lists</h1>
+ <Link to="/lists/new">+ List</Link>
+ </div>
+
+ {lists.length === 0 ? (
+ <p className="muted">
+ リストがまだありません。{" "}
+ <Link to="/lists/new">作成する</Link>
+ </p>
+ ) : (
+ <ul className="band-list">
+ {lists.map((list) => (
+ <li key={list.id}>
+ <Link to={`/lists/of/${list.id}`}>{list.title}</Link>
+ {list.description && (
+ <span className="muted" style={{ fontSize: ".75rem" }}>{list.description}</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ )}
+ </main>
+ );
+}
diff --git a/app/routes/list-new.tsx b/app/routes/list-new.tsx
new file mode 100644
index 0000000..472f0d8
--- /dev/null
+++ b/app/routes/list-new.tsx
@@ -0,0 +1,133 @@
+import { useState } from "react";
+import { Form, Link, redirect, useActionData } from "react-router";
+import type { ActionFunctionArgs } from "react-router";
+import { createBandList, getIpAddress, type ListEntryInput } from "~/lib/db.server";
+
+export async function action({ request }: ActionFunctionArgs) {
+ const fd = await request.formData();
+ const title = (fd.get("title") as string).trim();
+ const slug = (fd.get("slug") as string).trim();
+ const description = (fd.get("description") as string).trim();
+ const message = (fd.get("message") as string).trim();
+ const entries: ListEntryInput[] = JSON.parse((fd.get("entries") as string) || "[]");
+
+ const errors: Record<string, string> = {};
+ if (!title) errors.title = "必須です";
+ if (!slug) errors.slug = "必須です";
+ if (!message) errors.message = "必須です";
+ if (Object.keys(errors).length > 0) return { errors };
+
+ const id = crypto.randomUUID();
+ try {
+ createBandList({ id, slug, title, description, entries, message, ip_address: getIpAddress(request) });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: lists.slug")) {
+ return { errors: { slug: "このslugは既に使用されています" } };
+ }
+ throw e;
+ }
+ return redirect(`/lists/of/${id}`);
+}
+
+function toSlug(s: string) {
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+type EntryRow = { key: string; band_name: string; note: string };
+
+export default function ListNew() {
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [title, setTitle] = useState("");
+ const [slug, setSlug] = useState("");
+ const [slugManual, setSlugManual] = useState(false);
+ const [description, setDescription] = useState("");
+ const [entries, setEntries] = useState<EntryRow[]>([]);
+
+ function addEntry() {
+ setEntries((prev) => [...prev, { key: crypto.randomUUID(), band_name: "", note: "" }]);
+ }
+
+ function removeEntry(key: string) {
+ setEntries((prev) => prev.filter((e) => e.key !== key));
+ }
+
+ function updateEntry(key: string, field: "band_name" | "note", value: string) {
+ setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e));
+ }
+
+ return (
+ <main>
+ <div className="page-header">
+ <Link to="/lists" className="back">←</Link>
+ <h1>New List</h1>
+ </div>
+
+ <Form method="post">
+ <input type="hidden" name="entries" value={JSON.stringify(entries.map(({ band_name, note }) => ({ band_name, note })))} />
+
+ <div>
+ <label>タイトル <span className="req">*</span></label>
+ <input
+ name="title"
+ value={title}
+ onChange={(e) => { setTitle(e.target.value); if (!slugManual) setSlug(toSlug(e.target.value)); }}
+ />
+ {errors.title && <p className="error">{errors.title}</p>}
+ </div>
+
+ <div>
+ <label>Slug <span className="req">*</span></label>
+ <input
+ name="slug"
+ value={slug}
+ onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
+ className="mono"
+ />
+ {errors.slug && <p className="error">{errors.slug}</p>}
+ </div>
+
+ <div>
+ <label>説明</label>
+ <input name="description" value={description} onChange={(e) => setDescription(e.target.value)} />
+ </div>
+
+ <div>
+ <label>エントリ</label>
+ <div>
+ {entries.map((entry) => (
+ <div key={entry.key} className="entry-row">
+ <input
+ className="band-input"
+ value={entry.band_name}
+ onChange={(e) => updateEntry(entry.key, "band_name", e.target.value)}
+ placeholder="バンド名"
+ />
+ <input
+ className="note-input"
+ value={entry.note}
+ onChange={(e) => updateEntry(entry.key, "note", e.target.value)}
+ placeholder="メモ"
+ />
+ <button type="button" className="btn-icon" onClick={() => removeEntry(entry.key)}>×</button>
+ </div>
+ ))}
+ </div>
+ <button type="button" className="btn-text" onClick={addEntry}>+ エントリを追加</button>
+ </div>
+
+ <div>
+ <label>更新メッセージ <span className="req">*</span></label>
+ <input name="message" placeholder="例: 初回作成" />
+ {errors.message && <p className="error">{errors.message}</p>}
+ </div>
+
+ <div className="actions">
+ <button type="submit">作成</button>
+ <Link to="/lists" className="btn">キャンセル</Link>
+ </div>
+ </Form>
+ </main>
+ );
+}
diff --git a/scripts/seed-mosquitone.ts b/scripts/seed-mosquitone.ts
new file mode 100644
index 0000000..e3d532d
--- /dev/null
+++ b/scripts/seed-mosquitone.ts
@@ -0,0 +1,137 @@
+#!/usr/bin/env npx tsx
+/**
+ * Seeds a "mosquitone と共演したバンド" list by fetching Gatsby page-data JSON.
+ * Usage: npx tsx scripts/seed-mosquitone.ts
+ * Override DB path: DB_PATH=/path/to/whois.db npx tsx scripts/seed-mosquitone.ts
+ */
+
+import { createBandList, getBandListBySlug, toSlug } from "../app/lib/db.server";
+
+interface LiveEvent {
+ date: string;
+ venueName: string;
+ description: string;
+ title: string;
+ published?: boolean;
+ [key: string]: unknown;
+}
+
+interface PageData {
+ result?: {
+ data?: {
+ amplify?: {
+ listEvents?: {
+ items: LiveEvent[];
+ };
+ };
+ };
+ };
+}
+
+const PAGE_DATA_URL = "https://www.mosquit.one/page-data/live/page-data.json";
+
+async function main() {
+ console.log("Fetching mosquit.one live data...");
+ const res = await fetch(PAGE_DATA_URL);
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
+ const json = await res.json() as PageData;
+
+ const items = json?.result?.data?.amplify?.listEvents?.items ?? [];
+ console.log(`Found ${items.length} events`);
+
+ // band_name → [{ date, venue }]
+ const coPerformers = new Map<string, { date: string; venue: string }[]>();
+
+ for (const event of items) {
+ const rawDesc = event.description ?? "";
+ const date = event.date ?? "";
+ const venue = (event.venueName ?? "").trim();
+
+ // Split on actual newlines or literal \n, then find the performer line
+ // (some events have OPEN/START on the first line and performers on the second)
+ const lines = rawDesc.split(/\n|\\n/);
+ const perfLine = lines.find((l) =>
+ !l.includes("://") && ( // skip URL-containing lines
+ l.includes("//") || (l.split("/").length > 2 && !/^\d/.test(l.trim()))
+ )
+ );
+ if (!perfLine) continue;
+
+ // Remove "出演:" / "出演者" prefix
+ const cleaned = perfLine.replace(/^(出演者?[::]?\s*)/u, "").trim();
+
+ // Must still contain // or " / " after cleanup
+ if (!cleaned.includes("//") && !cleaned.includes(" / ")) continue;
+
+ // Split by " // " or " / "
+ const parts = cleaned.split(/\s*\/\/\s*|\s*\/\s*/);
+
+ for (const raw of parts) {
+ // Remove parenthetical suffixes like "(東京)" "(いわき)"
+ let name = raw.replace(/[\((][^))]*[\))]/gu, "").trim();
+ // Remove any remaining literal \n and opening-paren-without-close
+ name = name.replace(/\\n/g, "").replace(/[\((].*$/, "").trim();
+ // Remove stray closing parentheses
+ name = name.replace(/[\))]/g, "").trim();
+
+ // Validation filters
+ if (!name || name.length < 2) continue;
+ if (/mosquitone/iu.test(name)) continue;
+ if (/^\d/.test(name)) continue; // starts with number (dates etc.)
+ if (/\d{1,2}:\d{2}/.test(name)) continue; // contains time HH:MM
+ if (/^(OPEN|START)/i.test(name)) continue;
+ if (/出演|二日間|開催|開場/.test(name)) continue;
+ if (/\.(com|net|jp|org|co)\b/.test(name)) continue; // domain names
+ if (name.length > 60) continue;
+
+ const appearances = coPerformers.get(name) ?? [];
+ appearances.push({ date, venue });
+ coPerformers.set(name, appearances);
+ }
+ }
+
+ console.log(`Parsed ${coPerformers.size} co-performers`);
+
+ // Build entries sorted by band name
+ const entries = [...coPerformers.entries()]
+ .sort(([a], [b]) => a.localeCompare(b, "ja"))
+ .map(([band_name, appearances]) => {
+ appearances.sort((a, b) => a.date.localeCompare(b.date));
+ const note = appearances
+ .map(({ date, venue }) => {
+ const displayDate = date.replace(/-/g, "/");
+ return venue ? `${displayDate} に ${venue} で共演` : `${displayDate} に共演`;
+ })
+ .join("、");
+ return { band_name, note };
+ });
+
+ const title = "mosquitone と共演したバンド";
+ const slug = toSlug(title);
+
+ // Check for existing list
+ const existing = getBandListBySlug(slug);
+ if (existing) {
+ console.log(`List already exists (slug: "${slug}"), skipping.`);
+ console.log(` url: /lists/of/${existing.id}`);
+ return;
+ }
+
+ const id = crypto.randomUUID();
+ const list = createBandList({
+ id,
+ slug,
+ title,
+ description: "mosquitone の共演バンドを自動収集したリストです。",
+ entries,
+ message: "seed-mosquitone スクリプトによる自動生成",
+ ip_address: "cli",
+ });
+
+ console.log(`Created list: ${list.title}`);
+ console.log(` id: ${list.id}`);
+ console.log(` entries: ${entries.length}件`);
+ console.log(` url: /lists/of/${list.id}`);
+}
+
+main().catch((e) => { console.error(e); process.exit(1); });