diff options
Diffstat (limited to 'app/routes')
| -rw-r--r-- | app/routes/list-by-slug.tsx | 13 | ||||
| -rw-r--r-- | app/routes/list-by-uuid.tsx | 54 | ||||
| -rw-r--r-- | app/routes/list-edit.tsx | 137 | ||||
| -rw-r--r-- | app/routes/list-history.tsx | 48 | ||||
| -rw-r--r-- | app/routes/list-index.tsx | 36 | ||||
| -rw-r--r-- | app/routes/list-new.tsx | 133 |
6 files changed, 421 insertions, 0 deletions
diff --git a/app/routes/list-by-slug.tsx b/app/routes/list-by-slug.tsx new file mode 100644 index 0000000..902c74e --- /dev/null +++ b/app/routes/list-by-slug.tsx @@ -0,0 +1,13 @@ +import { data, redirect } from "react-router"; +import type { LoaderFunctionArgs } from "react-router"; +import { getBandListBySlug } from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListBySlug(params.slug!); + if (!list) throw data("Not found", { status: 404 }); + return redirect(`/lists/of/${list.id}`); +} + +export default function ListBySlug() { + return null; +} diff --git a/app/routes/list-by-uuid.tsx b/app/routes/list-by-uuid.tsx new file mode 100644 index 0000000..f8c7380 --- /dev/null +++ b/app/routes/list-by-uuid.tsx @@ -0,0 +1,54 @@ +import { data, Link, useLoaderData } from "react-router"; +import type { LoaderFunctionArgs } from "react-router"; +import { getBandListById, getListEntries, getListRevisions } from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + const entries = getListEntries(list.id); + const revisions = getListRevisions(list.id); + return { list, entries, latest: revisions[0] ?? null }; +} + +export default function ListDetail() { + const { list, entries, latest } = useLoaderData<typeof loader>(); + + return ( + <main> + <div className="detail-header"> + <div className="detail-info"> + <h1>{list.title}</h1> + {list.description && <p className="detail-desc">{list.description}</p>} + </div> + <div className="detail-actions"> + <Link to={`/lists/of/${list.id}/history`} className="history">履歴</Link> + <Link to={`/lists/of/${list.id}/edit`} className="edit">編集</Link> + </div> + </div> + + {entries.length === 0 ? ( + <p className="muted">エントリがありません。</p> + ) : ( + <ul className="entry-list"> + {entries.map((entry) => ( + <li key={entry.id}> + <div className="entry-band">{entry.band_name}</div> + {entry.note && <div className="entry-note">{entry.note}</div>} + </li> + ))} + </ul> + )} + + <hr /> + <div className="meta"> + <p>/lists/of/{list.id}</p> + <p> + <Link to={`/lists/named/${list.slug}`}>/lists/named/{list.slug}</Link> + </p> + {latest && ( + <p className="updated">最終更新: {latest.created_at} — {latest.message}</p> + )} + </div> + </main> + ); +} diff --git a/app/routes/list-edit.tsx b/app/routes/list-edit.tsx new file mode 100644 index 0000000..5d47737 --- /dev/null +++ b/app/routes/list-edit.tsx @@ -0,0 +1,137 @@ +import { useState } from "react"; +import { data, Form, Link, redirect, useActionData, useLoaderData } from "react-router"; +import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router"; +import { + getBandListById, + getListEntries, + getIpAddress, + updateBandList, + type ListEntryInput, +} from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + const entries = getListEntries(list.id); + return { list, entries }; +} + +export async function action({ params, request }: ActionFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + + const fd = await request.formData(); + const title = (fd.get("title") as string).trim(); + const slug = (fd.get("slug") as string).trim(); + const description = (fd.get("description") as string).trim(); + const message = (fd.get("message") as string).trim(); + const entries: ListEntryInput[] = JSON.parse((fd.get("entries") as string) || "[]"); + + const errors: Record<string, string> = {}; + if (!title) errors.title = "必須です"; + if (!slug) errors.slug = "必須です"; + if (!message) errors.message = "必須です"; + if (Object.keys(errors).length > 0) return { errors }; + + try { + updateBandList(list.id, { slug, title, description, entries, message, ip_address: getIpAddress(request) }); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: lists.slug")) { + return { errors: { slug: "このslugは既に使用されています" } }; + } + throw e; + } + return redirect(`/lists/of/${list.id}`); +} + +type EntryRow = { key: string; band_name: string; note: string }; + +export default function ListEdit() { + const { list, entries: initEntries } = useLoaderData<typeof loader>(); + const actionData = useActionData<typeof action>(); + const errors = actionData?.errors ?? {}; + + const [title, setTitle] = useState(list.title); + const [slug, setSlug] = useState(list.slug); + const [description, setDescription] = useState(list.description); + const [entries, setEntries] = useState<EntryRow[]>( + initEntries.map((e) => ({ key: crypto.randomUUID(), band_name: e.band_name, note: e.note })) + ); + + function addEntry() { + setEntries((prev) => [...prev, { key: crypto.randomUUID(), band_name: "", note: "" }]); + } + + function removeEntry(key: string) { + setEntries((prev) => prev.filter((e) => e.key !== key)); + } + + function updateEntry(key: string, field: "band_name" | "note", value: string) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e)); + } + + return ( + <main> + <div className="page-header"> + <Link to={`/lists/of/${list.id}`} className="back">←</Link> + <h1>{list.title} — 編集</h1> + </div> + + <Form method="post"> + <input type="hidden" name="entries" value={JSON.stringify(entries.map(({ band_name, note }) => ({ band_name, note })))} /> + + <div> + <label>タイトル <span className="req">*</span></label> + <input name="title" value={title} onChange={(e) => setTitle(e.target.value)} /> + {errors.title && <p className="error">{errors.title}</p>} + </div> + + <div> + <label>Slug <span className="req">*</span></label> + <input name="slug" value={slug} onChange={(e) => setSlug(e.target.value)} className="mono" /> + {errors.slug && <p className="error">{errors.slug}</p>} + </div> + + <div> + <label>説明</label> + <input name="description" value={description} onChange={(e) => setDescription(e.target.value)} /> + </div> + + <div> + <label>エントリ</label> + <div> + {entries.map((entry) => ( + <div key={entry.key} className="entry-row"> + <input + className="band-input" + value={entry.band_name} + onChange={(e) => updateEntry(entry.key, "band_name", e.target.value)} + placeholder="バンド名" + /> + <input + className="note-input" + value={entry.note} + onChange={(e) => updateEntry(entry.key, "note", e.target.value)} + placeholder="メモ" + /> + <button type="button" className="btn-icon" onClick={() => removeEntry(entry.key)}>×</button> + </div> + ))} + </div> + <button type="button" className="btn-text" onClick={addEntry}>+ エントリを追加</button> + </div> + + <div> + <label>更新メッセージ <span className="req">*</span></label> + <input name="message" placeholder="例: エントリ追加" /> + {errors.message && <p className="error">{errors.message}</p>} + </div> + + <div className="actions"> + <button type="submit">保存</button> + <Link to={`/lists/of/${list.id}`} className="btn">キャンセル</Link> + </div> + </Form> + </main> + ); +} diff --git a/app/routes/list-history.tsx b/app/routes/list-history.tsx new file mode 100644 index 0000000..c47614b --- /dev/null +++ b/app/routes/list-history.tsx @@ -0,0 +1,48 @@ +import { data, Link, useLoaderData } from "react-router"; +import type { LoaderFunctionArgs } from "react-router"; +import { getBandListById, getListRevisions } from "~/lib/db.server"; + +export async function loader({ params }: LoaderFunctionArgs) { + const list = getBandListById(params.uuid!); + if (!list) throw data("Not found", { status: 404 }); + const revisions = getListRevisions(list.id); + return { list, revisions }; +} + +export default function ListHistory() { + const { list, revisions } = useLoaderData<typeof loader>(); + return ( + <main> + <div className="page-header"> + <Link to={`/lists/of/${list.id}`} className="back">←</Link> + <h1>{list.title} — 編集履歴</h1> + </div> + + {revisions.length === 0 ? ( + <p className="muted">履歴がありません。</p> + ) : ( + <ol className="rev-list"> + {revisions.map((rev, i) => { + let snap: { title?: string; entries?: unknown[] } = {}; + try { snap = JSON.parse(rev.snapshot); } catch { /* ignore */ } + return ( + <li key={rev.id} className="rev"> + <div className="rev-header"> + <div className="rev-main"> + <p className="rev-message">{rev.message}</p> + <p className="rev-time">{rev.created_at} · {rev.ip_address}</p> + </div> + {i === 0 && <span className="rev-latest">最新</span>} + </div> + <div className="rev-snap"> + <p>タイトル: {snap.title ?? "—"}</p> + <p>エントリ数: {snap.entries?.length ?? 0}件</p> + </div> + </li> + ); + })} + </ol> + )} + </main> + ); +} diff --git a/app/routes/list-index.tsx b/app/routes/list-index.tsx new file mode 100644 index 0000000..7995068 --- /dev/null +++ b/app/routes/list-index.tsx @@ -0,0 +1,36 @@ +import { Link, useLoaderData } from "react-router"; +import { listBandLists } from "~/lib/db.server"; + +export function loader() { + return { lists: listBandLists() }; +} + +export default function ListIndex() { + const { lists } = useLoaderData<typeof loader>(); + return ( + <main> + <div className="page-header"> + <h1>Lists</h1> + <Link to="/lists/new">+ List</Link> + </div> + + {lists.length === 0 ? ( + <p className="muted"> + リストがまだありません。{" "} + <Link to="/lists/new">作成する</Link> + </p> + ) : ( + <ul className="band-list"> + {lists.map((list) => ( + <li key={list.id}> + <Link to={`/lists/of/${list.id}`}>{list.title}</Link> + {list.description && ( + <span className="muted" style={{ fontSize: ".75rem" }}>{list.description}</span> + )} + </li> + ))} + </ul> + )} + </main> + ); +} diff --git a/app/routes/list-new.tsx b/app/routes/list-new.tsx new file mode 100644 index 0000000..472f0d8 --- /dev/null +++ b/app/routes/list-new.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { Form, Link, redirect, useActionData } from "react-router"; +import type { ActionFunctionArgs } from "react-router"; +import { createBandList, getIpAddress, type ListEntryInput } from "~/lib/db.server"; + +export async function action({ request }: ActionFunctionArgs) { + const fd = await request.formData(); + const title = (fd.get("title") as string).trim(); + const slug = (fd.get("slug") as string).trim(); + const description = (fd.get("description") as string).trim(); + const message = (fd.get("message") as string).trim(); + const entries: ListEntryInput[] = JSON.parse((fd.get("entries") as string) || "[]"); + + const errors: Record<string, string> = {}; + if (!title) errors.title = "必須です"; + if (!slug) errors.slug = "必須です"; + if (!message) errors.message = "必須です"; + if (Object.keys(errors).length > 0) return { errors }; + + const id = crypto.randomUUID(); + try { + createBandList({ id, slug, title, description, entries, message, ip_address: getIpAddress(request) }); + } catch (e) { + if (e instanceof Error && e.message.includes("UNIQUE constraint failed: lists.slug")) { + return { errors: { slug: "このslugは既に使用されています" } }; + } + throw e; + } + return redirect(`/lists/of/${id}`); +} + +function toSlug(s: string) { + return s.trim().toLowerCase().replace(/\s+/g, "-").replace(/[^\w-ヿ一-鿿--]/g, "").replace(/^-+|-+$/g, ""); +} + +type EntryRow = { key: string; band_name: string; note: string }; + +export default function ListNew() { + const actionData = useActionData<typeof action>(); + const errors = actionData?.errors ?? {}; + + const [title, setTitle] = useState(""); + const [slug, setSlug] = useState(""); + const [slugManual, setSlugManual] = useState(false); + const [description, setDescription] = useState(""); + const [entries, setEntries] = useState<EntryRow[]>([]); + + function addEntry() { + setEntries((prev) => [...prev, { key: crypto.randomUUID(), band_name: "", note: "" }]); + } + + function removeEntry(key: string) { + setEntries((prev) => prev.filter((e) => e.key !== key)); + } + + function updateEntry(key: string, field: "band_name" | "note", value: string) { + setEntries((prev) => prev.map((e) => e.key === key ? { ...e, [field]: value } : e)); + } + + return ( + <main> + <div className="page-header"> + <Link to="/lists" className="back">←</Link> + <h1>New List</h1> + </div> + + <Form method="post"> + <input type="hidden" name="entries" value={JSON.stringify(entries.map(({ band_name, note }) => ({ band_name, note })))} /> + + <div> + <label>タイトル <span className="req">*</span></label> + <input + name="title" + value={title} + onChange={(e) => { setTitle(e.target.value); if (!slugManual) setSlug(toSlug(e.target.value)); }} + /> + {errors.title && <p className="error">{errors.title}</p>} + </div> + + <div> + <label>Slug <span className="req">*</span></label> + <input + name="slug" + value={slug} + onChange={(e) => { setSlugManual(true); setSlug(e.target.value); }} + className="mono" + /> + {errors.slug && <p className="error">{errors.slug}</p>} + </div> + + <div> + <label>説明</label> + <input name="description" value={description} onChange={(e) => setDescription(e.target.value)} /> + </div> + + <div> + <label>エントリ</label> + <div> + {entries.map((entry) => ( + <div key={entry.key} className="entry-row"> + <input + className="band-input" + value={entry.band_name} + onChange={(e) => updateEntry(entry.key, "band_name", e.target.value)} + placeholder="バンド名" + /> + <input + className="note-input" + value={entry.note} + onChange={(e) => updateEntry(entry.key, "note", e.target.value)} + placeholder="メモ" + /> + <button type="button" className="btn-icon" onClick={() => removeEntry(entry.key)}>×</button> + </div> + ))} + </div> + <button type="button" className="btn-text" onClick={addEntry}>+ エントリを追加</button> + </div> + + <div> + <label>更新メッセージ <span className="req">*</span></label> + <input name="message" placeholder="例: 初回作成" /> + {errors.message && <p className="error">{errors.message}</p>} + </div> + + <div className="actions"> + <button type="submit">作成</button> + <Link to="/lists" className="btn">キャンセル</Link> + </div> + </Form> + </main> + ); +} |
