glam/frontend/src/pages/InstitutionBrowserPage.tsx
2025-12-15 22:31:41 +01:00

1883 lines
76 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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