glam/docs/MULTI_FRONTEND_ARCHITECTURE.md
2025-12-21 00:01:54 +01:00

28 KiB

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

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):

# Install pnpm globally
npm install -g pnpm

# Initialize workspace in project root
cd /Users/kempersc/apps/glam

Create pnpm-workspace.yaml:

packages:
  - 'packages/*'
  - 'apps/*'

Update root package.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:

{
  "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:

{
  "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:

{
  "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):

// frontend/src/components/common/ErrorBoundary.tsx
export class ErrorBoundary extends React.Component { ... }

After (package):

// 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:

// 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:

{
  "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:

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:

{
  "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:

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:

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:

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:

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:

# 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):

#!/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:

# 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:

// 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:

// 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:

// 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:

// 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

# 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

# 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

# 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.