1079 lines
28 KiB
Markdown
1079 lines
28 KiB
Markdown
# Multi-Frontend Architecture Plan
|
|
|
|
## Overview
|
|
|
|
This document outlines the architecture for managing **one shared backend** with **two separate frontends**:
|
|
|
|
| Frontend | Domain | Purpose |
|
|
|----------|--------|---------|
|
|
| **Heritage Custodian Portal** | `bronhouder.nl` | Heritage custodian management, ontology visualization, SPARQL queries |
|
|
| **ArchiefAssistent (AA)** | `archief.support` | National Archives services, archival research assistance |
|
|
|
|
## Current State
|
|
|
|
### Infrastructure
|
|
- **Server**: Hetzner Cloud CX22 (`91.98.224.44`)
|
|
- **Web Server**: Caddy (automatic HTTPS, reverse proxy)
|
|
- **Deployment**: Local SSH/rsync via `deploy.sh`
|
|
- **Multi-domain**: Already supported in Caddyfile
|
|
|
|
### Backend Services (Shared)
|
|
| Service | Port | Purpose |
|
|
|---------|------|---------|
|
|
| Oxigraph | 7878 | SPARQL triplestore |
|
|
| FastAPI | 8000 | General API |
|
|
| PostgreSQL API | 8001 | Postgres queries |
|
|
| Geo API | 8002 | PostGIS geographic data |
|
|
| TypeDB API | 8003 | Graph database |
|
|
| DuckLake | 8765 | Time travel analytics |
|
|
| Qdrant | 6333 | Vector search |
|
|
| Valkey | 8090 | Semantic cache |
|
|
|
|
### Current Frontend
|
|
- **Framework**: React 19 + TypeScript + Vite
|
|
- **UI Library**: MUI (Material-UI)
|
|
- **State**: React Context + TanStack Query
|
|
- **Visualization**: D3, MapLibre GL, Mermaid, Three.js
|
|
|
|
---
|
|
|
|
## Proposed Architecture
|
|
|
|
### Option 1: Monorepo with Shared Packages (RECOMMENDED)
|
|
|
|
```
|
|
glam/
|
|
├── packages/ # Shared packages
|
|
│ ├── ui/ # Shared UI components
|
|
│ │ ├── src/
|
|
│ │ │ ├── components/
|
|
│ │ │ │ ├── Button/
|
|
│ │ │ │ ├── Card/
|
|
│ │ │ │ ├── DataTable/
|
|
│ │ │ │ ├── ErrorBoundary/
|
|
│ │ │ │ ├── Layout/
|
|
│ │ │ │ ├── Navigation/
|
|
│ │ │ │ └── Tooltip/
|
|
│ │ │ └── index.ts
|
|
│ │ ├── package.json
|
|
│ │ └── tsconfig.json
|
|
│ │
|
|
│ ├── hooks/ # Shared React hooks
|
|
│ │ ├── src/
|
|
│ │ │ ├── useApi.ts
|
|
│ │ │ ├── useSparql.ts
|
|
│ │ │ ├── useGeocoding.ts
|
|
│ │ │ ├── useVectorSearch.ts
|
|
│ │ │ └── index.ts
|
|
│ │ ├── package.json
|
|
│ │ └── tsconfig.json
|
|
│ │
|
|
│ ├── api-client/ # Shared API client
|
|
│ │ ├── src/
|
|
│ │ │ ├── sparql.ts
|
|
│ │ │ ├── geo.ts
|
|
│ │ │ ├── rag.ts
|
|
│ │ │ ├── ducklake.ts
|
|
│ │ │ └── index.ts
|
|
│ │ ├── package.json
|
|
│ │ └── tsconfig.json
|
|
│ │
|
|
│ ├── visualizations/ # Shared visualization components
|
|
│ │ ├── src/
|
|
│ │ │ ├── Graph/
|
|
│ │ │ ├── Map/
|
|
│ │ │ ├── UML/
|
|
│ │ │ ├── RDF/
|
|
│ │ │ └── Timeline/
|
|
│ │ ├── package.json
|
|
│ │ └── tsconfig.json
|
|
│ │
|
|
│ └── theme/ # Shared theming (but customizable)
|
|
│ ├── src/
|
|
│ │ ├── base.ts # Base MUI theme
|
|
│ │ ├── bronhouder.ts # bronhouder.nl theme overrides
|
|
│ │ ├── archief.ts # archief.support theme overrides
|
|
│ │ └── index.ts
|
|
│ ├── package.json
|
|
│ └── tsconfig.json
|
|
│
|
|
├── apps/ # Individual frontends
|
|
│ ├── bronhouder/ # bronhouder.nl frontend
|
|
│ │ ├── src/
|
|
│ │ │ ├── pages/ # Site-specific pages
|
|
│ │ │ ├── components/ # Site-specific components
|
|
│ │ │ ├── contexts/ # Site-specific contexts
|
|
│ │ │ ├── App.tsx
|
|
│ │ │ └── main.tsx
|
|
│ │ ├── public/
|
|
│ │ ├── index.html
|
|
│ │ ├── vite.config.ts
|
|
│ │ ├── package.json
|
|
│ │ └── tsconfig.json
|
|
│ │
|
|
│ └── archief-assistent/ # archief.support frontend
|
|
│ ├── src/
|
|
│ │ ├── pages/ # AA-specific pages
|
|
│ │ ├── components/ # AA-specific components
|
|
│ │ ├── contexts/ # AA-specific contexts
|
|
│ │ ├── App.tsx
|
|
│ │ └── main.tsx
|
|
│ ├── public/
|
|
│ ├── index.html
|
|
│ ├── vite.config.ts
|
|
│ ├── package.json
|
|
│ └── tsconfig.json
|
|
│
|
|
├── backend/ # Unchanged - shared backend
|
|
└── infrastructure/ # Updated for multi-site
|
|
├── deploy.sh # Updated with --site flag
|
|
└── caddy/
|
|
└── Caddyfile # Multi-domain configuration
|
|
```
|
|
|
|
### Why Monorepo with Shared Packages?
|
|
|
|
| Benefit | Description |
|
|
|---------|-------------|
|
|
| **Code Reuse** | Share UI components, hooks, and API clients across both frontends |
|
|
| **Consistency** | Single source of truth for shared logic |
|
|
| **Independent Deployment** | Deploy frontends separately without affecting each other |
|
|
| **Customization** | Each frontend can override/extend shared components |
|
|
| **Type Safety** | Shared TypeScript types across packages |
|
|
| **Testing** | Test shared components once, use everywhere |
|
|
|
|
---
|
|
|
|
## Implementation Plan
|
|
|
|
### Phase 1: Monorepo Setup (Week 1)
|
|
|
|
#### 1.1 Install Workspace Tool
|
|
|
|
We'll use **pnpm workspaces** (faster than npm/yarn, excellent monorepo support):
|
|
|
|
```bash
|
|
# Install pnpm globally
|
|
npm install -g pnpm
|
|
|
|
# Initialize workspace in project root
|
|
cd /Users/kempersc/apps/glam
|
|
```
|
|
|
|
Create `pnpm-workspace.yaml`:
|
|
|
|
```yaml
|
|
packages:
|
|
- 'packages/*'
|
|
- 'apps/*'
|
|
```
|
|
|
|
Update root `package.json`:
|
|
|
|
```json
|
|
{
|
|
"name": "glam-monorepo",
|
|
"private": true,
|
|
"scripts": {
|
|
"dev:bronhouder": "pnpm --filter @glam/bronhouder dev",
|
|
"dev:archief": "pnpm --filter @glam/archief-assistent dev",
|
|
"build:all": "pnpm -r build",
|
|
"build:bronhouder": "pnpm --filter @glam/bronhouder build",
|
|
"build:archief": "pnpm --filter @glam/archief-assistent build",
|
|
"test:all": "pnpm -r test",
|
|
"lint:all": "pnpm -r lint"
|
|
},
|
|
"devDependencies": {
|
|
"typescript": "~5.9.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 1.2 Create Shared Packages
|
|
|
|
**packages/ui/package.json**:
|
|
|
|
```json
|
|
{
|
|
"name": "@glam/ui",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"main": "./src/index.ts",
|
|
"types": "./src/index.ts",
|
|
"exports": {
|
|
".": "./src/index.ts",
|
|
"./components/*": "./src/components/*/index.ts"
|
|
},
|
|
"peerDependencies": {
|
|
"react": "^19.0.0",
|
|
"react-dom": "^19.0.0",
|
|
"@mui/material": "^7.0.0",
|
|
"@emotion/react": "^11.0.0",
|
|
"@emotion/styled": "^11.0.0"
|
|
},
|
|
"devDependencies": {
|
|
"@types/react": "^19.2.5",
|
|
"typescript": "~5.9.3"
|
|
}
|
|
}
|
|
```
|
|
|
|
**packages/hooks/package.json**:
|
|
|
|
```json
|
|
{
|
|
"name": "@glam/hooks",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"main": "./src/index.ts",
|
|
"types": "./src/index.ts",
|
|
"peerDependencies": {
|
|
"react": "^19.0.0",
|
|
"@tanstack/react-query": "^5.0.0",
|
|
"axios": "^1.0.0"
|
|
}
|
|
}
|
|
```
|
|
|
|
**packages/api-client/package.json**:
|
|
|
|
```json
|
|
{
|
|
"name": "@glam/api-client",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"main": "./src/index.ts",
|
|
"types": "./src/index.ts",
|
|
"dependencies": {
|
|
"axios": "^1.13.2"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Phase 2: Extract Shared Components (Week 2)
|
|
|
|
#### 2.1 Identify Shared vs. Site-Specific Components
|
|
|
|
**Shared Components** (move to `packages/ui`):
|
|
|
|
| Component | Current Location | Shared Because |
|
|
|-----------|------------------|----------------|
|
|
| `ErrorBoundary` | `components/common/` | Universal error handling |
|
|
| `LazyLoadError` | `components/common/` | Code splitting support |
|
|
| `Tooltip` | `components/ui/` | Generic UI primitive |
|
|
| `SearchableMultiSelect` | `components/ui/` | Generic form control |
|
|
| `Layout` | `components/layout/` | App shell (customizable) |
|
|
| `Navigation` | `components/layout/` | Navigation (customizable menu) |
|
|
| `DataTable` | (new) | Tabular data display |
|
|
| `Card` | (new) | Content container |
|
|
| `Modal` | (new) | Dialog/modal wrapper |
|
|
|
|
**Shared Visualization Components** (move to `packages/visualizations`):
|
|
|
|
| Component | Current Location | Shared Because |
|
|
|-----------|------------------|----------------|
|
|
| `Graph*` | `components/graph/` | Knowledge graph visualization |
|
|
| `Map*` | `components/map/` | Geographic visualization |
|
|
| `UML*` | `components/uml/` | Schema/ontology diagrams |
|
|
| `RDF*` | `components/rdf/` | RDF data visualization |
|
|
| `Timeline` | (new) | Temporal data display |
|
|
|
|
**Site-Specific Components**:
|
|
|
|
| Component | Site | Purpose |
|
|
|-----------|------|---------|
|
|
| `CustodianCard` | bronhouder.nl | Heritage institution display |
|
|
| `OntologyExplorer` | bronhouder.nl | LinkML schema browser |
|
|
| `SparqlWorkbench` | bronhouder.nl | Query interface |
|
|
| `ArchiveSearch` | archief.support | Archival record search |
|
|
| `ResearchAssistant` | archief.support | AI-powered research help |
|
|
| `FamilyTreeViewer` | archief.support | Genealogical data viz |
|
|
|
|
#### 2.2 Component Extraction Pattern
|
|
|
|
**Before** (monolithic):
|
|
```tsx
|
|
// frontend/src/components/common/ErrorBoundary.tsx
|
|
export class ErrorBoundary extends React.Component { ... }
|
|
```
|
|
|
|
**After** (package):
|
|
```tsx
|
|
// packages/ui/src/components/ErrorBoundary/ErrorBoundary.tsx
|
|
export class ErrorBoundary extends React.Component { ... }
|
|
|
|
// packages/ui/src/components/ErrorBoundary/index.ts
|
|
export { ErrorBoundary } from './ErrorBoundary';
|
|
export type { ErrorBoundaryProps } from './ErrorBoundary';
|
|
|
|
// packages/ui/src/index.ts
|
|
export * from './components/ErrorBoundary';
|
|
export * from './components/Tooltip';
|
|
// ... etc
|
|
```
|
|
|
|
**Usage in app**:
|
|
```tsx
|
|
// apps/bronhouder/src/App.tsx
|
|
import { ErrorBoundary, Tooltip, Layout } from '@glam/ui';
|
|
import { useSparql, useGeocoding } from '@glam/hooks';
|
|
import { sparqlClient, geoClient } from '@glam/api-client';
|
|
```
|
|
|
|
### Phase 3: Create Site Frontends (Week 3)
|
|
|
|
#### 3.1 bronhouder.nl Frontend
|
|
|
|
**apps/bronhouder/package.json**:
|
|
|
|
```json
|
|
{
|
|
"name": "@glam/bronhouder",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "tsc -b && vite build",
|
|
"preview": "vite preview"
|
|
},
|
|
"dependencies": {
|
|
"@glam/ui": "workspace:*",
|
|
"@glam/hooks": "workspace:*",
|
|
"@glam/api-client": "workspace:*",
|
|
"@glam/visualizations": "workspace:*",
|
|
"@glam/theme": "workspace:*",
|
|
"react": "^19.2.0",
|
|
"react-dom": "^19.2.0",
|
|
"react-router-dom": "^7.9.6",
|
|
"@mui/material": "^7.3.5",
|
|
"@emotion/react": "^11.14.0",
|
|
"@emotion/styled": "^11.14.1",
|
|
"@tanstack/react-query": "^5.90.10"
|
|
}
|
|
}
|
|
```
|
|
|
|
**apps/bronhouder/src/App.tsx**:
|
|
|
|
```tsx
|
|
import { ThemeProvider } from '@mui/material';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
import { Layout, ErrorBoundary } from '@glam/ui';
|
|
import { bronhouderTheme } from '@glam/theme';
|
|
|
|
// Site-specific pages
|
|
import { HomePage } from './pages/Home';
|
|
import { CustodianExplorer } from './pages/CustodianExplorer';
|
|
import { OntologyViewer } from './pages/OntologyViewer';
|
|
import { SparqlWorkbench } from './pages/SparqlWorkbench';
|
|
|
|
const queryClient = new QueryClient();
|
|
|
|
export function App() {
|
|
return (
|
|
<ErrorBoundary>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider theme={bronhouderTheme}>
|
|
<BrowserRouter>
|
|
<Layout
|
|
siteName="Bronhouder"
|
|
logo="/assets/bronhouder-logo.svg"
|
|
navigation={[
|
|
{ label: 'Home', path: '/' },
|
|
{ label: 'Custodians', path: '/custodians' },
|
|
{ label: 'Ontology', path: '/ontology' },
|
|
{ label: 'SPARQL', path: '/sparql' },
|
|
]}
|
|
>
|
|
<Routes>
|
|
<Route path="/" element={<HomePage />} />
|
|
<Route path="/custodians/*" element={<CustodianExplorer />} />
|
|
<Route path="/ontology/*" element={<OntologyViewer />} />
|
|
<Route path="/sparql" element={<SparqlWorkbench />} />
|
|
</Routes>
|
|
</Layout>
|
|
</BrowserRouter>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
```
|
|
|
|
#### 3.2 archief.support Frontend (ArchiefAssistent)
|
|
|
|
**apps/archief-assistent/package.json**:
|
|
|
|
```json
|
|
{
|
|
"name": "@glam/archief-assistent",
|
|
"version": "0.1.0",
|
|
"private": true,
|
|
"scripts": {
|
|
"dev": "vite",
|
|
"build": "tsc -b && vite build",
|
|
"preview": "vite preview"
|
|
},
|
|
"dependencies": {
|
|
"@glam/ui": "workspace:*",
|
|
"@glam/hooks": "workspace:*",
|
|
"@glam/api-client": "workspace:*",
|
|
"@glam/visualizations": "workspace:*",
|
|
"@glam/theme": "workspace:*",
|
|
"react": "^19.2.0",
|
|
"react-dom": "^19.2.0",
|
|
"react-router-dom": "^7.9.6",
|
|
"@mui/material": "^7.3.5",
|
|
"@emotion/react": "^11.14.0",
|
|
"@emotion/styled": "^11.14.1",
|
|
"@tanstack/react-query": "^5.90.10"
|
|
}
|
|
}
|
|
```
|
|
|
|
**apps/archief-assistent/src/App.tsx**:
|
|
|
|
```tsx
|
|
import { ThemeProvider } from '@mui/material';
|
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
import { Layout, ErrorBoundary } from '@glam/ui';
|
|
import { archiefTheme } from '@glam/theme';
|
|
|
|
// Site-specific pages
|
|
import { HomePage } from './pages/Home';
|
|
import { SearchPage } from './pages/Search';
|
|
import { AssistantPage } from './pages/Assistant';
|
|
import { CollectionsPage } from './pages/Collections';
|
|
import { GenealogyPage } from './pages/Genealogy';
|
|
|
|
const queryClient = new QueryClient();
|
|
|
|
export function App() {
|
|
return (
|
|
<ErrorBoundary>
|
|
<QueryClientProvider client={queryClient}>
|
|
<ThemeProvider theme={archiefTheme}>
|
|
<BrowserRouter>
|
|
<Layout
|
|
siteName="ArchiefAssistent"
|
|
logo="/assets/archief-logo.svg"
|
|
navigation={[
|
|
{ label: 'Home', path: '/' },
|
|
{ label: 'Zoeken', path: '/zoeken' },
|
|
{ label: 'Assistent', path: '/assistent' },
|
|
{ label: 'Collecties', path: '/collecties' },
|
|
{ label: 'Stamboom', path: '/stamboom' },
|
|
]}
|
|
>
|
|
<Routes>
|
|
<Route path="/" element={<HomePage />} />
|
|
<Route path="/zoeken/*" element={<SearchPage />} />
|
|
<Route path="/assistent" element={<AssistantPage />} />
|
|
<Route path="/collecties/*" element={<CollectionsPage />} />
|
|
<Route path="/stamboom/*" element={<GenealogyPage />} />
|
|
</Routes>
|
|
</Layout>
|
|
</BrowserRouter>
|
|
</ThemeProvider>
|
|
</QueryClientProvider>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Phase 4: Theming Strategy (Week 3)
|
|
|
|
#### 4.1 Base Theme
|
|
|
|
**packages/theme/src/base.ts**:
|
|
|
|
```tsx
|
|
import { createTheme, ThemeOptions } from '@mui/material';
|
|
|
|
export const baseThemeOptions: ThemeOptions = {
|
|
typography: {
|
|
fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif',
|
|
h1: { fontSize: '2.5rem', fontWeight: 600 },
|
|
h2: { fontSize: '2rem', fontWeight: 600 },
|
|
// ... etc
|
|
},
|
|
shape: {
|
|
borderRadius: 8,
|
|
},
|
|
components: {
|
|
MuiButton: {
|
|
styleOverrides: {
|
|
root: {
|
|
textTransform: 'none',
|
|
},
|
|
},
|
|
},
|
|
MuiCard: {
|
|
styleOverrides: {
|
|
root: {
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
|
|
export const baseTheme = createTheme(baseThemeOptions);
|
|
```
|
|
|
|
#### 4.2 Site-Specific Themes
|
|
|
|
**packages/theme/src/bronhouder.ts**:
|
|
|
|
```tsx
|
|
import { createTheme, ThemeOptions } from '@mui/material';
|
|
import { baseThemeOptions } from './base';
|
|
import { deepmerge } from '@mui/utils';
|
|
|
|
const bronhouderOverrides: ThemeOptions = {
|
|
palette: {
|
|
primary: {
|
|
main: '#1976d2', // Blue - professional, data-focused
|
|
light: '#42a5f5',
|
|
dark: '#1565c0',
|
|
},
|
|
secondary: {
|
|
main: '#7c4dff', // Purple - ontology/knowledge
|
|
light: '#b47cff',
|
|
dark: '#3f1dcb',
|
|
},
|
|
background: {
|
|
default: '#f5f7fa',
|
|
paper: '#ffffff',
|
|
},
|
|
},
|
|
};
|
|
|
|
export const bronhouderTheme = createTheme(
|
|
deepmerge(baseThemeOptions, bronhouderOverrides)
|
|
);
|
|
```
|
|
|
|
**packages/theme/src/archief.ts**:
|
|
|
|
```tsx
|
|
import { createTheme, ThemeOptions } from '@mui/material';
|
|
import { baseThemeOptions } from './base';
|
|
import { deepmerge } from '@mui/utils';
|
|
|
|
const archiefOverrides: ThemeOptions = {
|
|
palette: {
|
|
primary: {
|
|
main: '#2e7d32', // Green - archival, heritage
|
|
light: '#60ad5e',
|
|
dark: '#005005',
|
|
},
|
|
secondary: {
|
|
main: '#ff8f00', // Orange - attention, assistance
|
|
light: '#ffc046',
|
|
dark: '#c56000',
|
|
},
|
|
background: {
|
|
default: '#fafaf5', // Warm paper-like background
|
|
paper: '#ffffff',
|
|
},
|
|
},
|
|
};
|
|
|
|
export const archiefTheme = createTheme(
|
|
deepmerge(baseThemeOptions, archiefOverrides)
|
|
);
|
|
```
|
|
|
|
### Phase 5: Infrastructure Updates (Week 4)
|
|
|
|
#### 5.1 Updated Caddyfile
|
|
|
|
**infrastructure/caddy/Caddyfile**:
|
|
|
|
```caddy
|
|
# Shared API routes snippet
|
|
(api_routes) {
|
|
# DuckLake - Time travel analytics
|
|
handle /ducklake/* {
|
|
reverse_proxy 127.0.0.1:8765
|
|
}
|
|
|
|
# FastAPI general API
|
|
handle /api/* {
|
|
reverse_proxy 127.0.0.1:8000
|
|
}
|
|
|
|
# PostgreSQL API
|
|
handle /api/postgres/* {
|
|
reverse_proxy 127.0.0.1:8001
|
|
}
|
|
|
|
# Geographic API
|
|
handle /api/geo/* {
|
|
reverse_proxy 127.0.0.1:8002
|
|
}
|
|
|
|
# TypeDB API
|
|
handle /api/typedb/* {
|
|
reverse_proxy 127.0.0.1:8003
|
|
}
|
|
|
|
# RAG API
|
|
handle /api/rag/* {
|
|
reverse_proxy 127.0.0.1:8004
|
|
}
|
|
|
|
# Semantic Cache
|
|
handle /api/cache/* {
|
|
reverse_proxy 127.0.0.1:8090
|
|
}
|
|
|
|
# Qdrant vector database
|
|
handle /qdrant/* {
|
|
reverse_proxy 127.0.0.1:6333
|
|
}
|
|
|
|
# SPARQL endpoint
|
|
handle /query {
|
|
reverse_proxy 127.0.0.1:7878
|
|
}
|
|
|
|
handle /sparql/* {
|
|
uri strip_prefix /sparql
|
|
reverse_proxy 127.0.0.1:7878
|
|
}
|
|
}
|
|
|
|
# Static file serving snippet (parameterized)
|
|
(static_files) {
|
|
try_files {path} /index.html
|
|
file_server
|
|
encode gzip
|
|
|
|
header {
|
|
X-Content-Type-Options nosniff
|
|
X-Frame-Options DENY
|
|
Referrer-Policy strict-origin-when-cross-origin
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# bronhouder.nl - Heritage Custodian Portal
|
|
# =============================================================================
|
|
bronhouder.nl, www.bronhouder.nl {
|
|
log {
|
|
output file /var/log/caddy/bronhouder.log
|
|
format json
|
|
}
|
|
|
|
# Import shared API routes
|
|
import api_routes
|
|
|
|
# Health check
|
|
handle /health {
|
|
respond "OK" 200
|
|
}
|
|
|
|
# Static frontend files
|
|
handle {
|
|
root * /var/www/bronhouder
|
|
import static_files
|
|
}
|
|
}
|
|
|
|
# SPARQL subdomain for bronhouder
|
|
sparql.bronhouder.nl {
|
|
reverse_proxy 127.0.0.1:7878 {
|
|
header_up Host {http.request.host}
|
|
header_up X-Real-IP {http.request.remote.host}
|
|
}
|
|
}
|
|
|
|
# =============================================================================
|
|
# archief.support - ArchiefAssistent (National Archives Services)
|
|
# =============================================================================
|
|
archief.support, www.archief.support {
|
|
log {
|
|
output file /var/log/caddy/archief-support.log
|
|
format json
|
|
}
|
|
|
|
# Import shared API routes
|
|
import api_routes
|
|
|
|
# Health check
|
|
handle /health {
|
|
respond "OK" 200
|
|
}
|
|
|
|
# Static frontend files
|
|
handle {
|
|
root * /var/www/archief-assistent
|
|
import static_files
|
|
}
|
|
}
|
|
|
|
# API subdomain for archief.support (optional)
|
|
api.archief.support {
|
|
import api_routes
|
|
|
|
handle {
|
|
respond "ArchiefAssistent API" 200
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 5.2 Updated deploy.sh
|
|
|
|
**infrastructure/deploy.sh** (additions):
|
|
|
|
```bash
|
|
#!/bin/bash
|
|
set -euo pipefail
|
|
|
|
# ... existing code ...
|
|
|
|
BRONHOUDER_FRONTEND_PATH="/var/www/bronhouder"
|
|
ARCHIEF_FRONTEND_PATH="/var/www/archief-assistent"
|
|
|
|
# Parse --site argument
|
|
SITE=""
|
|
while [[ $# -gt 0 ]]; do
|
|
case $1 in
|
|
--site=*)
|
|
SITE="${1#*=}"
|
|
shift
|
|
;;
|
|
--frontend)
|
|
DEPLOY_FRONTEND=true
|
|
shift
|
|
;;
|
|
# ... other flags ...
|
|
esac
|
|
done
|
|
|
|
deploy_frontend() {
|
|
local site="$1"
|
|
local source_dir=""
|
|
local target_path=""
|
|
|
|
case "$site" in
|
|
bronhouder)
|
|
source_dir="apps/bronhouder"
|
|
target_path="$BRONHOUDER_FRONTEND_PATH"
|
|
;;
|
|
archief|archief-assistent)
|
|
source_dir="apps/archief-assistent"
|
|
target_path="$ARCHIEF_FRONTEND_PATH"
|
|
;;
|
|
all)
|
|
deploy_frontend "bronhouder"
|
|
deploy_frontend "archief-assistent"
|
|
return
|
|
;;
|
|
*)
|
|
echo "Unknown site: $site"
|
|
echo "Valid sites: bronhouder, archief-assistent, all"
|
|
exit 1
|
|
;;
|
|
esac
|
|
|
|
echo "=== Deploying $site frontend ==="
|
|
|
|
# Build
|
|
echo "Building $site..."
|
|
cd "$source_dir"
|
|
pnpm install
|
|
pnpm build
|
|
|
|
# Deploy
|
|
echo "Deploying to $target_path..."
|
|
rsync -avz --delete dist/ "root@${SERVER_IP}:${target_path}/"
|
|
|
|
cd ../..
|
|
echo "=== $site frontend deployed ==="
|
|
}
|
|
|
|
# In main logic
|
|
if [[ "$DEPLOY_FRONTEND" == "true" ]]; then
|
|
if [[ -z "$SITE" ]]; then
|
|
echo "Error: --frontend requires --site=<name>"
|
|
echo "Usage: ./deploy.sh --frontend --site=bronhouder"
|
|
echo " ./deploy.sh --frontend --site=archief-assistent"
|
|
echo " ./deploy.sh --frontend --site=all"
|
|
exit 1
|
|
fi
|
|
deploy_frontend "$SITE"
|
|
fi
|
|
```
|
|
|
|
**Usage**:
|
|
|
|
```bash
|
|
# Deploy bronhouder.nl frontend only
|
|
./infrastructure/deploy.sh --frontend --site=bronhouder
|
|
|
|
# Deploy archief.support frontend only
|
|
./infrastructure/deploy.sh --frontend --site=archief-assistent
|
|
|
|
# Deploy both frontends
|
|
./infrastructure/deploy.sh --frontend --site=all
|
|
```
|
|
|
|
---
|
|
|
|
## Component Sharing Patterns
|
|
|
|
### Pattern 1: Polymorphic Components
|
|
|
|
Design components that adapt based on props:
|
|
|
|
```tsx
|
|
// packages/ui/src/components/Layout/Layout.tsx
|
|
interface LayoutProps {
|
|
siteName: string;
|
|
logo: string;
|
|
navigation: NavItem[];
|
|
children: React.ReactNode;
|
|
variant?: 'default' | 'compact' | 'fullwidth';
|
|
footer?: React.ReactNode;
|
|
}
|
|
|
|
export function Layout({
|
|
siteName,
|
|
logo,
|
|
navigation,
|
|
children,
|
|
variant = 'default',
|
|
footer
|
|
}: LayoutProps) {
|
|
return (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
|
|
<Navigation siteName={siteName} logo={logo} items={navigation} />
|
|
<Box component="main" sx={layoutVariants[variant]}>
|
|
{children}
|
|
</Box>
|
|
{footer ?? <DefaultFooter siteName={siteName} />}
|
|
</Box>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pattern 2: Composition Over Inheritance
|
|
|
|
Create building blocks that can be composed differently:
|
|
|
|
```tsx
|
|
// packages/ui/src/components/Card/Card.tsx
|
|
export function Card({ children, ...props }: CardProps) { ... }
|
|
export function CardHeader({ title, subtitle, actions }: CardHeaderProps) { ... }
|
|
export function CardContent({ children }: CardContentProps) { ... }
|
|
export function CardActions({ children }: CardActionsProps) { ... }
|
|
|
|
// apps/bronhouder - Custodian card
|
|
import { Card, CardHeader, CardContent } from '@glam/ui';
|
|
|
|
export function CustodianCard({ custodian }) {
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title={custodian.name}
|
|
subtitle={custodian.type}
|
|
actions={<BookmarkButton />}
|
|
/>
|
|
<CardContent>
|
|
<CustodianDetails custodian={custodian} />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
// apps/archief-assistent - Archive record card
|
|
import { Card, CardHeader, CardContent } from '@glam/ui';
|
|
|
|
export function ArchiveRecordCard({ record }) {
|
|
return (
|
|
<Card>
|
|
<CardHeader
|
|
title={record.title}
|
|
subtitle={record.dateRange}
|
|
actions={<RequestButton />}
|
|
/>
|
|
<CardContent>
|
|
<RecordMetadata record={record} />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pattern 3: Render Props / Slots
|
|
|
|
Allow customization through slots:
|
|
|
|
```tsx
|
|
// packages/visualizations/src/Graph/Graph.tsx
|
|
interface GraphProps<T> {
|
|
data: T[];
|
|
renderNode?: (node: T) => React.ReactNode;
|
|
renderEdge?: (edge: Edge) => React.ReactNode;
|
|
renderTooltip?: (node: T) => React.ReactNode;
|
|
}
|
|
|
|
export function Graph<T>({
|
|
data,
|
|
renderNode = DefaultNode,
|
|
renderEdge = DefaultEdge,
|
|
renderTooltip = DefaultTooltip
|
|
}: GraphProps<T>) {
|
|
// Graph implementation using D3/ELK
|
|
return (
|
|
<svg>
|
|
{edges.map(edge => renderEdge(edge))}
|
|
{nodes.map(node => renderNode(node))}
|
|
</svg>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Pattern 4: Feature Flags
|
|
|
|
Control feature visibility per site:
|
|
|
|
```tsx
|
|
// packages/ui/src/contexts/FeatureFlagsContext.tsx
|
|
interface FeatureFlags {
|
|
sparqlWorkbench: boolean;
|
|
genealogyTools: boolean;
|
|
aiAssistant: boolean;
|
|
advancedFilters: boolean;
|
|
}
|
|
|
|
const defaultFlags: Record<string, FeatureFlags> = {
|
|
bronhouder: {
|
|
sparqlWorkbench: true,
|
|
genealogyTools: false,
|
|
aiAssistant: false,
|
|
advancedFilters: true,
|
|
},
|
|
archiefAssistent: {
|
|
sparqlWorkbench: false,
|
|
genealogyTools: true,
|
|
aiAssistant: true,
|
|
advancedFilters: true,
|
|
},
|
|
};
|
|
|
|
// Usage
|
|
import { useFeatureFlags } from '@glam/ui';
|
|
|
|
function Navigation() {
|
|
const { sparqlWorkbench, aiAssistant } = useFeatureFlags();
|
|
|
|
return (
|
|
<nav>
|
|
{sparqlWorkbench && <NavLink to="/sparql">SPARQL</NavLink>}
|
|
{aiAssistant && <NavLink to="/assistant">AI Assistant</NavLink>}
|
|
</nav>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Migration Strategy
|
|
|
|
### Step 1: Setup (Day 1-2)
|
|
|
|
1. Install pnpm: `npm install -g pnpm`
|
|
2. Create workspace configuration
|
|
3. Create package directory structure
|
|
4. Set up TypeScript project references
|
|
|
|
### Step 2: Extract Shared Code (Day 3-5)
|
|
|
|
1. Move UI components to `packages/ui`
|
|
2. Move hooks to `packages/hooks`
|
|
3. Move API clients to `packages/api-client`
|
|
4. Move visualization components to `packages/visualizations`
|
|
5. Update imports in existing frontend
|
|
|
|
### Step 3: Create Second Frontend (Day 6-8)
|
|
|
|
1. Create `apps/archief-assistent` scaffold
|
|
2. Set up routing and pages
|
|
3. Apply archief theme
|
|
4. Implement AA-specific features
|
|
|
|
### Step 4: Infrastructure (Day 9-10)
|
|
|
|
1. Update Caddyfile for multi-domain
|
|
2. Update deploy.sh for multi-site
|
|
3. Set up DNS for archief.support
|
|
4. Test deployments
|
|
|
|
### Step 5: Testing & Polish (Day 11-14)
|
|
|
|
1. E2E tests for both sites
|
|
2. Performance optimization
|
|
3. Accessibility audit
|
|
4. Documentation
|
|
|
|
---
|
|
|
|
## Development Workflow
|
|
|
|
### Daily Development
|
|
|
|
```bash
|
|
# Terminal 1: Develop bronhouder.nl
|
|
pnpm dev:bronhouder
|
|
|
|
# Terminal 2: Develop archief.support
|
|
pnpm dev:archief
|
|
|
|
# Terminal 3: Backend services
|
|
./infrastructure/deploy.sh --status
|
|
```
|
|
|
|
### Making Changes to Shared Packages
|
|
|
|
```bash
|
|
# Changes to packages/* are immediately available to apps
|
|
# because we use workspace:* references
|
|
|
|
# If you modify packages/ui/src/components/Button.tsx
|
|
# Both apps will see the changes on next hot reload
|
|
```
|
|
|
|
### Deploying
|
|
|
|
```bash
|
|
# Deploy specific site
|
|
./infrastructure/deploy.sh --frontend --site=bronhouder
|
|
|
|
# Deploy both sites
|
|
./infrastructure/deploy.sh --frontend --site=all
|
|
|
|
# Deploy everything (backend + both frontends)
|
|
./infrastructure/deploy.sh --all --site=all
|
|
```
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
| Aspect | Approach |
|
|
|--------|----------|
|
|
| **Monorepo Tool** | pnpm workspaces |
|
|
| **Package Structure** | `packages/*` (shared), `apps/*` (sites) |
|
|
| **Shared Components** | `@glam/ui`, `@glam/hooks`, `@glam/api-client`, `@glam/visualizations`, `@glam/theme` |
|
|
| **Theming** | Base theme + site-specific overrides |
|
|
| **Deployment** | SSH/rsync per site, Caddy multi-domain |
|
|
| **Development** | Parallel Vite dev servers |
|
|
| **Type Safety** | TypeScript project references |
|
|
|
|
This architecture provides maximum code reuse while allowing full customization per site, with clear separation of concerns and independent deployment capability.
|