From b8d24d292d99c8da285092ce923b5e2b546d8f45 Mon Sep 17 00:00:00 2001 From: yyamashita Date: Sat, 9 May 2026 00:27:19 +0900 Subject: 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 --- app/lib/db.server.ts | 388 +++++++++++++++++++++++++++++++++++++++--- app/root.tsx | 36 +++- app/routes.ts | 12 +- app/routes/artist-by-slug.tsx | 13 ++ app/routes/artist-by-uuid.tsx | 100 +++++++++++ app/routes/artist-edit.tsx | 165 ++++++++++++++++++ app/routes/artist-history.tsx | 57 +++++++ app/routes/artist-new.tsx | 150 ++++++++++++++++ app/routes/band-by-slug.tsx | 13 ++ app/routes/band-by-uuid.tsx | 108 ++++++++++++ app/routes/band-edit.tsx | 236 +++++++++++++++++++++++++ app/routes/band-history.tsx | 61 +++++++ app/routes/band-new.tsx | 219 ++++++++++++++++++++++++ app/routes/home.tsx | 44 ++++- 14 files changed, 1572 insertions(+), 30 deletions(-) create mode 100644 app/routes/artist-by-slug.tsx create mode 100644 app/routes/artist-by-uuid.tsx create mode 100644 app/routes/artist-edit.tsx create mode 100644 app/routes/artist-history.tsx create mode 100644 app/routes/artist-new.tsx create mode 100644 app/routes/band-by-slug.tsx create mode 100644 app/routes/band-by-uuid.tsx create mode 100644 app/routes/band-edit.tsx create mode 100644 app/routes/band-history.tsx create mode 100644 app/routes/band-new.tsx (limited to 'app') 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 ( - + @@ -43,7 +44,30 @@ export function Layout({ children }: { children: React.ReactNode }) { } export default function App() { - return ; + return ( + <> + + + + ); } export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { @@ -63,11 +87,11 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { } return ( -
-

{message}

-

{details}

+
+

{message}

+

{details}

{stack && ( -
+        
           {stack}
         
)} 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(); + return ( +
+
+

{artist.name}

+
+ + 履歴 + + + 編集 + +
+
+ + {bands.length > 0 && ( +
+

+ バンド +

+
    + {bands.map((b) => ( +
  • + + {b.band_name} + + {b.role && ( + {b.role} + )} +
  • + ))} +
+
+ )} + + {links.length > 0 && ( +
+

+ リンク +

+ +
+ )} + +
+
+

/artists/of/{artist.id}

+

+ + /artists/named/{artist.slug} + +

+ {latest && ( +

+ 最終更新: {latest.created_at} — {latest.message} +

+ )} +
+
+ ); +} 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 = {}; + 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(); + const actionData = useActionData(); + 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 ( +
+
+ + ← + +

Edit Artist

+
+ +
+ + +
+ + { + 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 &&

{errors.name}

} +
+ +
+ + { 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 &&

{errors.slug}

} +
+ +
+ +
+ {links.map((link, i) => ( +
+ 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" + /> + 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" + /> + +
+ ))} +
+ +
+ +
+ + + {errors.message &&

{errors.message}

} +
+ +
+ + + キャンセル + +
+
+
+ ); +} 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(); + return ( +
+
+ + ← + +

{artist.name} — 編集履歴

+
+ + {revisions.length === 0 ? ( +

履歴がありません。

+ ) : ( +
    + {revisions.map((rev, i) => { + let snap: { name?: string; links?: unknown[] } = {}; + try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ } + return ( +
  1. +
    +
    +

    {rev.message}

    +

    + {rev.created_at} · {rev.ip_address} +

    +
    + {i === 0 && ( + 最新 + )} +
    +
    +

    名前: {snap.name ?? "—"}

    +

    リンク: {snap.links?.length ?? 0}件

    +
    +
  2. + ); + })} +
+ )} +
+ ); +} 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 = {}; + 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(); + 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 ( +
+
+ ← +

New Artist

+
+ +
+ + +
+ + { + 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 &&

{errors.name}

} +
+ +
+ + { 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 &&

{errors.slug}

} +
+ +
+ +
+ {links.map((link, i) => ( +
+ 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" + /> + 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" + /> + +
+ ))} +
+ +
+ +
+ + + {errors.message &&

{errors.message}

} +
+ +
+ + + キャンセル + +
+
+
+ ); +} 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(); + return ( +
+
+
+

{band.name}

+ {band.area &&

{band.area}

} +
+
+ + 履歴 + + + 編集 + +
+
+ + {artists.length > 0 && ( +
+

+ メンバー +

+
    + {artists.map((a) => ( +
  • + + {a.artist_name} + + {a.role && ( + {a.role} + )} +
  • + ))} +
+
+ )} + + {links.length > 0 && ( +
+

+ リンク +

+ +
+ )} + +
+
+

/bands/of/{band.id}

+

+ + /bands/named/{band.slug} + +

+ {latest && ( +

+ 最終更新: {latest.created_at} — {latest.message} +

+ )} +
+
+ ); +} 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 = {}; + 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(); + const actionData = useActionData(); + 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 ( +
+
+ + ← + +

Edit Band

+
+ +
+ + ({ id: p.id, role: p.role || null })))} + /> + +
+ + { + 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 &&

{errors.name}

} +
+ +
+ + { 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 &&

{errors.slug}

} +
+ +
+ + +
+ +
+ +
+ {links.map((link, i) => ( +
+ 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" + /> + 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" + /> + +
+ ))} +
+ +
+ +
+ +
+ {picked.map((p, i) => ( +
+ {p.name} + 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" + /> + +
+ ))} +
+ {available.length > 0 && ( + + )} +
+ +
+ + + {errors.message &&

{errors.message}

} +
+ +
+ + + キャンセル + +
+
+
+ ); +} 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(); + return ( +
+
+ + ← + +

{band.name} — 編集履歴

+
+ + {revisions.length === 0 ? ( +

履歴がありません。

+ ) : ( +
    + {revisions.map((rev, i) => { + let snap: { name?: string; area?: string; links?: unknown[]; artists?: unknown[] } = {}; + try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ } + return ( +
  1. +
    +
    +

    {rev.message}

    +

    + {rev.created_at} · {rev.ip_address} +

    +
    + {i === 0 && ( + 最新 + )} +
    +
    +

    名前: {snap.name ?? "—"}

    + {snap.area &&

    拠点: {snap.area}

    } +

    + リンク: {snap.links?.length ?? 0}件 / メンバー:{" "} + {snap.artists?.length ?? 0}人 +

    +
    +
  2. + ); + })} +
+ )} +
+ ); +} 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 = {}; + 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(); + const actionData = useActionData(); + 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 ( +
+
+ ← +

New Band

+
+ +
+ + ({ id: p.id, role: p.role || null })))} + /> + +
+ + { + 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 &&

{errors.name}

} +
+ +
+ + { 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 &&

{errors.slug}

} +
+ +
+ + +
+ +
+ +
+ {links.map((link, i) => ( +
+ 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" + /> + 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" + /> + +
+ ))} +
+ +
+ +
+ +
+ {picked.map((p, i) => ( +
+ {p.name} + 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" + /> + +
+ ))} +
+ {available.length > 0 ? ( + + ) : artists.length === 0 ? ( +

+ アーティストがいません。{" "} + 先に作成 +

+ ) : null} +
+ +
+ + + {errors.message &&

{errors.message}

} +
+ +
+ + + キャンセル + +
+
+
+ ); +} 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(); return ( -
-

whois.band

-

Band identification service. Coming soon.

+
+
+

Bands

+ + + New Band + +
+ {bands.length === 0 ? ( +

+ まだバンドがありません。{" "} + + 追加する + +

+ ) : ( +
    + {bands.map((band) => ( +
  • + + + {band.name} + + {band.area && ( + {band.area} + )} + +
  • + ))} +
+ )}
); } -- cgit v1.2.3