Files
topola-viewer/src/app.tsx
2023-09-11 23:29:43 +02:00

573 lines
17 KiB
TypeScript

import * as H from 'history';
import * as queryString from 'query-string';
import {analyticsEvent} from './util/analytics';
import {Changelog} from './changelog';
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
import {Details} from './details/details';
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
import {FormattedMessage, useIntl} from 'react-intl';
import {getI18nMessage} from './util/error_i18n';
import {IndiInfo} from 'topola';
import {Intro} from './intro';
import {Loader, Message, Portal, Tab} from 'semantic-ui-react';
import {Media} from './util/media';
import {Redirect, Route, Switch} from 'react-router-dom';
import {TopBar} from './menu/top_bar';
import {TopolaData} from './util/gedcom_util';
import {useEffect, useState} from 'react';
import {useHistory, useLocation} from 'react-router';
import {idToIndiMap} from './util/gedcom_util';
import {
Chart,
ChartType,
downloadPdf,
downloadPng,
downloadSvg,
printChart,
} from './chart';
import {
argsToConfig,
Config,
ConfigPanel,
configToArgs,
DEFALUT_CONFIG,
Ids,
Sex,
} from './config';
import {
getSelection,
UploadSourceSpec,
UrlSourceSpec,
GedcomUrlDataSource,
UploadedDataSource,
} from './datasource/load_data';
import {
loadWikiTree,
PRIVATE_ID_PREFIX,
WikiTreeDataSource,
WikiTreeSourceSpec,
} from './datasource/wikitree';
/** Shows an error message in the middle of the screen. */
function ErrorMessage(props: {message?: string}) {
return (
<Message negative className="error">
<Message.Header>
<FormattedMessage
id="error.failed_to_load_file"
defaultMessage={'Failed to load file'}
/>
</Message.Header>
<p>{props.message}</p>
</Message>
);
}
interface ErrorPopupProps {
message?: string;
open: boolean;
onDismiss: () => void;
}
/**
* Shows a dismissable error message in the bottom left corner of the screen.
*/
function ErrorPopup(props: ErrorPopupProps) {
return (
<Portal open={props.open} onClose={props.onDismiss}>
<Message negative className="errorPopup" onDismiss={props.onDismiss}>
<Message.Header>
<FormattedMessage id="error.error" defaultMessage={'Error'} />
</Message.Header>
<p>{props.message}</p>
</Message>
</Portal>
);
}
enum AppState {
INITIAL,
LOADING,
ERROR,
SHOWING_CHART,
LOADING_MORE,
}
type DataSourceSpec =
| UrlSourceSpec
| UploadSourceSpec
| WikiTreeSourceSpec
| EmbeddedSourceSpec;
/**
* Arguments passed to the application, primarily through URL parameters.
* Non-optional arguments get populated with default values.
*/
interface Arguments {
sourceSpec?: DataSourceSpec;
selection?: IndiInfo;
chartType: ChartType;
standalone: boolean;
showWikiTreeMenus: boolean;
freezeAnimation: boolean;
showSidePanel: boolean;
config: Config;
}
function getParamFromSearch(name: string, search: queryString.ParsedQuery<string>) {
const value = search[name];
return typeof value === 'string' ? value : undefined;
}
/**
* Retrieve arguments passed into the application through the URL and uploaded
* data.
*/
function getArguments(location: H.Location<any>): Arguments {
const search = queryString.parse(location.search);
const getParam = (name: string) => getParamFromSearch(name, search);
const view = getParam('view');
const chartTypes = new Map<string | undefined, ChartType>([
['relatives', ChartType.Relatives],
['fancy', ChartType.Fancy],
]);
const hash = getParam('file');
const url = getParam('url');
const embedded = getParam('embedded') === 'true'; // False by default.
var sourceSpec: DataSourceSpec | undefined = undefined;
if (getParam('source') === 'wikitree') {
const windowSearch = queryString.parse(window.location.search);
sourceSpec = {
source: DataSourceEnum.WIKITREE,
authcode: getParam('authcode') || getParamFromSearch('authcode', windowSearch),
};
} else if (hash) {
sourceSpec = {
source: DataSourceEnum.UPLOADED,
hash,
gedcom: location.state && location.state.data,
images: location.state && location.state.images,
};
} else if (url) {
sourceSpec = {
source: DataSourceEnum.GEDCOM_URL,
url,
handleCors: getParam('handleCors') !== 'false', // True by default.
};
} else if (embedded) {
sourceSpec = {source: DataSourceEnum.EMBEDDED};
}
const indi = getParam('indi');
const parsedGen = Number(getParam('gen'));
const selection = indi
? {id: indi, generation: !isNaN(parsedGen) ? parsedGen : 0}
: undefined;
return {
sourceSpec,
selection,
// Hourglass is the default view.
chartType: chartTypes.get(view) || ChartType.Hourglass,
showSidePanel: getParam('sidePanel') !== 'false', // True by default.
standalone: getParam('standalone') !== 'false' && !embedded,
showWikiTreeMenus: getParam('showWikiTreeMenus') !== 'false', // True by default.
freezeAnimation: getParam('freeze') === 'true', // False by default
config: argsToConfig(search),
};
}
export function App() {
/** State of the application. */
const [state, setState] = useState<AppState>(AppState.INITIAL);
/** Loaded data. */
const [data, setData] = useState<TopolaData>();
/** Selected individual. */
const [selection, setSelection] = useState<IndiInfo>();
/** Error to display. */
const [error, setError] = useState<string>();
/** 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>(ChartType.Hourglass);
/** Whether to show the error popup. */
const [showErrorPopup, setShowErrorPopup] = useState(false);
/** Specification of the source of the data. */
const [sourceSpec, setSourceSpec] = useState<DataSourceSpec>();
/** Freeze animations after initial chart render. */
const [freezeAnimation, setFreezeAnimation] = useState(false);
const [config, setConfig] = useState(DEFALUT_CONFIG);
const intl = useIntl();
const history = useHistory();
const location = useLocation();
/** Sets the state with a new individual selection and chart type. */
function updateDisplay(newSelection: IndiInfo) {
if (
!selection ||
selection.id !== newSelection.id ||
selection!.generation !== newSelection.generation
) {
setSelection(newSelection);
}
}
function toggleDetails(config: Config, data: TopolaData | undefined) {
if (data === undefined) {
return;
}
let shouldHideIds = config.id === Ids.HIDE;
let shouldHideSex = config.sex === Sex.HIDE;
let indiMap = idToIndiMap(data.chartData);
indiMap.forEach((indi) => {
indi.hideId = shouldHideIds;
indi.hideSex = shouldHideSex;
});
}
/** Sets error message after data load failure. */
function setErrorMessage(message: string) {
setError(message);
setState(AppState.ERROR);
}
const uploadedDataSource = new UploadedDataSource();
const gedcomUrlDataSource = new GedcomUrlDataSource();
const wikiTreeDataSource = new WikiTreeDataSource(intl);
const embeddedDataSource = new EmbeddedDataSource();
function isNewData(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<UploadSourceSpec>,
oldSouce as SourceSelection<UploadSourceSpec>,
data,
);
case DataSourceEnum.GEDCOM_URL:
return gedcomUrlDataSource.isNewData(
newSource as SourceSelection<UrlSourceSpec>,
oldSouce as SourceSelection<UrlSourceSpec>,
data,
);
case DataSourceEnum.WIKITREE:
return wikiTreeDataSource.isNewData(
newSource as SourceSelection<WikiTreeSourceSpec>,
oldSouce as SourceSelection<WikiTreeSourceSpec>,
data,
);
case DataSourceEnum.EMBEDDED:
return embeddedDataSource.isNewData(
newSource as SourceSelection<EmbeddedSourceSpec>,
oldSouce as SourceSelection<EmbeddedSourceSpec>,
data,
);
}
}
function loadData(newSourceSpec: DataSourceSpec, newSelection?: IndiInfo) {
switch (newSourceSpec.source) {
case DataSourceEnum.UPLOADED:
return uploadedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.GEDCOM_URL:
return gedcomUrlDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.WIKITREE:
return wikiTreeDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
case DataSourceEnum.EMBEDDED:
return embeddedDataSource.loadData({
spec: newSourceSpec,
selection: newSelection,
});
}
}
useEffect(() => {
(async () => {
if (location.pathname !== '/view') {
if (state !== AppState.INITIAL) {
setState(AppState.INITIAL);
}
return;
}
const args = getArguments(location);
if (!args.sourceSpec) {
history.replace({pathname: '/'});
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);
setStandalone(args.standalone);
setShowWikiTreeMenus(args.showWikiTreeMenus);
setChartType(args.chartType);
setFreezeAnimation(args.freezeAnimation);
setConfig(args.config);
try {
const data = await loadData(args.sourceSpec, args.selection);
// Set state with data.
setData(data);
toggleDetails(args.config, data);
setShowSidePanel(args.showSidePanel);
setState(AppState.SHOWING_CHART);
} catch (error: any) {
setErrorMessage(getI18nMessage(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,
);
updateDisplay(args.selection!);
if (loadMoreFromWikitree) {
try {
const data = await loadWikiTree(args.selection!.id, intl);
const newSelection = getSelection(data.chartData, args.selection);
setData(data);
setSelection(newSelection);
setState(AppState.SHOWING_CHART);
} catch (error: any) {
setState(AppState.SHOWING_CHART);
displayErrorPopup(
intl.formatMessage(
{
id: 'error.failed_wikitree_load_more',
defaultMessage: 'Failed to load data from WikiTree. {error}',
},
{error},
),
);
}
}
}
})();
});
function updateUrl(args: queryString.ParsedQuery<any>) {
const search = queryString.parse(location.search);
for (const key in args) {
search[key] = args[key];
}
location.search = queryString.stringify(search);
history.push(location);
}
/**
* 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: selection.generation,
});
}
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 renderMainArea() {
switch (state) {
case AppState.SHOWING_CHART:
case AppState.LOADING_MORE:
const updatedSelection = getSelection(data!.chartData, selection);
const sidePanelTabs = [
{
menuItem: intl.formatMessage({
id: 'tab.info',
defaultMessage: 'Info',
}),
render: () => (
<Details gedcom={data!.gedcom} indi={updatedSelection.id} />
),
},
{
menuItem: intl.formatMessage({
id: 'tab.settings',
defaultMessage: 'Settings',
}),
render: () => (
<ConfigPanel
config={config}
onChange={(config) => {
setConfig(config);
toggleDetails(config, data);
updateUrl(configToArgs(config));
}}
/>
),
},
];
return (
<div id="content">
<ErrorPopup
open={showErrorPopup}
message={error}
onDismiss={onDismissErrorPopup}
/>
{state === AppState.LOADING_MORE ? (
<Loader active size="small" className="loading-more" />
) : null}
<Chart
data={data!.chartData}
selection={updatedSelection}
chartType={chartType}
onSelection={onSelection}
freezeAnimation={freezeAnimation}
colors={config.color}
hideIds={config.id}
hideSex={config.sex}
/>
{showSidePanel ? (
<Media greaterThanOrEqual="large" className="sidePanel">
<Tab panes={sidePanelTabs} />
</Media>
) : null}
<Changelog />
</div>
);
case AppState.ERROR:
return <ErrorMessage message={error!} />;
case AppState.INITIAL:
case AppState.LOADING:
return <Loader active size="large" />;
}
}
return (
<>
<Route
render={() => (
<TopBar
data={data?.chartData}
allowAllRelativesChart={
sourceSpec?.source !== DataSourceEnum.WIKITREE
}
showingChart={
history.location.pathname === '/view' &&
(state === AppState.SHOWING_CHART ||
state === AppState.LOADING_MORE)
}
standalone={standalone}
eventHandlers={{
onSelection,
onPrint,
onDownloadPdf,
onDownloadPng,
onDownloadSvg,
}}
showWikiTreeMenus={
sourceSpec?.source === DataSourceEnum.WIKITREE &&
showWikiTreeMenus
}
/>
)}
/>
<Switch>
<Route exact path="/" component={Intro} />
<Route exact path="/view" render={renderMainArea} />
<Redirect to={'/'} />
</Switch>
</>
);
}