diff --git a/web/src/App.tsx b/web/src/App.tsx
index 16123b4..0227651 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,7 +1,46 @@
+import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'
+import type { ReactNode } from 'react'
+import { AuthProvider, useAuth } from './auth'
+import Shell from './shell'
+import SetupPage from './pages/SetupPage'
+import LoginPage from './pages/LoginPage'
+import LibraryPage from './pages/LibraryPage'
+import ScanPage from './pages/ScanPage'
+import AddPage from './pages/AddPage'
+import SettingsPage from './pages/SettingsPage'
+
+function Gate({ children }: { children: ReactNode }) {
+ const { status } = useAuth()
+ if (status === 'loading') {
+ return
+ }
+ if (status === 'setup') return
+ if (status === 'unauthenticated') return
+ return <>{children}>
+}
+
export default function App() {
return (
-
-
record-shop
-
+
+
+
+ } />
+ } />
+
+
+
+ }
+ >
+ } />
+ } />
+ } />
+ } />
+
+ } />
+
+
+
)
}
diff --git a/web/src/pages/AddPage.tsx b/web/src/pages/AddPage.tsx
new file mode 100644
index 0000000..93c0d4d
--- /dev/null
+++ b/web/src/pages/AddPage.tsx
@@ -0,0 +1,3 @@
+export default function AddPage() {
+ return Search goes here.
+}
diff --git a/web/src/pages/LibraryPage.tsx b/web/src/pages/LibraryPage.tsx
new file mode 100644
index 0000000..c785435
--- /dev/null
+++ b/web/src/pages/LibraryPage.tsx
@@ -0,0 +1,3 @@
+export default function LibraryPage() {
+ return Library goes here.
+}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..7a1fe18
--- /dev/null
+++ b/web/src/pages/LoginPage.tsx
@@ -0,0 +1,71 @@
+import { useState, type FormEvent } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { api, ApiError } from '../api'
+import { useAuth } from '../auth'
+
+export default function LoginPage() {
+ const { onLogin } = useAuth()
+ const navigate = useNavigate()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ async function submit(e: FormEvent) {
+ e.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ const { user } = await api.login(username, password)
+ onLogin(user)
+ navigate('/library', { replace: true })
+ } catch (err) {
+ setError(err instanceof ApiError && err.status === 401 ? 'Wrong username or password' : 'Something went wrong')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/pages/ScanPage.tsx b/web/src/pages/ScanPage.tsx
new file mode 100644
index 0000000..711e97d
--- /dev/null
+++ b/web/src/pages/ScanPage.tsx
@@ -0,0 +1,3 @@
+export default function ScanPage() {
+ return Scanner goes here.
+}
diff --git a/web/src/pages/SettingsPage.tsx b/web/src/pages/SettingsPage.tsx
new file mode 100644
index 0000000..04f323c
--- /dev/null
+++ b/web/src/pages/SettingsPage.tsx
@@ -0,0 +1,14 @@
+import { useAuth } from '../auth'
+
+export default function SettingsPage() {
+ const { user, onLogout } = useAuth()
+ return (
+
+ Settings
+ Account
+
+ {user?.username} {user?.isAdmin ? '(admin)' : ''}
+
+
+ )
+}
diff --git a/web/src/pages/SetupPage.tsx b/web/src/pages/SetupPage.tsx
new file mode 100644
index 0000000..1dcacbc
--- /dev/null
+++ b/web/src/pages/SetupPage.tsx
@@ -0,0 +1,74 @@
+import { useState, type FormEvent } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { api, ApiError } from '../api'
+import { useAuth } from '../auth'
+
+export default function SetupPage() {
+ const { onSetupComplete } = useAuth()
+ const navigate = useNavigate()
+ const [username, setUsername] = useState('')
+ const [password, setPassword] = useState('')
+ const [error, setError] = useState(null)
+ const [busy, setBusy] = useState(false)
+
+ async function submit(e: FormEvent) {
+ e.preventDefault()
+ setBusy(true)
+ setError(null)
+ try {
+ const { user } = await api.setup(username, password)
+ onSetupComplete(user)
+ navigate('/library', { replace: true })
+ } catch (err) {
+ setError(err instanceof ApiError ? err.detail ?? err.code : 'Something went wrong')
+ } finally {
+ setBusy(false)
+ }
+ }
+
+ return (
+
+ )
+}
diff --git a/web/src/shell.tsx b/web/src/shell.tsx
new file mode 100644
index 0000000..693e8d9
--- /dev/null
+++ b/web/src/shell.tsx
@@ -0,0 +1,79 @@
+import { NavLink, Outlet } from 'react-router-dom'
+import type { ReactNode } from 'react'
+
+const TABS: { to: string; label: string; icon: ReactNode }[] = [
+ {
+ to: '/library',
+ label: 'Library',
+ icon: (
+
+ ),
+ },
+ {
+ to: '/scan',
+ label: 'Scan',
+ icon: (
+
+ ),
+ },
+ {
+ to: '/add',
+ label: 'Add',
+ icon: (
+
+ ),
+ },
+ {
+ to: '/settings',
+ label: 'Settings',
+ icon: (
+
+ ),
+ },
+]
+
+export default function Shell({ title }: { title?: string }) {
+ return (
+
+
+ {title ?? 'record-shop'}
+
+
+
+
+
+
+ )
+}
diff --git a/web/test/router.test.tsx b/web/test/router.test.tsx
new file mode 100644
index 0000000..af39344
--- /dev/null
+++ b/web/test/router.test.tsx
@@ -0,0 +1,99 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import App from '../src/App'
+
+const fetchMock = vi.fn()
+
+beforeEach(() => {
+ fetchMock.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+ window.history.replaceState(null, '', '/')
+})
+
+function json(status: number, body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+}
+
+function loggedInServer() {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ if (url === '/api/me') return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
+ return Promise.resolve(json(404, { error: 'not_found' }))
+ })
+}
+
+describe('routing', () => {
+ it('shows the setup form when the server needs setup', async () => {
+ fetchMock.mockImplementation((url: string) =>
+ url === '/api/setup' ? Promise.resolve(json(200, { needed: true })) : Promise.resolve(json(404, {}))
+ )
+ render()
+ await waitFor(() => expect(screen.getByRole('heading', { name: /welcome/i })).toBeTruthy())
+ })
+
+ it('shows the login form when unauthenticated', async () => {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ return Promise.resolve(json(401, { error: 'unauthorized' }))
+ })
+ render()
+ await waitFor(() => expect(screen.getByRole('heading', { name: /sign in/i })).toBeTruthy())
+ })
+
+ it('lands on Library with the tab bar when authenticated', async () => {
+ loggedInServer()
+ render()
+ await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
+ for (const tab of ['Library', 'Scan', 'Add', 'Settings']) {
+ expect(screen.getByRole('tab', { name: new RegExp(tab, 'i') })).toBeTruthy()
+ }
+ })
+
+ it('login form authenticates and enters the app', async () => {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ if (url === '/api/me') return Promise.resolve(json(401, { error: 'unauthorized' }))
+ if (url === '/api/login') {
+ return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
+ }
+ return Promise.resolve(json(404, {}))
+ })
+ render()
+ await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
+ await userEvent.type(screen.getByLabelText(/username/i), 'sam')
+ await userEvent.type(screen.getByLabelText(/password/i), 'password123')
+ await userEvent.click(screen.getByRole('button', { name: /sign in/i }))
+ await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
+ })
+
+ it('setup form creates the admin account and enters the app', async () => {
+ fetchMock.mockImplementation((url: string, init?: RequestInit) => {
+ if (url === '/api/setup' && (!init || !init.method || init.method === 'GET')) {
+ return Promise.resolve(json(200, { needed: true }))
+ }
+ if (url === '/api/setup' && init?.method === 'POST') {
+ return Promise.resolve(json(200, { user: { id: 1, username: 'boss', isAdmin: true } }))
+ }
+ return Promise.resolve(json(404, {}))
+ })
+ render()
+ await waitFor(() => expect(screen.getByLabelText(/username/i)).toBeTruthy())
+ await userEvent.type(screen.getByLabelText(/username/i), 'boss')
+ await userEvent.type(screen.getByLabelText(/password/i), 'password123')
+ await userEvent.click(screen.getByRole('button', { name: /create/i }))
+ await waitFor(() => expect(screen.getByRole('tab', { name: /library/i })).toBeTruthy())
+ })
+
+ it('tab navigation switches pages', async () => {
+ loggedInServer()
+ render()
+ await waitFor(() => expect(screen.getByRole('tab', { name: /settings/i })).toBeTruthy())
+ await userEvent.click(screen.getByRole('tab', { name: /settings/i }))
+ await waitFor(() => expect(screen.getByRole('heading', { name: /settings/i })).toBeTruthy())
+ expect(screen.getByText(/account/i)).toBeTruthy()
+ })
+})
diff --git a/web/test/smoke.test.tsx b/web/test/smoke.test.tsx
index 1ca1436..caf8445 100644
--- a/web/test/smoke.test.tsx
+++ b/web/test/smoke.test.tsx
@@ -1,10 +1,31 @@
-import { describe, it, expect } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
import App from '../src/App'
+const fetchMock = vi.fn()
+
+beforeEach(() => {
+ fetchMock.mockReset()
+ vi.stubGlobal('fetch', fetchMock)
+ window.history.replaceState(null, '', '/')
+})
+
+function json(status: number, body: unknown) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+}
+
describe('App', () => {
- it('renders the app title', () => {
+ it('renders the app title', async () => {
+ fetchMock.mockImplementation((url: string) => {
+ if (url === '/api/setup') return Promise.resolve(json(200, { needed: false }))
+ if (url === '/api/me')
+ return Promise.resolve(json(200, { user: { id: 1, username: 'sam', isAdmin: true } }))
+ return Promise.resolve(json(404, {}))
+ })
render()
- expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy()
+ await waitFor(() => expect(screen.getByRole('heading', { name: 'record-shop' })).toBeTruthy())
})
})