import queryString from 'query-string'; import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useIntl} from 'react-intl'; import {Navigate, Route, Routes, useLocation, useNavigate} from 'react-router'; import {Loader, SidebarPushable, SidebarPusher} from 'semantic-ui-react'; import {IndiInfo} from 'topola'; import { Chart, ChartType, downloadPdf, downloadPng, downloadSvg, printChart, } from './chart'; import {ErrorMessage, ErrorPopup} from './components/error_display'; import {DataSourceEnum, SourceSelection} from './datasource/data_source'; import {EmbeddedSourceSpec} from './datasource/embedded'; import { GoogleDriveAuthError, GoogleDriveSourceSpec, } from './datasource/google_drive'; import { clearGoogleDriveCache, googleDriveService, isGoogleDriveConfigured, } from './datasource/google_drive_service'; import { embeddedDataSource, gedcomUrlDataSource, googleDriveDataSource, uploadedDataSource, } from './datasource/instances'; import { getSelection, revokeObjectUrls, UploadSourceSpec, UrlSourceSpec, } from './datasource/load_data'; import { loadWikiTree, PRIVATE_ID_PREFIX, WikiTreeDataSource, WikiTreeSourceSpec, } from './datasource/wikitree'; import {DonatsoChart} from './donatso-chart'; import {useWebMcpBridge} from './hooks/use_webmcp_bridge'; import {Intro} from './intro'; import {GoogleAuthModal} from './menu/google_auth_modal'; import {TopBar} from './menu/top_bar'; import { Config, configToArgs, DEFALUT_CONFIG, Ids, Sex, } from './sidepanel/config/config'; import {SidePanel} from './sidepanel/side-panel'; import {analyticsEvent} from './util/analytics'; import {TopolaError} from './util/error'; import {getI18nMessage} from './util/error_i18n'; import {idToIndiMap, TopolaData} from './util/gedcom_util'; import { DataSourceSpec, getArguments, getStaticUrl, getUrlForArgs, } from './util/url_args'; const staticUrl = getStaticUrl(); enum AppState { INITIAL, LOADING, ERROR, SHOWING_CHART, LOADING_MORE, } export function App() { /** State of the application. */ const [state, setState] = useState(AppState.INITIAL); /** Progress message shown during LOADING and initial chart render. */ const [loadingStatus, setLoadingStatus] = useState('Loading…'); /** Loaded data. */ const [data, setData] = useState(); /** Selected individual. */ const [selection, setSelection] = useState(); /** Selected individual which should be displayed in the details pane. */ const [detailIndi, setDetailIndi] = useState(); /** Error to display. */ const [error, setError] = useState(); /** Whether the side panel is shown. */ const [showSidePanel, setShowSidePanel] = useState(false); /** Whether the app is in standalone mode, i.e. showing 'open file' menus. */ const [standalone, setStandalone] = useState(true); /** * Whether the app should display WikiTree-specific menus when showing data * from WikiTree. */ const [showWikiTreeMenus, setShowWikiTreeMenus] = useState(true); /** Type of displayed chart. */ const [chartType, setChartType] = useState(ChartType.Hourglass); /** Whether to show the error popup. */ const [showErrorPopup, setShowErrorPopup] = useState(false); /** Specification of the source of the data. */ const [sourceSpec, setSourceSpec] = useState(); /** Freeze animations after initial chart render. */ const [freezeAnimation, setFreezeAnimation] = useState(false); /** Configuration settings for chart display options (e.g. colors, hiding IDs). */ const [config, setConfig] = useState(DEFALUT_CONFIG); /** Controls the visibility of the Google Drive OAuth permission modal. */ const [showAuthModal, setShowAuthModal] = useState(false); /** Stores the file ID that failed to load from Google Drive due to authorization errors. */ const [failedFileId, setFailedFileId] = useState(); /** Tracks whether the user has a valid cached Google Drive OAuth access token. */ const [hasGoogleToken, setHasGoogleToken] = useState( () => !!googleDriveService.getAccessToken(), ); const intl = useIntl(); const navigate = useNavigate(); const location = useLocation(); /** Prevents the Google Drive "Open with" state from being processed more than once. */ const stateProcessed = useRef(false); /** Tracks whether the component is currently mounted to prevent state updates after unmount. */ const isMountedRef = useRef(true); /** Incremented with each load request to ensure only the latest asynchronous load result is applied. */ const fetchIdRef = useRef(0); /** Manages the mount lifecycle ref to avoid setting state on unmounted components. */ useEffect(() => { isMountedRef.current = true; return () => { isMountedRef.current = false; }; }, []); const updateDisplay = useCallback( (newSelection: IndiInfo) => { if ( !selection || selection.id !== newSelection.id || selection.generation !== newSelection.generation ) { setSelection(newSelection); setDetailIndi(newSelection.id); } }, [selection], ); function updateChartWithConfig(config: Config, data: TopolaData | undefined) { if (data === undefined) { return; } const shouldHideIds = config.id === Ids.HIDE; const shouldHideSex = config.sex === Sex.HIDE; const indiMap = idToIndiMap(data.chartData); indiMap.forEach((indi) => { indi.hideId = shouldHideIds; indi.hideSex = shouldHideSex; }); } function onToggleSidePanel() { const newShowSidePanel = !showSidePanel; setShowSidePanel(newShowSidePanel); updateUrl({ sidePanel: newShowSidePanel ? 'true' : 'false', }); } /** Sets error message after data load failure. */ function setErrorMessage(message: string) { setError(message); setState(AppState.ERROR); } const wikiTreeDataSource = useMemo( () => new WikiTreeDataSource(intl), [intl], ); const isNewData = useCallback( (newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) => { if (!sourceSpec || sourceSpec.source !== newSourceSpec.source) { // New data source means new data. return true; } const newSource = {spec: newSourceSpec, selection: newSelection}; const oldSouce = { spec: sourceSpec, selection: selection, }; switch (newSource.spec.source) { case DataSourceEnum.UPLOADED: return uploadedDataSource.isNewData( newSource as SourceSelection, oldSouce as SourceSelection, data, ); case DataSourceEnum.GEDCOM_URL: return gedcomUrlDataSource.isNewData( newSource as SourceSelection, oldSouce as SourceSelection, data, ); case DataSourceEnum.WIKITREE: return wikiTreeDataSource.isNewData( newSource as SourceSelection, oldSouce as SourceSelection, data, ); case DataSourceEnum.EMBEDDED: return embeddedDataSource.isNewData( newSource as SourceSelection, oldSouce as SourceSelection, data, ); case DataSourceEnum.GOOGLE_DRIVE: return googleDriveDataSource.isNewData( newSource as SourceSelection, oldSouce as SourceSelection, data, ); } }, [sourceSpec, selection, data, wikiTreeDataSource], ); const loadData = useCallback( ( newSourceSpec: DataSourceSpec, newSelection?: IndiInfo, onProgress?: (status: string) => void, ) => { switch (newSourceSpec.source) { case DataSourceEnum.UPLOADED: return uploadedDataSource.loadData( {spec: newSourceSpec, selection: newSelection}, onProgress, ); case DataSourceEnum.GEDCOM_URL: return gedcomUrlDataSource.loadData( {spec: newSourceSpec, selection: newSelection}, onProgress, ); case DataSourceEnum.WIKITREE: return wikiTreeDataSource.loadData( {spec: newSourceSpec, selection: newSelection}, onProgress, ); case DataSourceEnum.EMBEDDED: return embeddedDataSource.loadData( {spec: newSourceSpec, selection: newSelection}, onProgress, ); case DataSourceEnum.GOOGLE_DRIVE: if (!isGoogleDriveConfigured()) { throw new TopolaError( 'GOOGLE_DRIVE_NOT_CONFIGURED', 'Google Drive integration is not configured.', ); } return googleDriveDataSource.loadData( { spec: newSourceSpec as GoogleDriveSourceSpec, selection: newSelection, }, onProgress, ); } }, [wikiTreeDataSource], ); // Google Drive "Open with" flow state checking. useEffect(() => { const search = queryString.parse(location.search); const stateParam = search.state; if (typeof stateParam === 'string' && !stateProcessed.current) { try { const parsedState = JSON.parse(stateParam); if ( parsedState && parsedState.action === 'open' && Array.isArray(parsedState.ids) && parsedState.ids.length > 0 ) { stateProcessed.current = true; const fileId = parsedState.ids[0]; // Soft redirect to view file navigate( { pathname: '/view', search: queryString.stringify({ source: 'google-drive', fileId, }), }, {replace: true}, ); } } catch (err) { // Silently catch JSON parsing errors for state parameters not meant for us (e.g. from other auth tools) console.warn( 'Google Drive state query parameter JSON parsing failed or action mismatch:', err, ); } } }, [navigate, location.search]); async function onGoogleSignOut() { await googleDriveService.signOut(); setHasGoogleToken(false); setData(undefined); setSelection(undefined); setDetailIndi(undefined); // Purge sessionStorage keys starting with "google-drive:" clearGoogleDriveCache(); navigate({pathname: '/'}, {replace: true}); } useEffect(() => { (async () => { if (location.pathname !== '/view') { if (state !== AppState.INITIAL) { setState(AppState.INITIAL); } setData(undefined); return; } const args = getArguments(location); if (!args.sourceSpec) { navigate({pathname: '/'}, {replace: true}); return; } if ( state === AppState.INITIAL || isNewData(args.sourceSpec, args.selection) ) { // Set loading state. setState(AppState.LOADING); // Set state from URL parameters. setSourceSpec(args.sourceSpec); setSelection(args.selection); setDetailIndi(args.selection?.id); setStandalone(args.standalone); setShowWikiTreeMenus(args.showWikiTreeMenus); setChartType(args.chartType); setFreezeAnimation(args.freezeAnimation); setConfig(args.config); const currentFetchId = ++fetchIdRef.current; setLoadingStatus('Loading…'); try { const data = await loadData( args.sourceSpec, args.selection, (status) => { if (isMountedRef.current) setLoadingStatus(status); }, ); if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { return; } // Show "Rendering chart…" while the initial D3 layout runs (which // happens in the Chart useEffect after SHOWING_CHART is set). setLoadingStatus( `Rendering chart (${data.chartData.indis.length.toLocaleString()} people)…`, ); setData(data); updateChartWithConfig(args.config, data); setShowSidePanel(args.showSidePanel); setState(AppState.SHOWING_CHART); } catch (error: unknown) { if (!isMountedRef.current || fetchIdRef.current !== currentFetchId) { return; } if (error instanceof GoogleDriveAuthError) { if (args.sourceSpec.source === DataSourceEnum.GOOGLE_DRIVE) { setFailedFileId(args.sourceSpec.fileId); setShowAuthModal(true); } } else { setErrorMessage(getI18nMessage(error as Error, intl)); } } } else if ( state === AppState.SHOWING_CHART || state === AppState.LOADING_MORE ) { // Update selection if it has changed in the URL. const loadMoreFromWikitree = args.sourceSpec.source === DataSourceEnum.WIKITREE && (!selection || selection.id !== args.selection?.id); setChartType(args.chartType); setState( loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, ); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion updateDisplay(getSelection(data!.chartData, args.selection)); if (loadMoreFromWikitree) { const currentFetchId = ++fetchIdRef.current; try { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const data = await loadWikiTree(args.selection!.id, intl); if ( !isMountedRef.current || fetchIdRef.current !== currentFetchId ) { return; } const newSelection = getSelection(data.chartData, args.selection); setData(data); setSelection(newSelection); setDetailIndi(newSelection.id); setState(AppState.SHOWING_CHART); // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { if ( !isMountedRef.current || fetchIdRef.current !== currentFetchId ) { return; } setState(AppState.SHOWING_CHART); displayErrorPopup( intl.formatMessage( { id: 'error.failed_wikitree_load_more', defaultMessage: 'Failed to load data from WikiTree. {error}', }, {error}, ), ); } } } })(); }, [ location, state, selection, data, navigate, intl, isNewData, loadData, updateDisplay, ]); // Clean up object URLs created for uploaded images/files when the dataset // changes or the app unmounts to prevent memory leaks. useEffect(() => { return () => { revokeObjectUrls(data?.images); }; }, [data]); useWebMcpBridge(data, detailIndi, onSelection); function updateUrl( args: Record, ) { navigate(getUrlForArgs(location, args)); } /** * Called when the user clicks an individual box in the chart. * Updates the browser URL. */ function onSelection(selection: IndiInfo) { // Don't allow selecting WikiTree private profiles. if (selection.id.startsWith(PRIVATE_ID_PREFIX)) { return; } analyticsEvent('selection_changed'); updateUrl({ indi: selection.id, gen: String(selection.generation), }); } /** * Called when the user shift+clicks an individual box in the chart. * Shows the individual in the details pane. */ function onDetailSelection(selection: IndiInfo) { setDetailIndi(selection.id); } function onPrint() { analyticsEvent('print'); printChart(); } function displayErrorPopup(message: string) { setShowErrorPopup(true); setError(message); } async function onDownloadPdf() { analyticsEvent('download_pdf'); try { await downloadPdf(); } catch (e) { displayErrorPopup( intl.formatMessage({ id: 'error.failed_pdf', defaultMessage: 'Failed to generate PDF file.' + ' Please try with a smaller diagram or download an SVG file.', }), ); } } async function onDownloadPng() { analyticsEvent('download_png'); try { await downloadPng(); } catch (e) { displayErrorPopup( intl.formatMessage({ id: 'error.failed_png', defaultMessage: 'Failed to generate PNG file.' + ' Please try with a smaller diagram or download an SVG file.', }), ); } } function onDownloadSvg() { analyticsEvent('download_svg'); downloadSvg(); } function onDismissErrorPopup() { setShowErrorPopup(false); } function renderChart(selection: IndiInfo) { if (!data) { return null; } if (chartType === ChartType.Donatso) { return ( setLoadingStatus('')} /> ); } return ( setLoadingStatus('')} /> ); } function renderMainArea() { switch (state) { case AppState.SHOWING_CHART: case AppState.LOADING_MORE: { if (!data) { return null; } const updatedSelection = getSelection(data.chartData, selection); return (
{state === AppState.LOADING_MORE ? ( ) : null} { setConfig(config); updateChartWithConfig(config, data); updateUrl(configToArgs(config)); }} /> {renderChart(updatedSelection)}
); } case AppState.ERROR: return ; case AppState.INITIAL: case AppState.LOADING: return ; } } const progressPill = loadingStatus && (state === AppState.LOADING || state === AppState.SHOWING_CHART) ? (
{loadingStatus}
) : null; return ( <> {progressPill} setHasGoogleToken(true)} /> {staticUrl ? ( } /> ) : ( } /> } /> )} {showAuthModal && failedFileId && ( { setShowAuthModal(false); setHasGoogleToken(true); if (fileId === failedFileId) { setState(AppState.INITIAL); } else { navigate( { pathname: '/view', search: queryString.stringify({ source: 'google-drive', fileId, }), }, {replace: true}, ); } }} onCancel={() => { setShowAuthModal(false); navigate({pathname: '/'}, {replace: true}); }} /> )} ); }