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:
parent
ca0f7cf4b1
commit
300a418e85
12
apps/web/index.html
Normal file
12
apps/web/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>Dorfteich</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@ -4,12 +4,27 @@
|
||||
"private": true,
|
||||
"description": "Dorfteich single-page application",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"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": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
16
apps/web/src/App.tsx
Normal file
16
apps/web/src/App.tsx
Normal 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
8
apps/web/src/i18n/t.ts
Normal 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;
|
||||
}
|
||||
@ -1,2 +0,0 @@
|
||||
// Placeholder entry point; replaced by the Vite + React app in issue #4.
|
||||
export const APP_NAME = 'Dorfteich';
|
||||
24
apps/web/src/layout/AppLayout.tsx
Normal file
24
apps/web/src/layout/AppLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
24
apps/web/src/layout/Sidebar.tsx
Normal file
24
apps/web/src/layout/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/layout/TopBar.tsx
Normal file
35
apps/web/src/layout/TopBar.tsx
Normal 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
20
apps/web/src/lib/api.ts
Normal 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');
|
||||
}
|
||||
23
apps/web/src/lib/use-persistent-state.ts
Normal file
23
apps/web/src/lib/use-persistent-state.ts
Normal 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
25
apps/web/src/main.tsx
Normal 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>,
|
||||
);
|
||||
33
apps/web/src/pages/HomePage.tsx
Normal file
33
apps/web/src/pages/HomePage.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
apps/web/src/pages/NotFoundPage.tsx
Normal file
13
apps/web/src/pages/NotFoundPage.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
141
apps/web/src/styles/base.css
Normal file
141
apps/web/src/styles/base.css
Normal 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);
|
||||
}
|
||||
36
apps/web/src/styles/tokens.css
Normal file
36
apps/web/src/styles/tokens.css
Normal 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;
|
||||
}
|
||||
@ -3,7 +3,10 @@
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"outDir": "dist"
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
13
apps/web/vite.config.ts
Normal file
13
apps/web/vite.config.ts
Normal 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
730
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user