summaryrefslogtreecommitdiff
path: root/app/routes
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes')
-rw-r--r--app/routes/api.posts.tsx42
-rw-r--r--app/routes/home.tsx87
2 files changed, 129 insertions, 0 deletions
diff --git a/app/routes/api.posts.tsx b/app/routes/api.posts.tsx
new file mode 100644
index 0000000..2d5637c
--- /dev/null
+++ b/app/routes/api.posts.tsx
@@ -0,0 +1,42 @@
+import type { Route } from "./+types/api.posts";
+import { createPost, listPosts } from "~/lib/db.server";
+import { renderMarkdown } from "~/lib/md.server";
+
+export async function loader() {
+ const posts = listPosts();
+ return Response.json(
+ posts.map((p) => ({ ...p, html: renderMarkdown(p.content) }))
+ );
+}
+
+export async function action({ request }: Route.ActionArgs) {
+ if (request.method !== "POST") {
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
+ }
+
+ let content: string;
+ const ct = request.headers.get("content-type") ?? "";
+ if (ct.includes("application/json")) {
+ const body = (await request.json()) as { content?: unknown };
+ content = String(body.content ?? "").trim();
+ } else {
+ const form = await request.formData();
+ content = String(form.get("content") ?? "").trim();
+ }
+
+ if (!content) {
+ return Response.json({ error: "content is required" }, { status: 400 });
+ }
+ if (content.length > 10000) {
+ return Response.json(
+ { error: "content too long (max 10000 chars)" },
+ { status: 400 }
+ );
+ }
+
+ const post = createPost({ id: crypto.randomUUID(), content });
+ return Response.json(
+ { ...post, html: renderMarkdown(post.content) },
+ { status: 201 }
+ );
+}
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",
+ });
+}