From cba06f7e23417a9aef6c15ae26715385feb225ba Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Fri, 19 Jun 2026 19:45:41 +0900
Subject: Add white theme, post delete, and passkey authentication

- Rewrite CSS to light/white theme
- Add delete button (auth-gated) with confirm dialog
- Add passkey register/login/logout routes via @simplewebauthn
- Gate compose form and delete behind session authentication
- Add credentials table to SQLite schema
- Add SESSION_SECRET and WebAuthn env vars to docker-compose

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---
 app/routes/home.tsx | 121 +++++++++++++++++++++++++++++++++++++---------------
 1 file changed, 86 insertions(+), 35 deletions(-)

(limited to 'app/routes/home.tsx')

diff --git a/app/routes/home.tsx b/app/routes/home.tsx
index 58004c9..7732326 100644
--- a/app/routes/home.tsx
+++ b/app/routes/home.tsx
@@ -1,31 +1,51 @@
 import { useEffect, useRef } from "react";
-import { Form, redirect, useNavigation } from "react-router";
+import { Form, Link, redirect, useNavigation } from "react-router";
 import type { Route } from "./+types/home";
-import { createPost, listPosts } from "~/lib/db.server";
+import { createPost, deletePost, listPosts, listCredentials } from "~/lib/db.server";
 import { renderMarkdown } from "~/lib/md.server";
+import { getSession, commitSession } from "~/lib/session.server";
 
-export async function loader() {
+export async function loader({ request }: Route.LoaderArgs) {
+  const session = await getSession(request.headers.get("Cookie"));
+  const isAuthenticated = session.get("authenticated") === true;
+  const hasCredentials = listCredentials().length > 0;
   const posts = listPosts();
-  return { posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })) };
+  return {
+    posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })),
+    isAuthenticated,
+    hasCredentials,
+  };
 }
 
 export async function action({ request }: Route.ActionArgs) {
+  const session = await getSession(request.headers.get("Cookie"));
+  if (session.get("authenticated") !== true) {
+    throw new Response("Unauthorized", { status: 401 });
+  }
+
   const form = await request.formData();
-  const content = String(form.get("content") ?? "").trim();
+  const intent = String(form.get("intent") ?? "create");
 
-  if (!content) {
-    return { error: "内容を入力してください", content };
-  }
-  if (content.length > 10000) {
-    return { error: "投稿が長すぎます（最大10,000文字）", content };
+  if (intent === "delete") {
+    const id = String(form.get("id") ?? "");
+    if (id) deletePost(id);
+    return redirect("/", {
+      headers: { "Set-Cookie": await commitSession(session) },
+    });
   }
 
+  const content = String(form.get("content") ?? "").trim();
+  if (!content) return { error: "内容を入力してください", content };
+  if (content.length > 10000) return { error: "投稿が長すぎます（最大10,000文字）", content };
+
   createPost({ id: crypto.randomUUID(), content });
-  return redirect("/");
+  return redirect("/", {
+    headers: { "Set-Cookie": await commitSession(session) },
+  });
 }
 
 export default function Home({ loaderData, actionData }: Route.ComponentProps) {
-  const { posts } = loaderData;
+  const { posts, isAuthenticated, hasCredentials } = loaderData;
   const navigation = useNavigation();
   const isSubmitting = navigation.state === "submitting";
   const formRef = useRef<HTMLFormElement>(null);
@@ -43,28 +63,42 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
     <div className="wrap">
       <header className="site-header">
         <h1>log</h1>
+        <nav className="header-nav">
+          {isAuthenticated ? (
+            <Form method="post" action="/auth/logout">
+              <button type="submit" className="logout-btn">ログアウト</button>
+            </Form>
+          ) : hasCredentials ? (
+            <Link to="/auth/login" className="auth-link">ログイン</Link>
+          ) : (
+            <Link to="/auth/register" className="auth-link">登録</Link>
+          )}
+        </nav>
       </header>
 
-      <div className="compose">
-        <Form method="post" ref={formRef}>
-          <textarea
-            name="content"
-            placeholder="Markdown で投稿..."
-            rows={4}
-            defaultValue={actionData?.content ?? ""}
-            autoFocus
-          />
-          {actionData?.error && (
-            <p className="error-msg">{actionData.error}</p>
-          )}
-          <div className="compose-row">
-            <span className="hint">markdown</span>
-            <button type="submit" disabled={isSubmitting}>
-              {isSubmitting ? "投稿中…" : "投稿"}
-            </button>
-          </div>
-        </Form>
-      </div>
+      {isAuthenticated && (
+        <div className="compose">
+          <Form method="post" ref={formRef}>
+            <input type="hidden" name="intent" value="create" />
+            <textarea
+              name="content"
+              placeholder="Markdown で投稿..."
+              rows={4}
+              defaultValue={actionData?.content ?? ""}
+              autoFocus
+            />
+            {actionData?.error && (
+              <p className="error-msg">{actionData.error}</p>
+            )}
+            <div className="compose-row">
+              <span className="hint">markdown</span>
+              <button type="submit" disabled={isSubmitting}>
+                {isSubmitting ? "投稿中…" : "投稿"}
+              </button>
+            </div>
+          </Form>
+        </div>
+      )}
 
       <div className="feed">
         {posts.length === 0 && (
@@ -72,9 +106,26 @@ export default function Home({ loaderData, actionData }: Route.ComponentProps) {
         )}
         {posts.map((post) => (
           <article key={post.id} className="post">
-            <time className="post-meta" dateTime={post.created_at + "Z"}>
-              {formatDate(post.created_at)}
-            </time>
+            <div className="post-header">
+              <time className="post-meta" dateTime={post.created_at + "Z"}>
+                {formatDate(post.created_at)}
+              </time>
+              {isAuthenticated && (
+                <Form method="post">
+                  <input type="hidden" name="intent" value="delete" />
+                  <input type="hidden" name="id" value={post.id} />
+                  <button
+                    type="submit"
+                    className="delete-btn"
+                    onClick={(e) => {
+                      if (!confirm("この投稿を削除しますか？")) e.preventDefault();
+                    }}
+                  >
+                    削除
+                  </button>
+                </Form>
+              )}
+            </div>
             <div
               className="prose"
               dangerouslySetInnerHTML={{ __html: post.html }}
-- 
cgit v1.2.3

