diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-19 15:18:55 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-06-19 15:18:55 +0900 |
| commit | 30f24e137ee2d1acec11a0da61e134f512ee02dd (patch) | |
| tree | 28bd91bde3ad883f9f8fa543fca4b25ed50d05e9 /app/routes/home.tsx | |
| parent | da4934ae84f8a81fe3f4f8b392a4a3dc3d566f30 (diff) | |
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>
Diffstat (limited to 'app/routes/home.tsx')
| -rw-r--r-- | app/routes/home.tsx | 87 |
1 files changed, 87 insertions, 0 deletions
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", + }); +} |
