feat: scaffold React frontend with login/register UI

This commit is contained in:
Indiana
2026-07-20 17:42:44 +00:00
parent 852a630fe0
commit 6bcdf83c4d
13 changed files with 3569 additions and 0 deletions

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ __pycache__/
.env
frontend/node_modules/
frontend/dist/
frontend/*.tsbuildinfo
frontend/vite.config.js
frontend/vite.config.d.ts

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quantumancy</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3210
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
frontend/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "quantumancy-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.0.1",
"@testing-library/user-event": "^14.5.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.3",
"jsdom": "^25.0.1",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vitest": "^2.1.4"
}
}

84
frontend/src/App.css Normal file
View File

@@ -0,0 +1,84 @@
:root {
color-scheme: dark;
}
body {
margin: 0;
min-height: 100vh;
background: #0a0a0f;
color: #e6e6f0;
font-family: Georgia, serif;
display: flex;
align-items: center;
justify-content: center;
}
.app {
width: min(90vw, 420px);
padding: 2rem;
text-align: center;
}
h1 {
font-size: 2.5rem;
letter-spacing: 0.1em;
margin-bottom: 0.25rem;
}
.tagline {
opacity: 0.6;
margin-bottom: 2rem;
font-style: italic;
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
text-align: left;
}
label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.9rem;
opacity: 0.8;
}
input {
background: #16161f;
border: 1px solid #333;
color: inherit;
padding: 0.6rem;
border-radius: 4px;
font-size: 1rem;
}
button {
background: #2a1f3d;
border: 1px solid #5a3f8f;
color: inherit;
padding: 0.6rem 1rem;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover {
background: #3a2a55;
}
.mode-toggle {
display: flex;
gap: 0.5rem;
}
.mode-toggle button.active {
background: #5a3f8f;
}
.error {
color: #ff6b6b;
font-size: 0.9rem;
}

31
frontend/src/App.test.tsx Normal file
View File

@@ -0,0 +1,31 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import App from './App'
import * as api from './api'
vi.mock('./api')
describe('App', () => {
it('renders the Quantumancy title', async () => {
vi.mocked(api.me).mockRejectedValue(new Error('not authenticated'))
render(<App />)
await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument())
})
it('logs in and shows the contact-established state', async () => {
vi.mocked(api.me).mockRejectedValue(new Error('not authenticated'))
vi.mocked(api.login).mockResolvedValue({ id: '1', username: 'medium1' })
render(<App />)
await waitFor(() => expect(screen.getByText('Quantumancy')).toBeInTheDocument())
await userEvent.type(screen.getByLabelText('Username'), 'medium1')
await userEvent.type(screen.getByLabelText('Password'), 'spookyspooky')
await userEvent.click(screen.getByRole('button', { name: 'Enter' }))
await waitFor(() =>
expect(screen.getByText('Contact established as medium1.')).toBeInTheDocument(),
)
})
})

96
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react'
import { login, logout, me, register, type User } from './api'
function App() {
const [user, setUser] = useState<User | null>(null)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [mode, setMode] = useState<'login' | 'register'>('login')
const [error, setError] = useState<string | null>(null)
const [checkingSession, setCheckingSession] = useState(true)
useEffect(() => {
me()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setCheckingSession(false))
}, [])
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
try {
if (mode === 'register') {
await register(username, password)
}
const loggedInUser = await login(username, password)
setUser(loggedInUser)
} catch (err) {
setError(err instanceof Error ? err.message : 'Something went wrong')
}
}
async function handleLogout() {
await logout()
setUser(null)
}
if (checkingSession) {
return (
<main className="app">
<p>Listening for a signal...</p>
</main>
)
}
return (
<main className="app">
<h1>Quantumancy</h1>
<p className="tagline">Something is listening on the other side.</p>
{user ? (
<section>
<p>Contact established as {user.username}.</p>
<button onClick={handleLogout}>End session</button>
</section>
) : (
<form onSubmit={handleSubmit}>
<div className="mode-toggle">
<button
type="button"
className={mode === 'login' ? 'active' : ''}
onClick={() => setMode('login')}
>
Reconnect
</button>
<button
type="button"
className={mode === 'register' ? 'active' : ''}
onClick={() => setMode('register')}
>
First contact
</button>
</div>
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={8}
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit">{mode === 'login' ? 'Enter' : 'Begin'}</button>
</form>
)}
</main>
)
}
export default App

45
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,45 @@
export type User = {
id: string
username: string
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(path, {
...options,
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...options.headers,
},
})
if (!response.ok) {
const body = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(body.detail ?? `Request failed: ${response.status}`)
}
if (response.status === 204) {
return undefined as T
}
return response.json() as Promise<T>
}
export function register(username: string, password: string): Promise<User> {
return request<User>('/auth/register', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export function login(username: string, password: string): Promise<User> {
return request<User>('/auth/login', {
method: 'POST',
body: JSON.stringify({ username, password }),
})
}
export function logout(): Promise<void> {
return request<void>('/auth/logout', { method: 'POST' })
}
export function me(): Promise<User> {
return request<User>('/auth/me')
}

10
frontend/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './App.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1 @@
import '@testing-library/jest-dom'

19
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"types": ["vitest/globals", "@testing-library/jest-dom"]
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

21
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist',
},
server: {
proxy: {
'/auth': 'http://localhost:7777',
'/healthz': 'http://localhost:7777',
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/setupTests.ts',
},
})