mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-03-11 18:13:43 +00:00
503 lines
15 KiB
TypeScript
503 lines
15 KiB
TypeScript
import * as queryString from 'query-string';
|
|
import * as React from 'react';
|
|
import {analyticsEvent} from './analytics';
|
|
import {Chart, ChartType} from './chart';
|
|
import {Details} from './details';
|
|
import {FormattedMessage} from 'react-intl';
|
|
import {getSelection, loadFromUrl, loadGedcom} from './load_data';
|
|
import {getSoftware, TopolaData} from './gedcom_util';
|
|
import {IndiInfo} from 'topola';
|
|
import {intlShape} from 'react-intl';
|
|
import {Intro} from './intro';
|
|
import {Loader, Message, Portal, Responsive} from 'semantic-ui-react';
|
|
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
|
|
import {TopBar} from './top_bar';
|
|
import {loadWikiTree} from './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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Message types used in embedded mode.
|
|
* When the parent is ready to receive messages, it sends PARENT_READY.
|
|
* When the child (this app) is ready to receive messages, it sends READY.
|
|
* When the child receives PARENT_READY, it sends READY.
|
|
* When the parent receives READY, it sends data in a GEDCOM message.
|
|
*/
|
|
enum EmbeddedMessageType {
|
|
GEDCOM = 'gedcom',
|
|
READY = 'ready',
|
|
PARENT_READY = 'parent_ready',
|
|
}
|
|
|
|
/** Message sent to parent or received from parent in embedded mode. */
|
|
interface EmbeddedMessage {
|
|
message: EmbeddedMessageType;
|
|
}
|
|
|
|
interface GedcomMessage extends EmbeddedMessage {
|
|
message: EmbeddedMessageType.GEDCOM;
|
|
gedcom?: string;
|
|
}
|
|
|
|
/** Returs true if the changes object has values that are different than those in state. */
|
|
function hasUpdatedValues<T>(state: T, changes: Partial<T> | undefined) {
|
|
if (!changes) {
|
|
return false;
|
|
}
|
|
return Object.entries(changes).some(
|
|
([key, value]) => value !== undefined && state[key] !== value,
|
|
);
|
|
}
|
|
|
|
interface State {
|
|
/** Loaded data. */
|
|
data?: TopolaData;
|
|
/** Selected individual. */
|
|
selection?: IndiInfo;
|
|
/** Hash of the GEDCOM contents. */
|
|
hash?: string;
|
|
/** Error to display. */
|
|
error?: string;
|
|
/** True if data is currently being loaded. */
|
|
loading: boolean;
|
|
/** URL of the data that is loaded or is being loaded. */
|
|
url?: string;
|
|
/** Whether the side panel is shown. */
|
|
showSidePanel?: boolean;
|
|
/** Whether the app is in embedded mode, i.e. embedded in an iframe. */
|
|
embedded: boolean;
|
|
/** Whether the app is in standalone mode, i.e. showing 'open file' menus. */
|
|
standalone: boolean;
|
|
/** Type of displayed chart. */
|
|
chartType: ChartType;
|
|
/** Whether to show the error popup. */
|
|
showErrorPopup: boolean;
|
|
/** True if data is loaded from WikiTree. */
|
|
wikiTreeSource: boolean;
|
|
loadingMore?: boolean;
|
|
}
|
|
|
|
export class App extends React.Component<RouteComponentProps, {}> {
|
|
state: State = {
|
|
loading: false,
|
|
embedded: false,
|
|
standalone: true,
|
|
chartType: ChartType.Hourglass,
|
|
showErrorPopup: false,
|
|
wikiTreeSource: false,
|
|
};
|
|
chartRef: Chart | null = null;
|
|
|
|
/** Make intl appear in this.context. */
|
|
static contextTypes = {
|
|
intl: intlShape,
|
|
};
|
|
|
|
private isNewData(
|
|
hash: string | undefined,
|
|
url: string | undefined,
|
|
gedcom: string | undefined,
|
|
source: string | undefined,
|
|
): boolean {
|
|
return (
|
|
!!(hash && hash !== this.state.hash) ||
|
|
!!(url && this.state.url !== url) ||
|
|
(!!gedcom && !this.state.loading && !this.state.data) ||
|
|
(source === 'wikitree' &&
|
|
!this.state.loading &&
|
|
!this.state.data &&
|
|
!this.state.error)
|
|
);
|
|
}
|
|
|
|
/** Sets the state with a new individual selection and chart type. */
|
|
private updateDisplay(
|
|
selection: IndiInfo,
|
|
otherStateChanges?: Partial<State>,
|
|
) {
|
|
if (
|
|
!this.state.selection ||
|
|
this.state.selection.id !== selection.id ||
|
|
this.state.selection!.generation !== selection.generation ||
|
|
hasUpdatedValues(this.state, otherStateChanges)
|
|
) {
|
|
this.setState(
|
|
Object.assign({}, this.state, {selection}, otherStateChanges),
|
|
);
|
|
}
|
|
}
|
|
|
|
/** Sets error message after data load failure. */
|
|
private setError(error: string) {
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
error: error,
|
|
loading: false,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private async onMessage(message: EmbeddedMessage) {
|
|
if (message.message === EmbeddedMessageType.PARENT_READY) {
|
|
// Parent didn't receive the first 'ready' message, so we need to send it again.
|
|
window.parent.postMessage({message: EmbeddedMessageType.READY}, '*');
|
|
} else if (message.message === EmbeddedMessageType.GEDCOM) {
|
|
const gedcom = (message as GedcomMessage).gedcom;
|
|
if (!gedcom) {
|
|
return;
|
|
}
|
|
try {
|
|
const data = await loadGedcom('', gedcom);
|
|
const software = getSoftware(data.gedcom.head);
|
|
analyticsEvent('embedded_file_loaded', {
|
|
event_label: software,
|
|
});
|
|
// Set state with data.
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
data,
|
|
selection: getSelection(data.chartData),
|
|
error: undefined,
|
|
loading: false,
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
analyticsEvent('embedded_file_error');
|
|
this.setError(error.message);
|
|
}
|
|
}
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.componentDidUpdate();
|
|
}
|
|
|
|
async componentDidUpdate() {
|
|
if (this.props.location.pathname !== '/view') {
|
|
return;
|
|
}
|
|
|
|
const search = queryString.parse(this.props.location.search);
|
|
const getParam = (name: string) => {
|
|
const value = search[name];
|
|
return typeof value === 'string' ? value : undefined;
|
|
};
|
|
|
|
const showSidePanel = getParam('sidePanel') !== 'false'; // True by default.
|
|
const embedded = getParam('embedded') === 'true'; // False by default.
|
|
|
|
if (embedded && !this.state.embedded) {
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
embedded: true,
|
|
standalone: false,
|
|
showSidePanel,
|
|
}),
|
|
);
|
|
// Notify the parent window that we are ready.
|
|
window.parent.postMessage('ready', '*');
|
|
window.addEventListener('message', (data) => this.onMessage(data.data));
|
|
}
|
|
if (embedded) {
|
|
// If the app is embedded, do not run the normal loading code.
|
|
return;
|
|
}
|
|
|
|
const url = getParam('url');
|
|
const indi = getParam('indi');
|
|
const parsedGen = Number(getParam('gen'));
|
|
const generation = !isNaN(parsedGen) ? parsedGen : undefined;
|
|
const hash = getParam('file');
|
|
const handleCors = getParam('handleCors') !== 'false'; // True by default.
|
|
const standalone = getParam('standalone') !== 'false'; // True by default.
|
|
const view = getParam('view');
|
|
const source = getParam('source');
|
|
const authcode = getParam('?authcode');
|
|
|
|
const chartTypes = new Map<string | undefined, ChartType>([
|
|
['relatives', ChartType.Relatives],
|
|
['fancy', ChartType.Fancy],
|
|
]);
|
|
// Hourglass is the default view.
|
|
const chartType = chartTypes.get(view) || ChartType.Hourglass;
|
|
|
|
const gedcom = this.props.location.state && this.props.location.state.data;
|
|
const images =
|
|
this.props.location.state && this.props.location.state.images;
|
|
|
|
if (!url && !hash && !source) {
|
|
this.props.history.replace({pathname: '/'});
|
|
} else if (this.isNewData(hash, url, gedcom, source)) {
|
|
try {
|
|
// Set loading state.
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
data: undefined,
|
|
selection: undefined,
|
|
hash,
|
|
error: undefined,
|
|
loading: true,
|
|
url,
|
|
standalone,
|
|
chartType,
|
|
wikiTreeSource: source === 'wikitree',
|
|
}),
|
|
);
|
|
const data =
|
|
source === 'wikitree'
|
|
? await loadWikiTree(indi!, authcode)
|
|
: hash
|
|
? await loadGedcom(hash, gedcom, images)
|
|
: await loadFromUrl(url!, handleCors);
|
|
|
|
const software = getSoftware(data.gedcom.head);
|
|
if (source === 'wikitree') {
|
|
analyticsEvent('wikitree_loaded');
|
|
} else {
|
|
analyticsEvent(hash ? 'upload_file_loaded' : 'url_file_loaded', {
|
|
event_label: software,
|
|
event_value: (images && images.size) || 0,
|
|
});
|
|
}
|
|
|
|
// Set state with data.
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
data,
|
|
hash,
|
|
selection: getSelection(data.chartData, indi, generation),
|
|
error: undefined,
|
|
loading: false,
|
|
url,
|
|
showSidePanel,
|
|
standalone,
|
|
chartType,
|
|
wikiTreeSource: source === 'wikitree',
|
|
}),
|
|
);
|
|
} catch (error) {
|
|
analyticsEvent(hash ? 'upload_file_error' : 'url_file_error');
|
|
this.setError(error.message);
|
|
}
|
|
} else if (this.state.data && this.state.selection) {
|
|
// Update selection if it has changed in the URL.
|
|
const selection = getSelection(
|
|
this.state.data.chartData,
|
|
indi,
|
|
generation,
|
|
);
|
|
const loadMoreFromWikitree =
|
|
source === 'wikitree' &&
|
|
(!this.state.selection || this.state.selection.id !== selection.id);
|
|
this.updateDisplay(selection, {
|
|
chartType,
|
|
loadingMore: loadMoreFromWikitree || undefined,
|
|
});
|
|
if (loadMoreFromWikitree) {
|
|
const data = await loadWikiTree(indi!);
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
data,
|
|
hash,
|
|
selection: getSelection(data.chartData, indi, generation),
|
|
error: undefined,
|
|
loading: false,
|
|
url,
|
|
showSidePanel,
|
|
standalone,
|
|
chartType,
|
|
wikiTreeSource: source === 'wikitree',
|
|
loadingMore: false,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Called when the user clicks an individual box in the chart.
|
|
* Updates the browser URL.
|
|
*/
|
|
private onSelection = (selection: IndiInfo) => {
|
|
analyticsEvent('selection_changed');
|
|
if (this.state.embedded) {
|
|
// In embedded mode the URL doesn't change.
|
|
this.updateDisplay(selection);
|
|
return;
|
|
}
|
|
const location = this.props.location;
|
|
const search = queryString.parse(location.search);
|
|
search.indi = selection.id;
|
|
search.gen = String(selection.generation);
|
|
location.search = queryString.stringify(search);
|
|
this.props.history.push(location);
|
|
};
|
|
|
|
private onPrint = () => {
|
|
analyticsEvent('print');
|
|
this.chartRef && this.chartRef.print();
|
|
};
|
|
|
|
private showErrorPopup(message: string) {
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
showErrorPopup: true,
|
|
error: message,
|
|
}),
|
|
);
|
|
}
|
|
|
|
private onDownloadPdf = async () => {
|
|
analyticsEvent('download_pdf');
|
|
try {
|
|
this.chartRef && (await this.chartRef.downloadPdf());
|
|
} catch (e) {
|
|
this.showErrorPopup(
|
|
this.context.intl.formatMessage({
|
|
id: 'error.failed_pdf',
|
|
defaultMessage:
|
|
'Failed to generate PDF file.' +
|
|
' Please try with a smaller diagram or download an SVG file.',
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
private onDownloadPng = async () => {
|
|
analyticsEvent('download_png');
|
|
try {
|
|
this.chartRef && (await this.chartRef.downloadPng());
|
|
} catch (e) {
|
|
this.showErrorPopup(
|
|
this.context.intl.formatMessage({
|
|
id: 'error.failed_png',
|
|
defaultMessage:
|
|
'Failed to generate PNG file.' +
|
|
' Please try with a smaller diagram or download an SVG file.',
|
|
}),
|
|
);
|
|
}
|
|
};
|
|
|
|
private onDownloadSvg = () => {
|
|
analyticsEvent('download_svg');
|
|
this.chartRef && this.chartRef.downloadSvg();
|
|
};
|
|
|
|
onDismissErrorPopup = () => {
|
|
this.setState(
|
|
Object.assign({}, this.state, {
|
|
showErrorPopup: false,
|
|
}),
|
|
);
|
|
};
|
|
|
|
private renderMainArea = () => {
|
|
if (this.state.data && this.state.selection) {
|
|
return (
|
|
<div id="content">
|
|
<ErrorPopup
|
|
open={this.state.showErrorPopup}
|
|
message={this.state.error}
|
|
onDismiss={this.onDismissErrorPopup}
|
|
/>
|
|
{this.state.loadingMore ? (
|
|
<Loader active inline size="small" />
|
|
) : null}
|
|
<Chart
|
|
data={this.state.data.chartData}
|
|
selection={this.state.selection}
|
|
chartType={this.state.chartType}
|
|
onSelection={this.onSelection}
|
|
ref={(ref) => (this.chartRef = ref)}
|
|
/>
|
|
{this.state.showSidePanel ? (
|
|
<Responsive minWidth={768} id="sidePanel">
|
|
<Details
|
|
gedcom={this.state.data.gedcom}
|
|
indi={this.state.selection.id}
|
|
/>
|
|
</Responsive>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
if (this.state.error) {
|
|
return <ErrorMessage message={this.state.error!} />;
|
|
}
|
|
return <Loader active size="large" />;
|
|
};
|
|
|
|
render() {
|
|
return (
|
|
<>
|
|
<Route
|
|
render={(props: RouteComponentProps) => (
|
|
<TopBar
|
|
{...props}
|
|
gedcom={this.state.data && this.state.data.gedcom}
|
|
allowAllRelativesChart={!this.state.wikiTreeSource}
|
|
showingChart={
|
|
!!(
|
|
this.props.history.location.pathname === '/view' &&
|
|
this.state.data &&
|
|
this.state.selection
|
|
)
|
|
}
|
|
standalone={this.state.standalone}
|
|
eventHandlers={{
|
|
onSelection: this.onSelection,
|
|
onPrint: this.onPrint,
|
|
onDownloadPdf: this.onDownloadPdf,
|
|
onDownloadPng: this.onDownloadPng,
|
|
onDownloadSvg: this.onDownloadSvg,
|
|
}}
|
|
showWikiTreeLogin={this.state.wikiTreeSource}
|
|
/>
|
|
)}
|
|
/>
|
|
<Switch>
|
|
<Route exact path="/" component={Intro} />
|
|
<Route exact path="/view" render={this.renderMainArea} />
|
|
<Redirect to={'/'} />
|
|
</Switch>
|
|
</>
|
|
);
|
|
}
|
|
}
|