From e9e576abd9d6c6030aa4bb290e869890831488ad Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@mosquit.one>
Date: Mon, 11 May 2026 00:06:52 +0900
Subject: Add lists feature (band recommendation lists with history)

New lists, list_entries, list_revisions tables; full CRUD routes under /lists; nav link in root.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/list-new.tsx | 133 ++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 133 insertions(+)
 create mode 100644 app/routes/list-new.tsx

(limited to 'app/routes/list-new.tsx')

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>
+  );
+}
-- 
cgit v1.2.3

