1883 lines
76 KiB
TypeScript
1883 lines
76 KiB
TypeScript
/**
|
||
* Institution Browser Page
|
||
* Browse and search the PostGIS database of heritage custodian institutions
|
||
*
|
||
* Features:
|
||
* - Search by name
|
||
* - Filter by type, province, country
|
||
* - View detailed institution information
|
||
* - Direct link to map view
|
||
* - Respects user's data backend preference from Settings
|
||
*/
|
||
|
||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||
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';
|
||
import { MediaGallery } from '../components/map/MediaGallery';
|
||
import { CareerTimeline, type CareerPosition } from '../components/visualizations/CareerTimeline';
|
||
import '../components/visualizations/CareerTimeline.css';
|
||
import { useProgressiveInstitutions } from '../hooks/useProgressiveInstitutions';
|
||
import { useDuckLakeInstitutions } from '../hooks/useDuckLakeInstitutions';
|
||
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
|
||
const TYPE_INFO: Record<string, { name: string; icon: string; color: string }> = {
|
||
'G': { name: 'Gallery', icon: '🖼️', color: '#00bcd4' },
|
||
'L': { name: 'Library', icon: '📚', color: '#2ecc71' },
|
||
'A': { name: 'Archive', icon: '📜', color: '#3498db' },
|
||
'M': { name: 'Museum', icon: '🏛️', color: '#e74c3c' },
|
||
'O': { name: 'Official', icon: '🏢', color: '#f39c12' },
|
||
'R': { name: 'Research', icon: '🔬', color: '#1abc9c' },
|
||
'C': { name: 'Corporation', icon: '🏭', color: '#795548' },
|
||
'U': { name: 'Unknown', icon: '❓', color: '#9e9e9e' },
|
||
'B': { name: 'Botanical/Zoo', icon: '🌿', color: '#4caf50' },
|
||
'E': { name: 'Education', icon: '🎓', color: '#ff9800' },
|
||
'S': { name: 'Society', icon: '👥', color: '#9b59b6' },
|
||
'F': { name: 'Features', icon: '🗿', color: '#95a5a6' },
|
||
'I': { name: 'Intangible', icon: '🎭', color: '#673ab7' },
|
||
'X': { name: 'Mixed', icon: '🔀', color: '#607d8b' },
|
||
'P': { name: 'Personal', icon: '👤', color: '#8bc34a' },
|
||
'H': { name: 'Holy sites', icon: '⛪', color: '#607d8b' },
|
||
'D': { name: 'Digital', icon: '💻', color: '#34495e' },
|
||
'N': { name: 'NGO', icon: '🤝', color: '#e91e63' },
|
||
'T': { name: 'Taste/smell', icon: '🍷', color: '#ff5722' },
|
||
};
|
||
|
||
// Text translations
|
||
const TEXT = {
|
||
pageTitle: { nl: 'Bronhouders en Beschermers', en: 'Heritage Custodians and Professionals' },
|
||
pageSubtitle: {
|
||
nl: 'Doorzoek en verken de database van erfgoedbewaarders en erfgoedprofessionals wereldwijd',
|
||
en: 'Browse and explore the database of heritage custodians and professionals worldwide',
|
||
},
|
||
search: { nl: 'Zoeken', en: 'Search' },
|
||
searchPlaceholder: { nl: 'Zoek op naam of GHCID (bijv. NL-NH-AMS)', en: 'Search by name or GHCID (e.g. NL-NH-AMS)' },
|
||
searchHint: { nl: 'Tip: Zoek op exacte GHCID (NL-NH-AMS-M-RM) of gedeeltelijke (NL-NH-AMS)', en: 'Tip: Search by exact GHCID (NL-NH-AMS-M-RM) or partial (NL-NH-AMS)' },
|
||
filterByType: { nl: 'Type', en: 'Type' },
|
||
filterByCountry: { nl: 'Land', en: 'Country' },
|
||
allTypes: { nl: 'Alle bronhouder types', en: 'All custodian types' },
|
||
allCountries: { nl: 'Alle landen', en: 'All countries' },
|
||
results: { nl: 'resultaten', en: 'results' },
|
||
loading: { nl: 'Laden...', en: 'Loading...' },
|
||
noResults: { nl: 'Geen resultaten gevonden', en: 'No results found' },
|
||
viewOnMap: { nl: 'Bekijk op kaart', en: 'View on map' },
|
||
details: { nl: 'Details', en: 'Details' },
|
||
website: { nl: 'Website', en: 'Website' },
|
||
rating: { nl: 'Beoordeling', en: 'Rating' },
|
||
wikidata: { nl: 'Wikidata', en: 'Wikidata' },
|
||
ghcid: { nl: 'GHCID', en: 'GHCID' },
|
||
close: { nl: 'Sluiten', en: 'Close' },
|
||
totalInstitutions: { nl: 'Totaal aantal instellingen', en: 'Total institutions' },
|
||
showingPage: { nl: 'Pagina', en: 'Page' },
|
||
of: { nl: 'van', en: 'of' },
|
||
previous: { nl: 'Vorige', en: 'Previous' },
|
||
next: { nl: 'Volgende', en: 'Next' },
|
||
first: { nl: 'Eerste', en: 'First' },
|
||
last: { nl: 'Laatste', en: 'Last' },
|
||
filterActive: { nl: 'Filters actief', en: 'Filters active' },
|
||
clearFilters: { nl: 'Filters wissen', en: 'Clear filters' },
|
||
// Entity type toggle
|
||
bronhouders: { nl: 'Bronhouders', en: 'Custodians' },
|
||
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' },
|
||
};
|
||
|
||
// Note: COUNTRY_NAMES and getFlagEmoji are now imported from ../utils/countryNames
|
||
|
||
const PAGE_SIZE = 50;
|
||
|
||
export function InstitutionBrowserPage() {
|
||
const { language } = useLanguage();
|
||
const navigate = useNavigate();
|
||
const t = (key: keyof typeof TEXT) => TEXT[key][language];
|
||
|
||
// Get user's data backend preference from UIState
|
||
const { state: uiState } = useUIState();
|
||
const dataSourceMode = uiState.dataBackend;
|
||
|
||
// State
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
const [selectedTypes, setSelectedTypes] = useState<string[]>([]);
|
||
const [selectedCountries, setSelectedCountries] = useState<string[]>([]);
|
||
const [currentPage, setCurrentPage] = useState(0);
|
||
const [selectedInstitution, setSelectedInstitution] = useState<Institution | null>(null);
|
||
const [networkInstitution, setNetworkInstitution] = useState<Institution | null>(null);
|
||
const [entityType, setEntityType] = useState<'bronhouders' | 'beschermers'>('bronhouders');
|
||
const [selectedPerson, setSelectedPerson] = useState<PersonSummary | null>(null);
|
||
const [showHeritageOnly, setShowHeritageOnly] = useState(true);
|
||
|
||
// URL query parameter sync
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
// Initialize state from URL on mount
|
||
useEffect(() => {
|
||
const typesParam = searchParams.get('types');
|
||
const countriesParam = searchParams.get('countries');
|
||
const searchParam = searchParams.get('search');
|
||
|
||
if (typesParam) setSelectedTypes(typesParam.split(','));
|
||
if (countriesParam) setSelectedCountries(countriesParam.split(','));
|
||
if (searchParam) setSearchQuery(searchParam);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []); // Only on mount
|
||
|
||
// Sync state changes back to URL
|
||
useEffect(() => {
|
||
const params = new URLSearchParams();
|
||
if (selectedTypes.length > 0) params.set('types', selectedTypes.join(','));
|
||
if (selectedCountries.length > 0) params.set('countries', selectedCountries.join(','));
|
||
if (searchQuery) params.set('search', searchQuery);
|
||
setSearchParams(params, { replace: true });
|
||
}, [selectedTypes, selectedCountries, searchQuery, setSearchParams]);
|
||
|
||
// ============================================================================
|
||
// Data Hooks - Called unconditionally (React rules), selection based on mode
|
||
// ============================================================================
|
||
|
||
// GeoAPI - server-side PostGIS (full data)
|
||
const geoApiData = useGeoApiInstitutions();
|
||
|
||
// GeoAPI Lite - lightweight endpoint for fast loading
|
||
const liteData = useLiteInstitutions();
|
||
|
||
// Progressive - cache-first with lite fallback, background full load
|
||
const progressiveData = useProgressiveInstitutions();
|
||
|
||
// DuckLake - client-side WASM database
|
||
const duckLakeData = useDuckLakeInstitutions();
|
||
|
||
// Search results (debounced) - always from GeoAPI
|
||
const { institutions: searchResults, isLoading: isSearching, error: searchError } = useInstitutionSearch(searchQuery);
|
||
|
||
// Persons data hooks
|
||
const { total: personsCount, heritageRelevant: _personsHeritageRelevant, isLoading: isLoadingPersonsCount } = usePersonsCount();
|
||
const { persons: personsList, total: personsTotalFiltered, isLoading: isLoadingPersons, error: personsError } = usePersons({
|
||
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,
|
||
});
|
||
const { persons: personSearchResults, isLoading: isSearchingPersons } = usePersonSearch(searchQuery);
|
||
|
||
// Helper to convert LiteInstitution to Institution (for compatibility)
|
||
const liteToInstitution = useCallback((lite: LiteInstitution): Institution => ({
|
||
lat: lite.lat,
|
||
lon: lite.lon,
|
||
name: lite.name,
|
||
city: lite.city,
|
||
province: '',
|
||
type: lite.type,
|
||
type_name: lite.type_name,
|
||
color: lite.color,
|
||
website: '',
|
||
wikidata_id: '',
|
||
description: '',
|
||
rating: lite.rating,
|
||
ghcid: { current: lite.ghcid, uuid: '', numeric: undefined },
|
||
org_name: lite.name,
|
||
}), []);
|
||
|
||
// ============================================================================
|
||
// Select data source based on user's dataBackend preference
|
||
// ============================================================================
|
||
const { institutions, isLoading, error, totalCount, progress } = useMemo(() => {
|
||
// Default progress for modes that don't have progress tracking
|
||
const defaultProgress = { percent: 100, phase: 'complete' as const, message: 'Ready' };
|
||
|
||
switch (dataSourceMode) {
|
||
case 'progressive': {
|
||
// Progressive loading: cache-first with lite fallback
|
||
const loadingProgress = {
|
||
percent: progressiveData.state.percent,
|
||
phase: progressiveData.state.phase as 'connecting' | 'downloading' | 'parsing' | 'complete',
|
||
message: progressiveData.state.message,
|
||
};
|
||
return {
|
||
institutions: progressiveData.institutions,
|
||
isLoading: !progressiveData.isReady,
|
||
error: progressiveData.error,
|
||
totalCount: progressiveData.totalCount,
|
||
progress: progressiveData.isReady ? defaultProgress : loadingProgress,
|
||
};
|
||
}
|
||
|
||
case 'geoapi-lite': {
|
||
// Lite mode: fast loading with minimal data
|
||
const converted = liteData.institutions.map(liteToInstitution);
|
||
return {
|
||
institutions: converted,
|
||
isLoading: liteData.isLoading,
|
||
error: liteData.error,
|
||
totalCount: liteData.totalCount,
|
||
progress: liteData.isLoading
|
||
? { percent: 50, phase: 'downloading' as const, message: 'Loading lite data...' }
|
||
: defaultProgress,
|
||
};
|
||
}
|
||
|
||
case 'geoapi': {
|
||
// Full GeoAPI: server-side PostGIS
|
||
return {
|
||
institutions: geoApiData.institutions,
|
||
isLoading: geoApiData.isLoading,
|
||
error: geoApiData.error,
|
||
totalCount: geoApiData.totalCount,
|
||
progress: geoApiData.progress,
|
||
};
|
||
}
|
||
|
||
case 'ducklake': {
|
||
// DuckLake: client-side WASM database
|
||
const duckProgress = {
|
||
percent: duckLakeData.progress?.percent || 0,
|
||
phase: (duckLakeData.progress?.phase || 'connecting') as 'connecting' | 'downloading' | 'parsing' | 'complete',
|
||
message: duckLakeData.progress?.message || 'Connecting...',
|
||
};
|
||
return {
|
||
institutions: duckLakeData.institutions,
|
||
isLoading: duckLakeData.isLoading,
|
||
error: duckLakeData.error,
|
||
totalCount: duckLakeData.totalCount,
|
||
progress: duckLakeData.isLoading ? duckProgress : defaultProgress,
|
||
};
|
||
}
|
||
|
||
case 'auto':
|
||
default: {
|
||
// Auto mode: try DuckLake first, then GeoAPI
|
||
if (duckLakeData.isConnected && duckLakeData.institutions.length > 0) {
|
||
return {
|
||
institutions: duckLakeData.institutions,
|
||
isLoading: false,
|
||
error: null,
|
||
totalCount: duckLakeData.totalCount,
|
||
progress: defaultProgress,
|
||
};
|
||
}
|
||
// Fall back to GeoAPI
|
||
return {
|
||
institutions: geoApiData.institutions,
|
||
isLoading: geoApiData.isLoading || duckLakeData.isLoading,
|
||
error: geoApiData.error,
|
||
totalCount: geoApiData.totalCount,
|
||
progress: geoApiData.progress,
|
||
};
|
||
}
|
||
}
|
||
}, [
|
||
dataSourceMode,
|
||
progressiveData.institutions, progressiveData.isReady, progressiveData.error, progressiveData.totalCount, progressiveData.state,
|
||
liteData.institutions, liteData.isLoading, liteData.error, liteData.totalCount,
|
||
geoApiData.institutions, geoApiData.isLoading, geoApiData.error, geoApiData.totalCount, geoApiData.progress,
|
||
duckLakeData.institutions, duckLakeData.isLoading, duckLakeData.error, duckLakeData.totalCount, duckLakeData.progress, duckLakeData.isConnected,
|
||
liteToInstitution,
|
||
]);
|
||
|
||
// Debug logging
|
||
useEffect(() => {
|
||
console.log('[InstitutionBrowserPage] State update:', {
|
||
dataSourceMode,
|
||
searchQuery,
|
||
searchQueryLength: searchQuery.length,
|
||
searchResultsCount: searchResults.length,
|
||
isSearching,
|
||
searchError,
|
||
institutionsCount: institutions.length,
|
||
willUseSearchResults: searchQuery.length >= 2
|
||
});
|
||
}, [dataSourceMode, searchQuery, searchResults, isSearching, searchError, institutions]);
|
||
|
||
// Determine which data to show
|
||
const displayData = searchQuery.length >= 2 ? searchResults : institutions;
|
||
|
||
console.log('[InstitutionBrowserPage] Display data:', {
|
||
using: searchQuery.length >= 2 ? 'searchResults' : 'institutions',
|
||
count: displayData.length
|
||
});
|
||
|
||
// Filter by type and country (supports multi-select)
|
||
const filteredData = displayData.filter((inst) => {
|
||
// 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)?.toUpperCase() || '';
|
||
if (!selectedCountries.includes(countryCode)) return false;
|
||
}
|
||
return true;
|
||
});
|
||
|
||
// Pagination
|
||
const totalPages = entityType === 'bronhouders'
|
||
? Math.ceil(filteredData.length / PAGE_SIZE)
|
||
: Math.ceil((searchQuery ? personSearchResults.length : personsTotalFiltered) / PAGE_SIZE);
|
||
const paginatedData = filteredData.slice(
|
||
currentPage * PAGE_SIZE,
|
||
(currentPage + 1) * PAGE_SIZE
|
||
);
|
||
|
||
// Reset page when filters change
|
||
useEffect(() => {
|
||
setCurrentPage(0);
|
||
}, [searchQuery, selectedTypes, selectedCountries, entityType]);
|
||
|
||
// Get unique countries from data (normalized to uppercase)
|
||
const uniqueCountries = [...new Set(
|
||
institutions
|
||
.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 || selectedTypes.length > 0 || selectedCountries.length > 0 || showHeritageOnly;
|
||
|
||
// Clear all filters
|
||
const clearFilters = useCallback(() => {
|
||
setSearchQuery('');
|
||
setSelectedTypes([]);
|
||
setSelectedCountries([]);
|
||
setShowHeritageOnly(false);
|
||
setCurrentPage(0);
|
||
}, []);
|
||
|
||
// Navigate to map with institution focused
|
||
// Use lat/lon/zoom for immediate positioning + ghcid for institution selection
|
||
const viewOnMap = useCallback((inst: Institution) => {
|
||
const ghcidParam = inst.ghcid?.uuid || inst.ghcid?.current || '';
|
||
navigate(`/map?lat=${inst.lat}&lon=${inst.lon}&zoom=15&ghcid=${encodeURIComponent(ghcidParam)}`);
|
||
}, [navigate]);
|
||
|
||
return (
|
||
<div className="institution-browser-page">
|
||
{/* Show LoadingScreen while initial data is loading */}
|
||
{isLoading && progress.phase !== 'complete' && (
|
||
<LoadingScreen
|
||
progress={progress.percent}
|
||
message={progress.message}
|
||
/>
|
||
)}
|
||
|
||
{/* Header */}
|
||
<header className="browser-header">
|
||
<div className="header-content">
|
||
<h1>{t('pageTitle')}</h1>
|
||
<p>{t('pageSubtitle')}</p>
|
||
</div>
|
||
<div className="header-stats">
|
||
<button
|
||
className={`stat-badge ${entityType === 'bronhouders' ? 'active' : ''}`}
|
||
onClick={() => { setEntityType('bronhouders'); setCurrentPage(0); }}
|
||
aria-pressed={entityType === 'bronhouders'}
|
||
>
|
||
<span className="stat-icon">🏛️</span>
|
||
<span className="stat-value">{totalCount.toLocaleString()}</span>
|
||
<span className="stat-label">{t('totalBronhouders')}</span>
|
||
</button>
|
||
<button
|
||
className={`stat-badge ${entityType === 'beschermers' ? 'active' : ''}`}
|
||
onClick={() => { setEntityType('beschermers'); setCurrentPage(0); }}
|
||
aria-pressed={entityType === 'beschermers'}
|
||
>
|
||
<span className="stat-icon">👤</span>
|
||
<span className="stat-value">{isLoadingPersonsCount ? '...' : personsCount.toLocaleString()}</span>
|
||
<span className="stat-label">{t('totalBeschermers')}</span>
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* Search and Filters */}
|
||
<div className="search-filters-bar">
|
||
<div className="search-box">
|
||
<input
|
||
type="text"
|
||
placeholder={t('searchPlaceholder')}
|
||
value={searchQuery}
|
||
onChange={(e) => setSearchQuery(e.target.value)}
|
||
className="search-input"
|
||
/>
|
||
{searchQuery && (
|
||
<button
|
||
className="clear-search-btn"
|
||
onClick={() => setSearchQuery('')}
|
||
aria-label="Clear search"
|
||
>
|
||
×
|
||
</button>
|
||
)}
|
||
{entityType === 'bronhouders' && (
|
||
<span className="search-hint">{t('searchHint')}</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="filter-group">
|
||
<SearchableMultiSelect
|
||
options={typeOptions}
|
||
selectedValues={selectedTypes}
|
||
onChange={setSelectedTypes}
|
||
placeholder={t('allTypes')}
|
||
searchPlaceholder={t('searchTypes')}
|
||
className="filter-multi-select"
|
||
/>
|
||
|
||
<SearchableMultiSelect
|
||
options={countryOptions}
|
||
selectedValues={selectedCountries}
|
||
onChange={setSelectedCountries}
|
||
placeholder={t('allCountries')}
|
||
searchPlaceholder={t('searchCountries')}
|
||
className="filter-multi-select"
|
||
/>
|
||
|
||
{/* Heritage-relevant toggle - only show for Beschermers tab */}
|
||
{entityType === 'beschermers' && (
|
||
<label className="heritage-toggle">
|
||
<input
|
||
type="checkbox"
|
||
checked={showHeritageOnly}
|
||
onChange={(e) => {
|
||
setShowHeritageOnly(e.target.checked);
|
||
setCurrentPage(0);
|
||
}}
|
||
/>
|
||
<span className="toggle-label">
|
||
{language === 'nl' ? 'Alleen erfgoed-relevant' : 'Heritage-relevant only'}
|
||
</span>
|
||
</label>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Filter Chips */}
|
||
{(selectedTypes.length > 0 || selectedCountries.length > 0) && (
|
||
<div className="filter-chips">
|
||
{selectedTypes.map(type => {
|
||
const info = TYPE_INFO[type];
|
||
return (
|
||
<span key={type} className="filter-chip">
|
||
<span className="chip-icon">{info?.icon}</span>
|
||
<span className="chip-label">{info?.name || type}</span>
|
||
<button
|
||
className="chip-remove"
|
||
onClick={() => setSelectedTypes(prev => prev.filter(t => t !== type))}
|
||
aria-label={`Remove ${info?.name || type} filter`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
{selectedCountries.map(code => (
|
||
<span key={code} className="filter-chip">
|
||
<span className="chip-icon">{getFlagEmoji(code)}</span>
|
||
<span className="chip-label">{getCountryName(code, language)}</span>
|
||
<button
|
||
className="chip-remove"
|
||
onClick={() => setSelectedCountries(prev => prev.filter(c => c !== code))}
|
||
aria-label={`Remove ${getCountryName(code, language)} filter`}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Results info */}
|
||
<div className="results-info">
|
||
{entityType === 'bronhouders' ? (
|
||
// Institutions results info
|
||
isLoading || isSearching ? (
|
||
<span className="loading-text">
|
||
{progress.phase !== 'complete'
|
||
? `${progress.message} (${progress.percent}%)`
|
||
: t('loading')}
|
||
</span>
|
||
) : (
|
||
<span>
|
||
{filteredData.length.toLocaleString()} {t('results')}
|
||
{filtersActive && (
|
||
<>
|
||
<span className="filter-badge"> ({t('filterActive')})</span>
|
||
<button className="clear-filters-btn-inline" onClick={clearFilters}>
|
||
{t('clearFilters')} ×
|
||
</button>
|
||
</>
|
||
)}
|
||
</span>
|
||
)
|
||
) : (
|
||
// Persons results info
|
||
isLoadingPersons || isSearchingPersons ? (
|
||
<span className="loading-text">{t('loading')}</span>
|
||
) : (
|
||
<span>
|
||
{searchQuery
|
||
? personSearchResults.length.toLocaleString()
|
||
: personsTotalFiltered.toLocaleString()
|
||
} {t('results')}
|
||
{filtersActive && (
|
||
<>
|
||
<span className="filter-badge"> ({t('filterActive')})</span>
|
||
<button className="clear-filters-btn-inline" onClick={clearFilters}>
|
||
{t('clearFilters')} ×
|
||
</button>
|
||
</>
|
||
)}
|
||
</span>
|
||
)
|
||
)}
|
||
</div>
|
||
|
||
{/* Results Grid */}
|
||
<div className="results-grid">
|
||
{entityType === 'bronhouders' ? (
|
||
// Institutions grid
|
||
<>
|
||
{error ? (
|
||
<div className="error-message">
|
||
Error: {error.message}
|
||
</div>
|
||
) : paginatedData.length === 0 && !isLoading ? (
|
||
<div className="no-results">
|
||
{t('noResults')}
|
||
</div>
|
||
) : (
|
||
paginatedData.map((inst, idx) => (
|
||
<InstitutionCard
|
||
key={`${inst.ghcid?.current || idx}-${idx}`}
|
||
institution={inst}
|
||
language={language}
|
||
onViewDetails={() => setSelectedInstitution(inst)}
|
||
onViewMap={() => viewOnMap(inst)}
|
||
onViewNetwork={() => setNetworkInstitution(inst)}
|
||
/>
|
||
))
|
||
)}
|
||
</>
|
||
) : (
|
||
// Persons grid
|
||
<>
|
||
{personsError ? (
|
||
<div className="error-message">
|
||
Error: {personsError.message}
|
||
</div>
|
||
) : (searchQuery ? personSearchResults : personsList).length === 0 && !isLoadingPersons && !isSearchingPersons ? (
|
||
<div className="no-results">
|
||
{t('noResults')}
|
||
</div>
|
||
) : (
|
||
(searchQuery ? personSearchResults : personsList).map((person, idx) => (
|
||
<PersonCard
|
||
key={`${person.staff_id}-${idx}`}
|
||
person={person}
|
||
language={language}
|
||
onViewDetails={() => setSelectedPerson(person)}
|
||
/>
|
||
))
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Pagination */}
|
||
{totalPages > 1 && (
|
||
<div className="pagination">
|
||
<button
|
||
onClick={() => setCurrentPage(0)}
|
||
disabled={currentPage === 0}
|
||
className="page-btn"
|
||
>
|
||
{t('first')}
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.max(0, p - 1))}
|
||
disabled={currentPage === 0}
|
||
className="page-btn"
|
||
>
|
||
{t('previous')}
|
||
</button>
|
||
<span className="page-info">
|
||
{t('showingPage')} {currentPage + 1} {t('of')} {totalPages}
|
||
</span>
|
||
<button
|
||
onClick={() => setCurrentPage(p => Math.min(totalPages - 1, p + 1))}
|
||
disabled={currentPage >= totalPages - 1}
|
||
className="page-btn"
|
||
>
|
||
{t('next')}
|
||
</button>
|
||
<button
|
||
onClick={() => setCurrentPage(totalPages - 1)}
|
||
disabled={currentPage >= totalPages - 1}
|
||
className="page-btn"
|
||
>
|
||
{t('last')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{/* Detail Modal */}
|
||
{selectedInstitution && (
|
||
<InstitutionDetailModal
|
||
institution={selectedInstitution}
|
||
language={language}
|
||
onClose={() => setSelectedInstitution(null)}
|
||
onViewMap={() => {
|
||
viewOnMap(selectedInstitution);
|
||
setSelectedInstitution(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Social Network Modal */}
|
||
{networkInstitution && (
|
||
<SocialNetworkModal
|
||
institutionName={networkInstitution.name}
|
||
onClose={() => setNetworkInstitution(null)}
|
||
/>
|
||
)}
|
||
|
||
{/* Person Detail Modal */}
|
||
{selectedPerson && (
|
||
<PersonDetailModal
|
||
person={selectedPerson}
|
||
language={language}
|
||
onClose={() => setSelectedPerson(null)}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Institution Card Component
|
||
*/
|
||
function InstitutionCard({
|
||
institution,
|
||
language,
|
||
onViewDetails,
|
||
onViewMap,
|
||
onViewNetwork,
|
||
}: {
|
||
institution: Institution;
|
||
language: 'nl' | 'en';
|
||
onViewDetails: () => void;
|
||
onViewMap: () => void;
|
||
onViewNetwork?: () => void;
|
||
}) {
|
||
const typeInfo = TYPE_INFO[institution.type] || TYPE_INFO['U'];
|
||
const countryCode = institution.ghcid?.current?.substring(0, 2) || '';
|
||
const hasNetwork = hasStaffNetworkData(getCustodianSlug(institution.name));
|
||
const [logoError, setLogoError] = useState(false);
|
||
|
||
return (
|
||
<div className="institution-card" style={{ '--type-color': typeInfo.color } as React.CSSProperties}>
|
||
<div className="card-header">
|
||
{/* Logo or type icon */}
|
||
{institution.logo_url && !logoError ? (
|
||
<img
|
||
src={proxyImageUrl(institution.logo_url)}
|
||
alt=""
|
||
className="card-logo"
|
||
onError={() => setLogoError(true)}
|
||
/>
|
||
) : (
|
||
<span className="type-icon" title={typeInfo.name}>{typeInfo.icon}</span>
|
||
)}
|
||
<span className="type-badge" style={{ backgroundColor: typeInfo.color }}>
|
||
{typeInfo.name}
|
||
</span>
|
||
{countryCode && (
|
||
<span className="country-badge" title={COUNTRY_NAMES[countryCode]?.[language] || countryCode}>
|
||
{getFlagEmoji(countryCode)}
|
||
</span>
|
||
)}
|
||
{/* Search match type indicator */}
|
||
{institution.match_type && (
|
||
<span
|
||
className={`match-type-badge match-type-${institution.match_type}`}
|
||
title={
|
||
institution.match_type === 'exact_ghcid'
|
||
? (language === 'nl' ? 'Exacte GHCID match' : 'Exact GHCID match')
|
||
: institution.match_type === 'partial_ghcid'
|
||
? (language === 'nl' ? 'Gedeeltelijke GHCID match' : 'Partial GHCID match')
|
||
: (language === 'nl' ? 'Tekst zoekresultaat' : 'Text search result')
|
||
}
|
||
>
|
||
{institution.match_type === 'exact_ghcid' ? '🎯' : institution.match_type === 'partial_ghcid' ? '📍' : '🔎'}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<h3 className="card-title">{institution.name}</h3>
|
||
<div className="card-meta">
|
||
{institution.city && <span className="city">{institution.city}</span>}
|
||
{institution.rating && (
|
||
<span className="rating">
|
||
⭐ {institution.rating.toFixed(1)}
|
||
{institution.total_ratings && ` (${institution.total_ratings})`}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="card-actions">
|
||
<button className="action-btn details" onClick={onViewDetails}>
|
||
{language === 'nl' ? 'Details' : 'Details'}
|
||
</button>
|
||
<button className="action-btn map" onClick={onViewMap}>
|
||
🗺️ {language === 'nl' ? 'Kaart' : 'Map'}
|
||
</button>
|
||
{hasNetwork && onViewNetwork && (
|
||
<button
|
||
className="action-btn network"
|
||
onClick={onViewNetwork}
|
||
title={language === 'nl' ? 'Bekijk personeelsnetwerk' : 'View staff network'}
|
||
>
|
||
👥
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Person Card Component for Beschermers view
|
||
* Displays heritage professional/staff information
|
||
*/
|
||
function PersonCard({
|
||
person,
|
||
language,
|
||
onViewDetails,
|
||
}: {
|
||
person: PersonSummary;
|
||
language: 'nl' | 'en';
|
||
onViewDetails: () => void;
|
||
}) {
|
||
const [imageError, setImageError] = useState(false);
|
||
const countryCode = person.country_code || '';
|
||
|
||
// Get heritage type badge color (if person has heritage_types)
|
||
const primaryType = person.heritage_types?.[0] || null;
|
||
const typeInfo = primaryType ? TYPE_INFO[primaryType] : null;
|
||
|
||
return (
|
||
<div className="person-card">
|
||
<div className="card-header person-header">
|
||
{/* Profile image or placeholder */}
|
||
<div className="person-avatar-container">
|
||
{person.profile_image_url && !imageError ? (
|
||
<img
|
||
src={person.profile_image_url}
|
||
alt=""
|
||
className="person-avatar"
|
||
onError={() => setImageError(true)}
|
||
/>
|
||
) : (
|
||
<span className="person-avatar-placeholder">👤</span>
|
||
)}
|
||
</div>
|
||
<div className="person-badges">
|
||
{typeInfo && (
|
||
<span className="type-badge" style={{ backgroundColor: typeInfo.color }}>
|
||
{typeInfo.icon} {typeInfo.name}
|
||
</span>
|
||
)}
|
||
{countryCode && (
|
||
<span className="country-badge" title={COUNTRY_NAMES[countryCode]?.[language] || countryCode}>
|
||
{getFlagEmoji(countryCode)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<h3 className="card-title">{person.name}</h3>
|
||
{person.headline && (
|
||
<p className="person-headline">{person.headline}</p>
|
||
)}
|
||
<div className="card-meta">
|
||
{person.location && <span className="meta-item location">📍 {person.location}</span>}
|
||
{person.custodian_name && (
|
||
<span className="meta-item custodian">🏛️ {person.custodian_name}</span>
|
||
)}
|
||
</div>
|
||
<div className="card-actions">
|
||
<button className="action-btn details" onClick={onViewDetails}>
|
||
{language === 'nl' ? 'Details' : 'Details'}
|
||
</button>
|
||
{person.linkedin_url && (
|
||
<a
|
||
href={person.linkedin_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="action-btn linkedin"
|
||
>
|
||
💼 LinkedIn
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Social Media Links Component
|
||
* Displays icons linking to various social media platforms
|
||
*/
|
||
const SOCIAL_MEDIA_CONFIG: Record<string, { icon: string; label: string; color: string }> = {
|
||
facebook: { icon: '📘', label: 'Facebook', color: '#1877f2' },
|
||
twitter: { icon: '🐦', label: 'X/Twitter', color: '#1da1f2' },
|
||
instagram: { icon: '📷', label: 'Instagram', color: '#e4405f' },
|
||
linkedin: { icon: '💼', label: 'LinkedIn', color: '#0077b5' },
|
||
youtube: { icon: '▶️', label: 'YouTube', color: '#ff0000' },
|
||
tiktok: { icon: '🎵', label: 'TikTok', color: '#000000' },
|
||
pinterest: { icon: '📌', label: 'Pinterest', color: '#bd081c' },
|
||
flickr: { icon: '📸', label: 'Flickr', color: '#0063dc' },
|
||
vimeo: { icon: '🎬', label: 'Vimeo', color: '#1ab7ea' },
|
||
};
|
||
|
||
function SocialMediaLinks({
|
||
socialMedia,
|
||
language
|
||
}: {
|
||
socialMedia: {
|
||
facebook?: string;
|
||
twitter?: string;
|
||
instagram?: string;
|
||
linkedin?: string;
|
||
youtube?: string;
|
||
tiktok?: string;
|
||
};
|
||
language: 'nl' | 'en';
|
||
}) {
|
||
// Collect all valid links
|
||
const links: { platform: string; url: string }[] = [];
|
||
|
||
if (socialMedia.facebook) links.push({ platform: 'facebook', url: socialMedia.facebook });
|
||
if (socialMedia.twitter) links.push({ platform: 'twitter', url: socialMedia.twitter });
|
||
if (socialMedia.instagram) links.push({ platform: 'instagram', url: socialMedia.instagram });
|
||
if (socialMedia.linkedin) links.push({ platform: 'linkedin', url: socialMedia.linkedin });
|
||
if (socialMedia.youtube) links.push({ platform: 'youtube', url: socialMedia.youtube });
|
||
if (socialMedia.tiktok) links.push({ platform: 'tiktok', url: socialMedia.tiktok });
|
||
|
||
if (links.length === 0) return null;
|
||
|
||
return (
|
||
<div className="detail-section social-media-section">
|
||
<h4>🔗 {language === 'nl' ? 'Sociale Media' : 'Social Media'}</h4>
|
||
<div className="social-media-links">
|
||
{links.map(({ platform, url }) => {
|
||
const config = SOCIAL_MEDIA_CONFIG[platform] || {
|
||
icon: '🔗',
|
||
label: platform,
|
||
color: '#666',
|
||
};
|
||
return (
|
||
<a
|
||
key={platform}
|
||
href={url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="social-media-link"
|
||
title={config.label}
|
||
style={{ '--social-color': config.color } as React.CSSProperties}
|
||
>
|
||
<span className="social-icon">{config.icon}</span>
|
||
<span className="social-label">{config.label}</span>
|
||
</a>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Opening Hours Section Component
|
||
* Displays opening hours in a collapsible format
|
||
* Accepts either string[] (normalized) or raw JSON string from API
|
||
*/
|
||
function OpeningHoursSection({
|
||
openingHours,
|
||
language
|
||
}: {
|
||
openingHours: string[];
|
||
language: 'nl' | 'en';
|
||
}) {
|
||
const [isExpanded, setIsExpanded] = useState(false);
|
||
|
||
if (!openingHours || openingHours.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
// Determine today's index - weekday_text starts with Monday (index 0)
|
||
const today = new Date().getDay();
|
||
// JS: Sunday=0, Monday=1, ... Saturday=6
|
||
// Google weekday_text: Monday=0, Tuesday=1, ... Sunday=6
|
||
const todayIndex = today === 0 ? 6 : today - 1;
|
||
|
||
return (
|
||
<div className="detail-section opening-hours-section">
|
||
<h4
|
||
className="opening-hours-header clickable"
|
||
onClick={() => setIsExpanded(!isExpanded)}
|
||
>
|
||
🕐 {language === 'nl' ? 'Openingstijden' : 'Opening Hours'}
|
||
<span className="expand-icon">{isExpanded ? '▼' : '▶'}</span>
|
||
</h4>
|
||
{isExpanded && (
|
||
<div className="opening-hours-list">
|
||
{openingHours.map((dayText, idx) => {
|
||
const isToday = idx === todayIndex;
|
||
|
||
return (
|
||
<div
|
||
key={idx}
|
||
className={`opening-hours-day ${isToday ? 'today' : ''}`}
|
||
>
|
||
{dayText}
|
||
{isToday && <span className="today-badge">{language === 'nl' ? 'vandaag' : 'today'}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Institution Detail Modal
|
||
* Enhanced with media gallery, YouTube videos, and reviews from full institution data
|
||
*/
|
||
function InstitutionDetailModal({
|
||
institution,
|
||
language,
|
||
onClose,
|
||
onViewMap,
|
||
}: {
|
||
institution: Institution;
|
||
language: 'nl' | 'en';
|
||
onClose: () => void;
|
||
onViewMap: () => void;
|
||
}) {
|
||
const t = (key: keyof typeof TEXT) => TEXT[key][language];
|
||
const tMedia = (nl: string, en: string) => language === 'nl' ? nl : en;
|
||
const typeInfo = TYPE_INFO[institution.type] || TYPE_INFO['U'];
|
||
const [logoError, setLogoError] = useState(false);
|
||
const [activeTab, setActiveTab] = useState<'info' | 'youtube'>('info');
|
||
const [showReviews, setShowReviews] = useState(false);
|
||
|
||
// Fetch full institution data when modal opens (for photos, youtube, reviews)
|
||
const ghcid = institution.ghcid?.current || null;
|
||
const { institution: fullData, isLoading: isLoadingDetail } = useInstitutionDetail(ghcid);
|
||
|
||
// Use full data if available, otherwise fall back to lite data
|
||
const displayData = fullData || institution;
|
||
const hasYouTube = displayData.youtube && displayData.youtube.videos && displayData.youtube.videos.length > 0;
|
||
const hasPhotos = (displayData.photos && displayData.photos.length > 0) || displayData.logo_url || displayData.wikidata_image_url;
|
||
const hasMedia = hasPhotos || hasYouTube;
|
||
const hasReviews = displayData.reviews && displayData.reviews.length > 0;
|
||
|
||
// Close on escape key
|
||
useEffect(() => {
|
||
const handleEscape = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', handleEscape);
|
||
return () => window.removeEventListener('keydown', handleEscape);
|
||
}, [onClose]);
|
||
|
||
// Render star rating
|
||
const renderStars = (rating: number) => {
|
||
const fullStars = Math.floor(rating);
|
||
const hasHalf = rating % 1 >= 0.5;
|
||
let stars = '★'.repeat(fullStars);
|
||
if (hasHalf) stars += '½';
|
||
return stars;
|
||
};
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<div className="modal-content modal-content--with-media" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close" onClick={onClose}>×</button>
|
||
|
||
<div className="modal-header">
|
||
{/* Logo or type icon */}
|
||
{institution.logo_url && !logoError ? (
|
||
<img
|
||
src={proxyImageUrl(institution.logo_url)}
|
||
alt=""
|
||
className="modal-logo"
|
||
onError={() => setLogoError(true)}
|
||
/>
|
||
) : (
|
||
<span className="modal-type-icon">{typeInfo.icon}</span>
|
||
)}
|
||
<div>
|
||
<h2>{institution.name}</h2>
|
||
<span className="modal-type-badge" style={{ backgroundColor: typeInfo.color }}>
|
||
{typeInfo.name}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabs - show only if YouTube data exists */}
|
||
{hasYouTube && (
|
||
<div className="modal-tabs">
|
||
<button
|
||
className={`modal-tab ${activeTab === 'info' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('info')}
|
||
>
|
||
ℹ️ Info
|
||
</button>
|
||
<button
|
||
className={`modal-tab ${activeTab === 'youtube' ? 'active' : ''}`}
|
||
onClick={() => setActiveTab('youtube')}
|
||
>
|
||
▶️ YouTube ({displayData.youtube?.videos?.length || 0})
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
<div className="modal-body">
|
||
{/* Loading indicator for full data */}
|
||
{isLoadingDetail && (
|
||
<div className="modal-loading">
|
||
<span className="loading-spinner">⏳</span>
|
||
{language === 'nl' ? 'Media laden...' : 'Loading media...'}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'info' && (
|
||
<>
|
||
{/* Media Gallery - Photos */}
|
||
{hasMedia && !isLoadingDetail && (
|
||
<div className="modal-media-section">
|
||
<MediaGallery
|
||
photos={displayData.photos}
|
||
youtube={activeTab === 'info' ? undefined : displayData.youtube}
|
||
wikidataImageUrl={displayData.wikidata_image_url}
|
||
logoUrl={displayData.logo_url}
|
||
institutionName={displayData.name}
|
||
t={tMedia}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Location */}
|
||
<div className="detail-section">
|
||
<h4>📍 {language === 'nl' ? 'Locatie' : 'Location'}</h4>
|
||
<p>
|
||
{[displayData.address, displayData.city, displayData.province]
|
||
.filter(Boolean)
|
||
.join(', ') || '-'}
|
||
</p>
|
||
<p className="coordinates">
|
||
{institution.lat?.toFixed(6)}, {institution.lon?.toFixed(6)}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
{displayData.description && (
|
||
<div className="detail-section">
|
||
<h4>📝 {language === 'nl' ? 'Beschrijving' : 'Description'}</h4>
|
||
<p className="description-text">{displayData.description}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rating */}
|
||
{displayData.rating && (
|
||
<div className="detail-section">
|
||
<h4>⭐ {t('rating')}</h4>
|
||
<p className="rating-display">
|
||
<span className="stars">{renderStars(displayData.rating)}</span>
|
||
<span className="rating-value">{displayData.rating.toFixed(1)} / 5.0</span>
|
||
{displayData.total_ratings && (
|
||
<span className="rating-count">
|
||
({displayData.total_ratings.toLocaleString()} {language === 'nl' ? 'beoordelingen' : 'reviews'})
|
||
</span>
|
||
)}
|
||
</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Reviews Section - Collapsible */}
|
||
{hasReviews && (
|
||
<div className="detail-section reviews-section">
|
||
<h4
|
||
className="reviews-header clickable"
|
||
onClick={() => setShowReviews(!showReviews)}
|
||
>
|
||
💬 {language === 'nl' ? 'Beoordelingen' : 'Reviews'} ({displayData.reviews?.length})
|
||
<span className="expand-icon">{showReviews ? '▼' : '▶'}</span>
|
||
</h4>
|
||
{showReviews && (
|
||
<div className="reviews-list">
|
||
{displayData.reviews?.slice(0, 5).map((review, idx) => (
|
||
<div key={idx} className="review-item">
|
||
<div className="review-header">
|
||
<span className="review-author">{review.author}</span>
|
||
<span className="review-rating">{'★'.repeat(review.rating)}</span>
|
||
</div>
|
||
<p className="review-text">{review.text}</p>
|
||
{review.time && (
|
||
<span className="review-time">{review.time}</span>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Website */}
|
||
{displayData.website && (
|
||
<div className="detail-section">
|
||
<h4>🌐 {t('website')}</h4>
|
||
<a href={displayData.website} target="_blank" rel="noopener noreferrer">
|
||
{displayData.website}
|
||
</a>
|
||
</div>
|
||
)}
|
||
|
||
{/* Phone */}
|
||
{displayData.phone && (
|
||
<div className="detail-section">
|
||
<h4>📞 {language === 'nl' ? 'Telefoon' : 'Phone'}</h4>
|
||
<a href={`tel:${displayData.phone}`}>{displayData.phone}</a>
|
||
</div>
|
||
)}
|
||
|
||
{/* Social Media Links */}
|
||
{displayData.social_media && Object.keys(displayData.social_media).length > 0 && (
|
||
<SocialMediaLinks
|
||
socialMedia={displayData.social_media}
|
||
language={language}
|
||
/>
|
||
)}
|
||
|
||
{/* Opening Hours */}
|
||
{displayData.opening_hours && (
|
||
<OpeningHoursSection
|
||
openingHours={displayData.opening_hours}
|
||
language={language}
|
||
/>
|
||
)}
|
||
|
||
{/* Identifiers */}
|
||
<div className="detail-section identifiers">
|
||
<h4>🔖 {language === 'nl' ? 'Identificatoren' : 'Identifiers'}</h4>
|
||
{institution.ghcid?.current && (
|
||
<div className="identifier">
|
||
<span className="id-label">{t('ghcid')}:</span>
|
||
<code>{institution.ghcid.current}</code>
|
||
</div>
|
||
)}
|
||
{/* Enhanced GHCID with UUID and Numeric */}
|
||
{displayData.ghcid?.uuid && (
|
||
<div className="identifier">
|
||
<span className="id-label">GHCID UUID:</span>
|
||
<code className="uuid-code">{displayData.ghcid.uuid}</code>
|
||
</div>
|
||
)}
|
||
{displayData.ghcid?.numeric && (
|
||
<div className="identifier">
|
||
<span className="id-label">GHCID Numeric:</span>
|
||
<code>{displayData.ghcid.numeric}</code>
|
||
</div>
|
||
)}
|
||
{displayData.wikidata_id && (
|
||
<div className="identifier">
|
||
<span className="id-label">{t('wikidata')}:</span>
|
||
<a
|
||
href={`https://www.wikidata.org/wiki/${displayData.wikidata_id}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{displayData.wikidata_id}
|
||
</a>
|
||
</div>
|
||
)}
|
||
{displayData.isil?.code && (
|
||
<div className="identifier">
|
||
<span className="id-label">ISIL:</span>
|
||
<code>{displayData.isil.code}</code>
|
||
</div>
|
||
)}
|
||
{/* Google Place ID */}
|
||
{displayData.google_place_id && (
|
||
<div className="identifier">
|
||
<span className="id-label">Google Place:</span>
|
||
<a
|
||
href={`https://www.google.com/maps/place/?q=place_id:${displayData.google_place_id}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{displayData.google_place_id}
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Wikidata Enrichment Section */}
|
||
{displayData.wikidata && (
|
||
<div className="detail-section wikidata-enrichment">
|
||
<h4>📚 {language === 'nl' ? 'Wikidata Informatie' : 'Wikidata Information'}</h4>
|
||
{/* Multilingual labels */}
|
||
{(displayData.wikidata.label_nl || displayData.wikidata.label_en) && (
|
||
<div className="wikidata-labels">
|
||
{displayData.wikidata.label_nl && (
|
||
<div className="wikidata-field">
|
||
<span className="field-label">🇳🇱 Label:</span>
|
||
<span>{displayData.wikidata.label_nl}</span>
|
||
</div>
|
||
)}
|
||
{displayData.wikidata.label_en && displayData.wikidata.label_en !== displayData.wikidata.label_nl && (
|
||
<div className="wikidata-field">
|
||
<span className="field-label">🇬🇧 Label:</span>
|
||
<span>{displayData.wikidata.label_en}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
{/* Descriptions */}
|
||
{(displayData.wikidata.description_nl || displayData.wikidata.description_en) && (
|
||
<div className="wikidata-descriptions">
|
||
<div className="wikidata-field">
|
||
<span className="field-label">{language === 'nl' ? 'Beschrijving' : 'Description'}:</span>
|
||
<span>{language === 'nl' ? (displayData.wikidata.description_nl || displayData.wikidata.description_en) : (displayData.wikidata.description_en || displayData.wikidata.description_nl)}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Instance types */}
|
||
{displayData.wikidata.types && displayData.wikidata.types.length > 0 && (
|
||
<div className="wikidata-field">
|
||
<span className="field-label">{language === 'nl' ? 'Type' : 'Type'}:</span>
|
||
<span className="wikidata-types">
|
||
{displayData.wikidata.types.join(', ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* Inception date */}
|
||
{displayData.wikidata.inception && (
|
||
<div className="wikidata-field">
|
||
<span className="field-label">{language === 'nl' ? 'Opgericht' : 'Founded'}:</span>
|
||
<span>{displayData.wikidata.inception}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* CIDOC-CRM TimeSpan Section - Fuzzy Temporal Data */}
|
||
{displayData.timespan && (displayData.timespan.begin_of_the_begin || displayData.timespan.begin_of_the_end) && (
|
||
<div className="detail-section timespan-section">
|
||
<h4>📅 {language === 'nl' ? 'Temporele Gegevens' : 'Temporal Data'}</h4>
|
||
<div className="timespan-content">
|
||
{/* Founding date(s) */}
|
||
{displayData.timespan.begin_of_the_begin && (
|
||
<div className="timespan-field">
|
||
<span className="field-label">{language === 'nl' ? 'Opgericht' : 'Founded'}:</span>
|
||
<span>
|
||
{/* Check if we have fuzzy dates (begin != end of begin) */}
|
||
{displayData.timespan.end_of_the_begin &&
|
||
displayData.timespan.begin_of_the_begin !== displayData.timespan.end_of_the_begin
|
||
? `${language === 'nl' ? 'tussen' : 'between'} ${new Date(displayData.timespan.begin_of_the_begin).getFullYear()} ${language === 'nl' ? 'en' : 'and'} ${new Date(displayData.timespan.end_of_the_begin).getFullYear()}`
|
||
: new Date(displayData.timespan.begin_of_the_begin).toLocaleDateString(language === 'nl' ? 'nl-NL' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||
}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* Dissolution date(s) */}
|
||
{displayData.timespan.begin_of_the_end && (
|
||
<div className="timespan-field timespan-dissolution">
|
||
<span className="field-label">{language === 'nl' ? 'Opgeheven' : 'Dissolved'}:</span>
|
||
<span>
|
||
{/* Check if we have fuzzy dates (begin != end of end) */}
|
||
{displayData.timespan.end_of_the_end &&
|
||
displayData.timespan.begin_of_the_end !== displayData.timespan.end_of_the_end
|
||
? `${language === 'nl' ? 'tussen' : 'between'} ${new Date(displayData.timespan.begin_of_the_end).getFullYear()} ${language === 'nl' ? 'en' : 'and'} ${new Date(displayData.timespan.end_of_the_end).getFullYear()}`
|
||
: new Date(displayData.timespan.begin_of_the_end).toLocaleDateString(language === 'nl' ? 'nl-NL' : 'en-US', { year: 'numeric', month: 'long', day: 'numeric' })
|
||
}
|
||
</span>
|
||
</div>
|
||
)}
|
||
{/* Notes (e.g., destruction context) */}
|
||
{displayData.timespan.notes && (
|
||
<div className="timespan-field timespan-notes">
|
||
<span className="field-label">{language === 'nl' ? 'Opmerking' : 'Note'}:</span>
|
||
<span className="timespan-note-text">{displayData.timespan.notes}</span>
|
||
</div>
|
||
)}
|
||
{/* Sources */}
|
||
{displayData.timespan.sources && displayData.timespan.sources.length > 0 && (
|
||
<div className="timespan-field timespan-sources">
|
||
<span className="field-label">{language === 'nl' ? 'Bronnen' : 'Sources'}:</span>
|
||
<span className="timespan-sources-list">
|
||
{displayData.timespan.sources.map((source, idx) => (
|
||
<span key={idx} className="timespan-source">
|
||
{source.startsWith('Wikidata:') ? (
|
||
<a
|
||
href={`https://www.wikidata.org/wiki/${source.replace('Wikidata: ', '')}`}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
{source}
|
||
</a>
|
||
) : (
|
||
source
|
||
)}
|
||
{idx < displayData.timespan!.sources!.length - 1 && ', '}
|
||
</span>
|
||
))}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Web Claims Section - Images & Extracted Data */}
|
||
{displayData.web_claims && displayData.web_claims.length > 0 && (
|
||
<details className="detail-section web-claims-section">
|
||
<summary>
|
||
<h4>🔍 {language === 'nl' ? 'Website Gegevens' : 'Website Data'} ({displayData.web_claims.length})</h4>
|
||
</summary>
|
||
<div className="web-claims-content">
|
||
{/* Group claims by type */}
|
||
{(() => {
|
||
const imageClaims = displayData.web_claims?.filter(c =>
|
||
c.claim_type?.includes('image') || c.claim_type?.includes('logo') || c.claim_type?.includes('og_image')
|
||
) || [];
|
||
const contactClaims = displayData.web_claims?.filter(c =>
|
||
c.claim_type?.includes('email') || c.claim_type?.includes('phone') || c.claim_type?.includes('address')
|
||
) || [];
|
||
const otherClaims = displayData.web_claims?.filter(c =>
|
||
!imageClaims.includes(c) && !contactClaims.includes(c)
|
||
) || [];
|
||
|
||
return (
|
||
<>
|
||
{/* Image gallery from web claims */}
|
||
{imageClaims.length > 0 && (
|
||
<div className="web-claims-images">
|
||
<p className="claims-label">{language === 'nl' ? 'Afbeeldingen' : 'Images'}:</p>
|
||
<div className="claims-image-grid">
|
||
{imageClaims.slice(0, 6).map((claim, idx) => (
|
||
claim.claim_value && (
|
||
<a
|
||
key={idx}
|
||
href={claim.claim_value}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="claim-image-link"
|
||
title={claim.claim_type}
|
||
>
|
||
<img
|
||
src={claim.claim_value}
|
||
alt={claim.claim_type || 'Image'}
|
||
className="claim-image"
|
||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
||
/>
|
||
</a>
|
||
)
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Contact claims */}
|
||
{contactClaims.length > 0 && (
|
||
<div className="web-claims-contact">
|
||
{contactClaims.map((claim, idx) => (
|
||
<div key={idx} className="claim-item">
|
||
<span className="claim-type">{claim.claim_type}:</span>
|
||
<span className="claim-value">
|
||
{claim.claim_type?.includes('email') ? (
|
||
<a href={`mailto:${claim.claim_value}`}>{claim.claim_value}</a>
|
||
) : claim.claim_type?.includes('phone') ? (
|
||
<a href={`tel:${claim.claim_value}`}>{claim.claim_value}</a>
|
||
) : (
|
||
claim.claim_value
|
||
)}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{/* Other claims */}
|
||
{otherClaims.length > 0 && (
|
||
<div className="web-claims-other">
|
||
{otherClaims.slice(0, 10).map((claim, idx) => (
|
||
<div key={idx} className="claim-item">
|
||
<span className="claim-type">{claim.claim_type}:</span>
|
||
<span className="claim-value" title={claim.claim_value}>
|
||
{claim.claim_value && claim.claim_value.length > 50
|
||
? claim.claim_value.substring(0, 50) + '...'
|
||
: claim.claim_value}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</>
|
||
);
|
||
})()}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{/* Genealogiewerkbalk Section - Dutch Archive Links */}
|
||
{displayData.genealogiewerkbalk && (
|
||
<div className="detail-section genealogiewerkbalk">
|
||
<h4>🏛️ {language === 'nl' ? 'Archiefverbindingen' : 'Archive Connections'}</h4>
|
||
{displayData.genealogiewerkbalk.municipality && (
|
||
<div className="gwb-field">
|
||
<span className="field-label">{language === 'nl' ? 'Gemeente' : 'Municipality'}:</span>
|
||
<span>{displayData.genealogiewerkbalk.municipality.name}</span>
|
||
</div>
|
||
)}
|
||
{displayData.genealogiewerkbalk.municipal_archive && (
|
||
<div className="gwb-field">
|
||
<span className="field-label">{language === 'nl' ? 'Gemeentearchief' : 'Municipal Archive'}:</span>
|
||
{displayData.genealogiewerkbalk.municipal_archive.website ? (
|
||
<a href={displayData.genealogiewerkbalk.municipal_archive.website} target="_blank" rel="noopener noreferrer">
|
||
{displayData.genealogiewerkbalk.municipal_archive.name}
|
||
</a>
|
||
) : (
|
||
<span>{displayData.genealogiewerkbalk.municipal_archive.name}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
{displayData.genealogiewerkbalk.province && (
|
||
<div className="gwb-field">
|
||
<span className="field-label">{language === 'nl' ? 'Provincie' : 'Province'}:</span>
|
||
<span>{displayData.genealogiewerkbalk.province.name}</span>
|
||
</div>
|
||
)}
|
||
{displayData.genealogiewerkbalk.provincial_archive && (
|
||
<div className="gwb-field">
|
||
<span className="field-label">{language === 'nl' ? 'Provinciaal Archief' : 'Provincial Archive'}:</span>
|
||
{displayData.genealogiewerkbalk.provincial_archive.website ? (
|
||
<a href={displayData.genealogiewerkbalk.provincial_archive.website} target="_blank" rel="noopener noreferrer">
|
||
{displayData.genealogiewerkbalk.provincial_archive.name}
|
||
</a>
|
||
) : (
|
||
<span>{displayData.genealogiewerkbalk.provincial_archive.name}</span>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Provenance Section - Data Source Info */}
|
||
{displayData.provenance && (
|
||
<details className="detail-section provenance-section">
|
||
<summary>
|
||
<h4>📊 {language === 'nl' ? 'Gegevensbron' : 'Data Provenance'}</h4>
|
||
</summary>
|
||
<div className="provenance-content">
|
||
{displayData.provenance.data_source && (
|
||
<div className="provenance-field">
|
||
<span className="field-label">{language === 'nl' ? 'Bron' : 'Source'}:</span>
|
||
<span className="data-source-badge">{displayData.provenance.data_source}</span>
|
||
</div>
|
||
)}
|
||
{displayData.provenance.data_tier && (
|
||
<div className="provenance-field">
|
||
<span className="field-label">{language === 'nl' ? 'Kwaliteitsniveau' : 'Quality Tier'}:</span>
|
||
<span className={`data-tier-badge tier-${displayData.provenance.data_tier.toLowerCase().replace('tier_', '').split('_')[0]}`}>
|
||
{displayData.provenance.data_tier.replace(/_/g, ' ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{/* External Registry Enrichments */}
|
||
{(displayData.nan_isil_enrichment || displayData.kb_enrichment || displayData.zcbs_enrichment) && (
|
||
<details className="detail-section external-registries">
|
||
<summary>
|
||
<h4>📋 {language === 'nl' ? 'Externe Registers' : 'External Registries'}</h4>
|
||
</summary>
|
||
<div className="registries-content">
|
||
{displayData.nan_isil_enrichment && (
|
||
<div className="registry-section">
|
||
<h5>NAN ISIL {language === 'nl' ? 'Register' : 'Registry'}</h5>
|
||
<div className="registry-data">
|
||
{Object.entries(displayData.nan_isil_enrichment)
|
||
.filter(([key]) => !key.startsWith('_'))
|
||
.slice(0, 5)
|
||
.map(([key, value]) => (
|
||
<div key={key} className="registry-field">
|
||
<span className="field-label">{key}:</span>
|
||
<span>{String(value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{displayData.kb_enrichment && (
|
||
<div className="registry-section">
|
||
<h5>KB {language === 'nl' ? 'Bibliotheek' : 'Library'}</h5>
|
||
<div className="registry-data">
|
||
{Object.entries(displayData.kb_enrichment)
|
||
.filter(([key]) => !key.startsWith('_'))
|
||
.slice(0, 5)
|
||
.map(([key, value]) => (
|
||
<div key={key} className="registry-field">
|
||
<span className="field-label">{key}:</span>
|
||
<span>{String(value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{displayData.zcbs_enrichment && (
|
||
<div className="registry-section">
|
||
<h5>ZCBS</h5>
|
||
<div className="registry-data">
|
||
{Object.entries(displayData.zcbs_enrichment)
|
||
.filter(([key]) => !key.startsWith('_'))
|
||
.slice(0, 5)
|
||
.map(([key, value]) => (
|
||
<div key={key} className="registry-field">
|
||
<span className="field-label">{key}:</span>
|
||
<span>{String(value)}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{/* YouTube Tab Content */}
|
||
{activeTab === 'youtube' && hasYouTube && (
|
||
<div className="modal-youtube-section">
|
||
<MediaGallery
|
||
youtube={displayData.youtube}
|
||
institutionName={displayData.name}
|
||
t={tMedia}
|
||
/>
|
||
|
||
{/* YouTube Channel Info */}
|
||
{displayData.youtube && (
|
||
<div className="youtube-channel-info">
|
||
<h4>📺 {displayData.youtube.channel_title}</h4>
|
||
{displayData.youtube.subscriber_count && (
|
||
<p className="channel-stats">
|
||
{displayData.youtube.subscriber_count.toLocaleString()} {language === 'nl' ? 'abonnees' : 'subscribers'}
|
||
{displayData.youtube.video_count && ` • ${displayData.youtube.video_count} videos`}
|
||
</p>
|
||
)}
|
||
<a
|
||
href={displayData.youtube.channel_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="channel-link"
|
||
>
|
||
{language === 'nl' ? 'Bekijk op YouTube' : 'View on YouTube'} →
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="modal-footer">
|
||
<button className="modal-btn secondary" onClick={onClose}>
|
||
{t('close')}
|
||
</button>
|
||
<button className="modal-btn primary" onClick={onViewMap}>
|
||
🗺️ {t('viewOnMap')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Person Detail Modal
|
||
* Displays full profile information for a heritage professional
|
||
*/
|
||
function PersonDetailModal({
|
||
person,
|
||
language,
|
||
onClose,
|
||
}: {
|
||
person: PersonSummary;
|
||
language: 'nl' | 'en';
|
||
onClose: () => void;
|
||
}) {
|
||
const [imageError, setImageError] = useState(false);
|
||
|
||
// Fetch full person details
|
||
const { person: fullData, isLoading, error } = usePersonDetail(person.staff_id);
|
||
|
||
// Use full data if available, otherwise fall back to summary
|
||
const displayData = fullData || person;
|
||
const hasExperience = fullData?.experience && fullData.experience.length > 0;
|
||
const hasEducation = fullData?.education && fullData.education.length > 0;
|
||
const hasSkills = fullData?.skills && fullData.skills.length > 0;
|
||
const hasLanguages = fullData?.languages && fullData.languages.length > 0;
|
||
|
||
// Get heritage type badge info
|
||
const primaryType = person.heritage_types?.[0] || null;
|
||
const typeInfo = primaryType ? TYPE_INFO[primaryType] : null;
|
||
const countryCode = person.country_code || '';
|
||
|
||
// Close on escape key
|
||
useEffect(() => {
|
||
const handleEscape = (e: KeyboardEvent) => {
|
||
if (e.key === 'Escape') onClose();
|
||
};
|
||
window.addEventListener('keydown', handleEscape);
|
||
return () => window.removeEventListener('keydown', handleEscape);
|
||
}, [onClose]);
|
||
|
||
return (
|
||
<div className="modal-overlay" onClick={onClose}>
|
||
<div className="modal-content person-modal-content" onClick={(e) => e.stopPropagation()}>
|
||
<button className="modal-close" onClick={onClose}>×</button>
|
||
|
||
<div className="modal-header person-modal-header">
|
||
{/* Profile image or placeholder */}
|
||
<div className="person-modal-avatar">
|
||
{person.profile_image_url && !imageError ? (
|
||
<img
|
||
src={person.profile_image_url}
|
||
alt=""
|
||
className="person-avatar-large"
|
||
onError={() => setImageError(true)}
|
||
/>
|
||
) : (
|
||
<span className="person-avatar-placeholder-large">👤</span>
|
||
)}
|
||
</div>
|
||
<div className="person-modal-title">
|
||
<h2>{person.name}</h2>
|
||
<div className="person-modal-badges">
|
||
{typeInfo && (
|
||
<span className="type-badge" style={{ backgroundColor: typeInfo.color }}>
|
||
{typeInfo.icon} {typeInfo.name}
|
||
</span>
|
||
)}
|
||
{countryCode && (
|
||
<span className="country-badge" title={COUNTRY_NAMES[countryCode]?.[language] || countryCode}>
|
||
{getFlagEmoji(countryCode)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="modal-body">
|
||
{/* Loading indicator */}
|
||
{isLoading && (
|
||
<div className="modal-loading">
|
||
<span className="loading-spinner">⏳</span>
|
||
{language === 'nl' ? 'Profiel laden...' : 'Loading profile...'}
|
||
</div>
|
||
)}
|
||
|
||
{/* Error indicator */}
|
||
{error && (
|
||
<div className="modal-error">
|
||
⚠️ {language === 'nl' ? 'Kon profiel niet laden' : 'Could not load profile'}
|
||
</div>
|
||
)}
|
||
|
||
{/* Headline */}
|
||
{displayData.headline && (
|
||
<div className="detail-section">
|
||
<h4>💼 {language === 'nl' ? 'Functie' : 'Position'}</h4>
|
||
<p className="person-headline-full">{displayData.headline}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Location */}
|
||
{displayData.location && (
|
||
<div className="detail-section">
|
||
<h4>📍 {language === 'nl' ? 'Locatie' : 'Location'}</h4>
|
||
<p>{displayData.location}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Custodian/Affiliation */}
|
||
{displayData.custodian_name && (
|
||
<div className="detail-section">
|
||
<h4>🏛️ {language === 'nl' ? 'Organisatie' : 'Organization'}</h4>
|
||
<p>{displayData.custodian_name}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* About (from full data) */}
|
||
{fullData?.about && (
|
||
<div className="detail-section">
|
||
<h4>📝 {language === 'nl' ? 'Over' : 'About'}</h4>
|
||
<p className="person-about-text">{fullData.about}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Experience - Career Timeline Visualization */}
|
||
{hasExperience && (
|
||
<div className="detail-section experience-timeline-section">
|
||
<CareerTimeline
|
||
name={person.name}
|
||
career={fullData!.experience.map((exp): CareerPosition => ({
|
||
title: exp.title,
|
||
company: exp.company,
|
||
organization: exp.company,
|
||
role: exp.title,
|
||
location: exp.location,
|
||
dates: exp.start_date && exp.end_date
|
||
? `${exp.start_date} - ${exp.end_date}`
|
||
: exp.start_date
|
||
? `${exp.start_date} - ${language === 'nl' ? 'heden' : 'present'}`
|
||
: 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}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{/* Education */}
|
||
{hasEducation && (
|
||
<div className="detail-section">
|
||
<h4>🎓 {language === 'nl' ? 'Opleiding' : 'Education'}</h4>
|
||
<div className="education-list">
|
||
{fullData!.education.map((edu, idx) => (
|
||
<div key={idx} className="education-item">
|
||
{edu.school && <div className="edu-school">{edu.school}</div>}
|
||
{(edu.degree || edu.field) && (
|
||
<div className="edu-degree">
|
||
{[edu.degree, edu.field].filter(Boolean).join(', ')}
|
||
</div>
|
||
)}
|
||
{(edu.start_year || edu.end_year) && (
|
||
<div className="edu-years">
|
||
{edu.start_year || '?'} – {edu.end_year || '?'}
|
||
</div>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Skills */}
|
||
{hasSkills && (
|
||
<div className="detail-section">
|
||
<h4>🛠️ {language === 'nl' ? 'Vaardigheden' : 'Skills'}</h4>
|
||
<div className="skills-list">
|
||
{fullData!.skills.map((skill, idx) => (
|
||
<span key={idx} className="skill-tag">{skill}</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Languages */}
|
||
{hasLanguages && (
|
||
<div className="detail-section">
|
||
<h4>🌐 {language === 'nl' ? 'Talen' : 'Languages'}</h4>
|
||
<div className="languages-list">
|
||
{fullData!.languages.map((lang, idx) => (
|
||
<span key={idx} className="language-tag">
|
||
{typeof lang === 'string' ? lang : lang.language}
|
||
{typeof lang !== 'string' && lang.proficiency && ` (${lang.proficiency})`}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Connections info */}
|
||
{fullData?.connections && (
|
||
<div className="detail-section">
|
||
<h4>🔗 {language === 'nl' ? 'Connecties' : 'Connections'}</h4>
|
||
<p>{fullData.connections}</p>
|
||
</div>
|
||
)}
|
||
|
||
{/* Identifiers / Metadata */}
|
||
<div className="detail-section identifiers">
|
||
<h4>🔖 {language === 'nl' ? 'Identificatoren' : 'Identifiers'}</h4>
|
||
<div className="identifier">
|
||
<span className="id-label">Staff ID:</span>
|
||
<code>{person.staff_id}</code>
|
||
</div>
|
||
{fullData?.extraction_date && (
|
||
<div className="identifier">
|
||
<span className="id-label">{language === 'nl' ? 'Geëxtraheerd' : 'Extracted'}:</span>
|
||
<span>{new Date(fullData.extraction_date).toLocaleDateString(language === 'nl' ? 'nl-NL' : 'en-US')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="modal-footer">
|
||
<button className="modal-btn secondary" onClick={onClose}>
|
||
{language === 'nl' ? 'Sluiten' : 'Close'}
|
||
</button>
|
||
{person.linkedin_url && (
|
||
<a
|
||
href={person.linkedin_url}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="modal-btn primary"
|
||
>
|
||
💼 LinkedIn
|
||
</a>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default InstitutionBrowserPage;
|