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
|
.env
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
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