feat: scaffold React frontend with login/register UI
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
12
frontend/index.html
Normal 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
3210
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
frontend/package.json
Normal file
27
frontend/package.json
Normal 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
84
frontend/src/App.css
Normal 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
31
frontend/src/App.test.tsx
Normal 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
96
frontend/src/App.tsx
Normal 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
45
frontend/src/api.ts
Normal 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
10
frontend/src/main.tsx
Normal 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>,
|
||||
)
|
||||
1
frontend/src/setupTests.ts
Normal file
1
frontend/src/setupTests.ts
Normal file
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom'
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal 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" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal 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
21
frontend/vite.config.ts
Normal 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',
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user