feat(frontend): Add multi-select filters, URL params, and UI improvements

- Institution Browser: multi-select for types and countries
- URL query param sync for shareable filter URLs
- New utility: countryNames.ts with flag emoji support
- New utility: imageProxy.ts for image URL handling
- New component: SearchableMultiSelect dropdown
- Career timeline CSS and component updates
- Media gallery improvements
- Lazy load error boundary component
- Version check utility
This commit is contained in:
kempersc 2025-12-15 01:47:11 +01:00
parent 181b1cf705
commit 0a38225b36
24 changed files with 4071 additions and 166 deletions

View file

@ -9684,24 +9684,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/yaml": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View file

@ -1,5 +1,5 @@
{
"generated": "2025-12-14T15:51:34.503Z",
"generated": "2025-12-15T00:45:23.433Z",
"version": "1.0.0",
"categories": [
{

View file

@ -4,7 +4,7 @@
* © 2025 Netwerk Digitaal Erfgoed & TextPast. All rights reserved.
*/
import { lazy, Suspense, useEffect } from 'react';
import React, { Suspense, useEffect, useState, useCallback } from 'react';
import {
createBrowserRouter,
RouterProvider,
@ -15,31 +15,39 @@ import { LanguageProvider } from './contexts/LanguageContext';
import { ProtectedRoute } from './components/ProtectedRoute';
import { Layout } from './components/layout/Layout';
import { preloadInstitutions } from './hooks/useGeoApiInstitutions';
import {
lazyWithRetry,
RouterErrorBoundary,
LazyLoadErrorBoundary
} from './components/common/LazyLoadError';
import { startVersionCheck } from './lib/version-check';
import { RefreshCw } from 'lucide-react';
// Eagerly loaded pages (small, frequently accessed)
import { LoginPage } from './pages/LoginPage';
import { Settings } from './pages/Settings';
import ProjectPlanPage from './pages/ProjectPlanPage';
// Lazy loaded pages (heavy dependencies or less frequently accessed)
// Lazy loaded pages with automatic retry on chunk load failures
// These use lazyWithRetry() to handle deployment-related chunk errors gracefully
// Map page - imports maplibre-gl (~1MB)
const NDEMapPage = lazy(() => import('./pages/NDEMapPageMapLibre'));
const NDEMapPage = lazyWithRetry(() => import('./pages/NDEMapPageMapLibre'));
// Visualize page - imports mermaid, d3, elkjs (~1.5MB combined)
const Visualize = lazy(() => import('./pages/Visualize').then(m => ({ default: m.Visualize })));
const Visualize = lazyWithRetry(() => import('./pages/Visualize').then(m => ({ default: m.Visualize })));
// LinkML viewer - large schema parsing
const LinkMLViewerPage = lazy(() => import('./pages/LinkMLViewerPage'));
const LinkMLViewerPage = lazyWithRetry(() => import('./pages/LinkMLViewerPage'));
// Ontology viewer - imports visualization libraries
const OntologyViewerPage = lazy(() => import('./pages/OntologyViewerPage'));
const OntologyViewerPage = lazyWithRetry(() => import('./pages/OntologyViewerPage'));
// Query builder - medium complexity
const QueryBuilderPage = lazy(() => import('./pages/QueryBuilderPage'));
const QueryBuilderPage = lazyWithRetry(() => import('./pages/QueryBuilderPage'));
// Database page
const Database = lazy(() => import('./pages/Database').then(m => ({ default: m.Database })));
const Database = lazyWithRetry(() => import('./pages/Database').then(m => ({ default: m.Database })));
// Stats page
const NDEStatsPage = lazy(() => import('./pages/NDEStatsPage'));
const NDEStatsPage = lazyWithRetry(() => import('./pages/NDEStatsPage'));
// Conversation page
const ConversationPage = lazy(() => import('./pages/ConversationPage'));
const ConversationPage = lazyWithRetry(() => import('./pages/ConversationPage'));
// Institution browser page
const InstitutionBrowserPage = lazy(() => import('./pages/InstitutionBrowserPage'));
const InstitutionBrowserPage = lazyWithRetry(() => import('./pages/InstitutionBrowserPage'));
import './App.css';
@ -53,11 +61,13 @@ const PageLoader = () => (
</div>
);
// Wrap lazy components with Suspense
// Wrap lazy components with Suspense and error boundary for chunk load errors
const withSuspense = (Component: React.LazyExoticComponent<React.ComponentType>) => (
<Suspense fallback={<PageLoader />}>
<Component />
</Suspense>
<LazyLoadErrorBoundary>
<Suspense fallback={<PageLoader />}>
<Component />
</Suspense>
</LazyLoadErrorBoundary>
);
// Create router configuration with protected routes
@ -66,6 +76,7 @@ const router = createBrowserRouter([
{
path: '/login',
element: <LoginPage />,
errorElement: <RouterErrorBoundary />,
},
{
path: '/',
@ -74,6 +85,7 @@ const router = createBrowserRouter([
<Layout />
</ProtectedRoute>
),
errorElement: <RouterErrorBoundary />,
children: [
{
// Home page redirects to LinkML viewer
@ -135,6 +147,8 @@ const router = createBrowserRouter([
]);
function App() {
const [showUpdateBanner, setShowUpdateBanner] = useState(false);
// Preload institution data on app initialization
// This starts the fetch early so data is ready when user navigates to Map or Browse
useEffect(() => {
@ -145,9 +159,47 @@ function App() {
return () => clearTimeout(timer);
}, []);
// Check for new versions periodically
useEffect(() => {
const cleanup = startVersionCheck(() => {
setShowUpdateBanner(true);
});
return cleanup;
}, []);
const handleRefresh = useCallback(() => {
// Clear caches and reload
if ('caches' in window) {
caches.keys().then(names => {
names.forEach(name => caches.delete(name));
});
}
window.location.reload();
}, []);
return (
<LanguageProvider>
<AuthProvider>
{/* New version available banner */}
{showUpdateBanner && (
<div className="fixed top-0 left-0 right-0 z-[9999] bg-blue-600 text-white px-4 py-2 text-center text-sm flex items-center justify-center gap-3">
<span>A new version is available.</span>
<button
onClick={handleRefresh}
className="inline-flex items-center gap-1.5 px-3 py-1 bg-white text-blue-600 rounded font-medium hover:bg-blue-50 transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</button>
<button
onClick={() => setShowUpdateBanner(false)}
className="text-white/80 hover:text-white ml-2"
aria-label="Dismiss"
>
×
</button>
</div>
)}
<RouterProvider router={router} />
</AuthProvider>
</LanguageProvider>

View file

@ -0,0 +1,297 @@
/**
* LazyLoadError.tsx
*
* Error boundary and utilities for handling dynamic import failures.
*
* This handles the common case where a user has a cached version of the app
* but the chunk files have changed after a deployment. When this happens,
* the browser tries to load the old chunk URLs which no longer exist.
*
* Solution: Detect chunk load errors and prompt user to reload the page.
*/
import React, { Component, type ErrorInfo, type ReactNode } from 'react';
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
import { RefreshCw, AlertTriangle, Home } from 'lucide-react';
/**
* Check if an error is a chunk loading error (dynamic import failure)
*/
export function isChunkLoadError(error: unknown): boolean {
if (error instanceof Error) {
const message = error.message.toLowerCase();
return (
message.includes('loading chunk') ||
message.includes('loading css chunk') ||
message.includes('dynamically imported module') ||
message.includes('failed to fetch dynamically imported module') ||
message.includes('error loading dynamically imported module') ||
message.includes("cannot find module") ||
// Vite-specific errors
message.includes('failed to load') && message.includes('.js')
);
}
return false;
}
/**
* Session storage key to track reload attempts and prevent infinite loops
*/
const RELOAD_ATTEMPT_KEY = 'glam_chunk_reload_attempt';
const RELOAD_TIMESTAMP_KEY = 'glam_chunk_reload_timestamp';
const MAX_RELOAD_ATTEMPTS = 2;
const RELOAD_COOLDOWN_MS = 10000; // 10 seconds
/**
* Attempt to reload the page, clearing caches if possible.
* Includes protection against infinite reload loops.
*/
export function handleChunkLoadError(): void {
const now = Date.now();
const lastReloadTime = parseInt(sessionStorage.getItem(RELOAD_TIMESTAMP_KEY) || '0', 10);
const reloadAttempts = parseInt(sessionStorage.getItem(RELOAD_ATTEMPT_KEY) || '0', 10);
// Reset counter if enough time has passed (user may have navigated away and back)
if (now - lastReloadTime > RELOAD_COOLDOWN_MS) {
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, '1');
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
} else if (reloadAttempts >= MAX_RELOAD_ATTEMPTS) {
// Too many reloads in quick succession - don't reload, show error instead
console.error('[handleChunkLoadError] Max reload attempts reached, stopping to prevent loop');
sessionStorage.removeItem(RELOAD_ATTEMPT_KEY);
sessionStorage.removeItem(RELOAD_TIMESTAMP_KEY);
return; // Let the error boundary show the manual refresh UI
} else {
sessionStorage.setItem(RELOAD_ATTEMPT_KEY, (reloadAttempts + 1).toString());
sessionStorage.setItem(RELOAD_TIMESTAMP_KEY, now.toString());
}
// Clear service worker caches if available
if ('caches' in window) {
caches.keys().then(names => {
names.forEach(name => caches.delete(name));
});
}
// Force reload from server (bypass cache)
window.location.reload();
}
interface ChunkErrorFallbackProps {
error?: Error | null;
language?: 'nl' | 'en';
}
/**
* User-friendly error message for chunk loading failures
*/
export const ChunkErrorFallback: React.FC<ChunkErrorFallbackProps> = ({
error,
language = 'nl'
}) => {
const isChunk = error ? isChunkLoadError(error) : true;
const text = {
title: {
nl: isChunk ? 'Nieuwe versie beschikbaar' : 'Er is iets misgegaan',
en: isChunk ? 'New version available' : 'Something went wrong',
},
message: {
nl: isChunk
? 'De applicatie is bijgewerkt. Ververs de pagina om de nieuwe versie te laden.'
: 'Er is een fout opgetreden bij het laden van deze pagina.',
en: isChunk
? 'The application has been updated. Refresh the page to load the new version.'
: 'An error occurred while loading this page.',
},
refresh: {
nl: 'Pagina verversen',
en: 'Refresh page',
},
home: {
nl: 'Naar startpagina',
en: 'Go to home',
},
};
return (
<div className="flex items-center justify-center min-h-[400px] p-8">
<div className="text-center max-w-md">
<div className="mb-6">
{isChunk ? (
<RefreshCw className="w-16 h-16 mx-auto text-blue-500 animate-pulse" />
) : (
<AlertTriangle className="w-16 h-16 mx-auto text-amber-500" />
)}
</div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100 mb-3">
{text.title[language]}
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{text.message[language]}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<button
onClick={handleChunkLoadError}
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<RefreshCw className="w-4 h-4" />
{text.refresh[language]}
</button>
<a
href="/"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors font-medium"
>
<Home className="w-4 h-4" />
{text.home[language]}
</a>
</div>
{/* Debug info in development */}
{error && import.meta.env.DEV && (
<details className="mt-6 text-left text-xs text-gray-500">
<summary className="cursor-pointer">Technical details</summary>
<pre className="mt-2 p-2 bg-gray-100 dark:bg-gray-800 rounded overflow-auto">
{error.message}
</pre>
</details>
)}
</div>
</div>
);
};
/**
* Router error element that handles route-level errors
*/
export const RouterErrorBoundary: React.FC = () => {
const error = useRouteError();
// Check if it's a chunk load error
if (error instanceof Error && isChunkLoadError(error)) {
return <ChunkErrorFallback error={error} />;
}
// Handle 404 errors
if (isRouteErrorResponse(error) && error.status === 404) {
return (
<div className="flex items-center justify-center min-h-[400px] p-8">
<div className="text-center max-w-md">
<h2 className="text-4xl font-bold text-gray-900 dark:text-gray-100 mb-3">
404
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Page not found
</p>
<a
href="/"
className="inline-flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<Home className="w-4 h-4" />
Go home
</a>
</div>
</div>
);
}
// Generic error fallback
return <ChunkErrorFallback error={error instanceof Error ? error : null} />;
};
interface LazyLoadErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
interface LazyLoadErrorBoundaryProps {
children: ReactNode;
language?: 'nl' | 'en';
}
/**
* Error boundary specifically for lazy-loaded components.
* Catches chunk loading errors and provides a helpful recovery UI.
*/
export class LazyLoadErrorBoundary extends Component<
LazyLoadErrorBoundaryProps,
LazyLoadErrorBoundaryState
> {
constructor(props: LazyLoadErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): LazyLoadErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('LazyLoadErrorBoundary caught error:', error);
console.error('Component stack:', errorInfo.componentStack);
// If it's a chunk error, we could auto-reload after a short delay
// but for now we just show the UI
}
render() {
if (this.state.hasError) {
return (
<ChunkErrorFallback
error={this.state.error}
language={this.props.language}
/>
);
}
return this.props.children;
}
}
/**
* Create a lazy component with automatic retry on chunk load failure.
*
* When a dynamic import fails (e.g., due to a new deployment), this will:
* 1. First attempt: Try loading normally
* 2. On failure: Add a cache-busting query parameter and retry
* 3. If that fails: Reload the entire page
*
* @param importFn - The dynamic import function
* @param retries - Number of retries before giving up (default: 2)
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function lazyWithRetry<T extends React.ComponentType<any>>(
importFn: () => Promise<{ default: T }>,
retries = 2
): React.LazyExoticComponent<T> {
return React.lazy(async () => {
let lastError: Error | null = null;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await importFn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
// Only retry for chunk load errors
if (!isChunkLoadError(lastError)) {
throw lastError;
}
console.warn(`[LazyLoad] Attempt ${attempt + 1} failed, retrying...`, lastError.message);
// Small delay before retry
await new Promise(resolve => setTimeout(resolve, 500 * (attempt + 1)));
}
}
// All retries failed - throw the error to be caught by error boundary
throw lastError;
});
}
export default LazyLoadErrorBoundary;

View file

@ -0,0 +1,761 @@
/**
* ConversationMapLibre.tsx - MapLibre GL Map for Conversation Page
*
* Drop-in replacement for ConversationGeoMap.tsx with real map tiles
* and the rich InstitutionInfoPanel used on the production map page.
*
* Features:
* - Real map tiles (OSM light / CartoDB dark mode)
* - Proper marker sizing with zoom-based scaling
* - InstitutionInfoPanel integration for clicked markers
* - Same props interface as ConversationGeoMap for easy replacement
*/
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import maplibregl from 'maplibre-gl';
import type { StyleSpecification, MapLayerMouseEvent } from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import { InstitutionInfoPanel, type Institution } from '../map/InstitutionInfoPanel';
import type { GeoCoordinate, InstitutionData } from '../../hooks/useMultiDatabaseRAG';
// Custodian type colors matching GLAMORCUBESFIXPHDNT taxonomy (19 types)
const TYPE_COLORS: Record<string, string> = {
G: '#00bcd4', // Gallery - cyan
L: '#2ecc71', // Library - green
A: '#3498db', // Archive - blue
M: '#e74c3c', // Museum - red
O: '#f39c12', // Official - orange
R: '#1abc9c', // Research - teal
C: '#795548', // Corporation - brown
U: '#9e9e9e', // Unknown - gray
B: '#4caf50', // Botanical - green
E: '#ff9800', // Education - amber
S: '#9b59b6', // Society - purple
F: '#95a5a6', // Features - gray
I: '#673ab7', // Intangible - deep purple
X: '#607d8b', // Mixed - blue gray
P: '#8bc34a', // Personal - light green
H: '#607d8b', // Holy sites - blue gray
D: '#34495e', // Digital - dark gray
N: '#e91e63', // NGO - pink
T: '#ff5722', // Taste/smell - deep orange
};
const TYPE_NAMES: Record<string, { nl: string; en: string }> = {
G: { nl: 'Galerie', en: 'Gallery' },
L: { nl: 'Bibliotheek', en: 'Library' },
A: { nl: 'Archief', en: 'Archive' },
M: { nl: 'Museum', en: 'Museum' },
O: { nl: 'Officieel', en: 'Official' },
R: { nl: 'Onderzoek', en: 'Research' },
C: { nl: 'Bedrijf', en: 'Corporation' },
U: { nl: 'Onbekend', en: 'Unknown' },
B: { nl: 'Botanisch', en: 'Botanical' },
E: { nl: 'Onderwijs', en: 'Education' },
S: { nl: 'Vereniging', en: 'Society' },
F: { nl: 'Monumenten', en: 'Features' },
I: { nl: 'Immaterieel', en: 'Intangible' },
X: { nl: 'Gemengd', en: 'Mixed' },
P: { nl: 'Persoonlijk', en: 'Personal' },
H: { nl: 'Heilige plaatsen', en: 'Holy sites' },
D: { nl: 'Digitaal', en: 'Digital' },
N: { nl: 'NGO', en: 'NGO' },
T: { nl: 'Smaak/geur', en: 'Taste/smell' },
};
// Map tile styles for light and dark modes
const getMapStyle = (isDarkMode: boolean): StyleSpecification => {
if (isDarkMode) {
// CartoDB Dark Matter - dark mode tiles
return {
version: 8,
sources: {
'carto-dark': {
type: 'raster',
tiles: [
'https://a.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://b.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
'https://c.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}@2x.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
},
},
layers: [
{
id: 'carto-dark-tiles',
type: 'raster',
source: 'carto-dark',
minzoom: 0,
maxzoom: 19,
},
],
};
} else {
// OpenStreetMap - light mode tiles
return {
version: 8,
sources: {
'osm': {
type: 'raster',
tiles: [
'https://a.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://b.tile.openstreetmap.org/{z}/{x}/{y}.png',
'https://c.tile.openstreetmap.org/{z}/{x}/{y}.png',
],
tileSize: 256,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
},
},
layers: [
{
id: 'osm-tiles',
type: 'raster',
source: 'osm',
minzoom: 0,
maxzoom: 19,
},
],
};
}
};
// Props interface - same as ConversationGeoMap for drop-in replacement
export interface ConversationMapLibreProps {
coordinates: GeoCoordinate[];
width?: number;
height?: number;
onMarkerClick?: (data: InstitutionData) => void;
onMarkerHover?: (data: InstitutionData | null) => void;
selectedId?: string | null;
language?: 'nl' | 'en';
showClustering?: boolean;
className?: string;
}
/**
* Map a type name (like "Museum", "Archief") to a single-letter code
*/
function mapTypeNameToCode(typeName?: string): string {
if (!typeName) return 'U';
const normalized = typeName.toLowerCase();
if (normalized.includes('museum')) return 'M';
if (normalized.includes('archief') || normalized.includes('archive')) return 'A';
if (normalized.includes('bibliotheek') || normalized.includes('library')) return 'L';
if (normalized.includes('galerie') || normalized.includes('gallery')) return 'G';
if (normalized.includes('universiteit') || normalized.includes('university') || normalized.includes('onderwijs') || normalized.includes('education')) return 'E';
if (normalized.includes('onderzoek') || normalized.includes('research')) return 'R';
if (normalized.includes('vereniging') || normalized.includes('society')) return 'S';
if (normalized.includes('botanisch') || normalized.includes('botanical') || normalized.includes('zoo') || normalized.includes('dierentuin')) return 'B';
if (normalized.includes('officieel') || normalized.includes('official')) return 'O';
if (normalized.includes('bedrijf') || normalized.includes('corporation') || normalized.includes('corporate')) return 'C';
if (normalized.includes('monument') || normalized.includes('feature')) return 'F';
if (normalized.includes('immaterieel') || normalized.includes('intangible')) return 'I';
if (normalized.includes('persoonlijk') || normalized.includes('personal')) return 'P';
if (normalized.includes('heilig') || normalized.includes('holy') || normalized.includes('kerk') || normalized.includes('church')) return 'H';
if (normalized.includes('digitaal') || normalized.includes('digital')) return 'D';
if (normalized.includes('ngo')) return 'N';
if (normalized.includes('smaak') || normalized.includes('taste') || normalized.includes('geur') || normalized.includes('smell')) return 'T';
if (normalized.includes('gemengd') || normalized.includes('mixed')) return 'X';
return 'U';
}
/**
* Convert InstitutionData (from RAG) to Institution (for InfoPanel)
*/
function institutionDataToInstitution(data: InstitutionData): Institution {
const typeCode = mapTypeNameToCode(data.type);
return {
lat: data.latitude || 0,
lon: data.longitude || 0,
name: data.name,
city: data.city || '',
province: data.province || '',
type: typeCode,
type_name: data.type || '',
color: TYPE_COLORS[typeCode] || '#9e9e9e',
website: data.website || '',
wikidata_id: data.wikidata || '',
description: data.description || '',
rating: data.rating,
total_ratings: data.reviews,
// ISIL if available
isil: data.isil ? { code: data.isil } : undefined,
};
}
/**
* Convert coordinates to GeoJSON FeatureCollection
*/
function coordinatesToGeoJSON(coordinates: GeoCoordinate[]): GeoJSON.FeatureCollection {
return {
type: 'FeatureCollection',
features: coordinates.map((coord, index) => {
const data = coord.data;
const typeCode = mapTypeNameToCode(coord.type || data?.type);
const institutionId = data?.id || coord.label || `coord-${index}`;
return {
type: 'Feature' as const,
id: index,
geometry: {
type: 'Point' as const,
coordinates: [coord.lng, coord.lat],
},
properties: {
index,
institutionId, // Used for selectedId matching
name: coord.label || data?.name || 'Unknown',
type: typeCode,
typeName: coord.type || data?.type || '',
color: TYPE_COLORS[typeCode] || '#9e9e9e',
city: data?.city || '',
province: data?.province || '',
rating: data?.rating || null,
reviews: data?.reviews || null,
website: data?.website || '',
wikidata: data?.wikidata || '',
description: data?.description || '',
},
};
}),
};
}
export const ConversationMapLibre: React.FC<ConversationMapLibreProps> = ({
coordinates,
width = 600,
height = 500,
onMarkerClick,
onMarkerHover,
selectedId,
language = 'nl',
showClustering = false,
className = '',
}) => {
const mapContainerRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<maplibregl.Map | null>(null);
const [mapReady, setMapReady] = useState(false);
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(null);
const [markerPosition, setMarkerPosition] = useState<{ x: number; y: number }>({ x: 0, y: 0 });
// Detect dark mode from system preference
const [isDarkMode, setIsDarkMode] = useState(() =>
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false
);
// Listen for dark mode changes
useEffect(() => {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => setIsDarkMode(e.matches);
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, []);
// Translation helper
const t = useCallback((nl: string, en: string) => language === 'nl' ? nl : en, [language]);
// Get type name in current language
const getTypeName = useCallback((typeCode: string) => {
const names = TYPE_NAMES[typeCode];
return names ? (language === 'nl' ? names.nl : names.en) : typeCode;
}, [language]);
// Convert coordinates to GeoJSON
const geoJSON = useMemo(() => coordinatesToGeoJSON(coordinates), [coordinates]);
// Calculate bounds to fit all markers
const bounds = useMemo(() => {
if (coordinates.length === 0) return null;
const lngs = coordinates.map(c => c.lng);
const lats = coordinates.map(c => c.lat);
return new maplibregl.LngLatBounds(
[Math.min(...lngs), Math.min(...lats)],
[Math.max(...lngs), Math.max(...lats)]
);
}, [coordinates]);
// Initialize map
useEffect(() => {
if (!mapContainerRef.current || mapRef.current) return;
// Default center on Netherlands if no coordinates
const defaultCenter: [number, number] = [5.2913, 52.1326];
const defaultZoom = 7;
const map = new maplibregl.Map({
container: mapContainerRef.current,
style: getMapStyle(isDarkMode),
center: bounds ? bounds.getCenter().toArray() as [number, number] : defaultCenter,
zoom: defaultZoom,
attributionControl: true,
});
mapRef.current = map;
map.on('load', () => {
setMapReady(true);
// Fit to bounds if we have markers
if (bounds && coordinates.length > 1) {
map.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
});
} else if (coordinates.length === 1) {
map.setCenter([coordinates[0].lng, coordinates[0].lat]);
map.setZoom(12);
}
});
// Add navigation controls
map.addControl(new maplibregl.NavigationControl(), 'top-right');
return () => {
map.remove();
mapRef.current = null;
setMapReady(false);
};
}, []); // Only run once on mount
// Update map style when dark mode changes
useEffect(() => {
if (!mapRef.current || !mapReady) return;
mapRef.current.setStyle(getMapStyle(isDarkMode));
}, [isDarkMode, mapReady]);
// Add/update GeoJSON source and layers
useEffect(() => {
if (!mapRef.current || !mapReady) return;
const map = mapRef.current;
// Wait for style to be loaded
const addLayers = () => {
// Remove existing layers and sources
const layersToRemove = [
'institutions-circle',
'institutions-circle-stroke',
'institutions-selected-ring',
'clusters',
'cluster-count',
'unclustered-point',
'unclustered-point-stroke',
'unclustered-selected-ring',
];
layersToRemove.forEach(layerId => {
if (map.getLayer(layerId)) map.removeLayer(layerId);
});
const sourcesToRemove = ['institutions', 'institutions-clustered'];
sourcesToRemove.forEach(sourceId => {
if (map.getSource(sourceId)) map.removeSource(sourceId);
});
if (showClustering && coordinates.length > 20) {
// === CLUSTERING MODE ===
map.addSource('institutions-clustered', {
type: 'geojson',
data: geoJSON,
cluster: true,
clusterMaxZoom: 14,
clusterRadius: 50,
});
// Cluster circles
map.addLayer({
id: 'clusters',
type: 'circle',
source: 'institutions-clustered',
filter: ['has', 'point_count'],
paint: {
'circle-color': [
'step',
['get', 'point_count'],
'#51bbd6', // < 10 points
10, '#f1f075', // 10-29 points
30, '#f28cb1', // >= 30 points
],
'circle-radius': [
'step',
['get', 'point_count'],
15, // < 10 points: radius 15
10, 20, // 10-29 points: radius 20
30, 25, // >= 30 points: radius 25
],
'circle-stroke-width': 2,
'circle-stroke-color': isDarkMode ? '#ffffff' : '#1e293b',
},
});
// Cluster count labels
map.addLayer({
id: 'cluster-count',
type: 'symbol',
source: 'institutions-clustered',
filter: ['has', 'point_count'],
layout: {
'text-field': '{point_count_abbreviated}',
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
'text-size': 12,
},
paint: {
'text-color': isDarkMode ? '#1e293b' : '#1e293b',
},
});
// Unclustered points
map.addLayer({
id: 'unclustered-point',
type: 'circle',
source: 'institutions-clustered',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
5, 5,
10, 8,
15, 12,
],
'circle-color': ['get', 'color'],
'circle-opacity': 0.85,
},
});
// Unclustered point strokes
map.addLayer({
id: 'unclustered-point-stroke',
type: 'circle',
source: 'institutions-clustered',
filter: ['!', ['has', 'point_count']],
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
5, 6,
10, 9,
15, 13,
],
'circle-color': 'transparent',
'circle-stroke-color': isDarkMode ? '#ffffff' : '#1e293b',
'circle-stroke-width': 1.5,
'circle-stroke-opacity': 0.7,
},
});
// Selected marker highlight ring for clustering mode
map.addLayer({
id: 'unclustered-selected-ring',
type: 'circle',
source: 'institutions-clustered',
filter: selectedId
? ['all', ['!', ['has', 'point_count']], ['==', ['get', 'institutionId'], selectedId]]
: ['==', ['get', 'institutionId'], '__none__'], // Hide when no selection
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
5, 12,
10, 16,
15, 22,
],
'circle-color': 'transparent',
'circle-stroke-color': '#fbbf24', // Amber/gold highlight
'circle-stroke-width': 3,
'circle-stroke-opacity': 0.9,
},
});
// Click handler for clusters to zoom in
map.on('click', 'clusters', (e) => {
const features = map.queryRenderedFeatures(e.point, { layers: ['clusters'] });
if (!features.length) return;
const clusterId = features[0].properties?.cluster_id as number;
const source = map.getSource('institutions-clustered');
// GeoJSONSource with clustering has getClusterExpansionZoom method
// but it's not in the TypeScript types, so we need to cast
if (source && 'getClusterExpansionZoom' in source && typeof clusterId === 'number') {
(source as maplibregl.GeoJSONSource & {
getClusterExpansionZoom: (clusterId: number, callback: (err: Error | null, zoom: number) => void) => void;
}).getClusterExpansionZoom(clusterId, (err: Error | null, zoom: number) => {
if (err) return;
const geometry = features[0].geometry;
if (geometry.type === 'Point') {
map.easeTo({
center: geometry.coordinates as [number, number],
zoom: zoom || 10,
});
}
});
}
});
// Change cursor on cluster hover
map.on('mouseenter', 'clusters', () => {
map.getCanvas().style.cursor = 'pointer';
});
map.on('mouseleave', 'clusters', () => {
map.getCanvas().style.cursor = '';
});
} else {
// === NON-CLUSTERING MODE ===
map.addSource('institutions', {
type: 'geojson',
data: geoJSON,
});
// Main circle layer with zoom-based scaling
map.addLayer({
id: 'institutions-circle',
type: 'circle',
source: 'institutions',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
5, 5,
10, 8,
15, 12,
],
'circle-color': ['get', 'color'],
'circle-opacity': 0.85,
},
});
// Stroke layer
map.addLayer({
id: 'institutions-circle-stroke',
type: 'circle',
source: 'institutions',
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
5, 6,
10, 9,
15, 13,
],
'circle-color': 'transparent',
'circle-stroke-color': isDarkMode ? '#ffffff' : '#1e293b',
'circle-stroke-width': 1.5,
'circle-stroke-opacity': 0.7,
},
});
// Selected marker highlight ring (larger, pulsing effect via opacity)
map.addLayer({
id: 'institutions-selected-ring',
type: 'circle',
source: 'institutions',
filter: selectedId
? ['==', ['get', 'institutionId'], selectedId]
: ['==', ['get', 'institutionId'], '__none__'], // Hide when no selection
paint: {
'circle-radius': [
'interpolate', ['linear'], ['zoom'],
5, 12,
10, 16,
15, 22,
],
'circle-color': 'transparent',
'circle-stroke-color': '#fbbf24', // Amber/gold highlight
'circle-stroke-width': 3,
'circle-stroke-opacity': 0.9,
},
});
}
// Fit bounds after adding data
if (bounds && coordinates.length > 1) {
map.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
});
}
};
// If style is already loaded, add layers immediately
if (map.isStyleLoaded()) {
addLayers();
} else {
// Wait for style to load
map.once('styledata', addLayers);
}
}, [geoJSON, mapReady, bounds, coordinates.length, isDarkMode, showClustering, selectedId]);
// Determine which layer to use based on clustering mode
const isClusteringMode = showClustering && coordinates.length > 20;
// Handle click events on markers
useEffect(() => {
if (!mapRef.current || !mapReady) return;
const map = mapRef.current;
const layerName = isClusteringMode ? 'unclustered-point' : 'institutions-circle';
// Check if layer exists before binding
const layerExists = () => {
try {
return !!map.getLayer(layerName);
} catch {
return false;
}
};
const handleClick = (e: MapLayerMouseEvent) => {
if (!e.features || e.features.length === 0) return;
const feature = e.features[0];
const props = feature.properties;
const index = typeof props?.index === 'number' ? props.index : undefined;
if (index === undefined || !coordinates[index]) return;
const coord = coordinates[index];
const data = coord.data;
// Get screen position for the info panel
const point = map.project([coord.lng, coord.lat]);
setMarkerPosition({ x: point.x, y: point.y });
// Create Institution object for the panel
if (data) {
const institution = institutionDataToInstitution(data);
setSelectedInstitution(institution);
// Call the external click handler if provided
onMarkerClick?.(data);
}
};
const handleMouseEnter = (e: MapLayerMouseEvent) => {
map.getCanvas().style.cursor = 'pointer';
if (e.features && e.features.length > 0) {
const props = e.features[0].properties;
const index = typeof props?.index === 'number' ? props.index : undefined;
if (index !== undefined && coordinates[index]?.data) {
onMarkerHover?.(coordinates[index].data!);
}
}
};
const handleMouseLeave = () => {
map.getCanvas().style.cursor = '';
onMarkerHover?.(null);
};
// Bind events after a short delay to ensure layer exists
const bindEvents = () => {
if (!layerExists()) {
// Retry after a short delay if layer doesn't exist yet
setTimeout(bindEvents, 100);
return;
}
map.on('click', layerName, handleClick);
map.on('mouseenter', layerName, handleMouseEnter);
map.on('mouseleave', layerName, handleMouseLeave);
};
bindEvents();
return () => {
if (layerExists()) {
map.off('click', layerName, handleClick);
map.off('mouseenter', layerName, handleMouseEnter);
map.off('mouseleave', layerName, handleMouseLeave);
}
};
}, [mapReady, coordinates, onMarkerClick, onMarkerHover, isClusteringMode]);
// Close info panel
const handleClosePanel = useCallback(() => {
setSelectedInstitution(null);
}, []);
// Map container style
const containerStyle: React.CSSProperties = {
width: width,
height: height,
position: 'relative',
borderRadius: '8px',
overflow: 'hidden',
};
return (
<div className={`conversation-maplibre ${className}`} style={containerStyle}>
<div
ref={mapContainerRef}
style={{ width: '100%', height: '100%' }}
/>
{/* Legend */}
<div
style={{
position: 'absolute',
bottom: 10,
left: 10,
backgroundColor: isDarkMode ? 'rgba(30, 41, 59, 0.9)' : 'rgba(255, 255, 255, 0.9)',
padding: '8px 12px',
borderRadius: '6px',
fontSize: '11px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
maxHeight: '150px',
overflowY: 'auto',
}}
>
<div style={{
fontWeight: 600,
marginBottom: 4,
color: isDarkMode ? '#e2e8f0' : '#1e293b',
}}>
{t('Legenda', 'Legend')} ({coordinates.length})
</div>
{/* Show unique types in this dataset */}
{Array.from(new Set(coordinates.map(c => mapTypeNameToCode(c.type || c.data?.type))))
.filter(code => code !== 'U')
.slice(0, 6)
.map(code => (
<div key={code} style={{
display: 'flex',
alignItems: 'center',
gap: 6,
marginTop: 2,
color: isDarkMode ? '#cbd5e1' : '#475569',
}}>
<span style={{
width: 10,
height: 10,
borderRadius: '50%',
backgroundColor: TYPE_COLORS[code],
display: 'inline-block',
}} />
<span>{getTypeName(code)}</span>
</div>
))
}
</div>
{/* Institution Info Panel */}
{selectedInstitution && (
<InstitutionInfoPanel
institution={selectedInstitution}
markerScreenPosition={markerPosition}
onClose={handleClosePanel}
language={language}
t={t}
getTypeName={getTypeName}
/>
)}
</div>
);
};
export default ConversationMapLibre;

View file

@ -1,13 +1,18 @@
/**
* Conversation Components Index
*
* D3 visualization components for the Conversation page.
* Visualization components for the Conversation page.
* All components follow NDE house style and support Dutch/English.
*/
// Legacy D3-based map (kept for fallback)
export { ConversationGeoMap } from './ConversationGeoMap';
export type { ConversationGeoMapProps } from './ConversationGeoMap';
// New MapLibre GL-based map with real tiles and InstitutionInfoPanel
export { ConversationMapLibre } from './ConversationMapLibre';
export type { ConversationMapLibreProps } from './ConversationMapLibre';
export { ConversationBarChart } from './ConversationBarChart';
export type { ConversationBarChartProps } from './ConversationBarChart';

View file

@ -25,6 +25,7 @@ import { CustodianTimeline } from './CustodianTimeline';
import { ErrorBoundary } from '../common/ErrorBoundary';
import { safeString } from '../../utils/safeString';
import { isTargetInsideAny } from '../../utils/dom';
import { proxyImageUrl } from '../../utils/imageProxy';
import { useWikidataImage } from '../../hooks/useWikidataImage';
import type { Archive } from '../../types/werkgebied';
@ -172,6 +173,72 @@ interface UNESCOMoWEnrichment {
data_source?: string;
}
/**
* Provenance information for data source tracking.
* Shows where the institution data came from and its quality tier.
*/
export interface Provenance {
data_source?: string; // e.g., "ISIL_REGISTRY", "WIKIDATA", "GOOGLE_MAPS"
data_tier?: string; // e.g., "TIER_1_AUTHORITATIVE", "TIER_2_VERIFIED"
details?: Record<string, unknown>; // Additional provenance metadata
}
/**
* Wikidata enrichment with multilingual labels and descriptions.
*/
export interface WikidataEnrichment {
label_nl?: string;
label_en?: string;
description_nl?: string;
description_en?: string;
types?: string[]; // Wikidata instance-of types (e.g., "museum", "archive")
inception?: string; // Founding date from Wikidata
enrichment?: Record<string, unknown>; // Raw Wikidata enrichment data
}
/**
* Web claim extracted from institutional websites.
* Contains verified data with source provenance.
*/
export interface WebClaim {
claim_type?: string; // e.g., "logo_img_attr", "og_image", "email", "phone"
claim_value?: string; // The extracted value
source_url?: string; // URL where the claim was extracted from
extraction_method?: string; // How it was extracted (e.g., "firecrawl")
}
/**
* NAN (Nationaal Archief Nederland) ISIL enrichment data.
*/
export interface NANISILEnrichment {
isil_code?: string;
institution_name?: string;
city?: string;
assigned_date?: string;
remarks?: string;
[key: string]: unknown;
}
/**
* KB (Koninklijke Bibliotheek) enrichment data.
*/
export interface KBEnrichment {
library_type?: string;
membership_type?: string;
services?: string[];
[key: string]: unknown;
}
/**
* ZCBS (Zeeuwse Culturele Beschrijvingen) enrichment data.
* Used for cultural heritage institutions in Zeeland.
*/
export interface ZCBSEnrichment {
institution_type?: string;
collection_description?: string;
[key: string]: unknown;
}
export interface Institution {
lat: number;
lon: number;
@ -260,6 +327,38 @@ export interface Institution {
};
/** UNESCO Memory of the World inscriptions held by this custodian */
unesco_mow?: UNESCOMoWEnrichment;
// === NEW FIELDS FOR ENHANCED METADATA DISPLAY ===
/** Provenance information showing data source and quality tier */
provenance?: Provenance;
/** Wikidata enrichment with multilingual labels and descriptions */
wikidata?: WikidataEnrichment;
/** Web claims extracted from institutional websites */
web_claims?: WebClaim[];
/** NAN (Nationaal Archief Nederland) ISIL enrichment */
nan_isil_enrichment?: NANISILEnrichment;
/** KB (Koninklijke Bibliotheek) enrichment */
kb_enrichment?: KBEnrichment;
/** ZCBS (Zeeuwse Culturele Beschrijvingen) enrichment */
zcbs_enrichment?: ZCBSEnrichment;
/** Email address (if available from enrichment) */
email?: string;
/** Street address (separate from formatted address) */
street_address?: string;
/** Postal code */
postal_code?: string;
/** Direct Google Maps URL for the institution */
google_maps_url?: string;
}
interface Position {
@ -827,7 +926,7 @@ const InstitutionInfoPanelComponent: React.FC<InstitutionInfoPanelProps> = ({
</span>
</div>
<img
src={safeString(institution.logo_url)}
src={proxyImageUrl(safeString(institution.logo_url))}
alt={`${safeString(institution.name)} logo`}
style={{ width: 180, maxWidth: '100%', height: 'auto' }}
onError={(e) => {

View file

@ -13,6 +13,7 @@
import React, { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
import './MediaGallery.css';
import { safeString } from '../../utils/safeString';
import { proxyImageUrl } from '../../utils/imageProxy';
// YouTube IFrame API types
declare global {
@ -733,8 +734,9 @@ const MediaGalleryComponent: React.FC<MediaGalleryProps> = ({
const result: Photo[] = [];
// 1. Logo URL (highest priority - extracted from institution website)
// Proxy external logo URLs to avoid hotlinking issues
if (logoUrl) {
result.push({ url: logoUrl, attribution: 'Institution Website' });
result.push({ url: proxyImageUrl(logoUrl) || logoUrl, attribution: 'Institution Website' });
}
// 2. Google Maps photos

View file

@ -0,0 +1,320 @@
/**
* SearchableMultiSelect Styles
* Supports both light and dark mode
*/
.searchable-multi-select {
position: relative;
display: inline-block;
min-width: 200px;
}
.searchable-multi-select.disabled {
opacity: 0.6;
pointer-events: none;
}
/* Trigger Button */
.sms-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 12px;
background: var(--select-bg, #ffffff);
border: 1px solid var(--select-border, #d1d5db);
border-radius: 6px;
cursor: pointer;
font-size: 14px;
color: var(--text-primary, #1f2937);
transition: border-color 0.2s, box-shadow 0.2s;
gap: 8px;
}
.sms-trigger:hover:not(:disabled) {
border-color: var(--primary-color, #3b82f6);
}
.sms-trigger:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.2);
}
.searchable-multi-select.open .sms-trigger {
border-color: var(--primary-color, #3b82f6);
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.sms-trigger-text {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
text-align: left;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sms-count-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 20px;
height: 20px;
padding: 0 6px;
background: var(--primary-color, #3b82f6);
color: white;
border-radius: 10px;
font-size: 12px;
font-weight: 600;
}
.sms-trigger-arrow {
font-size: 10px;
color: var(--text-secondary, #6b7280);
flex-shrink: 0;
}
/* Dropdown Panel */
.sms-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
z-index: 1000;
background: var(--dropdown-bg, #ffffff);
border: 1px solid var(--select-border, #d1d5db);
border-top: none;
border-radius: 0 0 6px 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
max-height: 350px;
display: flex;
flex-direction: column;
}
/* Search Container */
.sms-search-container {
position: relative;
padding: 8px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
}
.sms-search-input {
width: 100%;
padding: 8px 30px 8px 10px;
border: 1px solid var(--input-border, #d1d5db);
border-radius: 4px;
font-size: 14px;
background: var(--input-bg, #f9fafb);
color: var(--text-primary, #1f2937);
}
.sms-search-input:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
background: var(--input-bg-focus, #ffffff);
}
.sms-search-input::placeholder {
color: var(--text-muted, #9ca3af);
}
.sms-search-clear {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
font-size: 18px;
color: var(--text-secondary, #6b7280);
cursor: pointer;
padding: 0;
line-height: 1;
}
.sms-search-clear:hover {
color: var(--text-primary, #1f2937);
}
/* Bulk Actions */
.sms-bulk-actions {
display: flex;
gap: 8px;
padding: 8px;
border-bottom: 1px solid var(--border-color, #e5e7eb);
background: var(--bulk-actions-bg, #f9fafb);
}
.sms-bulk-btn {
flex: 1;
padding: 6px 12px;
background: var(--btn-secondary-bg, #e5e7eb);
border: none;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary, #4b5563);
cursor: pointer;
transition: background 0.2s;
}
.sms-bulk-btn:hover {
background: var(--btn-secondary-hover, #d1d5db);
}
.sms-clear-btn {
background: var(--danger-light, #fee2e2);
color: var(--danger-color, #dc2626);
}
.sms-clear-btn:hover {
background: var(--danger-light-hover, #fecaca);
}
/* Options List */
.sms-options-list {
overflow-y: auto;
flex: 1;
max-height: 250px;
}
.sms-no-results {
padding: 16px;
text-align: center;
color: var(--text-muted, #9ca3af);
font-style: italic;
}
/* Option Item */
.sms-option {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
cursor: pointer;
transition: background 0.15s;
border-bottom: 1px solid var(--option-border, #f3f4f6);
}
.sms-option:last-child {
border-bottom: none;
}
.sms-option:hover {
background: var(--option-hover, #f3f4f6);
}
.sms-option.selected {
background: var(--option-selected, #eff6ff);
}
.sms-option.selected:hover {
background: var(--option-selected-hover, #dbeafe);
}
.sms-checkbox {
width: 16px;
height: 16px;
accent-color: var(--primary-color, #3b82f6);
cursor: pointer;
flex-shrink: 0;
}
.sms-option-icon {
font-size: 16px;
flex-shrink: 0;
}
.sms-option-label {
flex: 1;
font-size: 14px;
color: var(--text-primary, #1f2937);
}
.sms-option-color {
width: 12px;
height: 12px;
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgba(0, 0, 0, 0.1);
}
/* ============================================
Dark Mode Support
============================================ */
:root[data-theme="dark"] .searchable-multi-select,
.dark .searchable-multi-select {
--select-bg: #1f2937;
--select-border: #374151;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--text-muted: #6b7280;
--dropdown-bg: #1f2937;
--border-color: #374151;
--input-bg: #111827;
--input-bg-focus: #1f2937;
--input-border: #374151;
--bulk-actions-bg: #111827;
--btn-secondary-bg: #374151;
--btn-secondary-hover: #4b5563;
--option-border: #374151;
--option-hover: #374151;
--option-selected: rgba(59, 130, 246, 0.2);
--option-selected-hover: rgba(59, 130, 246, 0.3);
--danger-light: rgba(220, 38, 38, 0.2);
--danger-light-hover: rgba(220, 38, 38, 0.3);
}
/* Also support data-theme on body or html */
[data-theme="dark"] .sms-trigger,
.dark-mode .sms-trigger {
background: #1f2937;
border-color: #374151;
color: #f9fafb;
}
[data-theme="dark"] .sms-dropdown,
.dark-mode .sms-dropdown {
background: #1f2937;
border-color: #374151;
}
[data-theme="dark"] .sms-search-input,
.dark-mode .sms-search-input {
background: #111827;
border-color: #374151;
color: #f9fafb;
}
[data-theme="dark"] .sms-option-label,
.dark-mode .sms-option-label {
color: #f9fafb;
}
/* ============================================
Responsive adjustments
============================================ */
@media (max-width: 768px) {
.searchable-multi-select {
min-width: 160px;
}
.sms-trigger {
padding: 6px 10px;
font-size: 13px;
}
.sms-dropdown {
max-height: 300px;
}
.sms-option {
padding: 8px 10px;
}
}

View file

@ -0,0 +1,249 @@
/**
* SearchableMultiSelect Component
* A dropdown with search functionality and multi-select checkboxes
*
* Features:
* - Search bar to filter options
* - Multi-select with checkboxes
* - Shows selected count in trigger button
* - Support for icons/emojis with options
* - Click outside to close
* - Keyboard navigation (Escape to close)
*/
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import './SearchableMultiSelect.css';
export interface SelectOption {
value: string;
label: string;
icon?: string;
color?: string;
}
interface SearchableMultiSelectProps {
options: SelectOption[];
selectedValues: string[];
onChange: (values: string[]) => void;
placeholder: string;
searchPlaceholder?: string;
allSelectedLabel?: string;
className?: string;
disabled?: boolean;
}
export function SearchableMultiSelect({
options,
selectedValues,
onChange,
placeholder,
searchPlaceholder = 'Search...',
allSelectedLabel,
className = '',
disabled = false,
}: SearchableMultiSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
// Filter options based on search query
const filteredOptions = useMemo(() => {
if (!searchQuery.trim()) return options;
const query = searchQuery.toLowerCase();
return options.filter(opt =>
opt.label.toLowerCase().includes(query) ||
opt.value.toLowerCase().includes(query)
);
}, [options, searchQuery]);
// Handle click outside to close dropdown
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
setSearchQuery('');
}
}
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isOpen]);
// Handle keyboard events
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape' && isOpen) {
setIsOpen(false);
setSearchQuery('');
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);
// Focus search input when dropdown opens
useEffect(() => {
if (isOpen && searchInputRef.current) {
// Small delay to ensure dropdown is rendered
setTimeout(() => searchInputRef.current?.focus(), 50);
}
}, [isOpen]);
// Toggle a single option
const toggleOption = useCallback((value: string) => {
if (selectedValues.includes(value)) {
onChange(selectedValues.filter(v => v !== value));
} else {
onChange([...selectedValues, value]);
}
}, [selectedValues, onChange]);
// Select all visible options
const selectAll = useCallback(() => {
const allValues = filteredOptions.map(opt => opt.value);
const newValues = [...new Set([...selectedValues, ...allValues])];
onChange(newValues);
}, [filteredOptions, selectedValues, onChange]);
// Deselect all visible options
const deselectAll = useCallback(() => {
const filteredValues = new Set(filteredOptions.map(opt => opt.value));
onChange(selectedValues.filter(v => !filteredValues.has(v)));
}, [filteredOptions, selectedValues, onChange]);
// Clear all selections
const clearAll = useCallback(() => {
onChange([]);
}, [onChange]);
// Generate display text for trigger button
const displayText = useMemo(() => {
if (selectedValues.length === 0) {
return placeholder;
}
if (selectedValues.length === options.length && allSelectedLabel) {
return allSelectedLabel;
}
if (selectedValues.length === 1) {
const selected = options.find(opt => opt.value === selectedValues[0]);
return selected ? `${selected.icon || ''} ${selected.label}`.trim() : selectedValues[0];
}
return `${selectedValues.length} selected`;
}, [selectedValues, options, placeholder, allSelectedLabel]);
// Check if all filtered options are selected
const allFilteredSelected = useMemo(() => {
return filteredOptions.length > 0 &&
filteredOptions.every(opt => selectedValues.includes(opt.value));
}, [filteredOptions, selectedValues]);
return (
<div
ref={containerRef}
className={`searchable-multi-select ${className} ${isOpen ? 'open' : ''} ${disabled ? 'disabled' : ''}`}
>
{/* Trigger button */}
<button
type="button"
className="sms-trigger"
onClick={() => !disabled && setIsOpen(!isOpen)}
disabled={disabled}
aria-expanded={isOpen}
aria-haspopup="listbox"
>
<span className="sms-trigger-text">
{displayText}
{selectedValues.length > 0 && (
<span className="sms-count-badge">{selectedValues.length}</span>
)}
</span>
<span className="sms-trigger-arrow">{isOpen ? '▲' : '▼'}</span>
</button>
{/* Dropdown panel */}
{isOpen && (
<div className="sms-dropdown" role="listbox">
{/* Search input */}
<div className="sms-search-container">
<input
ref={searchInputRef}
type="text"
className="sms-search-input"
placeholder={searchPlaceholder}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{searchQuery && (
<button
type="button"
className="sms-search-clear"
onClick={() => setSearchQuery('')}
aria-label="Clear search"
>
×
</button>
)}
</div>
{/* Bulk actions */}
<div className="sms-bulk-actions">
<button
type="button"
className="sms-bulk-btn"
onClick={allFilteredSelected ? deselectAll : selectAll}
>
{allFilteredSelected ? 'Deselect All' : 'Select All'}
</button>
{selectedValues.length > 0 && (
<button
type="button"
className="sms-bulk-btn sms-clear-btn"
onClick={clearAll}
>
Clear ({selectedValues.length})
</button>
)}
</div>
{/* Options list */}
<div className="sms-options-list">
{filteredOptions.length === 0 ? (
<div className="sms-no-results">No matches found</div>
) : (
filteredOptions.map((option) => {
const isSelected = selectedValues.includes(option.value);
return (
<label
key={option.value}
className={`sms-option ${isSelected ? 'selected' : ''}`}
style={option.color ? { '--option-color': option.color } as React.CSSProperties : undefined}
>
<input
type="checkbox"
checked={isSelected}
onChange={() => toggleOption(option.value)}
className="sms-checkbox"
/>
{option.icon && <span className="sms-option-icon">{option.icon}</span>}
<span className="sms-option-label">{option.label}</span>
{option.color && (
<span
className="sms-option-color"
style={{ backgroundColor: option.color }}
/>
)}
</label>
);
})
)}
</div>
</div>
)}
</div>
);
}

View file

@ -75,6 +75,23 @@
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
}
.career-timeline__mode-btn--disabled {
opacity: 0.4;
cursor: not-allowed;
}
.career-timeline__mode-btn--disabled:hover {
background: transparent;
color: #666;
}
.career-timeline__mode-separator {
color: #ccc;
font-size: 10px;
padding: 0 2px;
user-select: none;
}
/* Summary */
.career-timeline__summary {
display: flex;
@ -95,6 +112,112 @@
font-size: 10px;
}
/* ============================================
SEQUENTIAL VIEW
============================================ */
.career-timeline__sequential-view {
display: flex;
flex-direction: column;
gap: 0;
padding: 8px 0;
}
.career-timeline__sequential-item {
display: flex;
gap: 12px;
padding: 8px 0;
transition: background-color 0.15s ease;
}
.career-timeline__sequential-item:hover {
background-color: rgba(0, 123, 255, 0.05);
border-radius: 6px;
margin: 0 -6px;
padding-left: 6px;
padding-right: 6px;
}
.career-timeline__sequential-item--current {
background-color: rgba(40, 167, 69, 0.08);
border-radius: 6px;
margin: 0 -6px;
padding-left: 6px;
padding-right: 6px;
}
.career-timeline__sequential-marker {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 16px;
}
.career-timeline__sequential-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
border: 2px solid #fff;
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
}
.career-timeline__sequential-line {
width: 2px;
flex-grow: 1;
min-height: 24px;
background: linear-gradient(180deg, #ccc 0%, #e0e0e0 100%);
margin-top: 4px;
}
.career-timeline__sequential-content {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
min-width: 0;
}
.career-timeline__sequential-header {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.career-timeline__sequential-role {
font-weight: 600;
color: #333;
font-size: 11px;
line-height: 1.3;
}
.career-timeline__sequential-badge {
font-size: 9px;
font-weight: 500;
color: #28a745;
background: rgba(40, 167, 69, 0.15);
padding: 1px 6px;
border-radius: 10px;
white-space: nowrap;
}
.career-timeline__sequential-company {
font-size: 10px;
color: #555;
font-weight: 500;
}
.career-timeline__sequential-dates {
font-size: 9px;
color: #888;
}
.career-timeline__sequential-location {
font-size: 9px;
color: #999;
}
/* ============================================
BAR VIEW
============================================ */
@ -453,4 +576,33 @@
.career-timeline__milestone-role {
color: #aaa;
}
/* Sequential view dark mode */
.career-timeline__sequential-item:hover {
background-color: rgba(77, 171, 247, 0.1);
}
.career-timeline__sequential-item--current {
background-color: rgba(40, 167, 69, 0.15);
}
.career-timeline__sequential-role {
color: #e0e0e0;
}
.career-timeline__sequential-company {
color: #bbb;
}
.career-timeline__sequential-dates {
color: #999;
}
.career-timeline__sequential-location {
color: #888;
}
.career-timeline__sequential-line {
background: linear-gradient(180deg, #555 0%, #444 100%);
}
}

View file

@ -26,6 +26,8 @@ export interface CareerPosition {
current?: boolean;
industry?: string;
description?: string;
heritage_relevant?: boolean;
heritage_type?: string; // GLAMORCUBESFIXPHDNT code: A, M, L, G, etc.
}
export interface CareerTimelineProps {
@ -40,7 +42,7 @@ export interface CareerTimelineProps {
}
// Visualization modes
type ViewMode = 'bar' | 'milestones' | 'beeswarm';
type ViewMode = 'bar' | 'milestones' | 'beeswarm' | 'sequential';
// Parse year from date strings like "Aug 2023", "2019 - Present", "Jan 2010 - Dec 2015"
const parseDateRange = (dates?: string): { startYear: number | null; endYear: number | null; isCurrent: boolean } => {
@ -103,10 +105,11 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
t,
}) => {
const [viewMode, setViewMode] = useState<ViewMode>('bar');
const [showHeritageOnly, setShowHeritageOnly] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Process career data into timeline events
const { positions, minYear, maxYear, yearsSpan } = useMemo(() => {
const { positions, minYear, maxYear, yearsSpan, rawPositions, heritageCount } = useMemo(() => {
const currentYear = new Date().getFullYear();
const processed: Array<{
company: string;
@ -118,28 +121,74 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
industry?: string;
location?: string;
color: string;
heritageRelevant?: boolean;
heritageType?: string;
}> = [];
// Keep raw positions for sequential view (when dates can't be parsed)
const raw: Array<{
company: string;
role: string;
dates?: string;
isCurrent: boolean;
location?: string;
color: string;
heritageRelevant?: boolean;
heritageType?: string;
}> = [];
let min = currentYear;
let max = currentYear - 50; // Start with old value
let heritageCount = 0;
career.forEach(pos => {
// Track if we've already assigned a "current" position
// Only the first (most recent) entry should be marked as current
// even if multiple entries claim "Present" in their date_range
let hasAssignedCurrent = false;
career.forEach((pos, index) => {
const company = pos.organization || pos.company || 'Unknown';
const role = pos.role || pos.title || 'Position';
const { startYear, endYear, isCurrent } = parseDateRange(pos.dates);
const duration = parseDurationToYears(pos.duration || pos.duration_text);
// Determine if this position should be marked as current
// Only the first entry (index 0) can be current, unless explicitly set via pos.current
const shouldBeCurrent = pos.current === true || (index === 0 && isCurrent && !hasAssignedCurrent);
if (shouldBeCurrent) {
hasAssignedCurrent = true;
}
// Always add to raw positions for sequential view
raw.push({
company,
role,
dates: pos.dates,
isCurrent: shouldBeCurrent,
location: pos.location,
color: getHeritageColor(pos.industry),
heritageRelevant: pos.heritage_relevant,
heritageType: pos.heritage_type,
});
// Count heritage-relevant positions
if (pos.heritage_relevant) {
heritageCount++;
}
if (startYear) {
processed.push({
company,
role,
startYear,
endYear: endYear || (isCurrent ? currentYear : startYear + Math.ceil(duration)),
isCurrent: pos.current ?? isCurrent,
isCurrent: shouldBeCurrent,
duration,
industry: pos.industry,
location: pos.location,
color: getHeritageColor(pos.industry),
heritageRelevant: pos.heritage_relevant,
heritageType: pos.heritage_type,
});
if (startYear < min) min = startYear;
@ -161,6 +210,8 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
minYear: min,
maxYear: max,
yearsSpan: max - min,
rawPositions: raw,
heritageCount,
};
}, [career]);
@ -177,8 +228,24 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
return newest - oldest;
}, [positions]);
// No career data available
if (positions.length === 0) {
// Determine if we have parseable dates or need sequential view
const hasParseableDates = positions.length > 0;
const hasCareerEntries = rawPositions.length > 0;
// If no dates can be parsed but we have career entries, default to sequential view
const effectiveViewMode = hasParseableDates ? viewMode : 'sequential';
// Filter positions based on heritage toggle
const filteredPositions = showHeritageOnly
? positions.filter(p => p.heritageRelevant)
: positions;
const filteredRawPositions = showHeritageOnly
? rawPositions.filter(p => p.heritageRelevant)
: rawPositions;
// No career data at all
if (!hasCareerEntries) {
return (
<div className="career-timeline career-timeline--empty">
<div className="career-timeline__empty-message">
@ -193,45 +260,116 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
{/* Header with mode selector */}
<div className="career-timeline__header">
<span className="career-timeline__title">
💼 {t('Loopbaan Tijdlijn', 'Career Timeline')}
💼 {t('Loopbaan', 'Career')}
</span>
<div className="career-timeline__mode-selector">
<button
className={`career-timeline__mode-btn ${viewMode === 'bar' ? 'career-timeline__mode-btn--active' : ''}`}
onClick={() => setViewMode('bar')}
title={t('Balkweergave', 'Bar view')}
className={`career-timeline__mode-btn ${effectiveViewMode === 'sequential' ? 'career-timeline__mode-btn--active' : ''}`}
onClick={() => setViewMode('sequential')}
title={t('Volgorde', 'Sequence')}
>
</button>
<button
className={`career-timeline__mode-btn ${effectiveViewMode === 'bar' ? 'career-timeline__mode-btn--active' : ''} ${!hasParseableDates ? 'career-timeline__mode-btn--disabled' : ''}`}
onClick={() => hasParseableDates && setViewMode('bar')}
title={hasParseableDates ? t('Balkweergave', 'Bar view') : t('Geen datums beschikbaar', 'No dates available')}
disabled={!hasParseableDates}
>
</button>
<button
className={`career-timeline__mode-btn ${viewMode === 'milestones' ? 'career-timeline__mode-btn--active' : ''}`}
onClick={() => setViewMode('milestones')}
title={t('Mijlpalen', 'Milestones')}
className={`career-timeline__mode-btn ${effectiveViewMode === 'milestones' ? 'career-timeline__mode-btn--active' : ''} ${!hasParseableDates ? 'career-timeline__mode-btn--disabled' : ''}`}
onClick={() => hasParseableDates && setViewMode('milestones')}
title={hasParseableDates ? t('Mijlpalen', 'Milestones') : t('Geen datums beschikbaar', 'No dates available')}
disabled={!hasParseableDates}
>
</button>
<button
className={`career-timeline__mode-btn ${viewMode === 'beeswarm' ? 'career-timeline__mode-btn--active' : ''}`}
onClick={() => setViewMode('beeswarm')}
title={t('Puntenweergave', 'Dot view')}
className={`career-timeline__mode-btn ${effectiveViewMode === 'beeswarm' ? 'career-timeline__mode-btn--active' : ''} ${!hasParseableDates ? 'career-timeline__mode-btn--disabled' : ''}`}
onClick={() => hasParseableDates && setViewMode('beeswarm')}
title={hasParseableDates ? t('Puntenweergave', 'Dot view') : t('Geen datums beschikbaar', 'No dates available')}
disabled={!hasParseableDates}
>
</button>
{/* Heritage filter toggle */}
{heritageCount > 0 && (
<>
<span className="career-timeline__mode-separator">|</span>
<button
className={`career-timeline__mode-btn ${showHeritageOnly ? 'career-timeline__mode-btn--active' : ''}`}
onClick={() => setShowHeritageOnly(!showHeritageOnly)}
title={showHeritageOnly
? t('Toon alle posities', 'Show all positions')
: t('Alleen erfgoed', 'Heritage only')}
>
🏛
</button>
</>
)}
</div>
</div>
{/* Summary */}
<div className="career-timeline__summary">
<span className="career-timeline__stats">
{positions.length} {t('posities', 'positions')} · {totalYears} {t('jaar', 'years')}
</span>
<span className="career-timeline__range">
{minYear} {t('heden', 'present')}
{showHeritageOnly
? `${filteredRawPositions.length} ${t('erfgoed', 'heritage')} / ${rawPositions.length} ${t('posities', 'positions')}`
: heritageCount > 0
? `${heritageCount} 🏛️ / ${rawPositions.length} ${t('posities', 'positions')}`
: `${rawPositions.length} ${t('posities', 'positions')}`
}{hasParseableDates ? ` · ${totalYears} ${t('jaar', 'years')}` : ''}
</span>
{hasParseableDates && (
<span className="career-timeline__range">
{minYear} {t('heden', 'present')}
</span>
)}
</div>
{/* SEQUENTIAL VIEW - Works without parseable dates */}
{effectiveViewMode === 'sequential' && (
<div className="career-timeline__sequential-view">
{filteredRawPositions.map((pos, i) => (
<div
key={i}
className={`career-timeline__sequential-item ${pos.isCurrent ? 'career-timeline__sequential-item--current' : ''}`}
>
<div className="career-timeline__sequential-marker">
<div
className="career-timeline__sequential-dot"
style={{ backgroundColor: pos.color }}
/>
{i < filteredRawPositions.length - 1 && (
<div className="career-timeline__sequential-line" />
)}
</div>
<div className="career-timeline__sequential-content">
<div className="career-timeline__sequential-header">
<span className="career-timeline__sequential-role">{pos.role}</span>
{pos.isCurrent && (
<span className="career-timeline__sequential-badge">
{t('Huidig', 'Current')}
</span>
)}
</div>
<span className="career-timeline__sequential-company">{pos.company}</span>
{pos.dates && (
<span className="career-timeline__sequential-dates">{pos.dates}</span>
)}
{pos.location && (
<span className="career-timeline__sequential-location">📍 {pos.location}</span>
)}
</div>
</div>
))}
</div>
)}
{/* BAR VIEW */}
{viewMode === 'bar' && (
{effectiveViewMode === 'bar' && (
<div className="career-timeline__bar-view">
{/* Timeline axis */}
<div className="career-timeline__axis">
@ -255,7 +393,7 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
{/* Position bars */}
<div className="career-timeline__bars-container">
{positions.map((pos, i) => (
{filteredPositions.map((pos, i) => (
<div
key={i}
className={`career-timeline__bar ${pos.isCurrent ? 'career-timeline__bar--current' : ''}`}
@ -280,14 +418,14 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
)}
{/* MILESTONES VIEW */}
{viewMode === 'milestones' && (
{effectiveViewMode === 'milestones' && (
<div className="career-timeline__milestones-view">
{/* Central timeline */}
<div className="career-timeline__milestones-line" />
{/* Events */}
<div className="career-timeline__milestones-events">
{positions.map((pos, i) => {
{filteredPositions.map((pos, i) => {
const isTop = i % 2 === 0;
return (
<div
@ -321,7 +459,7 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
)}
{/* BEESWARM VIEW */}
{viewMode === 'beeswarm' && (
{effectiveViewMode === 'beeswarm' && (
<div className="career-timeline__beeswarm-view">
{/* Timeline axis */}
<div className="career-timeline__beeswarm-axis">
@ -330,7 +468,7 @@ export const CareerTimeline: React.FC<CareerTimelineProps> = ({
{/* Position dots */}
<div className="career-timeline__beeswarm-dots">
{positions.map((pos, i) => {
{filteredPositions.map((pos, i) => {
const yOffset = (i % 3) * 14 - 14;
return (
<div

View file

@ -194,45 +194,48 @@ const PROVINCE_CODE_MAP: Record<string, string> = {
// SQL Query - fetch all columns needed for full Institution interface
// ============================================================================
// NOTE: JSON columns need TRIM(BOTH chr(34) FROM CAST(...)) because DuckDB's read_json_auto
// with maximum_depth=1 stores string values as JSON type with literal quote characters.
// Simple CAST to VARCHAR preserves the quotes; TRIM removes them.
const INSTITUTIONS_QUERY = `
SELECT
latitude,
longitude,
org_name,
emic_name,
name_language,
city,
ghcid_current,
org_type,
wikidata_id,
TRIM(BOTH chr(34) FROM CAST(org_name AS VARCHAR)) AS org_name,
TRIM(BOTH chr(34) FROM CAST(emic_name AS VARCHAR)) AS emic_name,
TRIM(BOTH chr(34) FROM CAST(name_language AS VARCHAR)) AS name_language,
TRIM(BOTH chr(34) FROM CAST(city AS VARCHAR)) AS city,
TRIM(BOTH chr(34) FROM CAST(ghcid_current AS VARCHAR)) AS ghcid_current,
TRIM(BOTH chr(34) FROM CAST(org_type AS VARCHAR)) AS org_type,
TRIM(BOTH chr(34) FROM CAST(wikidata_id AS VARCHAR)) AS wikidata_id,
google_rating,
google_total_ratings,
formatted_address,
record_id,
google_maps_enrichment_json,
identifiers_json,
genealogiewerkbalk_json,
file_name,
wikidata_enrichment_json,
original_entry_json,
service_area_json,
TRIM(BOTH chr(34) FROM CAST(formatted_address AS VARCHAR)) AS formatted_address,
TRIM(BOTH chr(34) FROM CAST(record_id AS VARCHAR)) AS record_id,
TRIM(BOTH chr(34) FROM CAST(google_maps_enrichment_json AS VARCHAR)) AS google_maps_enrichment_json,
TRIM(BOTH chr(34) FROM CAST(identifiers_json AS VARCHAR)) AS identifiers_json,
TRIM(BOTH chr(34) FROM CAST(genealogiewerkbalk_json AS VARCHAR)) AS genealogiewerkbalk_json,
TRIM(BOTH chr(34) FROM CAST(file_name AS VARCHAR)) AS file_name,
TRIM(BOTH chr(34) FROM CAST(wikidata_enrichment_json AS VARCHAR)) AS wikidata_enrichment_json,
TRIM(BOTH chr(34) FROM CAST(original_entry_json AS VARCHAR)) AS original_entry_json,
TRIM(BOTH chr(34) FROM CAST(service_area_json AS VARCHAR)) AS service_area_json,
-- Temporal data columns
timespan_begin,
timespan_end,
timespan_json,
time_of_destruction_json,
conflict_status_json,
destruction_date,
founding_date,
dissolution_date,
temporal_extent_json,
wikidata_inception,
TRIM(BOTH chr(34) FROM CAST(timespan_begin AS VARCHAR)) AS timespan_begin,
TRIM(BOTH chr(34) FROM CAST(timespan_end AS VARCHAR)) AS timespan_end,
TRIM(BOTH chr(34) FROM CAST(timespan_json AS VARCHAR)) AS timespan_json,
TRIM(BOTH chr(34) FROM CAST(time_of_destruction_json AS VARCHAR)) AS time_of_destruction_json,
TRIM(BOTH chr(34) FROM CAST(conflict_status_json AS VARCHAR)) AS conflict_status_json,
TRIM(BOTH chr(34) FROM CAST(destruction_date AS VARCHAR)) AS destruction_date,
TRIM(BOTH chr(34) FROM CAST(founding_date AS VARCHAR)) AS founding_date,
TRIM(BOTH chr(34) FROM CAST(dissolution_date AS VARCHAR)) AS dissolution_date,
TRIM(BOTH chr(34) FROM CAST(temporal_extent_json AS VARCHAR)) AS temporal_extent_json,
TRIM(BOTH chr(34) FROM CAST(wikidata_inception AS VARCHAR)) AS wikidata_inception,
-- YouTube enrichment data
youtube_enrichment_json,
TRIM(BOTH chr(34) FROM CAST(youtube_enrichment_json AS VARCHAR)) AS youtube_enrichment_json,
-- Web claims for logo extraction
web_claims_json,
TRIM(BOTH chr(34) FROM CAST(web_claims_json AS VARCHAR)) AS web_claims_json,
-- UNESCO Memory of the World inscriptions
unesco_mow_enrichment_json
TRIM(BOTH chr(34) FROM CAST(unesco_mow_enrichment_json AS VARCHAR)) AS unesco_mow_enrichment_json
FROM heritage.custodians_raw
WHERE latitude IS NOT NULL
AND longitude IS NOT NULL

View file

@ -649,6 +649,87 @@ function parseWebClaims(value: unknown): WebClaim[] | undefined {
}
}
/**
* Parse wikidata object from API response, handling JSON-encoded nested fields.
* The API may return nested arrays/objects as JSON strings due to DuckDB serialization.
*/
function parseWikidataObject(value: unknown): {
label_nl?: string;
label_en?: string;
description_nl?: string;
description_en?: string;
types?: string[];
inception?: string;
enrichment?: Record<string, unknown>;
} | undefined {
if (!value) return undefined;
try {
// Parse if string (entire wikidata object might be JSON string)
let wikidata = value;
if (typeof value === 'string') {
wikidata = JSON.parse(value);
}
if (typeof wikidata !== 'object' || wikidata === null) {
return undefined;
}
const wd = wikidata as Record<string, unknown>;
// Parse types field - can be JSON string containing array of {id, label, description}
let types: string[] | undefined;
if (wd.types) {
try {
let typesValue = wd.types;
if (typeof typesValue === 'string') {
typesValue = JSON.parse(typesValue);
}
if (Array.isArray(typesValue)) {
// Extract labels from array of type objects
types = typesValue.map((t: unknown) => {
if (typeof t === 'string') return t;
if (typeof t === 'object' && t !== null) {
const typeObj = t as Record<string, unknown>;
return (typeObj.label as string) || (typeObj.id as string) || String(t);
}
return String(t);
}).filter(Boolean);
}
} catch {
// If parsing fails, ignore types
}
}
// Parse enrichment field if it's a JSON string
let enrichment: Record<string, unknown> | undefined;
if (wd.enrichment) {
try {
if (typeof wd.enrichment === 'string') {
enrichment = JSON.parse(wd.enrichment);
} else if (typeof wd.enrichment === 'object') {
enrichment = wd.enrichment as Record<string, unknown>;
}
} catch {
// If parsing fails, ignore enrichment
}
}
return {
label_nl: wd.label_nl as string | undefined,
label_en: wd.label_en as string | undefined,
description_nl: wd.description_nl as string | undefined,
description_en: wd.description_en as string | undefined,
types: types && types.length > 0 ? types : undefined,
inception: wd.inception as string | undefined,
enrichment,
};
} catch {
return undefined;
}
}
/**
* Resolve a potentially relative URL against a base URL
*/
@ -1767,6 +1848,35 @@ function detailResponseToInstitution(data: Record<string, unknown>): Institution
social_media: socialMedia,
youtube,
logo_url: logoUrl,
// === NEW FIELDS FOR ENHANCED METADATA DISPLAY ===
// Provenance information
provenance: data.provenance as { data_source?: string; data_tier?: string; details?: Record<string, unknown> } | undefined,
// Wikidata enrichment (structured object from API)
// Use parseWikidataObject to handle JSON-encoded nested fields (types, enrichment)
// This prevents "types.join is not a function" errors when API returns types as JSON string
wikidata: parseWikidataObject(data.wikidata),
// Web claims extracted from institutional websites
// Use parseWebClaims to handle both JSON strings and already-parsed arrays
// This prevents "filter is not a function" errors when API returns a string
web_claims: parseWebClaims(data.web_claims),
// Genealogiewerkbalk (for Dutch institutions)
genealogiewerkbalk: data.genealogiewerkbalk as Institution['genealogiewerkbalk'] | undefined,
// External registry enrichments
nan_isil_enrichment: data.nan_isil_enrichment as Record<string, unknown> | undefined,
kb_enrichment: data.kb_enrichment as Record<string, unknown> | undefined,
zcbs_enrichment: data.zcbs_enrichment as Record<string, unknown> | undefined,
// Extended contact info (if available directly from API)
email: data.email as string | undefined,
street_address: data.street_address as string | undefined,
postal_code: data.postal_code as string | undefined,
google_maps_url: data.google_maps_url as string | undefined,
};
}
@ -1956,6 +2066,8 @@ export interface PersonDetail extends PersonSummary {
start_date?: string;
end_date?: string;
description?: string;
heritage_relevant?: boolean;
heritage_type?: string | null;
}>;
education: Array<{
school?: string;

View file

@ -885,8 +885,9 @@ export function useMultiDatabaseRAG(): UseMultiDatabaseRAGReturn {
queryType: dspyResponse.queryType,
};
// Step 4: Store in cache (if enabled)
if (cacheEnabled && storeInCache) {
// Step 4: Store in cache (if enabled and response is valid)
// Don't cache error responses (confidence: 0) - these are transient API errors
if (cacheEnabled && storeInCache && response.confidence > 0) {
try {
const cacheResponse: CachedResponse = {
answer: response.answer,

View file

@ -0,0 +1,101 @@
/**
* Version check utility to detect stale cached versions of the app.
*
* After each deployment, if a user has a cached HTML file pointing to old
* JavaScript chunks that no longer exist, they'll get chunk load errors.
*
* This utility helps detect version mismatches and prompts users to refresh.
*/
// Build timestamp is injected at build time via Vite's define
// Falls back to a static value in development
export const BUILD_TIMESTAMP = import.meta.env.VITE_BUILD_TIMESTAMP || 'development';
const VERSION_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes
const LAST_CHECK_KEY = 'glam_version_last_check';
const KNOWN_VERSION_KEY = 'glam_version_known';
/**
* Check if a newer version of the app is available.
* This fetches a version file from the server and compares it to the build timestamp.
*
* @returns true if a newer version is available
*/
export async function checkForNewVersion(): Promise<boolean> {
// Skip in development
if (BUILD_TIMESTAMP === 'development') {
return false;
}
// Don't check too frequently
const lastCheck = parseInt(localStorage.getItem(LAST_CHECK_KEY) || '0', 10);
if (Date.now() - lastCheck < VERSION_CHECK_INTERVAL) {
return false;
}
localStorage.setItem(LAST_CHECK_KEY, Date.now().toString());
try {
// Fetch version file with cache busting
const response = await fetch(`/version.json?t=${Date.now()}`, {
cache: 'no-cache',
});
if (!response.ok) {
// No version file - that's fine, feature is optional
return false;
}
const data = await response.json();
const serverVersion = data.buildTimestamp;
if (!serverVersion) {
return false;
}
// Check if this is a new version
const knownVersion = localStorage.getItem(KNOWN_VERSION_KEY);
if (serverVersion !== BUILD_TIMESTAMP) {
// Server has a different version than what we're running
console.info('[VersionCheck] New version detected:', serverVersion, 'current:', BUILD_TIMESTAMP);
localStorage.setItem(KNOWN_VERSION_KEY, serverVersion);
return true;
}
// Track the known version
if (!knownVersion) {
localStorage.setItem(KNOWN_VERSION_KEY, serverVersion);
}
return false;
} catch (error) {
// Network error or parsing error - silently ignore
console.debug('[VersionCheck] Check failed:', error);
return false;
}
}
/**
* Start periodic version checking.
* Returns a cleanup function to stop checking.
*/
export function startVersionCheck(onNewVersion?: () => void): () => void {
const check = async () => {
const hasNewVersion = await checkForNewVersion();
if (hasNewVersion && onNewVersion) {
onNewVersion();
}
};
// Check immediately (after a short delay to let app load)
const initialTimeout = setTimeout(check, 5000);
// Then check periodically
const interval = setInterval(check, VERSION_CHECK_INTERVAL);
return () => {
clearTimeout(initialTimeout);
clearInterval(interval);
};
}

View file

@ -4,6 +4,49 @@ import { GraphProvider } from './contexts/GraphContext'
import { UIStateProvider } from './contexts/UIStateContext'
import './index.css'
import App from './App.tsx'
import { isChunkLoadError, handleChunkLoadError } from './components/common/LazyLoadError'
/**
* Global error handler for uncaught errors, especially chunk loading failures.
*
* This catches errors that occur:
* 1. During dynamic import before React mounts
* 2. In async code outside of React's error boundary
* 3. During router initialization
*
* When a chunk load error is detected, we clear caches and reload.
*/
window.addEventListener('error', (event) => {
if (event.error && isChunkLoadError(event.error)) {
console.warn('[GlobalErrorHandler] Chunk load error detected, reloading...', event.error.message);
event.preventDefault(); // Prevent the ugly default error UI
handleChunkLoadError();
}
});
/**
* Handle unhandled promise rejections (e.g., failed dynamic imports)
*/
window.addEventListener('unhandledrejection', (event) => {
if (event.reason && isChunkLoadError(event.reason)) {
console.warn('[GlobalErrorHandler] Chunk load rejection detected, reloading...', event.reason.message);
event.preventDefault();
handleChunkLoadError();
}
});
/**
* Vite-specific: Handle module loading errors during HMR
* This catches the "Failed to fetch dynamically imported module" errors
*/
if (import.meta.hot) {
import.meta.hot.on('vite:error', (payload) => {
if (payload.err?.message?.includes('dynamically imported module')) {
console.warn('[HMR] Chunk error during HMR, triggering full reload');
window.location.reload();
}
});
}
createRoot(document.getElementById('root')!).render(
<StrictMode>

View file

@ -198,6 +198,38 @@
transform: scale(0.98);
}
/* ============================================================================
Collapsible Header Styles
============================================================================ */
.conversation-chat__header {
transition: padding 0.3s ease, min-height 0.3s ease;
}
.conversation-chat__header--collapsed {
padding: 8px 24px;
}
.conversation-chat__header--collapsed .conversation-chat__title h1 {
font-size: 1rem;
transition: font-size 0.3s ease;
}
.conversation-chat__header--collapsed .conversation-chat__title p {
display: none;
}
.conversation-chat__header--collapsed .conversation-chat__icon {
width: 18px;
height: 18px;
transition: width 0.3s ease, height 0.3s ease;
}
.conversation-chat__header--collapsed .conversation-chat__new-btn {
padding: 6px 10px;
font-size: 0.75rem;
}
/* ============================================================================
Input Area
============================================================================ */

View file

@ -50,9 +50,10 @@ import { useLanguage } from '../contexts/LanguageContext';
import { useMultiDatabaseRAG, type RAGResponse, type ConversationMessage, type VisualizationType, type InstitutionData } from '../hooks/useMultiDatabaseRAG';
import type { CacheStats } from '../lib/storage/semantic-cache';
import { useQdrant } from '../hooks/useQdrant';
import { ConversationGeoMap, ConversationBarChart, ConversationTimeline, ConversationNetworkGraph, ConversationSocialNetworkGraph } from '../components/conversation';
import { ConversationMapLibre, ConversationBarChart, ConversationTimeline, ConversationNetworkGraph, ConversationSocialNetworkGraph } from '../components/conversation';
import type { RetrievedResult, QueryType } from '../hooks/useMultiDatabaseRAG';
import { EmbeddingProjector, type EmbeddingPoint } from '../components/database/EmbeddingProjector';
import { useCollapsibleHeader } from '../hooks/useCollapsibleHeader';
import './ConversationPage.css';
// ============================================================================
@ -423,7 +424,7 @@ const VisualizationPanel: React.FC<VisualizationPanelProps> = ({
return (
<div className="conversation-viz__map">
{mapCoordinates.length > 0 ? (
<ConversationGeoMap
<ConversationMapLibre
coordinates={mapCoordinates}
width={vizWidth}
height={vizHeight}
@ -671,11 +672,15 @@ const ConversationPage: React.FC = () => {
// Refs
const messagesEndRef = useRef<HTMLDivElement>(null);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelDropdownRef = useRef<HTMLDivElement>(null);
const historyDropdownRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
// Collapsible header hook
const { isCollapsed } = useCollapsibleHeader(messagesContainerRef);
// ============================================================================
// Effects
// ============================================================================
@ -1171,7 +1176,7 @@ const ConversationPage: React.FC = () => {
{/* Chat Panel */}
<div className="conversation-chat">
{/* Header */}
<div className="conversation-chat__header">
<div className={`conversation-chat__header ${isCollapsed ? 'conversation-chat__header--collapsed' : ''}`}>
<div className="conversation-chat__title">
<Sparkles size={24} className="conversation-chat__icon" />
<div>
@ -1423,7 +1428,7 @@ const ConversationPage: React.FC = () => {
</div>
{/* Messages */}
<div className="conversation-chat__messages">
<div className="conversation-chat__messages" ref={messagesContainerRef}>
{messages.length === 0 ? (
<div className="conversation-chat__welcome">
<div className="conversation-chat__welcome-header">

View file

@ -182,6 +182,54 @@
color: #172a59;
}
/* Multi-select filter dropdowns */
.filter-multi-select {
min-width: 160px;
max-width: 220px;
}
.filter-multi-select .searchable-multiselect-trigger {
padding: 0.75rem 1rem;
background: #f5f7fa;
border: 1px solid #e5e7eb;
border-radius: 8px;
font-size: 0.875rem;
font-family: 'Roboto', sans-serif;
color: #172a59;
transition: all 0.2s;
}
.filter-multi-select .searchable-multiselect-trigger:hover {
background: #eef1f6;
border-color: #d1d5db;
}
.filter-multi-select .searchable-multiselect-trigger:focus {
outline: none;
border-color: #0a3dfa;
background: white;
box-shadow: 0 0 0 3px rgba(10, 61, 250, 0.1);
}
/* Dropdown panel positioning and sizing */
.filter-group .searchable-multiselect-dropdown {
max-height: 320px;
min-width: 280px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
}
/* Selected count badge styling */
.filter-multi-select .selected-count {
background: #0a3dfa;
color: white;
font-size: 0.7rem;
font-weight: 600;
padding: 0.15rem 0.4rem;
border-radius: 10px;
margin-left: 0.25rem;
}
/* Clear filters button (inline next to results info) */
.clear-filters-btn-inline {
margin-left: 0.5rem;
@ -713,6 +761,12 @@
min-width: 120px;
}
.filter-multi-select {
flex: 1;
min-width: 140px;
max-width: none;
}
.results-grid {
grid-template-columns: 1fr;
}
@ -831,6 +885,34 @@
color: #e0e0e0;
}
/* Multi-select filter dropdowns - Dark Mode */
[data-theme="dark"] .filter-multi-select .searchable-multiselect-trigger {
background: #1e1e32;
border-color: #3d3d5c;
color: #e0e0e0;
}
[data-theme="dark"] .filter-multi-select .searchable-multiselect-trigger:hover {
background: #2a2a44;
border-color: #4d4d6d;
}
[data-theme="dark"] .filter-multi-select .searchable-multiselect-trigger:focus {
border-color: #4a7dff;
background: #252542;
box-shadow: 0 0 0 3px rgba(74, 125, 255, 0.2);
}
[data-theme="dark"] .filter-group .searchable-multiselect-dropdown {
background: #252542;
border-color: #3d3d5c;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
}
[data-theme="dark"] .filter-multi-select .selected-count {
background: #4a7dff;
}
/* Clear filters button dark mode */
[data-theme="dark"] .clear-filters-btn-inline {
border-color: #3d3d5c;
@ -2007,3 +2089,662 @@
background: #3d1f2f;
color: #f87171;
}
/* ============================================
FILTER CHIPS
============================================ */
.filter-chips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0 1rem 1rem 1rem;
margin-top: -0.5rem;
}
.filter-chip {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.5rem 0.35rem 0.65rem;
background: #eef2ff;
border: 1px solid #c7d2fe;
border-radius: 20px;
font-size: 0.8rem;
color: #4338ca;
transition: all 0.2s;
}
.filter-chip:hover {
background: #e0e7ff;
border-color: #a5b4fc;
}
.chip-icon {
font-size: 0.9rem;
}
.chip-label {
font-weight: 500;
}
.chip-remove {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
padding: 0;
margin-left: 0.15rem;
background: rgba(67, 56, 202, 0.1);
border: none;
border-radius: 50%;
color: #4338ca;
font-size: 0.9rem;
cursor: pointer;
transition: all 0.2s;
}
.chip-remove:hover {
background: #dc2626;
color: white;
}
/* Dark mode */
[data-theme="dark"] .filter-chip {
background: #312e81;
border-color: #4338ca;
color: #c7d2fe;
}
[data-theme="dark"] .filter-chip:hover {
background: #3730a3;
border-color: #6366f1;
}
[data-theme="dark"] .chip-remove {
background: rgba(199, 210, 254, 0.1);
color: #c7d2fe;
}
[data-theme="dark"] .chip-remove:hover {
background: #dc2626;
color: white;
}
/* ============================================
ENHANCED IDENTIFIERS SECTION
============================================ */
.uuid-code {
font-size: 0.7rem;
word-break: break-all;
line-height: 1.3;
padding: 0.25rem 0.4rem;
background: #f3f4f6;
border-radius: 4px;
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
[data-theme="dark"] .uuid-code {
background: #2d2d4a;
color: #93c5fd;
}
/* ============================================
WIKIDATA ENRICHMENT SECTION
============================================ */
.wikidata-enrichment {
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
border-radius: 8px;
padding: 1rem;
}
.wikidata-labels {
margin-bottom: 0.75rem;
}
.wikidata-field {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: flex-start;
}
.wikidata-field .field-label {
font-weight: 600;
color: #0369a1;
min-width: 80px;
flex-shrink: 0;
}
.wikidata-descriptions {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(3, 105, 161, 0.2);
}
.wikidata-types {
color: #0c4a6e;
font-style: italic;
}
[data-theme="dark"] .wikidata-enrichment {
background: linear-gradient(135deg, #0c1929 0%, #0f2744 100%);
}
[data-theme="dark"] .wikidata-field .field-label {
color: #7dd3fc;
}
[data-theme="dark"] .wikidata-types {
color: #bae6fd;
}
[data-theme="dark"] .wikidata-descriptions {
border-top-color: rgba(125, 211, 252, 0.2);
}
/* ============================================
WEB CLAIMS SECTION (COLLAPSIBLE)
============================================ */
.web-claims-section {
background: #fefce8;
border: 1px solid #fef08a;
border-radius: 8px;
overflow: hidden;
}
.web-claims-section > summary {
padding: 0.75rem 1rem;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
background: linear-gradient(135deg, #fefce8 0%, #fef9c3 100%);
}
.web-claims-section > summary::-webkit-details-marker {
display: none;
}
.web-claims-section > summary::before {
content: '▶';
margin-right: 0.5rem;
transition: transform 0.2s;
font-size: 0.7rem;
color: #a16207;
}
.web-claims-section[open] > summary::before {
transform: rotate(90deg);
}
.web-claims-section > summary h4 {
margin: 0;
font-size: 0.95rem;
color: #a16207;
}
.web-claims-content {
padding: 1rem;
background: #fffef7;
}
.claims-label {
font-weight: 600;
color: #854d0e;
margin-bottom: 0.5rem;
font-size: 0.85rem;
}
.claims-image-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
max-width: 100%;
}
.claim-image-link {
display: block;
border-radius: 6px;
overflow: hidden;
border: 2px solid transparent;
transition: all 0.2s;
}
.claim-image-link:hover {
border-color: #f59e0b;
transform: scale(1.05);
}
.claim-image {
width: 100%;
height: 60px;
object-fit: cover;
display: block;
}
.web-claims-contact,
.web-claims-other {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid rgba(161, 98, 7, 0.2);
}
.claim-item {
display: flex;
gap: 0.5rem;
margin-bottom: 0.35rem;
font-size: 0.85rem;
align-items: flex-start;
}
.claim-type {
font-weight: 600;
color: #92400e;
min-width: 100px;
flex-shrink: 0;
text-transform: capitalize;
}
.claim-value {
color: #78350f;
word-break: break-word;
}
.claim-value a {
color: #b45309;
text-decoration: underline;
}
.claim-value a:hover {
color: #d97706;
}
[data-theme="dark"] .web-claims-section {
background: #1c1917;
border-color: #78350f;
}
[data-theme="dark"] .web-claims-section > summary {
background: linear-gradient(135deg, #1c1917 0%, #292524 100%);
}
[data-theme="dark"] .web-claims-section > summary::before {
color: #fbbf24;
}
[data-theme="dark"] .web-claims-section > summary h4 {
color: #fbbf24;
}
[data-theme="dark"] .web-claims-content {
background: #1c1917;
}
[data-theme="dark"] .claims-label {
color: #fcd34d;
}
[data-theme="dark"] .claim-image-link:hover {
border-color: #fbbf24;
}
[data-theme="dark"] .web-claims-contact,
[data-theme="dark"] .web-claims-other {
border-top-color: rgba(251, 191, 36, 0.2);
}
[data-theme="dark"] .claim-type {
color: #fcd34d;
}
[data-theme="dark"] .claim-value {
color: #fef3c7;
}
[data-theme="dark"] .claim-value a {
color: #fbbf24;
}
/* ============================================
GENEALOGIEWERKBALK / ARCHIVE CONNECTIONS
============================================ */
.genealogiewerkbalk {
background: linear-gradient(135deg, #fdf4ff 0%, #fae8ff 100%);
border-radius: 8px;
padding: 1rem;
}
.genealogiewerkbalk h4 {
color: #86198f;
margin-bottom: 0.75rem;
}
.gwb-field {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: flex-start;
}
.gwb-field .field-label {
font-weight: 600;
color: #a21caf;
min-width: 120px;
flex-shrink: 0;
}
.gwb-field a {
color: #c026d3;
text-decoration: underline;
}
.gwb-field a:hover {
color: #d946ef;
}
[data-theme="dark"] .genealogiewerkbalk {
background: linear-gradient(135deg, #1e1b2e 0%, #2d1f3d 100%);
}
[data-theme="dark"] .genealogiewerkbalk h4 {
color: #e879f9;
}
[data-theme="dark"] .gwb-field .field-label {
color: #d946ef;
}
[data-theme="dark"] .gwb-field a {
color: #e879f9;
}
[data-theme="dark"] .gwb-field a:hover {
color: #f0abfc;
}
/* ============================================
PROVENANCE SECTION (COLLAPSIBLE)
============================================ */
.provenance-section {
background: #f0fdf4;
border: 1px solid #86efac;
border-radius: 8px;
overflow: hidden;
}
.provenance-section > summary {
padding: 0.75rem 1rem;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
background: linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%);
}
.provenance-section > summary::-webkit-details-marker {
display: none;
}
.provenance-section > summary::before {
content: '▶';
margin-right: 0.5rem;
transition: transform 0.2s;
font-size: 0.7rem;
color: #16a34a;
}
.provenance-section[open] > summary::before {
transform: rotate(90deg);
}
.provenance-section > summary h4 {
margin: 0;
font-size: 0.95rem;
color: #15803d;
}
.provenance-content {
padding: 1rem;
background: #f7fef9;
}
.provenance-field {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
align-items: center;
}
.provenance-field .field-label {
font-weight: 600;
color: #166534;
min-width: 100px;
flex-shrink: 0;
}
.data-source-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
background: #dcfce7;
color: #166534;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.data-tier-badge {
display: inline-block;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.75rem;
font-weight: 600;
text-transform: capitalize;
}
.data-tier-badge.tier-1 {
background: #dcfce7;
color: #166534;
}
.data-tier-badge.tier-2 {
background: #dbeafe;
color: #1e40af;
}
.data-tier-badge.tier-3 {
background: #ffedd5;
color: #c2410c;
}
.data-tier-badge.tier-4 {
background: #fef3c7;
color: #92400e;
}
[data-theme="dark"] .provenance-section {
background: #052e16;
border-color: #166534;
}
[data-theme="dark"] .provenance-section > summary {
background: linear-gradient(135deg, #052e16 0%, #14532d 100%);
}
[data-theme="dark"] .provenance-section > summary::before {
color: #4ade80;
}
[data-theme="dark"] .provenance-section > summary h4 {
color: #4ade80;
}
[data-theme="dark"] .provenance-content {
background: #052e16;
}
[data-theme="dark"] .provenance-field .field-label {
color: #86efac;
}
[data-theme="dark"] .data-source-badge {
background: #14532d;
color: #86efac;
}
[data-theme="dark"] .data-tier-badge.tier-1 {
background: #14532d;
color: #86efac;
}
[data-theme="dark"] .data-tier-badge.tier-2 {
background: #1e3a5f;
color: #93c5fd;
}
[data-theme="dark"] .data-tier-badge.tier-3 {
background: #7c2d12;
color: #fdba74;
}
[data-theme="dark"] .data-tier-badge.tier-4 {
background: #78350f;
color: #fcd34d;
}
/* ============================================
EXTERNAL REGISTRIES SECTION (COLLAPSIBLE)
============================================ */
.external-registries {
background: #f5f3ff;
border: 1px solid #c4b5fd;
border-radius: 8px;
overflow: hidden;
}
.external-registries > summary {
padding: 0.75rem 1rem;
cursor: pointer;
list-style: none;
display: flex;
align-items: center;
background: linear-gradient(135deg, #f5f3ff 0%, #ede9fe 100%);
}
.external-registries > summary::-webkit-details-marker {
display: none;
}
.external-registries > summary::before {
content: '▶';
margin-right: 0.5rem;
transition: transform 0.2s;
font-size: 0.7rem;
color: #7c3aed;
}
.external-registries[open] > summary::before {
transform: rotate(90deg);
}
.external-registries > summary h4 {
margin: 0;
font-size: 0.95rem;
color: #6d28d9;
}
.registries-content {
padding: 1rem;
background: #faf8ff;
}
.registry-section {
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid rgba(124, 58, 237, 0.15);
}
.registry-section:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.registry-section h5 {
margin: 0 0 0.5rem 0;
font-size: 0.85rem;
color: #7c3aed;
font-weight: 600;
}
.registry-data {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.registry-field {
display: flex;
gap: 0.5rem;
font-size: 0.8rem;
align-items: flex-start;
}
.registry-field .field-label {
font-weight: 600;
color: #8b5cf6;
min-width: 80px;
flex-shrink: 0;
text-transform: capitalize;
}
.registry-field span:last-child {
color: #5b21b6;
word-break: break-word;
}
[data-theme="dark"] .external-registries {
background: #1e1b2e;
border-color: #5b21b6;
}
[data-theme="dark"] .external-registries > summary {
background: linear-gradient(135deg, #1e1b2e 0%, #2d2350 100%);
}
[data-theme="dark"] .external-registries > summary::before {
color: #a78bfa;
}
[data-theme="dark"] .external-registries > summary h4 {
color: #a78bfa;
}
[data-theme="dark"] .registries-content {
background: #1e1b2e;
}
[data-theme="dark"] .registry-section {
border-bottom-color: rgba(167, 139, 250, 0.2);
}
[data-theme="dark"] .registry-section h5 {
color: #c4b5fd;
}
[data-theme="dark"] .registry-field .field-label {
color: #a78bfa;
}
[data-theme="dark"] .registry-field span:last-child {
color: #ddd6fe;
}

View file

@ -11,7 +11,7 @@
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useLanguage } from '../contexts/LanguageContext';
import { useUIState } from '../contexts/UIStateContext';
import { useGeoApiInstitutions, useInstitutionSearch, useLiteInstitutions, useInstitutionDetail, usePersonsCount, usePersons, usePersonSearch, usePersonDetail, type LiteInstitution, type PersonSummary } from '../hooks/useGeoApiInstitutions';
@ -24,6 +24,9 @@ import { LoadingScreen } from '../components/LoadingScreen';
import { SocialNetworkModal } from '../components/visualizations/SocialNetworkModal';
import { hasStaffNetworkData, getCustodianSlug } from '../hooks/useStaffNetworkData';
import type { Institution } from '../components/map/InstitutionInfoPanel';
import { proxyImageUrl } from '../utils/imageProxy';
import { SearchableMultiSelect, type SelectOption } from '../components/ui/SearchableMultiSelect';
import { COUNTRY_NAMES, getFlagEmoji, getCountryName } from '../utils/countryNames';
import './InstitutionBrowserPage.css';
// Institution type code to color/icon mapping
@ -86,31 +89,14 @@ const TEXT = {
beschermers: { nl: 'Beschermers', en: 'Professionals' },
totalBronhouders: { nl: 'Bronhouders', en: 'Custodians' },
totalBeschermers: { nl: 'Beschermers', en: 'Professionals' },
// Multi-select filter labels
searchTypes: { nl: 'Zoek types...', en: 'Search types...' },
searchCountries: { nl: 'Zoek landen...', en: 'Search countries...' },
typesSelected: { nl: 'types geselecteerd', en: 'types selected' },
countriesSelected: { nl: 'landen geselecteerd', en: 'countries selected' },
};
// Country code to name mapping (common ones)
const COUNTRY_NAMES: Record<string, { nl: string; en: string }> = {
'NL': { nl: 'Nederland', en: 'Netherlands' },
'BE': { nl: 'België', en: 'Belgium' },
'DE': { nl: 'Duitsland', en: 'Germany' },
'FR': { nl: 'Frankrijk', en: 'France' },
'GB': { nl: 'Verenigd Koninkrijk', en: 'United Kingdom' },
'US': { nl: 'Verenigde Staten', en: 'United States' },
'JP': { nl: 'Japan', en: 'Japan' },
'IT': { nl: 'Italië', en: 'Italy' },
'ES': { nl: 'Spanje', en: 'Spain' },
'AU': { nl: 'Australië', en: 'Australia' },
};
// Convert ISO 3166-1 alpha-2 country code to Unicode flag emoji
function getFlagEmoji(countryCode: string): string {
if (!countryCode || countryCode.length !== 2) return '';
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// Note: COUNTRY_NAMES and getFlagEmoji are now imported from ../utils/countryNames
const PAGE_SIZE = 50;
@ -125,8 +111,8 @@ export function InstitutionBrowserPage() {
// State
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<string>('');
const [selectedCountry, setSelectedCountry] = useState<string>('');
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
const [selectedCountries, setSelectedCountries] = useState<string[]>([]);
const [currentPage, setCurrentPage] = useState(0);
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(null);
const [networkInstitution, setNetworkInstitution] = useState<Institution | null>(null);
@ -134,6 +120,30 @@ export function InstitutionBrowserPage() {
const [selectedPerson, setSelectedPerson] = useState<PersonSummary | null>(null);
const [showHeritageOnly, setShowHeritageOnly] = useState(true);
// URL query parameter sync
const [searchParams, setSearchParams] = useSearchParams();
// Initialize state from URL on mount
useEffect(() => {
const typesParam = searchParams.get('types');
const countriesParam = searchParams.get('countries');
const searchParam = searchParams.get('search');
if (typesParam) setSelectedTypes(typesParam.split(','));
if (countriesParam) setSelectedCountries(countriesParam.split(','));
if (searchParam) setSearchQuery(searchParam);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount
// Sync state changes back to URL
useEffect(() => {
const params = new URLSearchParams();
if (selectedTypes.length > 0) params.set('types', selectedTypes.join(','));
if (selectedCountries.length > 0) params.set('countries', selectedCountries.join(','));
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params, { replace: true });
}, [selectedTypes, selectedCountries, searchQuery, setSearchParams]);
// ============================================================================
// Data Hooks - Called unconditionally (React rules), selection based on mode
// ============================================================================
@ -156,8 +166,8 @@ export function InstitutionBrowserPage() {
// Persons data hooks
const { total: personsCount, heritageRelevant: _personsHeritageRelevant, isLoading: isLoadingPersonsCount } = usePersonsCount();
const { persons: personsList, total: personsTotalFiltered, isLoading: isLoadingPersons, error: personsError } = usePersons({
heritage_type: selectedType || undefined,
country_code: selectedCountry || undefined,
heritage_type: selectedTypes.length === 1 ? selectedTypes[0] : undefined,
country_code: selectedCountries.length === 1 ? selectedCountries[0] : undefined,
heritage_relevant: showHeritageOnly ? true : undefined,
limit: PAGE_SIZE,
offset: currentPage * PAGE_SIZE,
@ -300,13 +310,15 @@ export function InstitutionBrowserPage() {
count: displayData.length
});
// Filter by type and country
// Filter by type and country (supports multi-select)
const filteredData = displayData.filter((inst) => {
if (selectedType && inst.type !== selectedType) return false;
if (selectedCountry) {
// Type filter: if any types selected, institution must match one of them
if (selectedTypes.length > 0 && !selectedTypes.includes(inst.type)) return false;
// Country filter: if any countries selected, institution must match one of them
if (selectedCountries.length > 0) {
// Parse country from GHCID (first 2 letters)
const countryCode = inst.ghcid?.current?.substring(0, 2) || '';
if (countryCode !== selectedCountry) return false;
const countryCode = inst.ghcid?.current?.substring(0, 2)?.toUpperCase() || '';
if (!selectedCountries.includes(countryCode)) return false;
}
return true;
});
@ -323,23 +335,44 @@ export function InstitutionBrowserPage() {
// Reset page when filters change
useEffect(() => {
setCurrentPage(0);
}, [searchQuery, selectedType, selectedCountry, entityType]);
}, [searchQuery, selectedTypes, selectedCountries, entityType]);
// Get unique countries from data
// Get unique countries from data (normalized to uppercase)
const uniqueCountries = [...new Set(
institutions
.map(inst => inst.ghcid?.current?.substring(0, 2))
.filter(Boolean)
)].sort() as string[];
.map(inst => inst.ghcid?.current?.substring(0, 2)?.toUpperCase())
.filter((code): code is string => typeof code === 'string' && code.length === 2)
)].sort();
// Create options for type multi-select
const typeOptions: SelectOption[] = useMemo(() =>
Object.entries(TYPE_INFO).map(([code, info]) => ({
value: code,
label: info.name,
icon: info.icon,
color: info.color,
})),
[]
);
// Create options for country multi-select
const countryOptions: SelectOption[] = useMemo(() =>
uniqueCountries.map((code) => ({
value: code,
label: getCountryName(code, language),
icon: getFlagEmoji(code),
})),
[uniqueCountries, language]
);
// Check if filters are active
const filtersActive = searchQuery.length > 0 || selectedType.length > 0 || selectedCountry.length > 0 || showHeritageOnly;
const filtersActive = searchQuery.length > 0 || selectedTypes.length > 0 || selectedCountries.length > 0 || showHeritageOnly;
// Clear all filters
const clearFilters = useCallback(() => {
setSearchQuery('');
setSelectedType('');
setSelectedCountry('');
setSelectedTypes([]);
setSelectedCountries([]);
setShowHeritageOnly(false);
setCurrentPage(0);
}, []);
@ -411,31 +444,23 @@ export function InstitutionBrowserPage() {
</div>
<div className="filter-group">
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="filter-select"
>
<option value="">{t('allTypes')}</option>
{Object.entries(TYPE_INFO).map(([code, info]) => (
<option key={code} value={code}>
{info.icon} {info.name}
</option>
))}
</select>
<SearchableMultiSelect
options={typeOptions}
selectedValues={selectedTypes}
onChange={setSelectedTypes}
placeholder={t('allTypes')}
searchPlaceholder={t('searchTypes')}
className="filter-multi-select"
/>
<select
value={selectedCountry}
onChange={(e) => setSelectedCountry(e.target.value)}
className="filter-select"
>
<option value="">{t('allCountries')}</option>
{uniqueCountries.map((code) => (
<option key={code} value={code}>
{getFlagEmoji(code)} {COUNTRY_NAMES[code]?.[language] || code}
</option>
))}
</select>
<SearchableMultiSelect
options={countryOptions}
selectedValues={selectedCountries}
onChange={setSelectedCountries}
placeholder={t('allCountries')}
searchPlaceholder={t('searchCountries')}
className="filter-multi-select"
/>
{/* Heritage-relevant toggle - only show for Beschermers tab */}
{entityType === 'beschermers' && (
@ -456,6 +481,41 @@ export function InstitutionBrowserPage() {
</div>
</div>
{/* Filter Chips */}
{(selectedTypes.length > 0 || selectedCountries.length > 0) && (
<div className="filter-chips">
{selectedTypes.map(type => {
const info = TYPE_INFO[type];
return (
<span key={type} className="filter-chip">
<span className="chip-icon">{info?.icon}</span>
<span className="chip-label">{info?.name || type}</span>
<button
className="chip-remove"
onClick={() => setSelectedTypes(prev => prev.filter(t => t !== type))}
aria-label={`Remove ${info?.name || type} filter`}
>
×
</button>
</span>
);
})}
{selectedCountries.map(code => (
<span key={code} className="filter-chip">
<span className="chip-icon">{getFlagEmoji(code)}</span>
<span className="chip-label">{getCountryName(code, language)}</span>
<button
className="chip-remove"
onClick={() => setSelectedCountries(prev => prev.filter(c => c !== code))}
aria-label={`Remove ${getCountryName(code, language)} filter`}
>
×
</button>
</span>
))}
</div>
)}
{/* Results info */}
<div className="results-info">
{entityType === 'bronhouders' ? (
@ -650,7 +710,7 @@ function InstitutionCard({
{/* Logo or type icon */}
{institution.logo_url && !logoError ? (
<img
src={institution.logo_url}
src={proxyImageUrl(institution.logo_url)}
alt=""
className="card-logo"
onError={() => setLogoError(true)}
@ -962,7 +1022,7 @@ function InstitutionDetailModal({
{/* Logo or type icon */}
{institution.logo_url && !logoError ? (
<img
src={institution.logo_url}
src={proxyImageUrl(institution.logo_url)}
alt=""
className="modal-logo"
onError={() => setLogoError(true)}
@ -1130,6 +1190,19 @@ function InstitutionDetailModal({
<code>{institution.ghcid.current}</code>
</div>
)}
{/* Enhanced GHCID with UUID and Numeric */}
{displayData.ghcid?.uuid && (
<div className="identifier">
<span className="id-label">GHCID UUID:</span>
<code className="uuid-code">{displayData.ghcid.uuid}</code>
</div>
)}
{displayData.ghcid?.numeric && (
<div className="identifier">
<span className="id-label">GHCID Numeric:</span>
<code>{displayData.ghcid.numeric}</code>
</div>
)}
{displayData.wikidata_id && (
<div className="identifier">
<span className="id-label">{t('wikidata')}:</span>
@ -1148,7 +1221,285 @@ function InstitutionDetailModal({
<code>{displayData.isil.code}</code>
</div>
)}
{/* Google Place ID */}
{displayData.google_place_id && (
<div className="identifier">
<span className="id-label">Google Place:</span>
<a
href={`https://www.google.com/maps/place/?q=place_id:${displayData.google_place_id}`}
target="_blank"
rel="noopener noreferrer"
>
{displayData.google_place_id}
</a>
</div>
)}
</div>
{/* Wikidata Enrichment Section */}
{displayData.wikidata && (
<div className="detail-section wikidata-enrichment">
<h4>📚 {language === 'nl' ? 'Wikidata Informatie' : 'Wikidata Information'}</h4>
{/* Multilingual labels */}
{(displayData.wikidata.label_nl || displayData.wikidata.label_en) && (
<div className="wikidata-labels">
{displayData.wikidata.label_nl && (
<div className="wikidata-field">
<span className="field-label">🇳🇱 Label:</span>
<span>{displayData.wikidata.label_nl}</span>
</div>
)}
{displayData.wikidata.label_en && displayData.wikidata.label_en !== displayData.wikidata.label_nl && (
<div className="wikidata-field">
<span className="field-label">🇬🇧 Label:</span>
<span>{displayData.wikidata.label_en}</span>
</div>
)}
</div>
)}
{/* Descriptions */}
{(displayData.wikidata.description_nl || displayData.wikidata.description_en) && (
<div className="wikidata-descriptions">
<div className="wikidata-field">
<span className="field-label">{language === 'nl' ? 'Beschrijving' : 'Description'}:</span>
<span>{language === 'nl' ? (displayData.wikidata.description_nl || displayData.wikidata.description_en) : (displayData.wikidata.description_en || displayData.wikidata.description_nl)}</span>
</div>
</div>
)}
{/* Instance types */}
{displayData.wikidata.types && displayData.wikidata.types.length > 0 && (
<div className="wikidata-field">
<span className="field-label">{language === 'nl' ? 'Type' : 'Type'}:</span>
<span className="wikidata-types">
{displayData.wikidata.types.join(', ')}
</span>
</div>
)}
{/* Inception date */}
{displayData.wikidata.inception && (
<div className="wikidata-field">
<span className="field-label">{language === 'nl' ? 'Opgericht' : 'Founded'}:</span>
<span>{displayData.wikidata.inception}</span>
</div>
)}
</div>
)}
{/* Web Claims Section - Images & Extracted Data */}
{displayData.web_claims && displayData.web_claims.length > 0 && (
<details className="detail-section web-claims-section">
<summary>
<h4>🔍 {language === 'nl' ? 'Website Gegevens' : 'Website Data'} ({displayData.web_claims.length})</h4>
</summary>
<div className="web-claims-content">
{/* Group claims by type */}
{(() => {
const imageClaims = displayData.web_claims?.filter(c =>
c.claim_type?.includes('image') || c.claim_type?.includes('logo') || c.claim_type?.includes('og_image')
) || [];
const contactClaims = displayData.web_claims?.filter(c =>
c.claim_type?.includes('email') || c.claim_type?.includes('phone') || c.claim_type?.includes('address')
) || [];
const otherClaims = displayData.web_claims?.filter(c =>
!imageClaims.includes(c) && !contactClaims.includes(c)
) || [];
return (
<>
{/* Image gallery from web claims */}
{imageClaims.length > 0 && (
<div className="web-claims-images">
<p className="claims-label">{language === 'nl' ? 'Afbeeldingen' : 'Images'}:</p>
<div className="claims-image-grid">
{imageClaims.slice(0, 6).map((claim, idx) => (
claim.claim_value && (
<a
key={idx}
href={claim.claim_value}
target="_blank"
rel="noopener noreferrer"
className="claim-image-link"
title={claim.claim_type}
>
<img
src={claim.claim_value}
alt={claim.claim_type || 'Image'}
className="claim-image"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
</a>
)
))}
</div>
</div>
)}
{/* Contact claims */}
{contactClaims.length > 0 && (
<div className="web-claims-contact">
{contactClaims.map((claim, idx) => (
<div key={idx} className="claim-item">
<span className="claim-type">{claim.claim_type}:</span>
<span className="claim-value">
{claim.claim_type?.includes('email') ? (
<a href={`mailto:${claim.claim_value}`}>{claim.claim_value}</a>
) : claim.claim_type?.includes('phone') ? (
<a href={`tel:${claim.claim_value}`}>{claim.claim_value}</a>
) : (
claim.claim_value
)}
</span>
</div>
))}
</div>
)}
{/* Other claims */}
{otherClaims.length > 0 && (
<div className="web-claims-other">
{otherClaims.slice(0, 10).map((claim, idx) => (
<div key={idx} className="claim-item">
<span className="claim-type">{claim.claim_type}:</span>
<span className="claim-value" title={claim.claim_value}>
{claim.claim_value && claim.claim_value.length > 50
? claim.claim_value.substring(0, 50) + '...'
: claim.claim_value}
</span>
</div>
))}
</div>
)}
</>
);
})()}
</div>
</details>
)}
{/* Genealogiewerkbalk Section - Dutch Archive Links */}
{displayData.genealogiewerkbalk && (
<div className="detail-section genealogiewerkbalk">
<h4>🏛 {language === 'nl' ? 'Archiefverbindingen' : 'Archive Connections'}</h4>
{displayData.genealogiewerkbalk.municipality && (
<div className="gwb-field">
<span className="field-label">{language === 'nl' ? 'Gemeente' : 'Municipality'}:</span>
<span>{displayData.genealogiewerkbalk.municipality.name}</span>
</div>
)}
{displayData.genealogiewerkbalk.municipal_archive && (
<div className="gwb-field">
<span className="field-label">{language === 'nl' ? 'Gemeentearchief' : 'Municipal Archive'}:</span>
{displayData.genealogiewerkbalk.municipal_archive.website ? (
<a href={displayData.genealogiewerkbalk.municipal_archive.website} target="_blank" rel="noopener noreferrer">
{displayData.genealogiewerkbalk.municipal_archive.name}
</a>
) : (
<span>{displayData.genealogiewerkbalk.municipal_archive.name}</span>
)}
</div>
)}
{displayData.genealogiewerkbalk.province && (
<div className="gwb-field">
<span className="field-label">{language === 'nl' ? 'Provincie' : 'Province'}:</span>
<span>{displayData.genealogiewerkbalk.province.name}</span>
</div>
)}
{displayData.genealogiewerkbalk.provincial_archive && (
<div className="gwb-field">
<span className="field-label">{language === 'nl' ? 'Provinciaal Archief' : 'Provincial Archive'}:</span>
{displayData.genealogiewerkbalk.provincial_archive.website ? (
<a href={displayData.genealogiewerkbalk.provincial_archive.website} target="_blank" rel="noopener noreferrer">
{displayData.genealogiewerkbalk.provincial_archive.name}
</a>
) : (
<span>{displayData.genealogiewerkbalk.provincial_archive.name}</span>
)}
</div>
)}
</div>
)}
{/* Provenance Section - Data Source Info */}
{displayData.provenance && (
<details className="detail-section provenance-section">
<summary>
<h4>📊 {language === 'nl' ? 'Gegevensbron' : 'Data Provenance'}</h4>
</summary>
<div className="provenance-content">
{displayData.provenance.data_source && (
<div className="provenance-field">
<span className="field-label">{language === 'nl' ? 'Bron' : 'Source'}:</span>
<span className="data-source-badge">{displayData.provenance.data_source}</span>
</div>
)}
{displayData.provenance.data_tier && (
<div className="provenance-field">
<span className="field-label">{language === 'nl' ? 'Kwaliteitsniveau' : 'Quality Tier'}:</span>
<span className={`data-tier-badge tier-${displayData.provenance.data_tier.toLowerCase().replace('tier_', '').split('_')[0]}`}>
{displayData.provenance.data_tier.replace(/_/g, ' ')}
</span>
</div>
)}
</div>
</details>
)}
{/* External Registry Enrichments */}
{(displayData.nan_isil_enrichment || displayData.kb_enrichment || displayData.zcbs_enrichment) && (
<details className="detail-section external-registries">
<summary>
<h4>📋 {language === 'nl' ? 'Externe Registers' : 'External Registries'}</h4>
</summary>
<div className="registries-content">
{displayData.nan_isil_enrichment && (
<div className="registry-section">
<h5>NAN ISIL {language === 'nl' ? 'Register' : 'Registry'}</h5>
<div className="registry-data">
{Object.entries(displayData.nan_isil_enrichment)
.filter(([key]) => !key.startsWith('_'))
.slice(0, 5)
.map(([key, value]) => (
<div key={key} className="registry-field">
<span className="field-label">{key}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
</div>
)}
{displayData.kb_enrichment && (
<div className="registry-section">
<h5>KB {language === 'nl' ? 'Bibliotheek' : 'Library'}</h5>
<div className="registry-data">
{Object.entries(displayData.kb_enrichment)
.filter(([key]) => !key.startsWith('_'))
.slice(0, 5)
.map(([key, value]) => (
<div key={key} className="registry-field">
<span className="field-label">{key}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
</div>
)}
{displayData.zcbs_enrichment && (
<div className="registry-section">
<h5>ZCBS</h5>
<div className="registry-data">
{Object.entries(displayData.zcbs_enrichment)
.filter(([key]) => !key.startsWith('_'))
.slice(0, 5)
.map(([key, value]) => (
<div key={key} className="registry-field">
<span className="field-label">{key}:</span>
<span>{String(value)}</span>
</div>
))}
</div>
</div>
)}
</div>
</details>
)}
</>
)}
@ -1339,6 +1690,8 @@ function PersonDetailModal({
: undefined,
current: !exp.end_date,
description: exp.description,
heritage_relevant: exp.heritage_relevant,
heritage_type: exp.heritage_type ?? undefined,
}))}
t={(nl, en) => language === 'nl' ? nl : en}
/>

View file

@ -0,0 +1,367 @@
/**
* Country Names Mapping
* ISO 3166-1 alpha-2 country codes to full names in Dutch and English
*
* Comprehensive mapping for heritage custodian data across 190+ countries
*/
export interface CountryInfo {
nl: string;
en: string;
flag?: string; // Flag emoji (generated from code)
}
// Convert ISO 3166-1 alpha-2 country code to Unicode flag emoji
export function getFlagEmoji(countryCode: string): string {
if (!countryCode || countryCode.length !== 2) return '';
const code = countryCode.toUpperCase();
const codePoints = code
.split('')
.map(char => 127397 + char.charCodeAt(0));
return String.fromCodePoint(...codePoints);
}
// Complete ISO 3166-1 alpha-2 country code to name mapping
export const COUNTRY_NAMES: Record<string, CountryInfo> = {
// A
'AD': { nl: 'Andorra', en: 'Andorra' },
'AE': { nl: 'Verenigde Arabische Emiraten', en: 'United Arab Emirates' },
'AF': { nl: 'Afghanistan', en: 'Afghanistan' },
'AG': { nl: 'Antigua en Barbuda', en: 'Antigua and Barbuda' },
'AI': { nl: 'Anguilla', en: 'Anguilla' },
'AL': { nl: 'Albanië', en: 'Albania' },
'AM': { nl: 'Armenië', en: 'Armenia' },
'AO': { nl: 'Angola', en: 'Angola' },
'AQ': { nl: 'Antarctica', en: 'Antarctica' },
'AR': { nl: 'Argentinië', en: 'Argentina' },
'AS': { nl: 'Amerikaans-Samoa', en: 'American Samoa' },
'AT': { nl: 'Oostenrijk', en: 'Austria' },
'AU': { nl: 'Australië', en: 'Australia' },
'AW': { nl: 'Aruba', en: 'Aruba' },
'AX': { nl: 'Åland', en: 'Åland Islands' },
'AZ': { nl: 'Azerbeidzjan', en: 'Azerbaijan' },
// B
'BA': { nl: 'Bosnië en Herzegovina', en: 'Bosnia and Herzegovina' },
'BB': { nl: 'Barbados', en: 'Barbados' },
'BD': { nl: 'Bangladesh', en: 'Bangladesh' },
'BE': { nl: 'België', en: 'Belgium' },
'BF': { nl: 'Burkina Faso', en: 'Burkina Faso' },
'BG': { nl: 'Bulgarije', en: 'Bulgaria' },
'BH': { nl: 'Bahrein', en: 'Bahrain' },
'BI': { nl: 'Burundi', en: 'Burundi' },
'BJ': { nl: 'Benin', en: 'Benin' },
'BL': { nl: 'Saint-Barthélemy', en: 'Saint Barthélemy' },
'BM': { nl: 'Bermuda', en: 'Bermuda' },
'BN': { nl: 'Brunei', en: 'Brunei' },
'BO': { nl: 'Bolivia', en: 'Bolivia' },
'BQ': { nl: 'Caribisch Nederland', en: 'Caribbean Netherlands' },
'BR': { nl: 'Brazilië', en: 'Brazil' },
'BS': { nl: 'Bahama\'s', en: 'Bahamas' },
'BT': { nl: 'Bhutan', en: 'Bhutan' },
'BV': { nl: 'Bouveteiland', en: 'Bouvet Island' },
'BW': { nl: 'Botswana', en: 'Botswana' },
'BY': { nl: 'Wit-Rusland', en: 'Belarus' },
'BZ': { nl: 'Belize', en: 'Belize' },
// C
'CA': { nl: 'Canada', en: 'Canada' },
'CC': { nl: 'Cocoseilanden', en: 'Cocos (Keeling) Islands' },
'CD': { nl: 'Congo-Kinshasa', en: 'DR Congo' },
'CF': { nl: 'Centraal-Afrikaanse Republiek', en: 'Central African Republic' },
'CG': { nl: 'Congo-Brazzaville', en: 'Republic of the Congo' },
'CH': { nl: 'Zwitserland', en: 'Switzerland' },
'CI': { nl: 'Ivoorkust', en: 'Ivory Coast' },
'CK': { nl: 'Cookeilanden', en: 'Cook Islands' },
'CL': { nl: 'Chili', en: 'Chile' },
'CM': { nl: 'Kameroen', en: 'Cameroon' },
'CN': { nl: 'China', en: 'China' },
'CO': { nl: 'Colombia', en: 'Colombia' },
'CR': { nl: 'Costa Rica', en: 'Costa Rica' },
'CU': { nl: 'Cuba', en: 'Cuba' },
'CV': { nl: 'Kaapverdië', en: 'Cape Verde' },
'CW': { nl: 'Curaçao', en: 'Curaçao' },
'CX': { nl: 'Christmaseiland', en: 'Christmas Island' },
'CY': { nl: 'Cyprus', en: 'Cyprus' },
'CZ': { nl: 'Tsjechië', en: 'Czechia' },
// D
'DE': { nl: 'Duitsland', en: 'Germany' },
'DJ': { nl: 'Djibouti', en: 'Djibouti' },
'DK': { nl: 'Denemarken', en: 'Denmark' },
'DM': { nl: 'Dominica', en: 'Dominica' },
'DO': { nl: 'Dominicaanse Republiek', en: 'Dominican Republic' },
'DZ': { nl: 'Algerije', en: 'Algeria' },
// E
'EC': { nl: 'Ecuador', en: 'Ecuador' },
'EE': { nl: 'Estland', en: 'Estonia' },
'EG': { nl: 'Egypte', en: 'Egypt' },
'EH': { nl: 'Westelijke Sahara', en: 'Western Sahara' },
'ER': { nl: 'Eritrea', en: 'Eritrea' },
'ES': { nl: 'Spanje', en: 'Spain' },
'ET': { nl: 'Ethiopië', en: 'Ethiopia' },
// F
'FI': { nl: 'Finland', en: 'Finland' },
'FJ': { nl: 'Fiji', en: 'Fiji' },
'FK': { nl: 'Falklandeilanden', en: 'Falkland Islands' },
'FM': { nl: 'Micronesia', en: 'Micronesia' },
'FO': { nl: 'Faeröer', en: 'Faroe Islands' },
'FR': { nl: 'Frankrijk', en: 'France' },
// G
'GA': { nl: 'Gabon', en: 'Gabon' },
'GB': { nl: 'Verenigd Koninkrijk', en: 'United Kingdom' },
'GD': { nl: 'Grenada', en: 'Grenada' },
'GE': { nl: 'Georgië', en: 'Georgia' },
'GF': { nl: 'Frans-Guyana', en: 'French Guiana' },
'GG': { nl: 'Guernsey', en: 'Guernsey' },
'GH': { nl: 'Ghana', en: 'Ghana' },
'GI': { nl: 'Gibraltar', en: 'Gibraltar' },
'GL': { nl: 'Groenland', en: 'Greenland' },
'GM': { nl: 'Gambia', en: 'Gambia' },
'GN': { nl: 'Guinee', en: 'Guinea' },
'GP': { nl: 'Guadeloupe', en: 'Guadeloupe' },
'GQ': { nl: 'Equatoriaal-Guinea', en: 'Equatorial Guinea' },
'GR': { nl: 'Griekenland', en: 'Greece' },
'GS': { nl: 'Zuid-Georgia en de Zuidelijke Sandwicheilanden', en: 'South Georgia' },
'GT': { nl: 'Guatemala', en: 'Guatemala' },
'GU': { nl: 'Guam', en: 'Guam' },
'GW': { nl: 'Guinee-Bissau', en: 'Guinea-Bissau' },
'GY': { nl: 'Guyana', en: 'Guyana' },
// H
'HK': { nl: 'Hongkong', en: 'Hong Kong' },
'HM': { nl: 'Heard en McDonaldeilanden', en: 'Heard Island and McDonald Islands' },
'HN': { nl: 'Honduras', en: 'Honduras' },
'HR': { nl: 'Kroatië', en: 'Croatia' },
'HT': { nl: 'Haïti', en: 'Haiti' },
'HU': { nl: 'Hongarije', en: 'Hungary' },
// I
'ID': { nl: 'Indonesië', en: 'Indonesia' },
'IE': { nl: 'Ierland', en: 'Ireland' },
'IL': { nl: 'Israël', en: 'Israel' },
'IM': { nl: 'Man', en: 'Isle of Man' },
'IN': { nl: 'India', en: 'India' },
'IO': { nl: 'Brits Indische Oceaanterritorium', en: 'British Indian Ocean Territory' },
'IQ': { nl: 'Irak', en: 'Iraq' },
'IR': { nl: 'Iran', en: 'Iran' },
'IS': { nl: 'IJsland', en: 'Iceland' },
'IT': { nl: 'Italië', en: 'Italy' },
// J
'JE': { nl: 'Jersey', en: 'Jersey' },
'JM': { nl: 'Jamaica', en: 'Jamaica' },
'JO': { nl: 'Jordanië', en: 'Jordan' },
'JP': { nl: 'Japan', en: 'Japan' },
// K
'KE': { nl: 'Kenia', en: 'Kenya' },
'KG': { nl: 'Kirgizië', en: 'Kyrgyzstan' },
'KH': { nl: 'Cambodja', en: 'Cambodia' },
'KI': { nl: 'Kiribati', en: 'Kiribati' },
'KM': { nl: 'Comoren', en: 'Comoros' },
'KN': { nl: 'Saint Kitts en Nevis', en: 'Saint Kitts and Nevis' },
'KP': { nl: 'Noord-Korea', en: 'North Korea' },
'KR': { nl: 'Zuid-Korea', en: 'South Korea' },
'KW': { nl: 'Koeweit', en: 'Kuwait' },
'KY': { nl: 'Kaaimaneilanden', en: 'Cayman Islands' },
'KZ': { nl: 'Kazachstan', en: 'Kazakhstan' },
// L
'LA': { nl: 'Laos', en: 'Laos' },
'LB': { nl: 'Libanon', en: 'Lebanon' },
'LC': { nl: 'Saint Lucia', en: 'Saint Lucia' },
'LI': { nl: 'Liechtenstein', en: 'Liechtenstein' },
'LK': { nl: 'Sri Lanka', en: 'Sri Lanka' },
'LR': { nl: 'Liberia', en: 'Liberia' },
'LS': { nl: 'Lesotho', en: 'Lesotho' },
'LT': { nl: 'Litouwen', en: 'Lithuania' },
'LU': { nl: 'Luxemburg', en: 'Luxembourg' },
'LV': { nl: 'Letland', en: 'Latvia' },
'LY': { nl: 'Libië', en: 'Libya' },
// M
'MA': { nl: 'Marokko', en: 'Morocco' },
'MC': { nl: 'Monaco', en: 'Monaco' },
'MD': { nl: 'Moldavië', en: 'Moldova' },
'ME': { nl: 'Montenegro', en: 'Montenegro' },
'MF': { nl: 'Sint-Maarten (Frans)', en: 'Saint Martin' },
'MG': { nl: 'Madagaskar', en: 'Madagascar' },
'MH': { nl: 'Marshalleilanden', en: 'Marshall Islands' },
'MK': { nl: 'Noord-Macedonië', en: 'North Macedonia' },
'ML': { nl: 'Mali', en: 'Mali' },
'MM': { nl: 'Myanmar', en: 'Myanmar' },
'MN': { nl: 'Mongolië', en: 'Mongolia' },
'MO': { nl: 'Macau', en: 'Macau' },
'MP': { nl: 'Noordelijke Marianen', en: 'Northern Mariana Islands' },
'MQ': { nl: 'Martinique', en: 'Martinique' },
'MR': { nl: 'Mauritanië', en: 'Mauritania' },
'MS': { nl: 'Montserrat', en: 'Montserrat' },
'MT': { nl: 'Malta', en: 'Malta' },
'MU': { nl: 'Mauritius', en: 'Mauritius' },
'MV': { nl: 'Maldiven', en: 'Maldives' },
'MW': { nl: 'Malawi', en: 'Malawi' },
'MX': { nl: 'Mexico', en: 'Mexico' },
'MY': { nl: 'Maleisië', en: 'Malaysia' },
'MZ': { nl: 'Mozambique', en: 'Mozambique' },
// N
'NA': { nl: 'Namibië', en: 'Namibia' },
'NC': { nl: 'Nieuw-Caledonië', en: 'New Caledonia' },
'NE': { nl: 'Niger', en: 'Niger' },
'NF': { nl: 'Norfolk', en: 'Norfolk Island' },
'NG': { nl: 'Nigeria', en: 'Nigeria' },
'NI': { nl: 'Nicaragua', en: 'Nicaragua' },
'NL': { nl: 'Nederland', en: 'Netherlands' },
'NO': { nl: 'Noorwegen', en: 'Norway' },
'NP': { nl: 'Nepal', en: 'Nepal' },
'NR': { nl: 'Nauru', en: 'Nauru' },
'NU': { nl: 'Niue', en: 'Niue' },
'NZ': { nl: 'Nieuw-Zeeland', en: 'New Zealand' },
// O
'OM': { nl: 'Oman', en: 'Oman' },
// P
'PA': { nl: 'Panama', en: 'Panama' },
'PE': { nl: 'Peru', en: 'Peru' },
'PF': { nl: 'Frans-Polynesië', en: 'French Polynesia' },
'PG': { nl: 'Papoea-Nieuw-Guinea', en: 'Papua New Guinea' },
'PH': { nl: 'Filipijnen', en: 'Philippines' },
'PK': { nl: 'Pakistan', en: 'Pakistan' },
'PL': { nl: 'Polen', en: 'Poland' },
'PM': { nl: 'Saint-Pierre en Miquelon', en: 'Saint Pierre and Miquelon' },
'PN': { nl: 'Pitcairneilanden', en: 'Pitcairn Islands' },
'PR': { nl: 'Puerto Rico', en: 'Puerto Rico' },
'PS': { nl: 'Palestina', en: 'Palestine' },
'PT': { nl: 'Portugal', en: 'Portugal' },
'PW': { nl: 'Palau', en: 'Palau' },
'PY': { nl: 'Paraguay', en: 'Paraguay' },
// Q
'QA': { nl: 'Qatar', en: 'Qatar' },
// R
'RE': { nl: 'Réunion', en: 'Réunion' },
'RO': { nl: 'Roemenië', en: 'Romania' },
'RS': { nl: 'Servië', en: 'Serbia' },
'RU': { nl: 'Rusland', en: 'Russia' },
'RW': { nl: 'Rwanda', en: 'Rwanda' },
// S
'SA': { nl: 'Saoedi-Arabië', en: 'Saudi Arabia' },
'SB': { nl: 'Salomonseilanden', en: 'Solomon Islands' },
'SC': { nl: 'Seychellen', en: 'Seychelles' },
'SD': { nl: 'Soedan', en: 'Sudan' },
'SE': { nl: 'Zweden', en: 'Sweden' },
'SG': { nl: 'Singapore', en: 'Singapore' },
'SH': { nl: 'Sint-Helena', en: 'Saint Helena' },
'SI': { nl: 'Slovenië', en: 'Slovenia' },
'SJ': { nl: 'Spitsbergen en Jan Mayen', en: 'Svalbard and Jan Mayen' },
'SK': { nl: 'Slowakije', en: 'Slovakia' },
'SL': { nl: 'Sierra Leone', en: 'Sierra Leone' },
'SM': { nl: 'San Marino', en: 'San Marino' },
'SN': { nl: 'Senegal', en: 'Senegal' },
'SO': { nl: 'Somalië', en: 'Somalia' },
'SR': { nl: 'Suriname', en: 'Suriname' },
'SS': { nl: 'Zuid-Soedan', en: 'South Sudan' },
'ST': { nl: 'Sao Tomé en Principe', en: 'São Tomé and Príncipe' },
'SV': { nl: 'El Salvador', en: 'El Salvador' },
'SX': { nl: 'Sint Maarten', en: 'Sint Maarten' },
'SY': { nl: 'Syrië', en: 'Syria' },
'SZ': { nl: 'Eswatini', en: 'Eswatini' },
// T
'TC': { nl: 'Turks- en Caicoseilanden', en: 'Turks and Caicos Islands' },
'TD': { nl: 'Tsjaad', en: 'Chad' },
'TF': { nl: 'Franse Zuidelijke Gebieden', en: 'French Southern Territories' },
'TG': { nl: 'Togo', en: 'Togo' },
'TH': { nl: 'Thailand', en: 'Thailand' },
'TJ': { nl: 'Tadzjikistan', en: 'Tajikistan' },
'TK': { nl: 'Tokelau', en: 'Tokelau' },
'TL': { nl: 'Oost-Timor', en: 'Timor-Leste' },
'TM': { nl: 'Turkmenistan', en: 'Turkmenistan' },
'TN': { nl: 'Tunesië', en: 'Tunisia' },
'TO': { nl: 'Tonga', en: 'Tonga' },
'TR': { nl: 'Turkije', en: 'Turkey' },
'TT': { nl: 'Trinidad en Tobago', en: 'Trinidad and Tobago' },
'TV': { nl: 'Tuvalu', en: 'Tuvalu' },
'TW': { nl: 'Taiwan', en: 'Taiwan' },
'TZ': { nl: 'Tanzania', en: 'Tanzania' },
// U
'UA': { nl: 'Oekraïne', en: 'Ukraine' },
'UG': { nl: 'Oeganda', en: 'Uganda' },
'UM': { nl: 'Kleine afgelegen eilanden van de VS', en: 'U.S. Minor Outlying Islands' },
'US': { nl: 'Verenigde Staten', en: 'United States' },
'UY': { nl: 'Uruguay', en: 'Uruguay' },
'UZ': { nl: 'Oezbekistan', en: 'Uzbekistan' },
// V
'VA': { nl: 'Vaticaanstad', en: 'Vatican City' },
'VC': { nl: 'Saint Vincent en de Grenadines', en: 'Saint Vincent and the Grenadines' },
'VE': { nl: 'Venezuela', en: 'Venezuela' },
'VG': { nl: 'Britse Maagdeneilanden', en: 'British Virgin Islands' },
'VI': { nl: 'Amerikaanse Maagdeneilanden', en: 'U.S. Virgin Islands' },
'VN': { nl: 'Vietnam', en: 'Vietnam' },
'VU': { nl: 'Vanuatu', en: 'Vanuatu' },
// W
'WF': { nl: 'Wallis en Futuna', en: 'Wallis and Futuna' },
'WS': { nl: 'Samoa', en: 'Samoa' },
// X - User-assigned codes (for special entities)
'XK': { nl: 'Kosovo', en: 'Kosovo' },
// Y
'YE': { nl: 'Jemen', en: 'Yemen' },
'YT': { nl: 'Mayotte', en: 'Mayotte' },
// Z
'ZA': { nl: 'Zuid-Afrika', en: 'South Africa' },
'ZM': { nl: 'Zambia', en: 'Zambia' },
'ZW': { nl: 'Zimbabwe', en: 'Zimbabwe' },
};
/**
* Get the country name with flag emoji for display
* @param code ISO 3166-1 alpha-2 country code
* @param language 'nl' or 'en'
* @returns Formatted string like "🇳🇱 Nederland" or the code if not found
*/
export function getCountryLabel(code: string, language: 'nl' | 'en' = 'en'): string {
const upperCode = code.toUpperCase();
const country = COUNTRY_NAMES[upperCode];
const flag = getFlagEmoji(upperCode);
if (country) {
return `${flag} ${country[language]}`.trim();
}
// Fallback: return code with flag if possible
return flag ? `${flag} ${upperCode}` : upperCode;
}
/**
* Get just the country name without flag
* @param code ISO 3166-1 alpha-2 country code
* @param language 'nl' or 'en'
* @returns Country name or the code if not found
*/
export function getCountryName(code: string, language: 'nl' | 'en' = 'en'): string {
const upperCode = code.toUpperCase();
const country = COUNTRY_NAMES[upperCode];
return country ? country[language] : upperCode;
}
/**
* Check if a country code is valid (exists in our mapping)
* @param code ISO 3166-1 alpha-2 country code
* @returns boolean
*/
export function isValidCountryCode(code: string): boolean {
return code.toUpperCase() in COUNTRY_NAMES;
}

View file

@ -0,0 +1,65 @@
/**
* imageProxy.ts
*
* Utility function to proxy external image URLs through the backend
* to avoid hotlinking issues and blocked images.
*
* Many institution websites and image hosts block direct embedding
* (hotlinking) of their images. This utility routes image requests
* through our backend proxy which fetches the image server-side.
*/
/**
* Convert an external image URL to use the backend image proxy.
*
* The proxy endpoint caches images for 1 hour and adds appropriate
* headers to bypass hotlink detection.
*
* @param url - The external image URL to proxy
* @returns Proxied URL via /api/geo/image-proxy, or the original URL if local
*
* @example
* // External image - will be proxied
* proxyImageUrl('https://example.com/logo.png')
* // Returns: '/api/geo/image-proxy?url=https%3A%2F%2Fexample.com%2Flogo.png'
*
* // Local image - returned as-is
* proxyImageUrl('/images/local-logo.png')
* // Returns: '/images/local-logo.png'
*
* // Data URL - returned as-is
* proxyImageUrl('data:image/png;base64,...')
* // Returns: 'data:image/png;base64,...'
*/
export function proxyImageUrl(url: string | undefined | null): string | undefined {
// Return undefined for null/undefined
if (!url) return undefined;
// Don't proxy local URLs (relative paths)
if (url.startsWith('/')) return url;
// Don't proxy data URLs (inline base64 images)
if (url.startsWith('data:')) return url;
// Don't proxy blob URLs
if (url.startsWith('blob:')) return url;
// Proxy all other (external) URLs
return `/api/geo/image-proxy?url=${encodeURIComponent(url)}`;
}
/**
* Check if a URL is an external URL that would benefit from proxying.
*
* @param url - URL to check
* @returns true if external, false if local/data/blob
*/
export function isExternalUrl(url: string | undefined | null): boolean {
if (!url) return false;
if (url.startsWith('/')) return false;
if (url.startsWith('data:')) return false;
if (url.startsWith('blob:')) return false;
return true;
}
export default proxyImageUrl;

View file

@ -1,11 +1,36 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
import fs from 'fs'
// Generate a build timestamp for version checking
const buildTimestamp = new Date().toISOString();
// https://vite.dev/config/
export default defineConfig({
logLevel: 'info',
plugins: [react()],
plugins: [
react(),
// Plugin to generate version.json on build
{
name: 'generate-version-file',
closeBundle() {
const versionInfo = {
buildTimestamp,
generatedAt: new Date().toISOString(),
};
fs.writeFileSync(
path.resolve(__dirname, 'dist/version.json'),
JSON.stringify(versionInfo, null, 2)
);
console.log('[generate-version-file] Created dist/version.json with timestamp:', buildTimestamp);
},
},
],
// Inject build timestamp into the app
define: {
'import.meta.env.VITE_BUILD_TIMESTAMP': JSON.stringify(buildTimestamp),
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),