summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/app.css150
-rw-r--r--app/lib/db.server.ts50
-rw-r--r--app/lib/md.server.ts33
-rw-r--r--app/root.tsx46
-rw-r--r--app/routes.ts6
-rw-r--r--app/routes/api.posts.tsx42
-rw-r--r--app/routes/home.tsx87
7 files changed, 414 insertions, 0 deletions
diff --git a/app/app.css b/app/app.css
new file mode 100644
index 0000000..d289252
--- /dev/null
+++ b/app/app.css
@@ -0,0 +1,150 @@
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html {
+ font-family: ui-serif, Georgia, 'Times New Roman', Times, serif;
+ background: #0c0e14;
+ color: #c9cdd6;
+ line-height: 1.6;
+}
+
+textarea {
+ font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', Menlo, Consolas, monospace;
+ font-size: .875rem;
+ background: #161924;
+ color: #c9cdd6;
+ border: 1px solid #2a2f3e;
+ border-radius: 4px;
+ padding: .75rem 1rem;
+ resize: vertical;
+ width: 100%;
+ line-height: 1.6;
+}
+textarea:focus { outline: 1px solid #5c6bc0; border-color: #5c6bc0; }
+textarea::placeholder { color: #3d4560; }
+
+button[type="submit"] {
+ background: #1e2640;
+ border: 1px solid #3d4f7c;
+ color: #8899cc;
+ border-radius: 4px;
+ padding: .375rem 1.125rem;
+ font-size: .8125rem;
+ font-family: ui-monospace, monospace;
+ cursor: pointer;
+ letter-spacing: .02em;
+}
+button[type="submit"]:hover { background: #253058; color: #aabce0; border-color: #5068a0; }
+button[type="submit"]:disabled { opacity: .4; cursor: not-allowed; }
+
+/* ── Layout ── */
+
+.wrap { max-width: 660px; margin: 0 auto; padding: 3rem 1.5rem 5rem; }
+
+/* ── Header ── */
+
+.site-header { margin-bottom: 2.5rem; padding-bottom: 1.25rem; border-bottom: 1px solid #1a1d29; }
+.site-header h1 {
+ font-size: .875rem;
+ font-weight: 400;
+ color: #4b5267;
+ letter-spacing: .12em;
+ text-transform: lowercase;
+ font-family: ui-monospace, monospace;
+}
+
+/* ── Compose form ── */
+
+.compose { margin-bottom: 2.75rem; }
+.compose textarea { margin-bottom: .5rem; min-height: 96px; }
+.compose-row { display: flex; justify-content: space-between; align-items: center; }
+.hint { font-size: .6875rem; color: #32384d; font-family: ui-monospace, monospace; letter-spacing: .04em; }
+.error-msg { font-size: .8125rem; color: #e06c75; margin-top: .375rem; margin-bottom: .375rem; }
+
+/* ── Feed ── */
+
+.feed { display: flex; flex-direction: column; }
+
+.post { padding: 1.375rem 0; border-top: 1px solid #15182200; }
+.feed > .post:first-child { border-top: 1px solid #1a1d29; }
+.post + .post { border-top: 1px solid #1a1d29; }
+.post:last-child { border-bottom: 1px solid #1a1d29; }
+
+.post-meta {
+ display: block;
+ font-size: .6875rem;
+ color: #3d4560;
+ font-family: ui-monospace, monospace;
+ margin-bottom: .625rem;
+ letter-spacing: .04em;
+}
+
+/* ── Prose (rendered markdown) ── */
+
+.prose { font-size: .9375rem; }
+.prose p { margin-bottom: .75rem; color: #c9cdd6; line-height: 1.8; }
+.prose p:last-child { margin-bottom: 0; }
+
+.prose h1 { font-size: 1.25rem; color: #e8eaf0; margin: 1.25rem 0 .5rem; font-weight: 600; }
+.prose h2 { font-size: 1.0625rem; color: #e8eaf0; margin: 1.125rem 0 .375rem; font-weight: 600; }
+.prose h3 { font-size: .9375rem; color: #dde1ed; margin: 1rem 0 .25rem; font-weight: 600; }
+.prose h1:first-child,
+.prose h2:first-child,
+.prose h3:first-child { margin-top: 0; }
+
+.prose strong { color: #dde1ed; font-weight: 600; }
+.prose em { font-style: italic; }
+.prose del { color: #6b7280; }
+
+.prose a { color: #7aa2f7; }
+.prose a:hover { color: #9db8ff; text-decoration: underline; }
+
+.prose code {
+ font-family: ui-monospace, monospace;
+ font-size: .8125rem;
+ background: #1a1d29;
+ color: #a9b1d6;
+ padding: .1em .35em;
+ border-radius: 3px;
+}
+
+.prose pre {
+ background: #161924;
+ border: 1px solid #2a2f3e;
+ border-radius: 4px;
+ padding: .875rem 1rem;
+ overflow-x: auto;
+ margin-bottom: .75rem;
+}
+.prose pre code { background: none; padding: 0; color: #a9b1d6; font-size: .8125rem; }
+
+.prose ul { padding-left: 1.5rem; margin-bottom: .75rem; list-style: disc; }
+.prose ol { padding-left: 1.5rem; margin-bottom: .75rem; list-style: decimal; }
+.prose li { margin-bottom: .25rem; line-height: 1.75; }
+
+.prose blockquote {
+ border-left: 2px solid #2a2f3e;
+ padding-left: .875rem;
+ color: #7a8099;
+ margin-bottom: .75rem;
+ font-style: italic;
+}
+
+.prose hr { border: none; border-top: 1px solid #1a1d29; margin: 1.25rem 0; }
+
+.prose table { width: 100%; border-collapse: collapse; font-size: .875rem; margin-bottom: .75rem; }
+.prose th,
+.prose td { padding: .375rem .75rem; border: 1px solid #2a2f3e; text-align: left; }
+.prose th { background: #161924; color: #c9cdd6; }
+
+.prose img { max-width: 100%; border-radius: 4px; }
+
+/* ── Empty state ── */
+
+.empty {
+ color: #32384d;
+ font-size: .875rem;
+ padding: 2.5rem 0;
+ text-align: center;
+ font-style: italic;
+ font-family: ui-monospace, monospace;
+}
diff --git a/app/lib/db.server.ts b/app/lib/db.server.ts
new file mode 100644
index 0000000..b076ec7
--- /dev/null
+++ b/app/lib/db.server.ts
@@ -0,0 +1,50 @@
+import Database from "better-sqlite3";
+import path from "path";
+
+let db: Database.Database | null = null;
+
+export function getDb(): Database.Database {
+ if (!db) {
+ const dbPath = process.env.DB_PATH ?? path.resolve("microblog.db");
+ db = new Database(dbPath);
+ db.pragma("journal_mode = WAL");
+ db.pragma("foreign_keys = ON");
+ initSchema(db);
+ }
+ return db;
+}
+
+function initSchema(db: Database.Database) {
+ db.exec(`
+ CREATE TABLE IF NOT EXISTS posts (
+ id TEXT PRIMARY KEY,
+ content TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+ );
+ CREATE INDEX IF NOT EXISTS idx_posts_created_at ON posts(created_at DESC);
+ `);
+}
+
+export interface Post {
+ id: string;
+ content: string;
+ created_at: string;
+}
+
+export interface CreatePostInput {
+ id: string;
+ content: string;
+}
+
+export function listPosts(): Post[] {
+ return getDb().prepare("SELECT * FROM posts ORDER BY created_at DESC").all() as Post[];
+}
+
+export function getPostById(id: string): Post | null {
+ return getDb().prepare("SELECT * FROM posts WHERE id = ?").get(id) as Post | null;
+}
+
+export function createPost(input: CreatePostInput): Post {
+ getDb().prepare("INSERT INTO posts (id, content) VALUES (?, ?)").run(input.id, input.content);
+ return getPostById(input.id)!;
+}
diff --git a/app/lib/md.server.ts b/app/lib/md.server.ts
new file mode 100644
index 0000000..8f0d6d6
--- /dev/null
+++ b/app/lib/md.server.ts
@@ -0,0 +1,33 @@
+import { marked } from "marked";
+import sanitizeHtml from "sanitize-html";
+
+const ALLOWED_TAGS = [
+ "p", "br",
+ "h1", "h2", "h3", "h4", "h5", "h6",
+ "ul", "ol", "li",
+ "blockquote",
+ "pre", "code",
+ "strong", "em", "del", "s",
+ "a",
+ "hr",
+ "table", "thead", "tbody", "tr", "th", "td",
+ "img",
+];
+
+const ALLOWED_ATTRIBUTES: sanitizeHtml.IOptions["allowedAttributes"] = {
+ a: ["href", "title"],
+ img: ["src", "alt", "title"],
+ code: ["class"],
+ td: ["align"],
+ th: ["align"],
+};
+
+export function renderMarkdown(content: string): string {
+ const raw = marked.parse(content, { async: false }) as string;
+ return sanitizeHtml(raw, {
+ allowedTags: ALLOWED_TAGS,
+ allowedAttributes: ALLOWED_ATTRIBUTES,
+ allowedSchemes: ["http", "https", "mailto"],
+ allowedSchemesByTag: { img: ["http", "https"] },
+ });
+}
diff --git a/app/root.tsx b/app/root.tsx
new file mode 100644
index 0000000..80570db
--- /dev/null
+++ b/app/root.tsx
@@ -0,0 +1,46 @@
+import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
+import type { Route } from "./+types/root";
+import "./app.css";
+
+export function Layout({ children }: { children: React.ReactNode }) {
+ return (
+ <html lang="ja">
+ <head>
+ <meta charSet="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <title>log</title>
+ <Meta />
+ <Links />
+ </head>
+ <body>
+ {children}
+ <ScrollRestoration />
+ <Scripts />
+ </body>
+ </html>
+ );
+}
+
+export default function App() {
+ return <Outlet />;
+}
+
+export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
+ let message = "Error";
+ let details = "An unexpected error occurred.";
+
+ if (isRouteErrorResponse(error)) {
+ message = String(error.status);
+ details = error.statusText || details;
+ } else if (import.meta.env.DEV && error instanceof Error) {
+ details = error.message;
+ }
+
+ return (
+ <div className="wrap">
+ <p style={{ color: "#e06c75", fontFamily: "monospace" }}>
+ {message}: {details}
+ </p>
+ </div>
+ );
+}
diff --git a/app/routes.ts b/app/routes.ts
new file mode 100644
index 0000000..e839849
--- /dev/null
+++ b/app/routes.ts
@@ -0,0 +1,6 @@
+import { type RouteConfig, index, route } from "@react-router/dev/routes";
+
+export default [
+ index("routes/home.tsx"),
+ route("/api/posts", "routes/api.posts.tsx"),
+] satisfies RouteConfig;
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",
+ });
+}