diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f608de039f..033ad48007 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/public/schemas/20251121/linkml/manifest.json b/frontend/public/schemas/20251121/linkml/manifest.json index c251922829..edaf565830 100644 --- a/frontend/public/schemas/20251121/linkml/manifest.json +++ b/frontend/public/schemas/20251121/linkml/manifest.json @@ -1,5 +1,5 @@ { - "generated": "2025-12-14T15:51:34.503Z", + "generated": "2025-12-15T00:45:23.433Z", "version": "1.0.0", "categories": [ { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0918eabbd1..d47526f566 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 = () => ( ); -// Wrap lazy components with Suspense +// Wrap lazy components with Suspense and error boundary for chunk load errors const withSuspense = (Component: React.LazyExoticComponent) => ( - }> - - + + }> + + + ); // Create router configuration with protected routes @@ -66,6 +76,7 @@ const router = createBrowserRouter([ { path: '/login', element: , + errorElement: , }, { path: '/', @@ -74,6 +85,7 @@ const router = createBrowserRouter([ ), + errorElement: , 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 ( + {/* New version available banner */} + {showUpdateBanner && ( +
+ A new version is available. + + +
+ )}
diff --git a/frontend/src/components/common/LazyLoadError.tsx b/frontend/src/components/common/LazyLoadError.tsx new file mode 100644 index 0000000000..f8153ef0f0 --- /dev/null +++ b/frontend/src/components/common/LazyLoadError.tsx @@ -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 = ({ + 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 ( +
+
+
+ {isChunk ? ( + + ) : ( + + )} +
+ +

+ {text.title[language]} +

+ +

+ {text.message[language]} +

+ +
+ + + + + {text.home[language]} + +
+ + {/* Debug info in development */} + {error && import.meta.env.DEV && ( +
+ Technical details +
+              {error.message}
+            
+
+ )} +
+
+ ); +}; + +/** + * 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 ; + } + + // Handle 404 errors + if (isRouteErrorResponse(error) && error.status === 404) { + return ( +
+
+

+ 404 +

+

+ Page not found +

+ + + Go home + +
+
+ ); + } + + // Generic error fallback + return ; +}; + +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 ( + + ); + } + + 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>( + importFn: () => Promise<{ default: T }>, + retries = 2 +): React.LazyExoticComponent { + 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; diff --git a/frontend/src/components/conversation/ConversationMapLibre.tsx b/frontend/src/components/conversation/ConversationMapLibre.tsx new file mode 100644 index 0000000000..b10ff06d15 --- /dev/null +++ b/frontend/src/components/conversation/ConversationMapLibre.tsx @@ -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 = { + 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 = { + 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: '© OpenStreetMap contributors © CARTO', + }, + }, + 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: '© OpenStreetMap 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 = ({ + coordinates, + width = 600, + height = 500, + onMarkerClick, + onMarkerHover, + selectedId, + language = 'nl', + showClustering = false, + className = '', +}) => { + const mapContainerRef = useRef(null); + const mapRef = useRef(null); + const [mapReady, setMapReady] = useState(false); + const [selectedInstitution, setSelectedInstitution] = useState(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 ( +
+
+ + {/* Legend */} +
+
+ {t('Legenda', 'Legend')} ({coordinates.length}) +
+ {/* 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 => ( +
+ + {getTypeName(code)} +
+ )) + } +
+ + {/* Institution Info Panel */} + {selectedInstitution && ( + + )} +
+ ); +}; + +export default ConversationMapLibre; diff --git a/frontend/src/components/conversation/index.ts b/frontend/src/components/conversation/index.ts index 4059bc907b..2693373fda 100644 --- a/frontend/src/components/conversation/index.ts +++ b/frontend/src/components/conversation/index.ts @@ -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'; diff --git a/frontend/src/components/map/InstitutionInfoPanel.tsx b/frontend/src/components/map/InstitutionInfoPanel.tsx index dbfce26b6c..ba5531171a 100644 --- a/frontend/src/components/map/InstitutionInfoPanel.tsx +++ b/frontend/src/components/map/InstitutionInfoPanel.tsx @@ -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; // 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; // 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 = ({
{`${safeString(institution.name)} { diff --git a/frontend/src/components/map/MediaGallery.tsx b/frontend/src/components/map/MediaGallery.tsx index 4449de60d2..b955280a84 100644 --- a/frontend/src/components/map/MediaGallery.tsx +++ b/frontend/src/components/map/MediaGallery.tsx @@ -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 = ({ 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 diff --git a/frontend/src/components/ui/SearchableMultiSelect.css b/frontend/src/components/ui/SearchableMultiSelect.css new file mode 100644 index 0000000000..5744d81161 --- /dev/null +++ b/frontend/src/components/ui/SearchableMultiSelect.css @@ -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; + } +} diff --git a/frontend/src/components/ui/SearchableMultiSelect.tsx b/frontend/src/components/ui/SearchableMultiSelect.tsx new file mode 100644 index 0000000000..caf8046179 --- /dev/null +++ b/frontend/src/components/ui/SearchableMultiSelect.tsx @@ -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(null); + const searchInputRef = useRef(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 ( +
+ {/* Trigger button */} + + + {/* Dropdown panel */} + {isOpen && ( +
+ {/* Search input */} +
+ setSearchQuery(e.target.value)} + onClick={(e) => e.stopPropagation()} + /> + {searchQuery && ( + + )} +
+ + {/* Bulk actions */} +
+ + {selectedValues.length > 0 && ( + + )} +
+ + {/* Options list */} +
+ {filteredOptions.length === 0 ? ( +
No matches found
+ ) : ( + filteredOptions.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + + ); + }) + )} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/visualizations/CareerTimeline.css b/frontend/src/components/visualizations/CareerTimeline.css index e10f4df303..71b8009bd5 100644 --- a/frontend/src/components/visualizations/CareerTimeline.css +++ b/frontend/src/components/visualizations/CareerTimeline.css @@ -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%); + } } diff --git a/frontend/src/components/visualizations/CareerTimeline.tsx b/frontend/src/components/visualizations/CareerTimeline.tsx index 66720dd641..b3fcf7a27f 100644 --- a/frontend/src/components/visualizations/CareerTimeline.tsx +++ b/frontend/src/components/visualizations/CareerTimeline.tsx @@ -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 = ({ t, }) => { const [viewMode, setViewMode] = useState('bar'); + const [showHeritageOnly, setShowHeritageOnly] = useState(false); const containerRef = useRef(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 = ({ 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 = ({ minYear: min, maxYear: max, yearsSpan: max - min, + rawPositions: raw, + heritageCount, }; }, [career]); @@ -177,8 +228,24 @@ export const CareerTimeline: React.FC = ({ 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 (
@@ -193,45 +260,116 @@ export const CareerTimeline: React.FC = ({ {/* Header with mode selector */}
- 💼 {t('Loopbaan Tijdlijn', 'Career Timeline')} + 💼 {t('Loopbaan', 'Career')}
+ + {/* Heritage filter toggle */} + {heritageCount > 0 && ( + <> + | + + + )}
{/* Summary */}
- {positions.length} {t('posities', 'positions')} · {totalYears} {t('jaar', 'years')} - - - {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')}` : ''} + {hasParseableDates && ( + + {minYear} — {t('heden', 'present')} + + )}
+ {/* SEQUENTIAL VIEW - Works without parseable dates */} + {effectiveViewMode === 'sequential' && ( +
+ {filteredRawPositions.map((pos, i) => ( +
+
+
+ {i < filteredRawPositions.length - 1 && ( +
+ )} +
+
+
+ {pos.role} + {pos.isCurrent && ( + + {t('Huidig', 'Current')} + + )} +
+ {pos.company} + {pos.dates && ( + {pos.dates} + )} + {pos.location && ( + 📍 {pos.location} + )} +
+
+ ))} +
+ )} + {/* BAR VIEW */} - {viewMode === 'bar' && ( + {effectiveViewMode === 'bar' && (
{/* Timeline axis */}
@@ -255,7 +393,7 @@ export const CareerTimeline: React.FC = ({ {/* Position bars */}
- {positions.map((pos, i) => ( + {filteredPositions.map((pos, i) => (
= ({ )} {/* MILESTONES VIEW */} - {viewMode === 'milestones' && ( + {effectiveViewMode === 'milestones' && (
{/* Central timeline */}
{/* Events */}
- {positions.map((pos, i) => { + {filteredPositions.map((pos, i) => { const isTop = i % 2 === 0; return (
= ({ )} {/* BEESWARM VIEW */} - {viewMode === 'beeswarm' && ( + {effectiveViewMode === 'beeswarm' && (
{/* Timeline axis */}
@@ -330,7 +468,7 @@ export const CareerTimeline: React.FC = ({ {/* Position dots */}
- {positions.map((pos, i) => { + {filteredPositions.map((pos, i) => { const yOffset = (i % 3) * 14 - 14; return (
= { // 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 diff --git a/frontend/src/hooks/useGeoApiInstitutions.ts b/frontend/src/hooks/useGeoApiInstitutions.ts index c372215e46..851794ae35 100644 --- a/frontend/src/hooks/useGeoApiInstitutions.ts +++ b/frontend/src/hooks/useGeoApiInstitutions.ts @@ -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; +} | 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; + + // 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; + 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 | 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; + } + } 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): 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 } | 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 | undefined, + kb_enrichment: data.kb_enrichment as Record | undefined, + zcbs_enrichment: data.zcbs_enrichment as Record | 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; diff --git a/frontend/src/hooks/useMultiDatabaseRAG.ts b/frontend/src/hooks/useMultiDatabaseRAG.ts index 286929cd5f..1355b1a97f 100644 --- a/frontend/src/hooks/useMultiDatabaseRAG.ts +++ b/frontend/src/hooks/useMultiDatabaseRAG.ts @@ -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, diff --git a/frontend/src/lib/version-check.ts b/frontend/src/lib/version-check.ts new file mode 100644 index 0000000000..142c5ce282 --- /dev/null +++ b/frontend/src/lib/version-check.ts @@ -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 { + // 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); + }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index c33a14e18f..3659e4128c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -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( diff --git a/frontend/src/pages/ConversationPage.css b/frontend/src/pages/ConversationPage.css index 36d60f3f99..15dd566957 100644 --- a/frontend/src/pages/ConversationPage.css +++ b/frontend/src/pages/ConversationPage.css @@ -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 ============================================================================ */ diff --git a/frontend/src/pages/ConversationPage.tsx b/frontend/src/pages/ConversationPage.tsx index 23a823e921..78edd0a234 100644 --- a/frontend/src/pages/ConversationPage.tsx +++ b/frontend/src/pages/ConversationPage.tsx @@ -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 = ({ return (
{mapCoordinates.length > 0 ? ( - { // Refs const messagesEndRef = useRef(null); + const messagesContainerRef = useRef(null); const inputRef = useRef(null); const modelDropdownRef = useRef(null); const historyDropdownRef = useRef(null); const fileInputRef = useRef(null); + // Collapsible header hook + const { isCollapsed } = useCollapsibleHeader(messagesContainerRef); + // ============================================================================ // Effects // ============================================================================ @@ -1171,7 +1176,7 @@ const ConversationPage: React.FC = () => { {/* Chat Panel */}
{/* Header */} -
+
@@ -1423,7 +1428,7 @@ const ConversationPage: React.FC = () => {
{/* Messages */} -
+
{messages.length === 0 ? (
diff --git a/frontend/src/pages/InstitutionBrowserPage.css b/frontend/src/pages/InstitutionBrowserPage.css index a7d40464aa..ab8870020f 100644 --- a/frontend/src/pages/InstitutionBrowserPage.css +++ b/frontend/src/pages/InstitutionBrowserPage.css @@ -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; +} diff --git a/frontend/src/pages/InstitutionBrowserPage.tsx b/frontend/src/pages/InstitutionBrowserPage.tsx index 274c17e690..6f7975bf2b 100644 --- a/frontend/src/pages/InstitutionBrowserPage.tsx +++ b/frontend/src/pages/InstitutionBrowserPage.tsx @@ -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 = { - '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(''); - const [selectedCountry, setSelectedCountry] = useState(''); + const [selectedTypes, setSelectedTypes] = useState([]); + const [selectedCountries, setSelectedCountries] = useState([]); const [currentPage, setCurrentPage] = useState(0); const [selectedInstitution, setSelectedInstitution] = useState(null); const [networkInstitution, setNetworkInstitution] = useState(null); @@ -134,6 +120,30 @@ export function InstitutionBrowserPage() { const [selectedPerson, setSelectedPerson] = useState(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() {
- + - + {/* Heritage-relevant toggle - only show for Beschermers tab */} {entityType === 'beschermers' && ( @@ -456,6 +481,41 @@ export function InstitutionBrowserPage() {
+ {/* Filter Chips */} + {(selectedTypes.length > 0 || selectedCountries.length > 0) && ( +
+ {selectedTypes.map(type => { + const info = TYPE_INFO[type]; + return ( + + {info?.icon} + {info?.name || type} + + + ); + })} + {selectedCountries.map(code => ( + + {getFlagEmoji(code)} + {getCountryName(code, language)} + + + ))} +
+ )} + {/* Results info */}
{entityType === 'bronhouders' ? ( @@ -650,7 +710,7 @@ function InstitutionCard({ {/* Logo or type icon */} {institution.logo_url && !logoError ? ( setLogoError(true)} @@ -962,7 +1022,7 @@ function InstitutionDetailModal({ {/* Logo or type icon */} {institution.logo_url && !logoError ? ( setLogoError(true)} @@ -1130,6 +1190,19 @@ function InstitutionDetailModal({ {institution.ghcid.current}
)} + {/* Enhanced GHCID with UUID and Numeric */} + {displayData.ghcid?.uuid && ( +
+ GHCID UUID: + {displayData.ghcid.uuid} +
+ )} + {displayData.ghcid?.numeric && ( +
+ GHCID Numeric: + {displayData.ghcid.numeric} +
+ )} {displayData.wikidata_id && (
{t('wikidata')}: @@ -1148,7 +1221,285 @@ function InstitutionDetailModal({ {displayData.isil.code}
)} + {/* Google Place ID */} + {displayData.google_place_id && ( + + )}
+ + {/* Wikidata Enrichment Section */} + {displayData.wikidata && ( +
+

📚 {language === 'nl' ? 'Wikidata Informatie' : 'Wikidata Information'}

+ {/* Multilingual labels */} + {(displayData.wikidata.label_nl || displayData.wikidata.label_en) && ( +
+ {displayData.wikidata.label_nl && ( +
+ 🇳🇱 Label: + {displayData.wikidata.label_nl} +
+ )} + {displayData.wikidata.label_en && displayData.wikidata.label_en !== displayData.wikidata.label_nl && ( +
+ 🇬🇧 Label: + {displayData.wikidata.label_en} +
+ )} +
+ )} + {/* Descriptions */} + {(displayData.wikidata.description_nl || displayData.wikidata.description_en) && ( +
+
+ {language === 'nl' ? 'Beschrijving' : 'Description'}: + {language === 'nl' ? (displayData.wikidata.description_nl || displayData.wikidata.description_en) : (displayData.wikidata.description_en || displayData.wikidata.description_nl)} +
+
+ )} + {/* Instance types */} + {displayData.wikidata.types && displayData.wikidata.types.length > 0 && ( +
+ {language === 'nl' ? 'Type' : 'Type'}: + + {displayData.wikidata.types.join(', ')} + +
+ )} + {/* Inception date */} + {displayData.wikidata.inception && ( +
+ {language === 'nl' ? 'Opgericht' : 'Founded'}: + {displayData.wikidata.inception} +
+ )} +
+ )} + + {/* Web Claims Section - Images & Extracted Data */} + {displayData.web_claims && displayData.web_claims.length > 0 && ( +
+ +

🔍 {language === 'nl' ? 'Website Gegevens' : 'Website Data'} ({displayData.web_claims.length})

+
+
+ {/* 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 && ( +
+

{language === 'nl' ? 'Afbeeldingen' : 'Images'}:

+
+ {imageClaims.slice(0, 6).map((claim, idx) => ( + claim.claim_value && ( + + {claim.claim_type { (e.target as HTMLImageElement).style.display = 'none'; }} + /> + + ) + ))} +
+
+ )} + {/* Contact claims */} + {contactClaims.length > 0 && ( +
+ {contactClaims.map((claim, idx) => ( +
+ {claim.claim_type}: + + {claim.claim_type?.includes('email') ? ( + {claim.claim_value} + ) : claim.claim_type?.includes('phone') ? ( + {claim.claim_value} + ) : ( + claim.claim_value + )} + +
+ ))} +
+ )} + {/* Other claims */} + {otherClaims.length > 0 && ( +
+ {otherClaims.slice(0, 10).map((claim, idx) => ( +
+ {claim.claim_type}: + + {claim.claim_value && claim.claim_value.length > 50 + ? claim.claim_value.substring(0, 50) + '...' + : claim.claim_value} + +
+ ))} +
+ )} + + ); + })()} +
+
+ )} + + {/* Genealogiewerkbalk Section - Dutch Archive Links */} + {displayData.genealogiewerkbalk && ( +
+

🏛️ {language === 'nl' ? 'Archiefverbindingen' : 'Archive Connections'}

+ {displayData.genealogiewerkbalk.municipality && ( +
+ {language === 'nl' ? 'Gemeente' : 'Municipality'}: + {displayData.genealogiewerkbalk.municipality.name} +
+ )} + {displayData.genealogiewerkbalk.municipal_archive && ( +
+ {language === 'nl' ? 'Gemeentearchief' : 'Municipal Archive'}: + {displayData.genealogiewerkbalk.municipal_archive.website ? ( + + {displayData.genealogiewerkbalk.municipal_archive.name} + + ) : ( + {displayData.genealogiewerkbalk.municipal_archive.name} + )} +
+ )} + {displayData.genealogiewerkbalk.province && ( +
+ {language === 'nl' ? 'Provincie' : 'Province'}: + {displayData.genealogiewerkbalk.province.name} +
+ )} + {displayData.genealogiewerkbalk.provincial_archive && ( +
+ {language === 'nl' ? 'Provinciaal Archief' : 'Provincial Archive'}: + {displayData.genealogiewerkbalk.provincial_archive.website ? ( + + {displayData.genealogiewerkbalk.provincial_archive.name} + + ) : ( + {displayData.genealogiewerkbalk.provincial_archive.name} + )} +
+ )} +
+ )} + + {/* Provenance Section - Data Source Info */} + {displayData.provenance && ( +
+ +

📊 {language === 'nl' ? 'Gegevensbron' : 'Data Provenance'}

+
+
+ {displayData.provenance.data_source && ( +
+ {language === 'nl' ? 'Bron' : 'Source'}: + {displayData.provenance.data_source} +
+ )} + {displayData.provenance.data_tier && ( +
+ {language === 'nl' ? 'Kwaliteitsniveau' : 'Quality Tier'}: + + {displayData.provenance.data_tier.replace(/_/g, ' ')} + +
+ )} +
+
+ )} + + {/* External Registry Enrichments */} + {(displayData.nan_isil_enrichment || displayData.kb_enrichment || displayData.zcbs_enrichment) && ( +
+ +

📋 {language === 'nl' ? 'Externe Registers' : 'External Registries'}

+
+
+ {displayData.nan_isil_enrichment && ( +
+
NAN ISIL {language === 'nl' ? 'Register' : 'Registry'}
+
+ {Object.entries(displayData.nan_isil_enrichment) + .filter(([key]) => !key.startsWith('_')) + .slice(0, 5) + .map(([key, value]) => ( +
+ {key}: + {String(value)} +
+ ))} +
+
+ )} + {displayData.kb_enrichment && ( +
+
KB {language === 'nl' ? 'Bibliotheek' : 'Library'}
+
+ {Object.entries(displayData.kb_enrichment) + .filter(([key]) => !key.startsWith('_')) + .slice(0, 5) + .map(([key, value]) => ( +
+ {key}: + {String(value)} +
+ ))} +
+
+ )} + {displayData.zcbs_enrichment && ( +
+
ZCBS
+
+ {Object.entries(displayData.zcbs_enrichment) + .filter(([key]) => !key.startsWith('_')) + .slice(0, 5) + .map(([key, value]) => ( +
+ {key}: + {String(value)} +
+ ))} +
+
+ )} +
+
+ )} )} @@ -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} /> diff --git a/frontend/src/utils/countryNames.ts b/frontend/src/utils/countryNames.ts new file mode 100644 index 0000000000..3d2f033f49 --- /dev/null +++ b/frontend/src/utils/countryNames.ts @@ -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 = { + // 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; +} diff --git a/frontend/src/utils/imageProxy.ts b/frontend/src/utils/imageProxy.ts new file mode 100644 index 0000000000..961efc3e92 --- /dev/null +++ b/frontend/src/utils/imageProxy.ts @@ -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; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ef879d43ab..dba1bf0514 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -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'),