summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/list-by-slug.tsx13
-rw-r--r--app/routes/list-by-uuid.tsx54
-rw-r--r--app/routes/list-edit.tsx137
-rw-r--r--app/routes/list-history.tsx48
-rw-r--r--app/routes/list-index.tsx36
-rw-r--r--app/routes/list-new.tsx133
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>
+ );
+}