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/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 +++++++- 11 files changed, 1163 insertions(+), 3 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/routes') 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