Add React SPA shell with routing, layout, and API status

apps/web becomes a Vite + React application: React Router with home
and 404 routes, base layout (top bar, collapsible sidebar remembered
per user via localStorage, main area), CSS design tokens including the
three font slots from ADR 0016, TanStack Query, and a typed fetch
helper showing live API health on the home page. All UI strings go
through a t() stub that issue #5 replaces with i18next. The Vite dev
server proxies /api to the api dev port (3001).

Closes #4

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Claude Fable 5 2026-07-04 19:19:12 +02:00
parent ca0f7cf4b1
commit 300a418e85
18 changed files with 1173 additions and 4 deletions

12
apps/web/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>Dorfteich</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -4,12 +4,27 @@
"private": true, "private": true,
"description": "Dorfteich single-page application", "description": "Dorfteich single-page application",
"license": "MIT", "license": "MIT",
"type": "module",
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "dev": "vite",
"build": "vite build",
"preview": "vite preview",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests" "test": "vitest run --passWithNoTests"
}, },
"dependencies": {
"@dorfteich/shared": "workspace:*",
"@tanstack/react-query": "^5.66.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.0"
},
"devDependencies": { "devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.7.0",
"vite": "^6.1.0",
"vitest": "^3.0.0" "vitest": "^3.0.0"
} }
} }

16
apps/web/src/App.tsx Normal file
View File

@ -0,0 +1,16 @@
import { Route, Routes } from 'react-router-dom';
import { AppLayout } from './layout/AppLayout';
import { HomePage } from './pages/HomePage';
import { NotFoundPage } from './pages/NotFoundPage';
export function App(): React.JSX.Element {
return (
<Routes>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
);
}

8
apps/web/src/i18n/t.ts Normal file
View File

@ -0,0 +1,8 @@
/**
* Temporary translation stub so no component hard-codes strings directly.
* Issue #5 replaces this module with the real i18next setup; call sites
* (`t('key', 'English fallback')`) already match the final signature.
*/
export function t(_key: string, fallback: string): string {
return fallback;
}

View File

@ -1,2 +0,0 @@
// Placeholder entry point; replaced by the Vite + React app in issue #4.
export const APP_NAME = 'Dorfteich';

View File

@ -0,0 +1,24 @@
import { Outlet } from 'react-router-dom';
import { usePersistentState } from '../lib/use-persistent-state';
import { Sidebar } from './Sidebar';
import { TopBar } from './TopBar';
export function AppLayout(): React.JSX.Element {
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
return (
<div className="app">
<TopBar
sidebarCollapsed={sidebarCollapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div className="app-body">
<Sidebar collapsed={sidebarCollapsed} />
<main className="main">
<Outlet />
</main>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
import { t } from '../i18n/t';
interface SidebarProps {
collapsed: boolean;
}
/**
* Left sidebar skeleton. The pond page list (issue #26) replaces the hint
* text; the collapse behavior and layout contract are final: collapsing
* must not reflow the main content beyond reclaiming the width.
*/
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
return (
<nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed}
aria-label={t('layout.sidebar.label', 'Pages')}
>
<p className="sidebar__hint">
{t('layout.sidebar.placeholder', 'Your ponds and pages will appear here.')}
</p>
</nav>
);
}

View File

@ -0,0 +1,35 @@
import { Link } from 'react-router-dom';
import { t } from '../i18n/t';
interface TopBarProps {
sidebarCollapsed: boolean;
onToggleSidebar: () => void;
}
export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): React.JSX.Element {
return (
<header className="topbar">
<button
type="button"
className="icon-button"
onClick={onToggleSidebar}
aria-expanded={!sidebarCollapsed}
aria-label={
sidebarCollapsed
? t('layout.sidebar.expand', 'Show sidebar')
: t('layout.sidebar.collapse', 'Hide sidebar')
}
>
{/* Simple hamburger glyph; replaced by an icon set later. */}
<span aria-hidden></span>
</button>
<Link to="/" className="topbar__brand">
Dorfteich
</Link>
<span className="topbar__spacer" />
{/* User menu arrives with authentication (issue #16). */}
<span className="sidebar__hint">{t('layout.user.anonymous', 'Not signed in')}</span>
</header>
);
}

20
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,20 @@
import type { ApiErrorBody, HealthResponse } from '@dorfteich/shared';
/**
* Minimal typed fetch helper for the REST api. Non-2xx responses reject
* with the uniform ApiErrorBody so callers can show localized errors.
*/
export async function apiGet<T>(path: string): Promise<T> {
const response = await fetch(`/api/v1${path}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
const body = (await response.json().catch(() => null)) as ApiErrorBody | null;
throw body ?? { code: `http_${response.status}`, message: response.statusText };
}
return (await response.json()) as T;
}
export function fetchHealth(): Promise<HealthResponse> {
return apiGet<HealthResponse>('/healthz');
}

View File

@ -0,0 +1,23 @@
import { useEffect, useState } from 'react';
/** useState persisted to localStorage — for per-user UI preferences only. */
export function usePersistentState<T>(key: string, initial: T): [T, (value: T) => void] {
const [value, setValue] = useState<T>(() => {
try {
const stored = window.localStorage.getItem(key);
return stored === null ? initial : (JSON.parse(stored) as T);
} catch {
return initial;
}
});
useEffect(() => {
try {
window.localStorage.setItem(key, JSON.stringify(value));
} catch {
// Storage may be unavailable (private mode); the preference is not critical.
}
}, [key, value]);
return [value, setValue];
}

25
apps/web/src/main.tsx Normal file
View File

@ -0,0 +1,25 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './styles/tokens.css';
import './styles/base.css';
const queryClient = new QueryClient();
const container = document.getElementById('root');
if (!container) {
throw new Error('index.html is missing the #root element');
}
createRoot(container).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

View File

@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import { t } from '../i18n/t';
import { fetchHealth } from '../lib/api';
export function HomePage(): React.JSX.Element {
const health = useQuery({ queryKey: ['healthz'], queryFn: fetchHealth, retry: 1 });
return (
<>
<h1>{t('home.title', 'Welcome to Dorfteich')}</h1>
<p>
{t(
'home.intro',
'Dorfteich is an open-source wiki with real-time collaboration. This instance is being set up.',
)}
</p>
{health.isPending && (
<span className="status-pill">{t('home.api.checking', 'Checking API …')}</span>
)}
{health.isSuccess && (
<span className="status-pill status-pill--ok">
{t('home.api.ok', 'API reachable')} · {health.data.version}
</span>
)}
{health.isError && (
<span className="status-pill status-pill--error">
{t('home.api.error', 'API not reachable')}
</span>
)}
</>
);
}

View File

@ -0,0 +1,13 @@
import { Link } from 'react-router-dom';
import { t } from '../i18n/t';
export function NotFoundPage(): React.JSX.Element {
return (
<>
<h1>{t('notFound.title', 'Page not found')}</h1>
<p>{t('notFound.body', 'The address you opened does not exist.')}</p>
<Link to="/">{t('notFound.home', 'Back to the start page')}</Link>
</>
);
}

View File

@ -0,0 +1,141 @@
*,
*::before,
*::after {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
font-family: var(--font-body);
font-weight: var(--font-weight-body);
color: var(--color-text);
background: var(--color-bg);
line-height: 1.6;
}
h1,
h2,
h3,
h4 {
font-family: var(--font-heading);
font-weight: var(--font-weight-heading);
line-height: 1.25;
}
code,
pre {
font-family: var(--font-mono);
}
a {
color: var(--color-accent);
}
button {
font: inherit;
}
/* Layout skeleton */
.app {
display: grid;
grid-template-rows: var(--topbar-height) 1fr;
height: 100%;
}
.app-body {
display: flex;
min-height: 0;
}
.topbar {
display: flex;
align-items: center;
gap: var(--space-4);
padding: 0 var(--space-4);
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
}
.topbar__brand {
font-family: var(--font-heading);
font-size: 1.1rem;
color: var(--color-text);
text-decoration: none;
}
.topbar__spacer {
flex: 1;
}
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
border-right: 1px solid var(--color-border);
background: var(--color-bg-subtle);
padding: var(--space-4);
overflow-y: auto;
transition: margin-left 0.15s ease-out;
}
.sidebar--collapsed {
margin-left: calc(-1 * var(--sidebar-width));
}
.sidebar__hint {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.main {
flex: 1;
min-width: 0;
overflow-y: auto;
padding: var(--space-6) var(--space-8);
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border: 1px solid transparent;
border-radius: var(--radius);
background: transparent;
cursor: pointer;
color: var(--color-text);
}
.icon-button:hover {
background: var(--color-bg-subtle);
border-color: var(--color-border);
}
.icon-button:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 1px;
}
.status-pill {
display: inline-flex;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) var(--space-3);
border-radius: 999px;
border: 1px solid var(--color-border);
font-size: 0.9rem;
}
.status-pill--ok {
color: var(--color-ok);
}
.status-pill--error {
color: var(--color-danger);
}

View File

@ -0,0 +1,36 @@
/*
* Design tokens. Components consume only these variables never raw
* values so pond-level font settings (ADR 0016) and future theming stay
* one-line changes. The look is deliberately plain and professional.
*/
:root {
/* Font slots per ADR 0016; the real self-hosted fonts arrive with issue #66. */
--font-heading: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-body: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-mono: 'Fira Code', ui-monospace, SFMono-Regular, Menlo, monospace;
--font-weight-heading: 400;
--font-weight-body: 300;
/* Color palette: calm neutrals with one pond-green accent. */
--color-text: #1f2933;
--color-text-muted: #616e7c;
--color-bg: #ffffff;
--color-bg-subtle: #f5f7fa;
--color-border: #d9e2ec;
--color-accent: #2f6f4f;
--color-accent-contrast: #ffffff;
--color-danger: #ab091e;
--color-ok: #14803c;
/* Spacing scale (rem-based). */
--space-1: 0.25rem;
--space-2: 0.5rem;
--space-3: 0.75rem;
--space-4: 1rem;
--space-6: 1.5rem;
--space-8: 2rem;
--radius: 6px;
--topbar-height: 3rem;
--sidebar-width: 16rem;
}

View File

@ -3,7 +3,10 @@
"compilerOptions": { "compilerOptions": {
"module": "ESNext", "module": "ESNext",
"moduleResolution": "Bundler", "moduleResolution": "Bundler",
"outDir": "dist" "lib": ["ES2022", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"types": ["vite/client"],
"noEmit": true
}, },
"include": ["src"] "include": ["src"]
} }

13
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,13 @@
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [react()],
server: {
// The api dev server runs on 3001 (3000 may be occupied by other
// projects on developer machines); see compose.dev.yml usage notes.
proxy: {
'/api': 'http://localhost:3001',
},
},
});

730
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff