# 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 ( } /> } /> } /> } /> ); } ``` #### 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 ( } /> } /> } /> } /> } /> ); } ``` ### 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=" 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 ( {children} {footer ?? } ); } ``` ### 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 ( } /> ); } // apps/archief-assistent - Archive record card import { Card, CardHeader, CardContent } from '@glam/ui'; export function ArchiveRecordCard({ record }) { return ( } /> ); } ``` ### Pattern 3: Render Props / Slots Allow customization through slots: ```tsx // packages/visualizations/src/Graph/Graph.tsx interface GraphProps { data: T[]; renderNode?: (node: T) => React.ReactNode; renderEdge?: (edge: Edge) => React.ReactNode; renderTooltip?: (node: T) => React.ReactNode; } export function Graph({ data, renderNode = DefaultNode, renderEdge = DefaultEdge, renderTooltip = DefaultTooltip }: GraphProps) { // Graph implementation using D3/ELK return ( {edges.map(edge => renderEdge(edge))} {nodes.map(node => renderNode(node))} ); } ``` ### 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 = { 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 ( ); } ``` --- ## 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.