diff options
Diffstat (limited to 'app')
| -rw-r--r-- | app/lib/db.server.ts | 388 | ||||
| -rw-r--r-- | app/root.tsx | 36 | ||||
| -rw-r--r-- | app/routes.ts | 12 | ||||
| -rw-r--r-- | app/routes/artist-by-slug.tsx | 13 | ||||
| -rw-r--r-- | app/routes/artist-by-uuid.tsx | 100 | ||||
| -rw-r--r-- | app/routes/artist-edit.tsx | 165 | ||||
| -rw-r--r-- | app/routes/artist-history.tsx | 57 | ||||
| -rw-r--r-- | app/routes/artist-new.tsx | 150 | ||||
| -rw-r--r-- | app/routes/band-by-slug.tsx | 13 | ||||
| -rw-r--r-- | app/routes/band-by-uuid.tsx | 108 | ||||
| -rw-r--r-- | app/routes/band-edit.tsx | 236 | ||||
| -rw-r--r-- | app/routes/band-history.tsx | 61 | ||||
| -rw-r--r-- | app/routes/band-new.tsx | 219 | ||||
| -rw-r--r-- | app/routes/home.tsx | 44 |
14 files changed, 1572 insertions, 30 deletions
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> ); } |
