summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authoryyamashita <yyamashita@mosquit.one>2026-05-09 00:27:19 +0900
committeryyamashita <yyamashita@mosquit.one>2026-05-09 00:27:19 +0900
commitb8d24d292d99c8da285092ce923b5e2b546d8f45 (patch)
treec8cde36d7a109dd8eb75b62a6aefd81e80d1f5ee
parent859e6d8ed530daac1180c7b03182d9389be084dc (diff)
Implement band/artist management with version history
Full CRUD for bands and artists: UUID + slug URLs, dynamic link editor, band-artist associations with roles, per-edit revision snapshots (message + IP). Add README and CLAUDE.md. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--CLAUDE.md55
-rw-r--r--README.md52
-rw-r--r--app/lib/db.server.ts388
-rw-r--r--app/root.tsx36
-rw-r--r--app/routes.ts12
-rw-r--r--app/routes/artist-by-slug.tsx13
-rw-r--r--app/routes/artist-by-uuid.tsx100
-rw-r--r--app/routes/artist-edit.tsx165
-rw-r--r--app/routes/artist-history.tsx57
-rw-r--r--app/routes/artist-new.tsx150
-rw-r--r--app/routes/band-by-slug.tsx13
-rw-r--r--app/routes/band-by-uuid.tsx108
-rw-r--r--app/routes/band-edit.tsx236
-rw-r--r--app/routes/band-history.tsx61
-rw-r--r--app/routes/band-new.tsx219
-rw-r--r--app/routes/home.tsx44
16 files changed, 1679 insertions, 30 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..39fde4e
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,55 @@
+# CLAUDE.md
+
+## 概要
+
+バンドとアーティストの情報管理サイト。React Router v7 (SSR) + SQLite + Tailwind CSS v4。
+
+## コマンド
+
+```bash
+npm run dev # 開発サーバー (http://localhost:5173)
+npm run build # プロダクションビルド
+npm run typecheck # react-router typegen + tsc
+```
+
+## 重要ファイル
+
+- `app/lib/db.server.ts` — DB 接続・スキーマ初期化・全 CRUD 関数
+- `app/routes.ts` — ルート定義
+- `app/root.tsx` — ルートレイアウト (nav バー含む)
+- `app/routes/` — 各ページのルートファイル
+
+## DB スキーマ
+
+SQLite (`whois.db`)。テーブル: `bands`, `band_links`, `artists`, `artist_links`, `band_artists`, `band_revisions`, `artist_revisions`。
+
+- バンドとアーティストは N:M 関係 (`band_artists` 中間テーブル)
+- 編集のたびに `*_revisions` テーブルに JSON スナップショット + 更新メッセージ + IP を記録
+
+## ルートファイルの命名
+
+`routes.ts` で明示的に設定しているため、ファイル名はルート構造と無関係。
+
+| ファイル | URL |
+|---|---|
+| `band-by-uuid.tsx` | `/bands/of/:uuid` |
+| `band-by-slug.tsx` | `/bands/named/:slug` (UUID へリダイレクト) |
+| `band-new.tsx` | `/bands/new` |
+| `band-edit.tsx` | `/bands/of/:uuid/edit` |
+| `band-history.tsx` | `/bands/of/:uuid/history` |
+| artist 系も同様 | `/artists/...` |
+
+## コーディング規約
+
+- `loader` / `action` のみ `~/lib/db.server` をインポートする (クライアントバンドルに含めないため)
+- フォームの動的フィールド (リンク・メンバー) は React `useState` で管理し、hidden input に JSON シリアライズして送信
+- slug は自動生成 (バンド名/アーティスト名から) かつ手動上書き可能
+- IP アドレスは `x-forwarded-for` → `x-real-ip` の順で取得 (nginx リバースプロキシ環境)
+
+## デプロイ
+
+```bash
+git push hetzner master
+```
+
+`server-setup.sh` で設定した post-receive フックが自動で `npm ci && npm run build` して PM2 を再起動する。
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..68b2c50
--- /dev/null
+++ b/README.md
@@ -0,0 +1,52 @@
+# whois.band
+
+バンドとアーティストの情報を管理するシンプルなサイト。ログイン不要、誰でも編集可能。
+
+## 機能
+
+- バンド情報の登録・編集(バンド名、活動拠点、リンク、メンバー)
+- アーティスト情報の独立管理(名前、リンク)
+- UUID と slug の2種類の URL でアクセス可能
+- 編集履歴管理(更新メッセージ + IP アドレス + JSON スナップショット)
+
+## URL 構造
+
+| URL | 内容 |
+|---|---|
+| `/` | バンド一覧 |
+| `/bands/of/:uuid` | バンド詳細 (UUID) |
+| `/bands/named/:slug` | バンド詳細 (slug) → UUID URL へリダイレクト |
+| `/bands/of/:uuid/edit` | バンド編集 |
+| `/bands/of/:uuid/history` | バンド編集履歴 |
+| `/bands/new` | バンド新規作成 |
+| `/artists/of/:uuid` | アーティスト詳細 (UUID) |
+| `/artists/named/:slug` | アーティスト詳細 (slug) → UUID URL へリダイレクト |
+| `/artists/of/:uuid/edit` | アーティスト編集 |
+| `/artists/of/:uuid/history` | アーティスト編集履歴 |
+| `/artists/new` | アーティスト新規作成 |
+
+## 技術スタック
+
+- [React Router v7](https://reactrouter.com/) (SSR フレームワーク)
+- [better-sqlite3](https://github.com/WiseLibs/better-sqlite3) (SQLite)
+- [Tailwind CSS v4](https://tailwindcss.com/)
+- TypeScript
+
+## 開発
+
+```bash
+npm install
+npm run dev # 開発サーバー起動 (http://localhost:5173)
+npm run build # プロダクションビルド
+npm run typecheck # 型チェック
+```
+
+DB ファイル (`whois.db`) はプロジェクトルートに自動生成されます。
+
+## デプロイ
+
+```bash
+git push hetzner master
+```
+
+Hetzner サーバー上で `git push` フックにより自動ビルド・再起動。
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
index 6aa9313..f1cacbb 100644
--- a/app/lib/db.server.ts
+++ b/app/lib/db.server.ts
@@ -17,29 +17,377 @@ export function getDb(): Database.Database {
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS bands (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- name_kana TEXT,
- formed_at TEXT,
- area TEXT,
- genre TEXT,
- url TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
- );
-
- CREATE TABLE IF NOT EXISTS members (
id TEXT PRIMARY KEY,
- band_id TEXT NOT NULL REFERENCES bands(id),
+ slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
- name_kana TEXT,
- role TEXT,
- joined_at TEXT,
- left_at TEXT,
- created_at TEXT NOT NULL DEFAULT (datetime('now')),
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
+ area TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
- CREATE INDEX IF NOT EXISTS idx_members_band_id ON members(band_id);
+ CREATE TABLE IF NOT EXISTS band_links (
+ id TEXT PRIMARY KEY,
+ band_id TEXT NOT NULL REFERENCES bands(id) ON DELETE CASCADE,
+ label TEXT NOT NULL,
+ url TEXT NOT NULL,
+ order_index INTEGER NOT NULL DEFAULT 0
+ );
+
+ CREATE TABLE IF NOT EXISTS artists (
+ id TEXT PRIMARY KEY,
+ slug TEXT UNIQUE NOT NULL,
+ name TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+
+ CREATE TABLE IF NOT EXISTS artist_links (
+ id TEXT PRIMARY KEY,
+ artist_id TEXT NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
+ label TEXT NOT NULL,
+ url TEXT NOT NULL,
+ order_index INTEGER NOT NULL DEFAULT 0
+ );
+
+ 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,
+ role TEXT,
+ order_index INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (band_id, artist_id)
+ );
+
+ CREATE TABLE IF NOT EXISTS band_revisions (
+ id TEXT PRIMARY KEY,
+ band_id TEXT NOT NULL REFERENCES bands(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'))
+ );
+
+ CREATE TABLE IF NOT EXISTS artist_revisions (
+ id TEXT PRIMARY KEY,
+ artist_id TEXT NOT NULL REFERENCES artists(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'))
+ );
+
+ CREATE INDEX IF NOT EXISTS idx_band_links_band_id ON band_links(band_id);
+ 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_band_revisions_band_id ON band_revisions(band_id);
+ CREATE INDEX IF NOT EXISTS idx_artist_revisions_artist_id ON artist_revisions(artist_id);
`);
}
+
+export interface Band {
+ id: string;
+ slug: string;
+ name: string;
+ area: string | null;
+ created_at: string;
+}
+
+export interface BandLink {
+ id: string;
+ band_id: string;
+ label: string;
+ url: string;
+ order_index: number;
+}
+
+export interface Artist {
+ id: string;
+ slug: string;
+ name: string;
+ created_at: string;
+}
+
+export interface ArtistLink {
+ id: string;
+ artist_id: string;
+ label: string;
+ url: string;
+ order_index: number;
+}
+
+export interface BandArtistRow {
+ band_id: string;
+ artist_id: string;
+ role: string | null;
+ order_index: number;
+ artist_name: string;
+ artist_slug: string;
+}
+
+export interface ArtistBandRow {
+ band_id: string;
+ artist_id: string;
+ role: string | null;
+ band_name: string;
+ band_slug: string;
+}
+
+export interface BandRevision {
+ id: string;
+ band_id: string;
+ snapshot: string;
+ message: string;
+ ip_address: string;
+ created_at: string;
+}
+
+export interface ArtistRevision {
+ id: string;
+ artist_id: string;
+ snapshot: string;
+ message: string;
+ ip_address: string;
+ created_at: string;
+}
+
+export function getIpAddress(request: Request): string {
+ return (
+ request.headers.get("x-forwarded-for")?.split(",")[0].trim() ??
+ request.headers.get("x-real-ip") ??
+ "unknown"
+ );
+}
+
+export function toSlug(name: string): string {
+ return name
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "")
+ .replace(/^-+|-+$/g, "");
+}
+
+// ── Band queries ──────────────────────────────────────────────────────────────
+
+export function listBands(): Band[] {
+ return getDb().prepare("SELECT * FROM bands ORDER BY name").all() as Band[];
+}
+
+export function getBandById(id: string): Band | null {
+ return getDb().prepare("SELECT * FROM bands WHERE id = ?").get(id) as Band | null;
+}
+
+export function getBandBySlug(slug: string): Band | null {
+ return getDb().prepare("SELECT * FROM bands WHERE slug = ?").get(slug) as Band | null;
+}
+
+export function getBandLinks(bandId: string): BandLink[] {
+ return getDb()
+ .prepare("SELECT * FROM band_links WHERE band_id = ? ORDER BY order_index")
+ .all(bandId) as BandLink[];
+}
+
+export function getBandArtists(bandId: string): BandArtistRow[] {
+ 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`
+ )
+ .all(bandId) as BandArtistRow[];
+}
+
+export function getBandRevisions(bandId: string): BandRevision[] {
+ return getDb()
+ .prepare("SELECT * FROM band_revisions WHERE band_id = ? ORDER BY created_at DESC")
+ .all(bandId) as BandRevision[];
+}
+
+export interface CreateBandInput {
+ id: string;
+ slug: string;
+ name: string;
+ area: string | null;
+ links: { label: string; url: string }[];
+ artists: { id: string; role: string | null }[];
+ message: string;
+ ip_address: string;
+}
+
+export function createBand(input: CreateBandInput): Band {
+ const db = getDb();
+ return db.transaction(() => {
+ db.prepare("INSERT INTO bands (id, slug, name, area) VALUES (?, ?, ?, ?)").run(
+ input.id, input.slug, input.name, input.area
+ );
+ input.links.forEach((link, i) => {
+ db.prepare(
+ "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) => {
+ db.prepare(
+ "INSERT INTO band_artists (band_id, artist_id, role, order_index) VALUES (?, ?, ?, ?)"
+ ).run(input.id, artist.id, artist.role, 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,
+ 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 })),
+ });
+ 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);
+ return band;
+ })() as Band;
+}
+
+export interface UpdateBandInput {
+ slug: string;
+ name: string;
+ area: string | null;
+ links: { label: string; url: string }[];
+ artists: { id: string; role: string | null }[];
+ message: string;
+ ip_address: string;
+}
+
+export function updateBand(id: string, input: UpdateBandInput): void {
+ const db = getDb();
+ db.transaction(() => {
+ db.prepare("UPDATE bands SET slug = ?, name = ?, area = ? WHERE id = ?").run(
+ input.slug, input.name, input.area, id
+ );
+ db.prepare("DELETE FROM band_links WHERE band_id = ?").run(id);
+ input.links.forEach((link, i) => {
+ db.prepare(
+ "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(
+ "INSERT INTO band_artists (band_id, artist_id, role, order_index) VALUES (?, ?, ?, ?)"
+ ).run(id, artist.id, artist.role, i);
+ });
+ const band = getBandById(id)!;
+ const links = getBandLinks(id);
+ const artists = getBandArtists(id);
+ const snapshot = JSON.stringify({
+ name: band.name,
+ area: band.area,
+ 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 })),
+ });
+ db.prepare(
+ "INSERT INTO band_revisions (id, band_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), id, snapshot, input.message, input.ip_address);
+ })();
+}
+
+// ── Artist queries ────────────────────────────────────────────────────────────
+
+export function listArtists(): Artist[] {
+ return getDb().prepare("SELECT * FROM artists ORDER BY name").all() as Artist[];
+}
+
+export function getArtistById(id: string): Artist | null {
+ return getDb().prepare("SELECT * FROM artists WHERE id = ?").get(id) as Artist | null;
+}
+
+export function getArtistBySlug(slug: string): Artist | null {
+ return getDb().prepare("SELECT * FROM artists WHERE slug = ?").get(slug) as Artist | null;
+}
+
+export function getArtistLinks(artistId: string): ArtistLink[] {
+ return getDb()
+ .prepare("SELECT * FROM artist_links WHERE artist_id = ? ORDER BY order_index")
+ .all(artistId) as ArtistLink[];
+}
+
+export function getArtistBands(artistId: string): ArtistBandRow[] {
+ 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`
+ )
+ .all(artistId) as ArtistBandRow[];
+}
+
+export function getArtistRevisions(artistId: string): ArtistRevision[] {
+ return getDb()
+ .prepare("SELECT * FROM artist_revisions WHERE artist_id = ? ORDER BY created_at DESC")
+ .all(artistId) as ArtistRevision[];
+}
+
+export interface CreateArtistInput {
+ id: string;
+ slug: string;
+ name: string;
+ links: { label: string; url: string }[];
+ message: string;
+ ip_address: string;
+}
+
+export function createArtist(input: CreateArtistInput): Artist {
+ const db = getDb();
+ return db.transaction(() => {
+ db.prepare("INSERT INTO artists (id, slug, name) VALUES (?, ?, ?)").run(
+ input.id, input.slug, input.name
+ );
+ input.links.forEach((link, i) => {
+ db.prepare(
+ "INSERT INTO artist_links (id, artist_id, label, url, order_index) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), input.id, link.label, link.url, i);
+ });
+ const artist = getArtistById(input.id)!;
+ const links = getArtistLinks(input.id);
+ const snapshot = JSON.stringify({
+ name: artist.name,
+ links: links.map((l) => ({ label: l.label, url: l.url })),
+ });
+ db.prepare(
+ "INSERT INTO artist_revisions (id, artist_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), input.id, snapshot, input.message, input.ip_address);
+ return artist;
+ })() as Artist;
+}
+
+export interface UpdateArtistInput {
+ slug: string;
+ name: string;
+ links: { label: string; url: string }[];
+ message: string;
+ ip_address: string;
+}
+
+export function updateArtist(id: string, input: UpdateArtistInput): void {
+ const db = getDb();
+ db.transaction(() => {
+ db.prepare("UPDATE artists SET slug = ?, name = ? WHERE id = ?").run(
+ input.slug, input.name, id
+ );
+ db.prepare("DELETE FROM artist_links WHERE artist_id = ?").run(id);
+ input.links.forEach((link, i) => {
+ db.prepare(
+ "INSERT INTO artist_links (id, artist_id, label, url, order_index) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), id, link.label, link.url, i);
+ });
+ const artist = getArtistById(id)!;
+ const links = getArtistLinks(id);
+ const snapshot = JSON.stringify({
+ name: artist.name,
+ links: links.map((l) => ({ label: l.label, url: l.url })),
+ });
+ db.prepare(
+ "INSERT INTO artist_revisions (id, artist_id, snapshot, message, ip_address) VALUES (?, ?, ?, ?, ?)"
+ ).run(crypto.randomUUID(), id, snapshot, input.message, input.ip_address);
+ })();
+}
diff --git a/app/root.tsx b/app/root.tsx
index 2c88ff1..5d66876 100644
--- a/app/root.tsx
+++ b/app/root.tsx
@@ -1,5 +1,6 @@
import {
isRouteErrorResponse,
+ Link,
Links,
Meta,
Outlet,
@@ -25,7 +26,7 @@ export const links: Route.LinksFunction = () => [
export function Layout({ children }: { children: React.ReactNode }) {
return (
- <html lang="en" className="dark">
+ <html lang="ja" className="dark">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -43,7 +44,30 @@ export function Layout({ children }: { children: React.ReactNode }) {
}
export default function App() {
- return <Outlet />;
+ return (
+ <>
+ <nav className="border-b border-gray-800">
+ <div className="max-w-3xl mx-auto px-4 py-3 flex items-center gap-6">
+ <Link to="/" className="font-bold text-white tracking-tight">
+ whois.band
+ </Link>
+ <Link
+ to="/bands/new"
+ className="text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ + Band
+ </Link>
+ <Link
+ to="/artists/new"
+ className="text-sm text-gray-400 hover:text-white transition-colors"
+ >
+ + Artist
+ </Link>
+ </div>
+ </nav>
+ <Outlet />
+ </>
+ );
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
@@ -63,11 +87,11 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
}
return (
- <main className="pt-16 p-4 container mx-auto">
- <h1>{message}</h1>
- <p>{details}</p>
+ <main className="max-w-3xl mx-auto px-4 py-16">
+ <h1 className="text-2xl font-bold">{message}</h1>
+ <p className="mt-2 text-gray-400">{details}</p>
{stack && (
- <pre className="w-full p-4 overflow-x-auto">
+ <pre className="mt-4 w-full p-4 overflow-x-auto bg-gray-900 rounded text-sm">
<code>{stack}</code>
</pre>
)}
diff --git a/app/routes.ts b/app/routes.ts
index 935792d..a4e737f 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -1,5 +1,15 @@
-import { type RouteConfig, index } from "@react-router/dev/routes";
+import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
+ route("/bands/new", "routes/band-new.tsx"),
+ route("/bands/of/:uuid", "routes/band-by-uuid.tsx"),
+ route("/bands/named/:slug", "routes/band-by-slug.tsx"),
+ route("/bands/of/:uuid/edit", "routes/band-edit.tsx"),
+ route("/bands/of/:uuid/history", "routes/band-history.tsx"),
+ route("/artists/new", "routes/artist-new.tsx"),
+ route("/artists/of/:uuid", "routes/artist-by-uuid.tsx"),
+ 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"),
] satisfies RouteConfig;
diff --git a/app/routes/artist-by-slug.tsx b/app/routes/artist-by-slug.tsx
new file mode 100644
index 0000000..5b38df2
--- /dev/null
+++ b/app/routes/artist-by-slug.tsx
@@ -0,0 +1,13 @@
+import { data, redirect } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getArtistBySlug } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const artist = getArtistBySlug(params.slug!);
+ if (!artist) throw data("Not found", { status: 404 });
+ return redirect(`/artists/of/${artist.id}`);
+}
+
+export default function ArtistBySlug() {
+ return null;
+}
diff --git a/app/routes/artist-by-uuid.tsx b/app/routes/artist-by-uuid.tsx
new file mode 100644
index 0000000..9b8a4b1
--- /dev/null
+++ b/app/routes/artist-by-uuid.tsx
@@ -0,0 +1,100 @@
+import { data, Link, useLoaderData } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getArtistBands, getArtistById, getArtistLinks, getArtistRevisions } 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 revisions = getArtistRevisions(artist.id);
+ return { artist, links, bands, latest: revisions[0] ?? null };
+}
+
+export default function ArtistDetail() {
+ const { artist, links, bands, latest } = useLoaderData<typeof loader>();
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-start justify-between mb-6">
+ <h1 className="text-2xl font-bold">{artist.name}</h1>
+ <div className="flex items-center gap-3 text-sm shrink-0 ml-4">
+ <Link
+ to={`/artists/of/${artist.id}/history`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ 履歴
+ </Link>
+ <Link
+ to={`/artists/of/${artist.id}/edit`}
+ className="bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1.5 rounded transition-colors"
+ >
+ 編集
+ </Link>
+ </div>
+ </div>
+
+ {bands.length > 0 && (
+ <section className="mb-6">
+ <h2 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
+ バンド
+ </h2>
+ <ul className="space-y-2">
+ {bands.map((b) => (
+ <li key={b.band_id} className="flex items-center gap-3">
+ <Link
+ to={`/bands/of/${b.band_id}`}
+ className="text-blue-400 hover:text-blue-300 transition-colors font-medium"
+ >
+ {b.band_name}
+ </Link>
+ {b.role && (
+ <span className="text-gray-400 text-sm">{b.role}</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+
+ {links.length > 0 && (
+ <section className="mb-6">
+ <h2 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
+ リンク
+ </h2>
+ <ul className="space-y-1.5">
+ {links.map((l) => (
+ <li key={l.id}>
+ <a
+ href={l.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-blue-400 hover:text-blue-300 transition-colors text-sm"
+ >
+ {l.label}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+
+ <hr className="border-gray-800 my-6" />
+ <div className="text-xs text-gray-600 space-y-1 font-mono">
+ <p>/artists/of/{artist.id}</p>
+ <p>
+ <Link
+ to={`/artists/named/${artist.slug}`}
+ className="hover:text-gray-400 transition-colors"
+ >
+ /artists/named/{artist.slug}
+ </Link>
+ </p>
+ {latest && (
+ <p className="font-sans text-gray-500 mt-2">
+ 最終更新: {latest.created_at} — {latest.message}
+ </p>
+ )}
+ </div>
+ </main>
+ );
+}
diff --git a/app/routes/artist-edit.tsx b/app/routes/artist-edit.tsx
new file mode 100644
index 0000000..f2e5c18
--- /dev/null
+++ b/app/routes/artist-edit.tsx
@@ -0,0 +1,165 @@
+import { useState } from "react";
+import { data, Form, Link, redirect, useActionData, useLoaderData } from "react-router";
+import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
+import { getArtistById, getArtistLinks, getIpAddress, updateArtist } 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);
+ return { artist, links };
+}
+
+export async function action({ params, request }: ActionFunctionArgs) {
+ const artist = getArtistById(params.uuid!);
+ if (!artist) throw data("Not found", { status: 404 });
+
+ const fd = await request.formData();
+ const name = (fd.get("name") as string).trim();
+ const slug = (fd.get("slug") as string).trim();
+ const message = (fd.get("message") as string).trim();
+ const links: { label: string; url: string }[] = JSON.parse(
+ (fd.get("links") as string) || "[]"
+ );
+
+ const errors: Record<string, string> = {};
+ if (!name) errors.name = "必須です";
+ if (!slug) errors.slug = "必須です";
+ if (!message) errors.message = "必須です";
+ if (Object.keys(errors).length > 0) return { errors };
+
+ try {
+ updateArtist(artist.id, { slug, name, links, message, ip_address: getIpAddress(request) });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: artists.slug")) {
+ return { errors: { slug: "このslugは既に使用されています" } };
+ }
+ throw e;
+ }
+ return redirect(`/artists/of/${artist.id}`);
+}
+
+function toSlug(s: string) {
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+export default function ArtistEdit() {
+ const { artist, links: initLinks } = useLoaderData<typeof loader>();
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [name, setName] = useState(artist.name);
+ const [slug, setSlug] = useState(artist.slug);
+ const [slugManual, setSlugManual] = useState(true);
+ const [links, setLinks] = useState(initLinks.map((l) => ({ label: l.label, url: l.url })));
+
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center gap-3 mb-6">
+ <Link
+ to={`/artists/of/${artist.id}`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ ←
+ </Link>
+ <h1 className="text-xl font-semibold">Edit Artist</h1>
+ </div>
+
+ <Form method="post" className="space-y-5">
+ <input type="hidden" name="links" value={JSON.stringify(links)} />
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ 名前 <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="name"
+ value={name}
+ onChange={(e) => {
+ setName(e.target.value);
+ if (!slugManual) setSlug(toSlug(e.target.value));
+ }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ Slug <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="slug"
+ value={slug}
+ onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm"
+ />
+ {errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">リンク</label>
+ <div className="space-y-2">
+ {links.map((link, i) => (
+ <div key={i} className="flex gap-2">
+ <input
+ value={link.label}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, label: e.target.value } : l))}
+ placeholder="ラベル (例: X)"
+ className="w-28 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <input
+ value={link.url}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, url: e.target.value } : l))}
+ placeholder="https://..."
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <button
+ type="button"
+ onClick={() => setLinks(links.filter((_, idx) => idx !== i))}
+ className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ <button
+ type="button"
+ onClick={() => setLinks([...links, { label: "", url: "" }])}
+ className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors"
+ >
+ + リンクを追加
+ </button>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ 更新メッセージ <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="message"
+ placeholder="例: SNSリンク追加"
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.message && <p className="text-red-400 text-sm mt-1">{errors.message}</p>}
+ </div>
+
+ <div className="flex gap-3 pt-2">
+ <button
+ type="submit"
+ className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded font-medium transition-colors"
+ >
+ 保存
+ </button>
+ <Link
+ to={`/artists/of/${artist.id}`}
+ className="bg-gray-800 hover:bg-gray-700 text-gray-300 px-4 py-2 rounded transition-colors"
+ >
+ キャンセル
+ </Link>
+ </div>
+ </Form>
+ </main>
+ );
+}
diff --git a/app/routes/artist-history.tsx b/app/routes/artist-history.tsx
new file mode 100644
index 0000000..c2fb4cb
--- /dev/null
+++ b/app/routes/artist-history.tsx
@@ -0,0 +1,57 @@
+import { data, Link, useLoaderData } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getArtistById, getArtistRevisions } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const artist = getArtistById(params.uuid!);
+ if (!artist) throw data("Not found", { status: 404 });
+ const revisions = getArtistRevisions(artist.id);
+ return { artist, revisions };
+}
+
+export default function ArtistHistory() {
+ const { artist, revisions } = useLoaderData<typeof loader>();
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center gap-3 mb-6">
+ <Link
+ to={`/artists/of/${artist.id}`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ ←
+ </Link>
+ <h1 className="text-xl font-semibold">{artist.name} — 編集履歴</h1>
+ </div>
+
+ {revisions.length === 0 ? (
+ <p className="text-gray-400">履歴がありません。</p>
+ ) : (
+ <ol className="space-y-4">
+ {revisions.map((rev, i) => {
+ let snap: { name?: string; links?: unknown[] } = {};
+ try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ }
+ return (
+ <li key={rev.id} className="bg-gray-900 rounded-lg p-4">
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <p className="font-medium text-gray-100 truncate">{rev.message}</p>
+ <p className="text-xs text-gray-500 mt-1">
+ {rev.created_at} · {rev.ip_address}
+ </p>
+ </div>
+ {i === 0 && (
+ <span className="text-xs text-blue-400 shrink-0">最新</span>
+ )}
+ </div>
+ <div className="mt-3 text-xs text-gray-400 space-y-0.5">
+ <p>名前: {snap.name ?? "—"}</p>
+ <p>リンク: {snap.links?.length ?? 0}件</p>
+ </div>
+ </li>
+ );
+ })}
+ </ol>
+ )}
+ </main>
+ );
+}
diff --git a/app/routes/artist-new.tsx b/app/routes/artist-new.tsx
new file mode 100644
index 0000000..168a7cc
--- /dev/null
+++ b/app/routes/artist-new.tsx
@@ -0,0 +1,150 @@
+import { useState } from "react";
+import { Form, Link, redirect, useActionData } from "react-router";
+import type { ActionFunctionArgs } from "react-router";
+import { createArtist, getIpAddress } from "~/lib/db.server";
+
+export async function action({ request }: ActionFunctionArgs) {
+ const fd = await request.formData();
+ const name = (fd.get("name") as string).trim();
+ const slug = (fd.get("slug") as string).trim();
+ const message = (fd.get("message") as string).trim();
+ const links: { label: string; url: string }[] = JSON.parse(
+ (fd.get("links") as string) || "[]"
+ );
+
+ const errors: Record<string, string> = {};
+ if (!name) errors.name = "必須です";
+ if (!slug) errors.slug = "必須です";
+ if (!message) errors.message = "必須です";
+ if (Object.keys(errors).length > 0) return { errors };
+
+ const id = crypto.randomUUID();
+ try {
+ createArtist({ id, slug, name, links, message, ip_address: getIpAddress(request) });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: artists.slug")) {
+ return { errors: { slug: "このslugは既に使用されています" } };
+ }
+ throw e;
+ }
+ return redirect(`/artists/of/${id}`);
+}
+
+function toSlug(s: string) {
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+export default function ArtistNew() {
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [slugManual, setSlugManual] = useState(false);
+ const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
+
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center gap-3 mb-6">
+ <Link to="/" className="text-gray-400 hover:text-gray-200 transition-colors">←</Link>
+ <h1 className="text-xl font-semibold">New Artist</h1>
+ </div>
+
+ <Form method="post" className="space-y-5">
+ <input type="hidden" name="links" value={JSON.stringify(links)} />
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ 名前 <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="name"
+ value={name}
+ onChange={(e) => {
+ setName(e.target.value);
+ if (!slugManual) setSlug(toSlug(e.target.value));
+ }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ Slug <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="slug"
+ value={slug}
+ onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm"
+ />
+ {errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">リンク</label>
+ <div className="space-y-2">
+ {links.map((link, i) => (
+ <div key={i} className="flex gap-2">
+ <input
+ value={link.label}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, label: e.target.value } : l))}
+ placeholder="ラベル (例: X)"
+ className="w-28 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <input
+ value={link.url}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, url: e.target.value } : l))}
+ placeholder="https://..."
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <button
+ type="button"
+ onClick={() => setLinks(links.filter((_, idx) => idx !== i))}
+ className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ <button
+ type="button"
+ onClick={() => setLinks([...links, { label: "", url: "" }])}
+ className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors"
+ >
+ + リンクを追加
+ </button>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ 更新メッセージ <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="message"
+ placeholder="例: 初回登録"
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.message && <p className="text-red-400 text-sm mt-1">{errors.message}</p>}
+ </div>
+
+ <div className="flex gap-3 pt-2">
+ <button
+ type="submit"
+ className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded font-medium transition-colors"
+ >
+ 作成
+ </button>
+ <Link
+ to="/"
+ className="bg-gray-800 hover:bg-gray-700 text-gray-300 px-4 py-2 rounded transition-colors"
+ >
+ キャンセル
+ </Link>
+ </div>
+ </Form>
+ </main>
+ );
+}
diff --git a/app/routes/band-by-slug.tsx b/app/routes/band-by-slug.tsx
new file mode 100644
index 0000000..9b432bf
--- /dev/null
+++ b/app/routes/band-by-slug.tsx
@@ -0,0 +1,13 @@
+import { data, redirect } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getBandBySlug } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const band = getBandBySlug(params.slug!);
+ if (!band) throw data("Not found", { status: 404 });
+ return redirect(`/bands/of/${band.id}`);
+}
+
+export default function BandBySlug() {
+ return null;
+}
diff --git a/app/routes/band-by-uuid.tsx b/app/routes/band-by-uuid.tsx
new file mode 100644
index 0000000..c55472e
--- /dev/null
+++ b/app/routes/band-by-uuid.tsx
@@ -0,0 +1,108 @@
+import { data, Link, useLoaderData } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import {
+ getBandById,
+ getBandLinks,
+ getBandArtists,
+ getBandRevisions,
+} from "~/lib/db.server";
+
+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 revisions = getBandRevisions(band.id);
+ return { band, links, artists, latest: revisions[0] ?? null };
+}
+
+export default function BandDetail() {
+ const { band, links, artists, latest } = useLoaderData<typeof loader>();
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-start justify-between mb-6">
+ <div>
+ <h1 className="text-2xl font-bold">{band.name}</h1>
+ {band.area && <p className="text-gray-400 mt-1 text-sm">{band.area}</p>}
+ </div>
+ <div className="flex items-center gap-3 text-sm shrink-0 ml-4">
+ <Link
+ to={`/bands/of/${band.id}/history`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ 履歴
+ </Link>
+ <Link
+ to={`/bands/of/${band.id}/edit`}
+ className="bg-gray-800 hover:bg-gray-700 text-gray-200 px-3 py-1.5 rounded transition-colors"
+ >
+ 編集
+ </Link>
+ </div>
+ </div>
+
+ {artists.length > 0 && (
+ <section className="mb-6">
+ <h2 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
+ メンバー
+ </h2>
+ <ul className="space-y-2">
+ {artists.map((a) => (
+ <li key={a.artist_id} className="flex items-center gap-3">
+ <Link
+ to={`/artists/of/${a.artist_id}`}
+ className="text-blue-400 hover:text-blue-300 transition-colors font-medium"
+ >
+ {a.artist_name}
+ </Link>
+ {a.role && (
+ <span className="text-gray-400 text-sm">{a.role}</span>
+ )}
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+
+ {links.length > 0 && (
+ <section className="mb-6">
+ <h2 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-3">
+ リンク
+ </h2>
+ <ul className="space-y-1.5">
+ {links.map((l) => (
+ <li key={l.id}>
+ <a
+ href={l.url}
+ target="_blank"
+ rel="noopener noreferrer"
+ className="text-blue-400 hover:text-blue-300 transition-colors text-sm"
+ >
+ {l.label}
+ </a>
+ </li>
+ ))}
+ </ul>
+ </section>
+ )}
+
+ <hr className="border-gray-800 my-6" />
+ <div className="text-xs text-gray-600 space-y-1 font-mono">
+ <p>/bands/of/{band.id}</p>
+ <p>
+ <Link
+ to={`/bands/named/${band.slug}`}
+ className="hover:text-gray-400 transition-colors"
+ >
+ /bands/named/{band.slug}
+ </Link>
+ </p>
+ {latest && (
+ <p className="font-sans text-gray-500 mt-2">
+ 最終更新: {latest.created_at} — {latest.message}
+ </p>
+ )}
+ </div>
+ </main>
+ );
+}
diff --git a/app/routes/band-edit.tsx b/app/routes/band-edit.tsx
new file mode 100644
index 0000000..70e1d60
--- /dev/null
+++ b/app/routes/band-edit.tsx
@@ -0,0 +1,236 @@
+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,
+ getIpAddress,
+ listArtists,
+ updateBand,
+} from "~/lib/db.server";
+
+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 allArtists = listArtists();
+ return { band, links, bandArtists, allArtists };
+}
+
+export async function action({ params, request }: ActionFunctionArgs) {
+ const band = getBandById(params.uuid!);
+ if (!band) throw data("Not found", { status: 404 });
+
+ const fd = await request.formData();
+ const name = (fd.get("name") as string).trim();
+ const slug = (fd.get("slug") as string).trim();
+ const area = (fd.get("area") as string).trim() || null;
+ 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 errors: Record<string, string> = {};
+ if (!name) errors.name = "必須です";
+ if (!slug) errors.slug = "必須です";
+ if (!message) errors.message = "必須です";
+ if (Object.keys(errors).length > 0) return { errors };
+
+ try {
+ updateBand(band.id, { slug, name, area, links, artists, message, ip_address: getIpAddress(request) });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) {
+ return { errors: { slug: "このslugは既に使用されています" } };
+ }
+ throw e;
+ }
+ return redirect(`/bands/of/${band.id}`);
+}
+
+function toSlug(s: string) {
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+export default function BandEdit() {
+ const { band, links: initLinks, bandArtists: initArtists, allArtists } = useLoaderData<typeof loader>();
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [name, setName] = useState(band.name);
+ const [slug, setSlug] = useState(band.slug);
+ const [slugManual, setSlugManual] = useState(true);
+ const [links, setLinks] = useState(initLinks.map((l) => ({ label: l.label, url: l.url })));
+ const [picked, setPicked] = useState(
+ initArtists.map((a) => ({ id: a.artist_id, name: a.artist_name, role: a.role ?? "" }))
+ );
+
+ const available = allArtists.filter((a) => !picked.some((p) => p.id === a.id));
+
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center gap-3 mb-6">
+ <Link
+ to={`/bands/of/${band.id}`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ ←
+ </Link>
+ <h1 className="text-xl font-semibold">Edit Band</h1>
+ </div>
+
+ <Form method="post" className="space-y-5">
+ <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.role || null })))}
+ />
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ バンド名 <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="name"
+ value={name}
+ onChange={(e) => {
+ setName(e.target.value);
+ if (!slugManual) setSlug(toSlug(e.target.value));
+ }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ Slug <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="slug"
+ value={slug}
+ onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm"
+ />
+ {errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">活動拠点</label>
+ <input
+ name="area"
+ defaultValue={band.area ?? ""}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">リンク</label>
+ <div className="space-y-2">
+ {links.map((link, i) => (
+ <div key={i} className="flex gap-2">
+ <input
+ value={link.label}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, label: e.target.value } : l))}
+ placeholder="ラベル (例: X)"
+ className="w-28 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <input
+ value={link.url}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, url: e.target.value } : l))}
+ placeholder="https://..."
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <button
+ type="button"
+ onClick={() => setLinks(links.filter((_, idx) => idx !== i))}
+ className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ <button
+ type="button"
+ onClick={() => setLinks([...links, { label: "", url: "" }])}
+ className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors"
+ >
+ + リンクを追加
+ </button>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">メンバー</label>
+ <div className="space-y-2 mb-2">
+ {picked.map((p, i) => (
+ <div key={p.id} className="flex gap-2 items-center">
+ <span className="text-gray-200 text-sm w-32 truncate">{p.name}</span>
+ <input
+ value={p.role}
+ onChange={(e) => setPicked(picked.map((a, idx) => idx === i ? { ...a, role: e.target.value } : a))}
+ placeholder="パート (例: Guitar)"
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <button
+ type="button"
+ onClick={() => setPicked(picked.filter((_, idx) => idx !== i))}
+ className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ {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, role: "" }]); e.target.value = ""; }
+ }}
+ defaultValue=""
+ className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-400 focus:outline-none focus:border-blue-500 text-sm"
+ >
+ <option value="">+ アーティストを追加...</option>
+ {available.map((a) => (
+ <option key={a.id} value={a.id}>{a.name}</option>
+ ))}
+ </select>
+ )}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ 更新メッセージ <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="message"
+ placeholder="例: メンバー追加"
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.message && <p className="text-red-400 text-sm mt-1">{errors.message}</p>}
+ </div>
+
+ <div className="flex gap-3 pt-2">
+ <button
+ type="submit"
+ className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded font-medium transition-colors"
+ >
+ 保存
+ </button>
+ <Link
+ to={`/bands/of/${band.id}`}
+ className="bg-gray-800 hover:bg-gray-700 text-gray-300 px-4 py-2 rounded transition-colors"
+ >
+ キャンセル
+ </Link>
+ </div>
+ </Form>
+ </main>
+ );
+}
diff --git a/app/routes/band-history.tsx b/app/routes/band-history.tsx
new file mode 100644
index 0000000..11e2f2f
--- /dev/null
+++ b/app/routes/band-history.tsx
@@ -0,0 +1,61 @@
+import { data, Link, useLoaderData } from "react-router";
+import type { LoaderFunctionArgs } from "react-router";
+import { getBandById, getBandRevisions } from "~/lib/db.server";
+
+export async function loader({ params }: LoaderFunctionArgs) {
+ const band = getBandById(params.uuid!);
+ if (!band) throw data("Not found", { status: 404 });
+ const revisions = getBandRevisions(band.id);
+ return { band, revisions };
+}
+
+export default function BandHistory() {
+ const { band, revisions } = useLoaderData<typeof loader>();
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center gap-3 mb-6">
+ <Link
+ to={`/bands/of/${band.id}`}
+ className="text-gray-400 hover:text-gray-200 transition-colors"
+ >
+ ←
+ </Link>
+ <h1 className="text-xl font-semibold">{band.name} — 編集履歴</h1>
+ </div>
+
+ {revisions.length === 0 ? (
+ <p className="text-gray-400">履歴がありません。</p>
+ ) : (
+ <ol className="space-y-4">
+ {revisions.map((rev, i) => {
+ let snap: { name?: string; area?: string; links?: unknown[]; artists?: unknown[] } = {};
+ try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ }
+ return (
+ <li key={rev.id} className="bg-gray-900 rounded-lg p-4">
+ <div className="flex items-start justify-between gap-4">
+ <div className="flex-1 min-w-0">
+ <p className="font-medium text-gray-100 truncate">{rev.message}</p>
+ <p className="text-xs text-gray-500 mt-1">
+ {rev.created_at} · {rev.ip_address}
+ </p>
+ </div>
+ {i === 0 && (
+ <span className="text-xs text-blue-400 shrink-0">最新</span>
+ )}
+ </div>
+ <div className="mt-3 text-xs text-gray-400 space-y-0.5">
+ <p>名前: {snap.name ?? "—"}</p>
+ {snap.area && <p>拠点: {snap.area}</p>}
+ <p>
+ リンク: {snap.links?.length ?? 0}件 / メンバー:{" "}
+ {snap.artists?.length ?? 0}人
+ </p>
+ </div>
+ </li>
+ );
+ })}
+ </ol>
+ )}
+ </main>
+ );
+}
diff --git a/app/routes/band-new.tsx b/app/routes/band-new.tsx
new file mode 100644
index 0000000..b0325a3
--- /dev/null
+++ b/app/routes/band-new.tsx
@@ -0,0 +1,219 @@
+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";
+
+export function loader() {
+ return { artists: listArtists() };
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ const fd = await request.formData();
+ const name = (fd.get("name") as string).trim();
+ const slug = (fd.get("slug") as string).trim();
+ const area = (fd.get("area") as string).trim() || null;
+ 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 errors: Record<string, string> = {};
+ if (!name) errors.name = "必須です";
+ if (!slug) errors.slug = "必須です";
+ if (!message) errors.message = "必須です";
+ if (Object.keys(errors).length > 0) return { errors };
+
+ const id = crypto.randomUUID();
+ try {
+ createBand({ id, slug, name, area, links, artists, message, ip_address: getIpAddress(request) });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) {
+ return { errors: { slug: "このslugは既に使用されています" } };
+ }
+ throw e;
+ }
+ return redirect(`/bands/of/${id}`);
+}
+
+function toSlug(s: string) {
+ return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w぀-ヿ一-鿿＀-￯-]/g, "").replace(/^-+|-+$/g, "");
+}
+
+export default function BandNew() {
+ const { artists } = useLoaderData<typeof loader>();
+ const actionData = useActionData<typeof action>();
+ const errors = actionData?.errors ?? {};
+
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [slugManual, setSlugManual] = useState(false);
+ const [links, setLinks] = useState<{ label: string; url: string }[]>([]);
+ const [picked, setPicked] = useState<{ id: string; name: string; role: string }[]>([]);
+
+ const available = artists.filter((a) => !picked.some((p) => p.id === a.id));
+
+ return (
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center gap-3 mb-6">
+ <Link to="/" className="text-gray-400 hover:text-gray-200 transition-colors">←</Link>
+ <h1 className="text-xl font-semibold">New Band</h1>
+ </div>
+
+ <Form method="post" className="space-y-5">
+ <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.role || null })))}
+ />
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ バンド名 <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="name"
+ value={name}
+ onChange={(e) => {
+ setName(e.target.value);
+ if (!slugManual) setSlug(toSlug(e.target.value));
+ }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.name && <p className="text-red-400 text-sm mt-1">{errors.name}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ Slug <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="slug"
+ value={slug}
+ onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }}
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 font-mono text-sm"
+ />
+ {errors.slug && <p className="text-red-400 text-sm mt-1">{errors.slug}</p>}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">活動拠点</label>
+ <input
+ name="area"
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">リンク</label>
+ <div className="space-y-2">
+ {links.map((link, i) => (
+ <div key={i} className="flex gap-2">
+ <input
+ value={link.label}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, label: e.target.value } : l))}
+ placeholder="ラベル (例: X)"
+ className="w-28 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <input
+ value={link.url}
+ onChange={(e) => setLinks(links.map((l, idx) => idx === i ? { ...l, url: e.target.value } : l))}
+ placeholder="https://..."
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <button
+ type="button"
+ onClick={() => setLinks(links.filter((_, idx) => idx !== i))}
+ className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ <button
+ type="button"
+ onClick={() => setLinks([...links, { label: "", url: "" }])}
+ className="mt-2 text-blue-400 hover:text-blue-300 text-sm transition-colors"
+ >
+ + リンクを追加
+ </button>
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-2">メンバー</label>
+ <div className="space-y-2 mb-2">
+ {picked.map((p, i) => (
+ <div key={p.id} className="flex gap-2 items-center">
+ <span className="text-gray-200 text-sm w-32 truncate">{p.name}</span>
+ <input
+ value={p.role}
+ onChange={(e) => setPicked(picked.map((a, idx) => idx === i ? { ...a, role: e.target.value } : a))}
+ placeholder="パート (例: Guitar)"
+ className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500 text-sm"
+ />
+ <button
+ type="button"
+ onClick={() => setPicked(picked.filter((_, idx) => idx !== i))}
+ className="text-gray-500 hover:text-red-400 px-2 transition-colors"
+ >
+ ×
+ </button>
+ </div>
+ ))}
+ </div>
+ {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, role: "" }]); e.target.value = ""; }
+ }}
+ defaultValue=""
+ className="bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-400 focus:outline-none focus:border-blue-500 text-sm"
+ >
+ <option value="">+ アーティストを追加...</option>
+ {available.map((a) => (
+ <option key={a.id} value={a.id}>{a.name}</option>
+ ))}
+ </select>
+ ) : artists.length === 0 ? (
+ <p className="text-gray-500 text-sm">
+ アーティストがいません。{" "}
+ <Link to="/artists/new" className="text-blue-400 hover:text-blue-300">先に作成</Link>
+ </p>
+ ) : null}
+ </div>
+
+ <div>
+ <label className="block text-sm font-medium text-gray-300 mb-1">
+ 更新メッセージ <span className="text-red-400">*</span>
+ </label>
+ <input
+ name="message"
+ placeholder="例: 初回登録"
+ className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-gray-100 focus:outline-none focus:border-blue-500"
+ />
+ {errors.message && <p className="text-red-400 text-sm mt-1">{errors.message}</p>}
+ </div>
+
+ <div className="flex gap-3 pt-2">
+ <button
+ type="submit"
+ className="bg-blue-600 hover:bg-blue-500 text-white px-4 py-2 rounded font-medium transition-colors"
+ >
+ 作成
+ </button>
+ <Link
+ to="/"
+ className="bg-gray-800 hover:bg-gray-700 text-gray-300 px-4 py-2 rounded transition-colors"
+ >
+ キャンセル
+ </Link>
+ </div>
+ </Form>
+ </main>
+ );
+}
diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 03ae39a..3df4ab5 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,8 +1,46 @@
+import { Link, useLoaderData } from "react-router";
+import { listBands } from "~/lib/db.server";
+
+export function loader() {
+ return { bands: listBands() };
+}
+
export default function Home() {
+ const { bands } = useLoaderData<typeof loader>();
return (
- <main className="container mx-auto px-4 py-16 text-center">
- <h1 className="text-4xl font-bold tracking-tight">whois.band</h1>
- <p className="mt-4 text-gray-400">Band identification service. Coming soon.</p>
+ <main className="max-w-3xl mx-auto px-4 py-8">
+ <div className="flex items-center justify-between mb-6">
+ <h1 className="text-xl font-semibold">Bands</h1>
+ <Link to="/bands/new" className="text-sm text-blue-400 hover:text-blue-300">
+ + New Band
+ </Link>
+ </div>
+ {bands.length === 0 ? (
+ <p className="text-gray-400">
+ まだバンドがありません。{" "}
+ <Link to="/bands/new" className="text-blue-400 hover:text-blue-300">
+ 追加する
+ </Link>
+ </p>
+ ) : (
+ <ul className="divide-y divide-gray-800">
+ {bands.map((band) => (
+ <li key={band.id} className="py-3">
+ <Link
+ to={`/bands/of/${band.id}`}
+ className="flex items-baseline gap-3 group"
+ >
+ <span className="font-medium group-hover:text-blue-300 transition-colors">
+ {band.name}
+ </span>
+ {band.area && (
+ <span className="text-gray-400 text-sm">{band.area}</span>
+ )}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ )}
</main>
);
}