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:
parent
181b1cf705
commit
0a38225b36
24 changed files with 4071 additions and 166 deletions
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"generated": "2025-12-14T15:51:34.503Z",
|
||||
"generated": "2025-12-15T00:45:23.433Z",
|
||||
"version": "1.0.0",
|
||||
"categories": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
297
frontend/src/components/common/LazyLoadError.tsx
Normal file
297
frontend/src/components/common/LazyLoadError.tsx
Normal 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;
|
||||
761
frontend/src/components/conversation/ConversationMapLibre.tsx
Normal file
761
frontend/src/components/conversation/ConversationMapLibre.tsx
Normal 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <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: '© <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;
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
320
frontend/src/components/ui/SearchableMultiSelect.css
Normal file
320
frontend/src/components/ui/SearchableMultiSelect.css
Normal 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;
|
||||
}
|
||||
}
|
||||
249
frontend/src/components/ui/SearchableMultiSelect.tsx
Normal file
249
frontend/src/components/ui/SearchableMultiSelect.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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%);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
101
frontend/src/lib/version-check.ts
Normal file
101
frontend/src/lib/version-check.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
============================================================================ */
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
367
frontend/src/utils/countryNames.ts
Normal file
367
frontend/src/utils/countryNames.ts
Normal 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;
|
||||
}
|
||||
65
frontend/src/utils/imageProxy.ts
Normal file
65
frontend/src/utils/imageProxy.ts
Normal 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;
|
||||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue