diff options
Diffstat (limited to 'app/routes')
| -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 |
11 files changed, 1163 insertions, 3 deletions
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> ); } |
