diff options
| author | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-22 19:21:12 +0900 |
|---|---|---|
| committer | yyamashita <yyamashita@hetzner.yyamashita.com> | 2026-08-22 19:21:12 +0900 |
| commit | 7c80f5ce7e44b95d16e19de40de0f8079377c6b9 (patch) | |
| tree | ad189b750361af8c7cb040b56f42df5d4e5a89ee /app/routes/home.tsx | |
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'app/routes/home.tsx')
| -rw-r--r-- | app/routes/home.tsx | 252 |
1 files changed, 252 insertions, 0 deletions
diff --git a/app/routes/home.tsx b/app/routes/home.tsx new file mode 100644 index 0000000..fd3c534 --- /dev/null +++ b/app/routes/home.tsx @@ -0,0 +1,252 @@ +import { useRef, useState } from "react"; +import { Form, redirect, useNavigation, useSearchParams, Link } from "react-router"; +import type { Route } from "./+types/home"; +import { + listTodos, + createTodo, + toggleTodo, + deleteTodo, + countActiveTodos, + hasAnyCredential, +} from "~/lib/db.server"; +import { getSession, commitSession } from "~/lib/session.server"; + +export async function loader({ request }: Route.LoaderArgs) { + const session = await getSession(request.headers.get("Cookie")); + const userId = session.get("userId") as string | undefined; + const username = session.get("username") as string | undefined; + const url = new URL(request.url); + const filter = url.searchParams.get("filter") ?? "all"; + const hasCredentials = hasAnyCredential(); + + if (!userId) { + return { isAuthenticated: false, hasCredentials, todos: [], activeCount: 0, filter, username: null }; + } + + const todos = listTodos(userId, filter); + const activeCount = countActiveTodos(userId); + return { isAuthenticated: true, hasCredentials, todos, activeCount, filter, username: username ?? null }; +} + +export async function action({ request }: Route.ActionArgs) { + const session = await getSession(request.headers.get("Cookie")); + const userId = session.get("userId") as string | undefined; + if (!userId) throw new Response("Unauthorized", { status: 401 }); + + const form = await request.formData(); + const intent = String(form.get("intent") ?? ""); + + if (intent === "create") { + const title = String(form.get("title") ?? "").trim(); + if (!title) return { error: "タイトルを入力してください" }; + createTodo(userId, title); + return redirect("/", { headers: { "Set-Cookie": await commitSession(session) } }); + } + + if (intent === "toggle") { + const id = String(form.get("id") ?? ""); + const done = form.get("done") === "1"; + if (id) toggleTodo(id, userId, done); + return redirect(request.headers.get("Referer") ?? "/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + if (intent === "delete") { + const id = String(form.get("id") ?? ""); + if (id) deleteTodo(id, userId); + return redirect(request.headers.get("Referer") ?? "/", { + headers: { "Set-Cookie": await commitSession(session) }, + }); + } + + return { error: "不正なリクエスト" }; +} + +function bufferToB64url(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let binary = ""; + for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, ""); +} + +function b64urlToBuffer(b64url: string): ArrayBuffer { + const base64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(base64 + pad); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} + +export default function Home({ loaderData, actionData }: Route.ComponentProps) { + const { isAuthenticated, hasCredentials, todos, activeCount, filter, username } = loaderData; + const navigation = useNavigation(); + const isSubmitting = navigation.state === "submitting"; + const inputRef = useRef<HTMLInputElement>(null); + const [searchParams] = useSearchParams(); + const [loginStatus, setLoginStatus] = useState<"idle" | "loading" | "error">("idle"); + const [loginError, setLoginError] = useState(""); + + async function handleLogin() { + setLoginStatus("loading"); + setLoginError(""); + try { + const optRes = await fetch("/api/passkey?intent=login-options", { method: "POST" }); + if (!optRes.ok) throw new Error("オプション取得に失敗しました"); + const options = await optRes.json() as Record<string, unknown>; + + const credential = await navigator.credentials.get({ + publicKey: { + challenge: b64urlToBuffer(options.challenge as string), + rpId: options.rpId as string, + allowCredentials: (options.allowCredentials as Array<{ id: string; transports?: string[] }> ?? []).map((c) => ({ + id: b64urlToBuffer(c.id), + type: "public-key" as const, + transports: c.transports as AuthenticatorTransport[] | undefined, + })), + userVerification: "required" as UserVerificationRequirement, + timeout: 60000, + }, + }) as PublicKeyCredential | null; + + if (!credential) throw new Error("認証に失敗しました"); + + const assertion = credential.response as AuthenticatorAssertionResponse; + const verRes = await fetch("/api/passkey?intent=login-verify", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: credential.id, + rawId: bufferToB64url(credential.rawId), + response: { + clientDataJSON: bufferToB64url(assertion.clientDataJSON), + authenticatorData: bufferToB64url(assertion.authenticatorData), + signature: bufferToB64url(assertion.signature), + userHandle: assertion.userHandle ? bufferToB64url(assertion.userHandle) : undefined, + }, + authenticatorAttachment: credential.authenticatorAttachment ?? undefined, + clientExtensionResults: credential.getClientExtensionResults(), + type: "public-key", + }), + }); + if (!verRes.ok) { + const err = await verRes.json() as { error?: string }; + throw new Error(err.error ?? "認証に失敗しました"); + } + window.location.href = "/"; + } catch (e) { + setLoginStatus("error"); + setLoginError(e instanceof Error ? e.message : "エラーが発生しました"); + } + } + + const currentFilter = searchParams.get("filter") ?? "all"; + + if (!isAuthenticated) { + return ( + <div className="wrap"> + <header className="site-header"> + <h1>todo</h1> + </header> + <div className="login-box"> + {hasCredentials ? ( + <> + <button className="btn-login" onClick={handleLogin} disabled={loginStatus === "loading"}> + {loginStatus === "loading" ? "認証中…" : "パスキーでログイン"} + </button> + {loginStatus === "error" && <p className="error-msg">{loginError}</p>} + </> + ) : ( + <Link to="/auth/register" className="btn-login">はじめる</Link> + )} + </div> + </div> + ); + } + + return ( + <div className="wrap"> + <header className="site-header"> + <h1>todo</h1> + <nav className="header-nav"> + <span className="username">{username}</span> + <Form method="post" action="/auth/logout"> + <button type="submit" className="logout-btn">ログアウト</button> + </Form> + </nav> + </header> + + <div className="compose"> + <Form + method="post" + onSubmit={() => { + // Reset input after successful submit + setTimeout(() => inputRef.current?.focus(), 50); + }} + > + <input type="hidden" name="intent" value="create" /> + <div className="compose-row"> + <input + ref={inputRef} + type="text" + name="title" + placeholder="新しいタスクを追加…" + autoComplete="off" + autoFocus + /> + <button type="submit" disabled={isSubmitting}>追加</button> + </div> + </Form> + {actionData && "error" in actionData && <p className="error-msg">{actionData.error}</p>} + </div> + + <nav className="filters"> + <Link to="/" className={currentFilter === "all" ? "active" : ""}>すべて</Link> + <Link to="/?filter=active" className={currentFilter === "active" ? "active" : ""}>未完了</Link> + <Link to="/?filter=done" className={currentFilter === "done" ? "active" : ""}>完了</Link> + </nav> + + <ul className="todo-list"> + {todos.length === 0 && ( + <li className="empty"> + {filter === "done" ? "完了したタスクはありません" : filter === "active" ? "未完了のタスクはありません" : "タスクがありません"} + </li> + )} + {todos.map((todo) => ( + <li key={todo.id} className={`todo-item ${todo.done ? "done" : ""}`}> + <Form method="post" className="toggle-form"> + <input type="hidden" name="intent" value="toggle" /> + <input type="hidden" name="id" value={todo.id} /> + <input type="hidden" name="done" value={todo.done ? "0" : "1"} /> + <button type="submit" className="checkbox" aria-label={todo.done ? "未完了に戻す" : "完了にする"}> + {todo.done ? ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> + <rect width="16" height="16" rx="3" fill="#6c63ff" /> + <path d="M3.5 8L6.5 11L12.5 5" stroke="white" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /> + </svg> + ) : ( + <svg width="16" height="16" viewBox="0 0 16 16" fill="none"> + <rect x="0.5" y="0.5" width="15" height="15" rx="2.5" stroke="#4b5563" /> + </svg> + )} + </button> + </Form> + <span className="todo-title">{todo.title}</span> + <Form method="post" className="delete-form"> + <input type="hidden" name="intent" value="delete" /> + <input type="hidden" name="id" value={todo.id} /> + <button type="submit" className="delete-btn" aria-label="削除">×</button> + </Form> + </li> + ))} + </ul> + + {todos.length > 0 && ( + <footer className="todo-footer"> + <span>{activeCount} 件未完了</span> + </footer> + )} + </div> + ); +} |
