summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md74
-rw-r--r--app/routes.ts2
-rw-r--r--app/routes/api-artists.tsx47
-rw-r--r--app/routes/api-bands.tsx51
-rw-r--r--package.json3
-rw-r--r--scripts/add.ts159
6 files changed, 335 insertions, 1 deletions
diff --git a/README.md b/README.md
index 68b2c50..44ff96b 100644
--- a/README.md
+++ b/README.md
@@ -25,6 +25,80 @@
| `/artists/of/:uuid/history` | アーティスト編集履歴 |
| `/artists/new` | アーティスト新規作成 |
+## API
+
+サーバー起動中に JSON API で操作できます。
+
+### バンド
+
+```bash
+# 一覧取得
+curl http://localhost:5173/api/bands
+
+# 新規作成
+curl -X POST http://localhost:5173/api/bands \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "バンド名",
+ "area": "Tokyo",
+ "status": "active",
+ "description": "説明文",
+ "links": [{"label": "Twitter", "url": "https://..."}],
+ "artists": [{"id": "<artist-uuid>", "role": "Vocal"}],
+ "message": "初回登録"
+ }'
+```
+
+### アーティスト
+
+```bash
+# 一覧取得
+curl http://localhost:5173/api/artists
+
+# 新規作成
+curl -X POST http://localhost:5173/api/artists \
+ -H "Content-Type: application/json" \
+ -d '{"name": "アーティスト名", "links": [{"label": "Twitter", "url": "https://..."}]}'
+```
+
+`slug` は省略すると名前から自動生成。成功時は `201` + 作成したレコードの JSON を返します。slug 重複時は `409`。
+
+## CLI
+
+サーバー不要でローカルの DB に直接書き込みます。プロジェクトルートから実行してください。
+
+```bash
+# アーティスト登録
+npm run add -- artist --name "アーティスト名"
+
+# バンド登録
+npm run add -- band --name "バンド名" --area Tokyo --status active
+
+# JSON ファイルから一括インポート
+npm run add -- import --file data.json
+```
+
+### 一括インポートの JSON 形式
+
+```json
+{
+ "artists": [
+ {"name": "アーティスト A", "links": [{"label": "Twitter", "url": "https://..."}]}
+ ],
+ "bands": [
+ {
+ "name": "バンド X",
+ "area": "Tokyo",
+ "status": "active",
+ "artists": [{"name": "アーティスト A", "role": "Vocal"}],
+ "links": [{"label": "Twitter", "url": "https://..."}]
+ }
+ ]
+}
+```
+
+`artists` を先に処理するので、`bands` 側でアーティスト名を参照できます。既存のアーティスト(名前一致)はスキップ。
+
## 技術スタック
- [React Router v7](https://reactrouter.com/) (SSR フレームワーク)
diff --git a/app/routes.ts b/app/routes.ts
index a4e737f..d02abf7 100644
--- a/app/routes.ts
+++ b/app/routes.ts
@@ -2,6 +2,8 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes";
export default [
index("routes/home.tsx"),
+ route("/api/bands", "routes/api-bands.tsx"),
+ route("/api/artists", "routes/api-artists.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"),
diff --git a/app/routes/api-artists.tsx b/app/routes/api-artists.tsx
new file mode 100644
index 0000000..ed762bf
--- /dev/null
+++ b/app/routes/api-artists.tsx
@@ -0,0 +1,47 @@
+import type { ActionFunctionArgs } from "react-router";
+import { createArtist, getIpAddress, listArtists, toSlug } from "~/lib/db.server";
+
+export function loader() {
+ return Response.json(listArtists());
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ if (request.method !== "POST") {
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const name = (body.name as string | undefined)?.trim();
+ if (!name) return Response.json({ error: "name is required" }, { status: 400 });
+
+ const slug = (body.slug as string | undefined)?.trim() || toSlug(name);
+ if (!slug) return Response.json({ error: "could not derive slug from name" }, { status: 400 });
+
+ const id = crypto.randomUUID();
+ try {
+ const artist = createArtist({
+ id,
+ slug,
+ name,
+ links: (body.links as { label: string; url: string }[]) || [],
+ message: (body.message as string) || "API import",
+ ip_address: getIpAddress(request),
+ });
+ return Response.json(artist, { status: 201 });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: artists.slug")) {
+ return Response.json({ error: "slug already in use" }, { status: 409 });
+ }
+ throw e;
+ }
+}
+
+export default function () {
+ return null;
+}
diff --git a/app/routes/api-bands.tsx b/app/routes/api-bands.tsx
new file mode 100644
index 0000000..64a9269
--- /dev/null
+++ b/app/routes/api-bands.tsx
@@ -0,0 +1,51 @@
+import type { ActionFunctionArgs } from "react-router";
+import { createBand, getIpAddress, listBands, toSlug } from "~/lib/db.server";
+
+export function loader() {
+ return Response.json(listBands());
+}
+
+export async function action({ request }: ActionFunctionArgs) {
+ if (request.method !== "POST") {
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ let body: Record<string, unknown>;
+ try {
+ body = await request.json();
+ } catch {
+ return Response.json({ error: "Invalid JSON body" }, { status: 400 });
+ }
+
+ const name = (body.name as string | undefined)?.trim();
+ if (!name) return Response.json({ error: "name is required" }, { status: 400 });
+
+ const slug = (body.slug as string | undefined)?.trim() || toSlug(name);
+ if (!slug) return Response.json({ error: "could not derive slug from name" }, { status: 400 });
+
+ const id = crypto.randomUUID();
+ try {
+ const band = createBand({
+ id,
+ slug,
+ name,
+ area: (body.area as string) || null,
+ 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 }[]) || [],
+ message: (body.message as string) || "API import",
+ ip_address: getIpAddress(request),
+ });
+ return Response.json(band, { status: 201 });
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) {
+ return Response.json({ error: "slug already in use" }, { status: 409 });
+ }
+ throw e;
+ }
+}
+
+export default function () {
+ return null;
+}
diff --git a/package.json b/package.json
index e153c04..fefa788 100644
--- a/package.json
+++ b/package.json
@@ -6,7 +6,8 @@
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
- "typecheck": "react-router typegen && tsc"
+ "typecheck": "react-router typegen && tsc",
+ "add": "tsx scripts/add.ts"
},
"dependencies": {
"@react-router/node": "^7.3.0",
diff --git a/scripts/add.ts b/scripts/add.ts
new file mode 100644
index 0000000..ca53dda
--- /dev/null
+++ b/scripts/add.ts
@@ -0,0 +1,159 @@
+#!/usr/bin/env npx tsx
+/**
+ * CLI for adding bands/artists directly to the DB (no server required).
+ *
+ * Usage:
+ * npx tsx scripts/add.ts band --name "Name" [--slug x] [--area X] [--status active|hiatus|disbanded] [--description X] [--message X]
+ * npx tsx scripts/add.ts artist --name "Name" [--slug x] [--message X]
+ * npx tsx scripts/add.ts import --file data.json
+ *
+ * JSON import format (data.json):
+ * {
+ * "artists": [{ "name": "...", "links": [{ "label": "Twitter", "url": "..." }] }],
+ * "bands": [{
+ * "name": "...", "area": "...", "status": "active",
+ * "artists": [{ "name": "...", "role": "Vocal" }],
+ * "links": [{ "label": "Twitter", "url": "..." }]
+ * }]
+ * }
+ *
+ * Artist references in bands can use { "id": "<uuid>" } or { "name": "..." } (looked up by name).
+ * Artists listed under "artists" are created first so band references by name work.
+ * Run from the project root so whois.db is found automatically.
+ */
+
+import { readFileSync } from "fs";
+import { createArtist, createBand, listArtists, toSlug } from "../app/lib/db.server";
+
+type LinkInput = { label: string; url: string };
+type ArtistRef = { id?: string; name?: string; role?: string | null };
+
+function parseFlags(argv: string[]): Record<string, string> {
+ const flags: Record<string, string> = {};
+ for (let i = 0; i < argv.length; i++) {
+ if (argv[i].startsWith("--")) {
+ const key = argv[i].slice(2);
+ flags[key] = argv[i + 1] ?? "true";
+ i++;
+ }
+ }
+ return flags;
+}
+
+const [, , command, ...rest] = process.argv;
+
+if (!command || command === "--help" || command === "-h") {
+ console.log(`Usage:
+ npx tsx scripts/add.ts band --name "Name" [--slug x] [--area X] [--status active|hiatus|disbanded] [--description X] [--message X]
+ npx tsx scripts/add.ts artist --name "Name" [--slug x] [--message X]
+ npx tsx scripts/add.ts import --file data.json`);
+ process.exit(0);
+}
+
+if (command === "artist") {
+ const flags = parseFlags(rest);
+ const name = flags.name?.trim();
+ if (!name) { console.error("Error: --name is required"); process.exit(1); }
+ const slug = flags.slug?.trim() || toSlug(name);
+ const id = crypto.randomUUID();
+ const artist = createArtist({
+ id, slug, name,
+ links: flags.links ? (JSON.parse(flags.links) as LinkInput[]) : [],
+ message: flags.message || "CLI import",
+ ip_address: "cli",
+ });
+ console.log(`Created artist: ${artist.name}`);
+ console.log(` id: ${artist.id}`);
+ console.log(` url: /artists/of/${artist.id}`);
+
+} else if (command === "band") {
+ const flags = parseFlags(rest);
+ const name = flags.name?.trim();
+ if (!name) { console.error("Error: --name is required"); process.exit(1); }
+ const slug = flags.slug?.trim() || toSlug(name);
+ const id = crypto.randomUUID();
+ try {
+ const band = createBand({
+ id, slug, name,
+ area: flags.area || null,
+ description: flags.description || null,
+ status: flags.status || "active",
+ links: flags.links ? (JSON.parse(flags.links) as LinkInput[]) : [],
+ artists: flags.artists ? (JSON.parse(flags.artists) as { id: string; role: string | null }[]) : [],
+ message: flags.message || "CLI import",
+ ip_address: "cli",
+ });
+ console.log(`Created band: ${band.name}`);
+ console.log(` id: ${band.id}`);
+ console.log(` url: /bands/of/${band.id}`);
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) {
+ console.error(`Error: slug "${slug}" is already in use`);
+ process.exit(1);
+ }
+ throw e;
+ }
+
+} else if (command === "import") {
+ const flags = parseFlags(rest);
+ if (!flags.file) { console.error("Error: --file is required"); process.exit(1); }
+
+ const data = JSON.parse(readFileSync(flags.file, "utf-8")) as {
+ artists?: { name: string; slug?: string; links?: LinkInput[]; message?: string }[];
+ bands?: { name: string; slug?: string; area?: string; description?: string; status?: string; links?: LinkInput[]; artists?: ArtistRef[]; message?: string }[];
+ };
+
+ // Build name→id map from existing artists, then create new ones
+ const nameToId = new Map<string, string>(listArtists().map((a) => [a.name, a.id]));
+
+ for (const a of data.artists ?? []) {
+ const name = a.name?.trim();
+ if (!name) continue;
+ if (nameToId.has(name)) { console.log(`Skip existing artist: ${name}`); continue; }
+ const id = crypto.randomUUID();
+ createArtist({
+ id, slug: a.slug?.trim() || toSlug(name), name,
+ links: a.links || [],
+ message: a.message || "JSON import",
+ ip_address: "cli",
+ });
+ nameToId.set(name, id);
+ console.log(`Created artist: ${name} (${id})`);
+ }
+
+ for (const b of data.bands ?? []) {
+ const name = b.name?.trim();
+ if (!name) continue;
+ const id = crypto.randomUUID();
+ const artists = (b.artists ?? [])
+ .map((ar) => {
+ const artistId = ar.id ?? (ar.name ? nameToId.get(ar.name) : undefined);
+ if (!artistId) { console.warn(` Warning: artist not found: "${ar.name}"`); return null; }
+ return { id: artistId, role: ar.role ?? null };
+ })
+ .filter((x): x is { id: string; role: string | null } => x !== null);
+ try {
+ createBand({
+ id, slug: b.slug?.trim() || toSlug(name), name,
+ area: b.area || null,
+ description: b.description || null,
+ status: b.status || "active",
+ links: b.links || [],
+ artists,
+ message: b.message || "JSON import",
+ ip_address: "cli",
+ });
+ console.log(`Created band: ${name} (${id})`);
+ } catch (e) {
+ if (e instanceof Error && e.message.includes("UNIQUE constraint failed: bands.slug")) {
+ console.error(`Skip band "${name}": slug already in use`);
+ } else {
+ throw e;
+ }
+ }
+ }
+
+} else {
+ console.error(`Unknown command: "${command}". Use band, artist, or import.`);
+ process.exit(1);
+}