1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
|
import { useEffect, useRef } from "react";
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";
const formRef = useRef<HTMLFormElement>(null);
const wasSubmitting = useRef(false);
useEffect(() => {
if (navigation.state === "submitting") wasSubmitting.current = true;
if (wasSubmitting.current && navigation.state === "idle" && !actionData?.error) {
formRef.current?.reset();
wasSubmitting.current = false;
}
}, [navigation.state, actionData?.error]);
return (
<div className="wrap">
<header className="site-header">
<h1>log</h1>
</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>
<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",
});
}
|