summaryrefslogtreecommitdiff
path: root/app/routes/home.tsx
diff options
context:
space:
mode:
authoryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-19 19:45:41 +0900
committeryyamashita <yyamashita@hetzner.yyamashita.com>2026-06-19 19:45:41 +0900
commitcba06f7e23417a9aef6c15ae26715385feb225ba (patch)
tree042f8e8343e7203ac9c6576e3c18fc6865a59ba1 /app/routes/home.tsx
parentc6bb8a0c75b4307a73a3e36d97c7b5e69294cfe9 (diff)
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>
Diffstat (limited to 'app/routes/home.tsx')
-rw-r--r--app/routes/home.tsx121
1 files changed, 86 insertions, 35 deletions
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 }}