From 30f24e137ee2d1acec11a0da61e134f512ee02dd Mon Sep 17 00:00:00 2001
From: yyamashita <yyamashita@hetzner.yyamashita.com>
Date: Fri, 19 Jun 2026 15:18:55 +0900
Subject: Initial implementation of microblog

Markdown-based personal microblog with REST API.
XSS protection via sanitize-html on server-side markdown rendering.

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

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

diff --git a/app/routes/home.tsx b/app/routes/home.tsx
new file mode 100644
index 0000000..51d6f04
--- /dev/null
+++ b/app/routes/home.tsx
@@ -0,0 +1,87 @@
+import { Form, redirect, useNavigation } from "react-router";
+import type { Route } from "./+types/home";
+import { createPost, listPosts } from "~/lib/db.server";
+import { renderMarkdown } from "~/lib/md.server";
+
+export async function loader() {
+  const posts = listPosts();
+  return { posts: posts.map((p) => ({ ...p, html: renderMarkdown(p.content) })) };
+}
+
+export async function action({ request }: Route.ActionArgs) {
+  const form = await request.formData();
+  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("/");
+}
+
+export default function Home({ loaderData, actionData }: Route.ComponentProps) {
+  const { posts } = loaderData;
+  const navigation = useNavigation();
+  const isSubmitting = navigation.state === "submitting";
+
+  return (
+    <div className="wrap">
+      <header className="site-header">
+        <h1>log</h1>
+      </header>
+
+      <div className="compose">
+        <Form method="post">
+          <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 && (
+          <p className="empty">まだ投稿がありません</p>
+        )}
+        {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="prose"
+              dangerouslySetInnerHTML={{ __html: post.html }}
+            />
+          </article>
+        ))}
+      </div>
+    </div>
+  );
+}
+
+function formatDate(isoUtc: string): string {
+  return new Date(isoUtc + "Z").toLocaleString("ja-JP", {
+    year: "numeric",
+    month: "2-digit",
+    day: "2-digit",
+    hour: "2-digit",
+    minute: "2-digit",
+    timeZone: "Asia/Tokyo",
+  });
+}
-- 
cgit v1.2.3

