feat: scaffold React frontend with login/register UI
This commit is contained in:
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'
|
||||
Reference in New Issue
Block a user