checkpoint

This commit is contained in:
polwex 2026-04-18 22:04:32 +07:00
parent 199dab69f9
commit 9439681df9
4 changed files with 296 additions and 227 deletions

View file

@ -1,16 +1,22 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⌨️</text></svg>" /> <link rel="icon"
href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>⌨️</text></svg>" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Leo's Typing Tutor</title> <title>カンポス家のお宿題</title>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" /> <link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap"
rel="stylesheet" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View file

@ -3,12 +3,12 @@ import {
verifyRegistrationResponse, verifyRegistrationResponse,
generateAuthenticationOptions, generateAuthenticationOptions,
verifyAuthenticationResponse, verifyAuthenticationResponse,
} from "@simplewebauthn/server" } from "@simplewebauthn/server";
import { isoBase64URL } from "@simplewebauthn/server/helpers" import { isoBase64URL } from "@simplewebauthn/server/helpers";
import type { import type {
RegistrationResponseJSON, RegistrationResponseJSON,
AuthenticationResponseJSON, AuthenticationResponseJSON,
} from "@simplewebauthn/server" } from "@simplewebauthn/server";
import { import {
createUser, createUser,
getUserByUsername, getUserByUsername,
@ -19,198 +19,212 @@ import {
createSession, createSession,
getSession, getSession,
deleteSession, deleteSession,
} from "./db" } from "./db";
const RP_NAME = "Leo's Typing Tutor" const RP_NAME = "shukudai";
// Temporary challenge store (in-memory, keyed by random ID → challenge + metadata) // Temporary challenge store (in-memory, keyed by random ID → challenge + metadata)
const challenges = new Map<string, { challenge: string; userId?: number; expires: number }>() const challenges = new Map<
string,
{ challenge: string; userId?: number; expires: number }
>();
// Cleanup expired challenges periodically // Cleanup expired challenges periodically
setInterval(() => { setInterval(() => {
const now = Date.now() const now = Date.now();
for (const [key, val] of challenges) { for (const [key, val] of challenges) {
if (val.expires < now) challenges.delete(key) if (val.expires < now) challenges.delete(key);
} }
}, 60_000) }, 60_000);
function getRpId(req: Request): string { function getRpId(req: Request): string {
const url = new URL(req.url) const url = new URL(req.url);
return url.hostname return url.hostname;
} }
function getOrigin(req: Request): string { function getOrigin(req: Request): string {
const url = new URL(req.url) const url = new URL(req.url);
return url.origin return url.origin;
} }
function getCookie(req: Request, name: string): string | undefined { function getCookie(req: Request, name: string): string | undefined {
const cookies = req.headers.get("cookie") const cookies = req.headers.get("cookie");
if (!cookies) return undefined if (!cookies) return undefined;
const match = cookies.split(";").find(c => c.trim().startsWith(`${name}=`)) const match = cookies.split(";").find((c) => c.trim().startsWith(`${name}=`));
return match?.split("=").slice(1).join("=").trim() return match?.split("=").slice(1).join("=").trim();
} }
function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string { function sessionCookie(token: string, maxAge = 7 * 24 * 60 * 60): string {
return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}` return `session=${token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${maxAge}`;
} }
function challengeCookie(key: string): string { function challengeCookie(key: string): string {
return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120` return `challenge_key=${key}; HttpOnly; SameSite=Strict; Path=/; Max-Age=120`;
} }
function clearChallengeCookie(): string { function clearChallengeCookie(): string {
return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0` return `challenge_key=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
} }
// ---- Route handlers ---- // ---- Route handlers ----
export async function registerOptions(req: Request): Promise<Response> { export async function registerOptions(req: Request): Promise<Response> {
const { username } = await req.json() as { username: string } const { username } = (await req.json()) as { username: string };
if (!username || username.length < 1 || username.length > 32) { if (!username || username.length < 1 || username.length > 32) {
return Response.json({ error: "Username must be 1-32 characters" }, { status: 400 }) return Response.json(
{ error: "Username must be 1-32 characters" },
{ status: 400 },
);
} }
// Check if username already taken // Check if username already taken
const existing = await getUserByUsername(username) const existing = await getUserByUsername(username);
if (existing) { if (existing) {
return Response.json({ error: "Username already taken" }, { status: 409 }) return Response.json({ error: "Username already taken" }, { status: 409 });
} }
// Create user // Create user
const user = await createUser(username) const user = await createUser(username);
const rpID = getRpId(req) const rpID = getRpId(req);
const options = await generateRegistrationOptions({ const options = await generateRegistrationOptions({
rpName: RP_NAME, rpName: RP_NAME,
rpID, rpID,
userName: username, userName: username,
userID: isoBase64URL.toBuffer(isoBase64URL.fromUTF8String(user.id.toString())), userID: isoBase64URL.toBuffer(
isoBase64URL.fromUTF8String(user.id.toString()),
),
attestationType: "none", attestationType: "none",
authenticatorSelection: { authenticatorSelection: {
residentKey: "required", residentKey: "required",
userVerification: "preferred", userVerification: "preferred",
}, },
}) });
// Store challenge // Store challenge
const challengeKey = crypto.randomUUID() const challengeKey = crypto.randomUUID();
challenges.set(challengeKey, { challenges.set(challengeKey, {
challenge: options.challenge, challenge: options.challenge,
userId: user.id, userId: user.id,
expires: Date.now() + 120_000, expires: Date.now() + 120_000,
}) });
return new Response(JSON.stringify(options), { return new Response(JSON.stringify(options), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Set-Cookie": challengeCookie(challengeKey), "Set-Cookie": challengeCookie(challengeKey),
}, },
}) });
} }
export async function registerVerify(req: Request): Promise<Response> { export async function registerVerify(req: Request): Promise<Response> {
const body = (await req.json()) as RegistrationResponseJSON const body = (await req.json()) as RegistrationResponseJSON;
const challengeKey = getCookie(req, "challenge_key") const challengeKey = getCookie(req, "challenge_key");
if (!challengeKey) { if (!challengeKey) {
return Response.json({ error: "No challenge found" }, { status: 400 }) return Response.json({ error: "No challenge found" }, { status: 400 });
} }
const stored = challenges.get(challengeKey) const stored = challenges.get(challengeKey);
if (!stored || stored.expires < Date.now()) { if (!stored || stored.expires < Date.now()) {
challenges.delete(challengeKey!) challenges.delete(challengeKey!);
return Response.json({ error: "Challenge expired" }, { status: 400 }) return Response.json({ error: "Challenge expired" }, { status: 400 });
} }
challenges.delete(challengeKey) challenges.delete(challengeKey);
const rpID = getRpId(req) const rpID = getRpId(req);
const origin = getOrigin(req) const origin = getOrigin(req);
let verification let verification;
try { try {
verification = await verifyRegistrationResponse({ verification = await verifyRegistrationResponse({
response: body, response: body,
expectedChallenge: stored.challenge, expectedChallenge: stored.challenge,
expectedOrigin: origin, expectedOrigin: origin,
expectedRPID: rpID, expectedRPID: rpID,
}) });
} catch (err) { } catch (err) {
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 }) return Response.json(
{ error: `Verification failed: ${err}` },
{ status: 400 },
);
} }
if (!verification.verified || !verification.registrationInfo) { if (!verification.verified || !verification.registrationInfo) {
return Response.json({ error: "Verification failed" }, { status: 400 }) return Response.json({ error: "Verification failed" }, { status: 400 });
} }
const { credential } = verification.registrationInfo const { credential } = verification.registrationInfo;
await storeCredential( await storeCredential(
credential.id, credential.id,
stored.userId!, stored.userId!,
isoBase64URL.fromBuffer(credential.publicKey), isoBase64URL.fromBuffer(credential.publicKey),
credential.counter, credential.counter,
credential.transports, credential.transports,
) );
// Create session // Create session
const token = await createSession(stored.userId!) const token = await createSession(stored.userId!);
const user = await getUserById(stored.userId!) const user = await getUserById(stored.userId!);
return new Response(JSON.stringify({ verified: true, username: user?.username }), { return new Response(
JSON.stringify({ verified: true, username: user?.username }),
{
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
}, },
}) },
);
} }
export async function loginOptions(req: Request): Promise<Response> { export async function loginOptions(req: Request): Promise<Response> {
const rpID = getRpId(req) const rpID = getRpId(req);
const options = await generateAuthenticationOptions({ const options = await generateAuthenticationOptions({
rpID, rpID,
userVerification: "preferred", userVerification: "preferred",
// Empty allowCredentials = discoverable credentials (passkey prompt) // Empty allowCredentials = discoverable credentials (passkey prompt)
}) });
const challengeKey = crypto.randomUUID() const challengeKey = crypto.randomUUID();
challenges.set(challengeKey, { challenges.set(challengeKey, {
challenge: options.challenge, challenge: options.challenge,
expires: Date.now() + 120_000, expires: Date.now() + 120_000,
}) });
return new Response(JSON.stringify(options), { return new Response(JSON.stringify(options), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Set-Cookie": challengeCookie(challengeKey), "Set-Cookie": challengeCookie(challengeKey),
}, },
}) });
} }
export async function loginVerify(req: Request): Promise<Response> { export async function loginVerify(req: Request): Promise<Response> {
const body = (await req.json()) as AuthenticationResponseJSON const body = (await req.json()) as AuthenticationResponseJSON;
const challengeKey = getCookie(req, "challenge_key") const challengeKey = getCookie(req, "challenge_key");
if (!challengeKey) { if (!challengeKey) {
return Response.json({ error: "No challenge found" }, { status: 400 }) return Response.json({ error: "No challenge found" }, { status: 400 });
} }
const stored = challenges.get(challengeKey) const stored = challenges.get(challengeKey);
if (!stored || stored.expires < Date.now()) { if (!stored || stored.expires < Date.now()) {
challenges.delete(challengeKey!) challenges.delete(challengeKey!);
return Response.json({ error: "Challenge expired" }, { status: 400 }) return Response.json({ error: "Challenge expired" }, { status: 400 });
} }
challenges.delete(challengeKey) challenges.delete(challengeKey);
// Look up credential // Look up credential
const credentialId = body.id const credentialId = body.id;
const credential = await getCredentialById(credentialId) const credential = await getCredentialById(credentialId);
if (!credential) { if (!credential) {
return Response.json({ error: "Unknown credential" }, { status: 400 }) return Response.json({ error: "Unknown credential" }, { status: 400 });
} }
const rpID = getRpId(req) const rpID = getRpId(req);
const origin = getOrigin(req) const origin = getOrigin(req);
let verification let verification;
try { try {
verification = await verifyAuthenticationResponse({ verification = await verifyAuthenticationResponse({
response: body, response: body,
@ -221,60 +235,73 @@ export async function loginVerify(req: Request): Promise<Response> {
id: credential.id, id: credential.id,
publicKey: isoBase64URL.toBuffer(credential.public_key), publicKey: isoBase64URL.toBuffer(credential.public_key),
counter: credential.counter, counter: credential.counter,
transports: credential.transports ? JSON.parse(credential.transports) : undefined, transports: credential.transports
? JSON.parse(credential.transports)
: undefined,
}, },
}) });
} catch (err) { } catch (err) {
return Response.json({ error: `Verification failed: ${err}` }, { status: 400 }) return Response.json(
{ error: `Verification failed: ${err}` },
{ status: 400 },
);
} }
if (!verification.verified) { if (!verification.verified) {
return Response.json({ error: "Verification failed" }, { status: 400 }) return Response.json({ error: "Verification failed" }, { status: 400 });
} }
// Update counter // Update counter
await updateCredentialCounter(credentialId, verification.authenticationInfo.newCounter) await updateCredentialCounter(
credentialId,
verification.authenticationInfo.newCounter,
);
// Create session // Create session
const token = await createSession(credential.user_id) const token = await createSession(credential.user_id);
const user = await getUserById(credential.user_id) const user = await getUserById(credential.user_id);
return new Response(JSON.stringify({ verified: true, username: user?.username }), { return new Response(
JSON.stringify({ verified: true, username: user?.username }),
{
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "), "Set-Cookie": [sessionCookie(token), clearChallengeCookie()].join(", "),
}, },
}) },
);
} }
export async function me(req: Request): Promise<Response> { export async function me(req: Request): Promise<Response> {
const token = getCookie(req, "session") const token = getCookie(req, "session");
if (!token) { if (!token) {
return Response.json({ user: null }) return Response.json({ user: null });
} }
const session = await getSession(token) const session = await getSession(token);
if (!session) { if (!session) {
return new Response(JSON.stringify({ user: null }), { return new Response(JSON.stringify({ user: null }), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Set-Cookie": sessionCookie("", 0), // clear expired cookie "Set-Cookie": sessionCookie("", 0), // clear expired cookie
}, },
}) });
} }
return Response.json({ user: { id: session.user_id, username: session.username } }) return Response.json({
user: { id: session.user_id, username: session.username },
});
} }
export async function logout(req: Request): Promise<Response> { export async function logout(req: Request): Promise<Response> {
const token = getCookie(req, "session") const token = getCookie(req, "session");
if (token) { if (token) {
await deleteSession(token) await deleteSession(token);
} }
return new Response(JSON.stringify({ ok: true }), { return new Response(JSON.stringify({ ok: true }), {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
"Set-Cookie": sessionCookie("", 0), "Set-Cookie": sessionCookie("", 0),
}, },
}) });
} }

View file

@ -1,110 +1,113 @@
import { useState, useEffect, type ReactNode } from 'react' import { useState, useEffect, type ReactNode } from "react";
import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import {
import '../styles/auth.css' startRegistration,
startAuthentication,
} from "@simplewebauthn/browser";
import "../styles/auth.css";
type User = { id: number; username: string } type User = { id: number; username: string };
type Props = { type Props = {
children: (user: User, onLogout: () => void) => ReactNode children: (user: User, onLogout: () => void) => ReactNode;
} };
export function AuthGate({ children }: Props) { export function AuthGate({ children }: Props) {
const [user, setUser] = useState<User | null>(null) const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<'register' | 'login'>('login') const [tab, setTab] = useState<"register" | "login">("login");
const [username, setUsername] = useState('') const [username, setUsername] = useState("");
const [error, setError] = useState('') const [error, setError] = useState("");
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false);
// Check existing session // Check existing session
useEffect(() => { useEffect(() => {
fetch('/api/auth/me') fetch("/api/auth/me")
.then(r => r.json()) .then((r) => r.json())
.then(data => { .then((data) => {
if (data.user) setUser(data.user) if (data.user) setUser(data.user);
}) })
.catch(() => {}) .catch(() => {})
.finally(() => setLoading(false)) .finally(() => setLoading(false));
}, []) }, []);
const handleRegister = async (e: React.FormEvent) => { const handleRegister = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault();
setError('') setError("");
setBusy(true) setBusy(true);
try { try {
const optRes = await fetch('/api/auth/register/options', { const optRes = await fetch("/api/auth/register/options", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: username.trim() }), body: JSON.stringify({ username: username.trim() }),
}) });
if (!optRes.ok) { if (!optRes.ok) {
const err = await optRes.json() const err = await optRes.json();
throw new Error(err.error || 'Failed to start registration') throw new Error(err.error || "Failed to start registration");
} }
const options = await optRes.json() const options = await optRes.json();
const credential = await startRegistration({ optionsJSON: options }) const credential = await startRegistration({ optionsJSON: options });
const verRes = await fetch('/api/auth/register/verify', { const verRes = await fetch("/api/auth/register/verify", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential), body: JSON.stringify(credential),
}) });
if (!verRes.ok) { if (!verRes.ok) {
const err = await verRes.json() const err = await verRes.json();
throw new Error(err.error || 'Registration failed') throw new Error(err.error || "Registration failed");
} }
const result = await verRes.json() const result = await verRes.json();
setUser({ id: 0, username: result.username }) setUser({ id: 0, username: result.username });
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
if (err.name === 'NotAllowedError') { if (err.name === "NotAllowedError") {
setError('Passkey creation was cancelled') setError("Passkey creation was cancelled");
} else { } else {
setError(err.message) setError(err.message);
} }
} }
} finally { } finally {
setBusy(false) setBusy(false);
}
} }
};
const handleLogin = async () => { const handleLogin = async () => {
setError('') setError("");
setBusy(true) setBusy(true);
try { try {
const optRes = await fetch('/api/auth/login/options', { method: 'POST' }) const optRes = await fetch("/api/auth/login/options", { method: "POST" });
if (!optRes.ok) throw new Error('Failed to start login') if (!optRes.ok) throw new Error("Failed to start login");
const options = await optRes.json() const options = await optRes.json();
const credential = await startAuthentication({ optionsJSON: options }) const credential = await startAuthentication({ optionsJSON: options });
const verRes = await fetch('/api/auth/login/verify', { const verRes = await fetch("/api/auth/login/verify", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(credential), body: JSON.stringify(credential),
}) });
if (!verRes.ok) { if (!verRes.ok) {
const err = await verRes.json() const err = await verRes.json();
throw new Error(err.error || 'Login failed') throw new Error(err.error || "Login failed");
} }
const result = await verRes.json() const result = await verRes.json();
setUser({ id: 0, username: result.username }) setUser({ id: 0, username: result.username });
} catch (err) { } catch (err) {
if (err instanceof Error) { if (err instanceof Error) {
if (err.name === 'NotAllowedError') { if (err.name === "NotAllowedError") {
setError('Passkey authentication was cancelled') setError("Passkey authentication was cancelled");
} else { } else {
setError(err.message) setError(err.message);
} }
} }
} finally { } finally {
setBusy(false) setBusy(false);
}
} }
};
const handleLogout = async () => { const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' }) await fetch("/api/auth/logout", { method: "POST" });
setUser(null) setUser(null);
} };
if (loading) { if (loading) {
return ( return (
@ -114,58 +117,71 @@ export function AuthGate({ children }: Props) {
<div className="auth-title">Loading...</div> <div className="auth-title">Loading...</div>
</div> </div>
</div> </div>
) );
} }
if (user) { if (user) {
return <>{children(user, handleLogout)}</> return <>{children(user, handleLogout)}</>;
} }
return ( return (
<div className="auth-container"> <div className="auth-container">
<div className="auth-card"> <div className="auth-card">
<div className="auth-passkey"></div> <div className="auth-passkey"></div>
<div className="auth-title">Leo's Typing Tutor</div> <div className="auth-title">宿</div>
<div className="auth-subtitle">Sign in with a passkey to track your progress</div> <div className="auth-subtitle">
Sign in with a passkey to track your progress
</div>
<div className="auth-tabs"> <div className="auth-tabs">
<button <button
className={`auth-tab ${tab === 'login' ? 'auth-tabActive' : ''}`} className={`auth-tab ${tab === "login" ? "auth-tabActive" : ""}`}
onClick={() => { setTab('login'); setError('') }} onClick={() => {
setTab("login");
setError("");
}}
> >
Login Login
</button> </button>
<button <button
className={`auth-tab ${tab === 'register' ? 'auth-tabActive' : ''}`} className={`auth-tab ${tab === "register" ? "auth-tabActive" : ""}`}
onClick={() => { setTab('register'); setError('') }} onClick={() => {
setTab("register");
setError("");
}}
> >
Register Register
</button> </button>
</div> </div>
{tab === 'register' ? ( {tab === "register" ? (
<form className="auth-form" onSubmit={handleRegister}> <form className="auth-form" onSubmit={handleRegister}>
<input <input
className="auth-input" className="auth-input"
type="text" type="text"
placeholder="Pick a username" placeholder="Pick a username"
value={username} value={username}
onChange={e => setUsername(e.target.value)} onChange={(e) => setUsername(e.target.value)}
maxLength={32} maxLength={32}
autoFocus autoFocus
required required
/> />
<button className="auth-btn" type="submit" disabled={busy || !username.trim()}> <button
{busy ? 'Creating passkey...' : 'Create Account'} className="auth-btn"
type="submit"
disabled={busy || !username.trim()}
>
{busy ? "Creating passkey..." : "Create Account"}
</button> </button>
<div className="auth-hint"> <div className="auth-hint">
You'll be asked to create a passkey using your device's fingerprint, face, or PIN. You'll be asked to create a passkey using your device's
fingerprint, face, or PIN.
</div> </div>
</form> </form>
) : ( ) : (
<div className="auth-form"> <div className="auth-form">
<button className="auth-btn" onClick={handleLogin} disabled={busy}> <button className="auth-btn" onClick={handleLogin} disabled={busy}>
{busy ? 'Authenticating...' : 'Sign in with Passkey'} {busy ? "Authenticating..." : "Sign in with Passkey"}
</button> </button>
<div className="auth-hint"> <div className="auth-hint">
Your device will show your saved passkeys. Pick yours to sign in. Your device will show your saved passkeys. Pick yours to sign in.
@ -176,5 +192,5 @@ export function AuthGate({ children }: Props) {
{error && <div className="auth-error">{error}</div>} {error && <div className="auth-error">{error}</div>}
</div> </div>
</div> </div>
) );
} }

View file

@ -1,52 +1,63 @@
import { type ReactNode } from 'react' import { type ReactNode } from "react";
type Tab = 'lessons' | 'free' | 'game' | 'stats' type Tab = "lessons" | "free" | "game" | "stats";
type Props = { type Props = {
activeTab: Tab activeTab: Tab;
onTabChange: (tab: Tab) => void onTabChange: (tab: Tab) => void;
username: string username: string;
onLogout: () => void onLogout: () => void;
children: ReactNode children: ReactNode;
} };
const TABS: { id: Tab; label: string; icon: string }[] = [ const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: 'lessons', label: 'Lessons', icon: '📚' }, { id: "lessons", label: "Lessons", icon: "📚" },
{ id: 'free', label: 'Free Type', icon: '⌨️' }, { id: "free", label: "Free Type", icon: "⌨️" },
{ id: 'game', label: 'Game', icon: '🎮' }, { id: "game", label: "Game", icon: "🎮" },
{ id: 'stats', label: 'Stats', icon: '📊' }, { id: "stats", label: "Stats", icon: "📊" },
] ];
export function Layout({ activeTab, onTabChange, username, onLogout, children }: Props) { export function Layout({
activeTab,
onTabChange,
username,
onLogout,
children,
}: Props) {
return ( return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}> <div style={{ maxWidth: 960, margin: "0 auto", padding: "20px 24px" }}>
<header style={{ <header
display: 'flex', style={{
alignItems: 'center', display: "flex",
justifyContent: 'space-between', alignItems: "center",
justifyContent: "space-between",
marginBottom: 32, marginBottom: 32,
}}> }}
<h1 style={{ >
<h1
style={{
fontSize: 26, fontSize: 26,
fontWeight: 800, fontWeight: 800,
background: 'linear-gradient(135deg, var(--accent), #e84393)', background: "linear-gradient(135deg, var(--accent), #e84393)",
WebkitBackgroundClip: 'text', WebkitBackgroundClip: "text",
WebkitTextFillColor: 'transparent', WebkitTextFillColor: "transparent",
}}> }}
Leo's Typing Tutor >
宿
</h1> </h1>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}> <div style={{ display: "flex", alignItems: "center", gap: 12 }}>
<nav style={{ display: 'flex', gap: 4 }}> <nav style={{ display: "flex", gap: 4 }}>
{TABS.map(tab => ( {TABS.map((tab) => (
<button <button
key={tab.id} key={tab.id}
onClick={() => onTabChange(tab.id)} onClick={() => onTabChange(tab.id)}
style={{ style={{
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)', background:
color: activeTab === tab.id ? '#fff' : 'var(--text)', activeTab === tab.id ? "var(--accent)" : "var(--bg-card)",
color: activeTab === tab.id ? "#fff" : "var(--text)",
fontWeight: activeTab === tab.id ? 600 : 400, fontWeight: activeTab === tab.id ? 600 : 400,
padding: '10px 18px', padding: "10px 18px",
borderRadius: 'var(--radius)', borderRadius: "var(--radius)",
fontSize: 14, fontSize: 14,
}} }}
> >
@ -54,16 +65,25 @@ export function Layout({ activeTab, onTabChange, username, onLogout, children }:
</button> </button>
))} ))}
</nav> </nav>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 8 }}> <div
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{username}</span> style={{
display: "flex",
alignItems: "center",
gap: 8,
marginLeft: 8,
}}
>
<span style={{ color: "var(--text-dim)", fontSize: 13 }}>
{username}
</span>
<button <button
onClick={onLogout} onClick={onLogout}
style={{ style={{
background: 'var(--bg-card)', background: "var(--bg-card)",
color: 'var(--text-dim)', color: "var(--text-dim)",
fontSize: 12, fontSize: 12,
padding: '6px 12px', padding: "6px 12px",
borderRadius: 'var(--radius)', borderRadius: "var(--radius)",
}} }}
> >
Logout Logout
@ -73,5 +93,5 @@ export function Layout({ activeTab, onTabChange, username, onLogout, children }:
</header> </header>
<main>{children}</main> <main>{children}</main>
</div> </div>
) );
} }