summaryrefslogtreecommitdiff
path: root/app/routes/home.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/routes/home.tsx')
-rw-r--r--app/routes/home.tsx252
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>
+ );
+}