From 499f88d23c88e81c578c8bbacd70c3e520f133e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemek=20Wi=C4=99ch?= Date: Thu, 11 Jun 2026 20:29:22 +0200 Subject: [PATCH] Extract `ViewPage` from app.tsx --- docs/APP_REFACTORING_DESIGN.md | 2 +- src/app.tsx | 608 +------------------------------ src/pages/view_page.tsx | 637 +++++++++++++++++++++++++++++++++ 3 files changed, 652 insertions(+), 595 deletions(-) create mode 100644 src/pages/view_page.tsx diff --git a/docs/APP_REFACTORING_DESIGN.md b/docs/APP_REFACTORING_DESIGN.md index 00c4cc3..cb2048a 100644 --- a/docs/APP_REFACTORING_DESIGN.md +++ b/docs/APP_REFACTORING_DESIGN.md @@ -101,7 +101,7 @@ We will gradually eliminate React state variables in `src/app.tsx` and derive th #### Phase 4: Structural Page Nesting * [x] **Step 4.1:** Create `src/hooks/use_google_auth.ts` to manage Google Drive authentication token updates. Remove `hasGoogleToken` state from `src/app.tsx`. -* [ ] **Step 4.2:** Create `src/pages/view_page.tsx` containing layout rendering (`renderMainArea`), asynchronous data loaders, view state management, `revokeObjectUrls` cleanups, and `` error fallback state and rendering. +* [x] **Step 4.2:** Create `src/pages/view_page.tsx` containing layout rendering (`renderMainArea`), asynchronous data loaders, view state management, `revokeObjectUrls` cleanups, and `` error fallback state and rendering. * [ ] **Step 4.3:** Create `src/pages/intro_page.tsx` rendering the landing screen and its own local header. * [ ] **Step 4.4:** Refactor `src/app.tsx` to serve as a pure routing switch routing to `` or ``. In `App`, handle Google Drive redirection synchronously during the render pass by returning a `` element (preventing render flashing), and ensure the redirection parser checks both the router `location.search` and the external `window.location.search` to handle HashRouter query structure robustly. Once external parameters (like `state` or `authcode`) are detected, they must be stripped from `window.location.search` using `window.history.replaceState` to prevent redirection loops on page refresh. Remove all layout state logic from the root file, including moving the `useWebMcpBridge` hook invocation into ``. Run final verification scripts (`npm run check:all`). diff --git a/src/app.tsx b/src/app.tsx index fb60ec0..324f167 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,273 +1,35 @@ import queryString from 'query-string'; -import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; -import {useIntl} from 'react-intl'; +import {useEffect, useMemo, useRef} from 'react'; 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 {useGoogleAuth} from './hooks/use_google_auth'; -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, 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'; +import {ViewPage} from './pages/view_page'; +import {getArguments, getStaticUrl} from './util/url_args'; const staticUrl = getStaticUrl(); -enum AppState { - INITIAL, - LOADING, - ERROR, - SHOWING_CHART, - LOADING_MORE, -} - +/** + * Root App component that orchestrates top-level routing, Google Drive "Open with" + * payload interception and redirection, and global layout rendering for the intro view. + */ 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(); - - /** Error to display. */ - const [error, setError] = useState(); - - /** Whether to show the error popup. */ - const [showErrorPopup, setShowErrorPopup] = useState(false); - /** Specification of the source of the data. */ - const [sourceSpec, setSourceSpec] = useState(); - - /** 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 and provides a state setter. */ const {hasGoogleToken, setHasGoogleToken} = useGoogleAuth(); - const intl = useIntl(); const navigate = useNavigate(); const location = useLocation(); const args = useMemo(() => getArguments(location), [location]); - /** Type of displayed chart. */ - const chartType = args.chartType; /** Whether the app is in standalone mode, i.e. showing 'open file' menus. */ const standalone = args.standalone; - /** - * Whether the app should display WikiTree-specific menus when showing data - * from WikiTree. - */ - const showWikiTreeMenus = args.showWikiTreeMenus; - /** Freeze animations after initial chart render. */ - const freezeAnimation = args.freezeAnimation; - /** Whether the side panel is shown. */ - const showSidePanel = args.showSidePanel; - /** Configuration settings for chart display options (e.g. colors, hiding IDs). */ - const config = args.config; - - useMemo(() => { - updateChartWithConfig(config, data); - }, [config, data]); - /** The currently selected individual. Fallback to default individual from loaded data if not specified. */ - const updatedSelection = useMemo(() => { - return data ? getSelection(data.chartData, args.selection) : undefined; - }, [data, args.selection]); - /** The individual displayed in the details pane. */ - const detailIndi = args.detail || updatedSelection?.id; /** 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); - /** Tracks the currently loaded selection to check if new data needs to be fetched. */ - const loadedSelectionRef = useRef(); - - /** Manages the mount lifecycle ref to avoid setting state on unmounted components. */ - useEffect(() => { - isMountedRef.current = true; - return () => { - isMountedRef.current = false; - }; - }, []); - - 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; - updateUrl( - { - sidePanel: newShowSidePanel ? 'true' : 'false', - }, - {replace: true}, - ); - } - - /** 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: loadedSelectionRef.current, - }; - 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, 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(() => { @@ -309,383 +71,41 @@ export function App() { async function onGoogleSignOut() { await googleDriveService.signOut(); setHasGoogleToken(false); - setData(undefined); - loadedSelectionRef.current = 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 isViewPage = location.pathname === '/view'; - 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); - 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); - loadedSelectionRef.current = getSelection( - data.chartData, - args.selection, - ); - 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 && - (!loadedSelectionRef.current || - loadedSelectionRef.current.id !== args.selection?.id); - setState( - loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, - ); - 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); - loadedSelectionRef.current = newSelection; - 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, data, navigate, intl, isNewData, loadData]); - - // 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, - options?: {replace?: boolean}, - ) { - navigate(getUrlForArgs(location, args), options); - } - - /** - * 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), - detail: null, - }); - } - /** - * Called when the user shift+clicks an individual box in the chart. - * Shows the individual in the details pane. - */ - function onDetailSelection(selection: IndiInfo) { - updateUrl({ - detail: 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('')} - /> - ); - } + if (isViewPage) { return ( - setLoadingStatus('')} - /> + + } /> + } /> + ); } - function renderMainArea() { - switch (state) { - case AppState.SHOWING_CHART: - case AppState.LOADING_MORE: { - if (!data || !updatedSelection) { - return null; - } - const selection = updatedSelection; - return ( -
- - {state === AppState.LOADING_MORE ? ( - - ) : null} - - { - updateUrl(configToArgs(config), {replace: true}); - }} - /> - {renderChart(selection)} - -
- ); - } - - 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}); - }} - /> - )} ); } diff --git a/src/pages/view_page.tsx b/src/pages/view_page.tsx new file mode 100644 index 0000000..8c05114 --- /dev/null +++ b/src/pages/view_page.tsx @@ -0,0 +1,637 @@ +import queryString from 'query-string'; +import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useIntl} from 'react-intl'; +import {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 {useGoogleAuth} from '../hooks/use_google_auth'; +import {useWebMcpBridge} from '../hooks/use_webmcp_bridge'; +import {GoogleAuthModal} from '../menu/google_auth_modal'; +import {TopBar} from '../menu/top_bar'; +import {Config, configToArgs, 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, getUrlForArgs} from '../util/url_args'; + +enum AppState { + INITIAL, + LOADING, + ERROR, + SHOWING_CHART, + LOADING_MORE, +} + +/** + * ViewPage is the page component that orchestrates the genealogy chart workspace. + * It manages asynchronous data loading, configuration parameters derived from the URL, + * chart rendering (both D3 and Donatso), side panel settings, and Google Drive auth workflows. + */ +export function ViewPage() { + /** 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(); + + /** Error to display. */ + const [error, setError] = useState(); + + /** Whether to show the error popup. */ + const [showErrorPopup, setShowErrorPopup] = useState(false); + /** Specification of the source of the data. */ + const [sourceSpec, setSourceSpec] = useState(); + + /** 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 and provides a state setter. */ + const {hasGoogleToken, setHasGoogleToken} = useGoogleAuth(); + + const intl = useIntl(); + const navigate = useNavigate(); + const location = useLocation(); + + const args = useMemo(() => getArguments(location), [location]); + /** Type of displayed chart. */ + const chartType = args.chartType; + /** Whether the app is in standalone mode, i.e. showing 'open file' menus. */ + const standalone = args.standalone; + /** + * Whether the app should display WikiTree-specific menus when showing data + * from WikiTree. + */ + const showWikiTreeMenus = args.showWikiTreeMenus; + /** Freeze animations after initial chart render. */ + const freezeAnimation = args.freezeAnimation; + /** Whether the side panel is shown. */ + const showSidePanel = args.showSidePanel; + /** Configuration settings for chart display options (e.g. colors, hiding IDs). */ + const config = args.config; + + useMemo(() => { + updateChartWithConfig(config, data); + }, [config, data]); + /** The currently selected individual. Fallback to default individual from loaded data if not specified. */ + const updatedSelection = useMemo(() => { + return data ? getSelection(data.chartData, args.selection) : undefined; + }, [data, args.selection]); + /** The individual displayed in the details pane. */ + const detailIndi = args.detail || updatedSelection?.id; + + /** 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); + /** Tracks the currently loaded selection to check if new data needs to be fetched. */ + const loadedSelectionRef = useRef(); + + /** Manages the mount lifecycle ref to avoid setting state on unmounted components. */ + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + 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; + updateUrl( + { + sidePanel: newShowSidePanel ? 'true' : 'false', + }, + {replace: true}, + ); + } + + /** 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 oldSource = { + spec: sourceSpec, + selection: loadedSelectionRef.current, + }; + switch (newSource.spec.source) { + case DataSourceEnum.UPLOADED: + return uploadedDataSource.isNewData( + newSource as SourceSelection, + oldSource as SourceSelection, + data, + ); + case DataSourceEnum.GEDCOM_URL: + return gedcomUrlDataSource.isNewData( + newSource as SourceSelection, + oldSource as SourceSelection, + data, + ); + case DataSourceEnum.WIKITREE: + return wikiTreeDataSource.isNewData( + newSource as SourceSelection, + oldSource as SourceSelection, + data, + ); + case DataSourceEnum.EMBEDDED: + return embeddedDataSource.isNewData( + newSource as SourceSelection, + oldSource as SourceSelection, + data, + ); + case DataSourceEnum.GOOGLE_DRIVE: + return googleDriveDataSource.isNewData( + newSource as SourceSelection, + oldSource as SourceSelection, + data, + ); + } + }, + [sourceSpec, 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], + ); + + async function onGoogleSignOut() { + await googleDriveService.signOut(); + setHasGoogleToken(false); + setData(undefined); + loadedSelectionRef.current = 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); + 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); + loadedSelectionRef.current = getSelection( + data.chartData, + args.selection, + ); + 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 && + (!loadedSelectionRef.current || + loadedSelectionRef.current.id !== args.selection?.id); + setState( + loadMoreFromWikitree ? AppState.LOADING_MORE : AppState.SHOWING_CHART, + ); + 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); + loadedSelectionRef.current = newSelection; + 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, data, navigate, intl, isNewData, loadData]); + + // 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, + options?: {replace?: boolean}, + ) { + navigate(getUrlForArgs(location, args), options); + } + + /** + * 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), + detail: null, + }); + } + /** + * Called when the user shift+clicks an individual box in the chart. + * Shows the individual in the details pane. + */ + function onDetailSelection(selection: IndiInfo) { + updateUrl({ + detail: 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 || !updatedSelection) { + return null; + } + const selection = updatedSelection; + return ( +
+ + {state === AppState.LOADING_MORE ? ( + + ) : null} + + { + updateUrl(configToArgs(config), {replace: true}); + }} + /> + {renderChart(selection)} + +
+ ); + } + + 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)} + /> + {renderMainArea()} + {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}); + }} + /> + )} + + ); +}