mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-08-03 09:31:52 +00:00
Merged ChartView component into App to push state up.
Hide "print" and "download" buttons when not displaying a chart.
This commit is contained in:
183
src/app.tsx
183
src/app.tsx
@@ -1,42 +1,185 @@
|
|||||||
|
import * as queryString from 'query-string';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import {ChartView} from './chart_view';
|
import {Chart} from './chart';
|
||||||
import {HashRouter as Router, Route, RouteComponentProps, Switch} from 'react-router-dom';
|
import {getSelection, loadFromUrl, loadGedcom} from './load_data';
|
||||||
|
import {IndiInfo, JsonGedcomData} from 'topola';
|
||||||
import {Intro} from './intro';
|
import {Intro} from './intro';
|
||||||
|
import {Loader, Message} from 'semantic-ui-react';
|
||||||
|
import {Route, RouteComponentProps, Switch, Redirect} from 'react-router-dom';
|
||||||
import {TopBar} from './top_bar';
|
import {TopBar} from './top_bar';
|
||||||
|
|
||||||
export class App extends React.Component<{}, {}> {
|
/** Shows an error message. */
|
||||||
chartViewRef?: ChartView;
|
export function ErrorMessage(props: {message: string}) {
|
||||||
|
return (
|
||||||
|
<Message negative className="error">
|
||||||
|
<Message.Header>Failed to load file</Message.Header>
|
||||||
|
<p>{props.message}</p>
|
||||||
|
</Message>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
/** Loaded data. */
|
||||||
|
data?: JsonGedcomData;
|
||||||
|
/** Selected individual. */
|
||||||
|
selection?: IndiInfo;
|
||||||
|
/** Hash of the GEDCOM contents. */
|
||||||
|
hash?: string;
|
||||||
|
/** Error to display. */
|
||||||
|
error?: string;
|
||||||
|
/** True if currently loading. */
|
||||||
|
loading: boolean;
|
||||||
|
/** URL of the data that is loaded or is being loaded. */
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class App extends React.Component<RouteComponentProps, {}> {
|
||||||
|
state: State = {loading: false};
|
||||||
|
chartRef: Chart | null = null;
|
||||||
|
|
||||||
|
private isNewData(
|
||||||
|
hash: string | undefined,
|
||||||
|
url: string | undefined,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
!!(hash && hash !== this.state.hash) || !!(url && this.state.url !== url)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.componentDidUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
if (this.props.location.pathname !== '/view') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gedcom = this.props.location.state && this.props.location.state.data;
|
||||||
|
const search = queryString.parse(this.props.location.search);
|
||||||
|
const getParam = (name: string) => {
|
||||||
|
const value = search[name];
|
||||||
|
return typeof value === 'string' ? value : undefined;
|
||||||
|
};
|
||||||
|
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';
|
||||||
|
|
||||||
|
if (!url && !hash) {
|
||||||
|
this.props.history.replace({pathname: '/'});
|
||||||
|
} else if (this.isNewData(hash, url)) {
|
||||||
|
const loadedData = hash
|
||||||
|
? loadGedcom(hash, gedcom)
|
||||||
|
: loadFromUrl(url!, handleCors);
|
||||||
|
loadedData.then(
|
||||||
|
(data) => {
|
||||||
|
// Set state with data.
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
data,
|
||||||
|
hash,
|
||||||
|
selection: getSelection(data, indi, generation),
|
||||||
|
error: undefined,
|
||||||
|
loading: false,
|
||||||
|
url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
// Set error state.
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
error: error.message,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Set loading state.
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
data: undefined,
|
||||||
|
selection: undefined,
|
||||||
|
hash,
|
||||||
|
error: undefined,
|
||||||
|
loading: true,
|
||||||
|
url,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (this.state.data && this.state.selection) {
|
||||||
|
// Update selection if it has changed in the URL.
|
||||||
|
const selection = getSelection(this.state.data, indi, generation);
|
||||||
|
if (
|
||||||
|
this.state.selection.id !== selection.id ||
|
||||||
|
this.state.selection.generation !== selection.generation
|
||||||
|
) {
|
||||||
|
this.setState(
|
||||||
|
Object.assign({}, this.state, {
|
||||||
|
selection,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the user clicks an individual box in the chart.
|
||||||
|
* Updates the browser URL.
|
||||||
|
*/
|
||||||
|
private onSelection = (selection: IndiInfo) => {
|
||||||
|
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 renderMainArea = () => {
|
||||||
|
if (this.state.data && this.state.selection) {
|
||||||
|
return (
|
||||||
|
<Chart
|
||||||
|
data={this.state.data}
|
||||||
|
onSelection={this.onSelection}
|
||||||
|
selection={this.state.selection}
|
||||||
|
ref={(ref) => (this.chartRef = ref)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (this.state.error) {
|
||||||
|
return <ErrorMessage message={this.state.error!} />;
|
||||||
|
}
|
||||||
|
return <Loader active size="large" />;
|
||||||
|
};
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<Router>
|
|
||||||
<>
|
<>
|
||||||
<Route
|
<Route
|
||||||
component={(props: RouteComponentProps) => (
|
render={(props: RouteComponentProps) => (
|
||||||
<TopBar
|
<TopBar
|
||||||
{...props}
|
{...props}
|
||||||
onPrint={() => this.chartViewRef && this.chartViewRef.print()}
|
showingChart={
|
||||||
onDownloadSvg={() =>
|
!!(
|
||||||
this.chartViewRef && this.chartViewRef.downloadSvg()
|
this.props.history.location.pathname === '/view' &&
|
||||||
}
|
this.state.data &&
|
||||||
onDownloadPng={() =>
|
this.state.selection
|
||||||
this.chartViewRef && this.chartViewRef.downloadPng()
|
)
|
||||||
}
|
}
|
||||||
|
onPrint={() => this.chartRef && this.chartRef.print()}
|
||||||
|
onDownloadSvg={() => this.chartRef && this.chartRef.downloadSvg()}
|
||||||
|
onDownloadPng={() => this.chartRef && this.chartRef.downloadPng()}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route exact path="/" component={Intro} />
|
<Route exact path="/" component={Intro} />
|
||||||
<Route
|
<Route exact path="/view" render={this.renderMainArea} />
|
||||||
exact
|
<Redirect to={'/'} />
|
||||||
path="/view"
|
|
||||||
component={(props: RouteComponentProps) => (
|
|
||||||
<ChartView {...props} ref={(ref) => (this.chartViewRef = ref!)} />
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</Switch>
|
</Switch>
|
||||||
</>
|
</>
|
||||||
</Router>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,168 +0,0 @@
|
|||||||
import * as queryString from 'query-string';
|
|
||||||
import * as React from 'react';
|
|
||||||
import {Chart} from './chart';
|
|
||||||
import {IndiInfo, JsonGedcomData} from 'topola';
|
|
||||||
import {Loader, Message} from 'semantic-ui-react';
|
|
||||||
import {RouteComponentProps} from 'react-router-dom';
|
|
||||||
import {getSelection, loadFromUrl, loadGedcom} from './load_data';
|
|
||||||
|
|
||||||
/** Shows an error message. */
|
|
||||||
export function ErrorMessage(props: {message: string}) {
|
|
||||||
return (
|
|
||||||
<Message negative className="error">
|
|
||||||
<Message.Header>Failed to load file</Message.Header>
|
|
||||||
<p>{props.message}</p>
|
|
||||||
</Message>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface State {
|
|
||||||
/** Loaded data. */
|
|
||||||
data?: JsonGedcomData;
|
|
||||||
/** Selected individual. */
|
|
||||||
selection?: IndiInfo;
|
|
||||||
/** Hash of the GEDCOM contents. */
|
|
||||||
hash?: string;
|
|
||||||
/** Error to display. */
|
|
||||||
error?: string;
|
|
||||||
/** True if currently loading. */
|
|
||||||
loading: boolean;
|
|
||||||
/** URL of the data that is loaded or is being loaded. */
|
|
||||||
url?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The main area of the application dedicated for rendering the family chart. */
|
|
||||||
export class ChartView extends React.Component<RouteComponentProps, State> {
|
|
||||||
state: State = {loading: false};
|
|
||||||
chartRef: Chart | null = null;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the user clicks an individual box in the chart.
|
|
||||||
* Updates the browser URL.
|
|
||||||
*/
|
|
||||||
onSelection = (selection: IndiInfo) => {
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
|
|
||||||
isNewData(hash: string | undefined, url: string | undefined): boolean {
|
|
||||||
return (
|
|
||||||
!!(hash && hash !== this.state.hash) || !!(url && this.state.url !== url)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this.componentDidUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate() {
|
|
||||||
const gedcom = this.props.location.state && this.props.location.state.data;
|
|
||||||
const search = queryString.parse(this.props.location.search);
|
|
||||||
const getParam = (name: string) => {
|
|
||||||
const value = search[name];
|
|
||||||
return typeof value === 'string' ? value : undefined;
|
|
||||||
};
|
|
||||||
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';
|
|
||||||
|
|
||||||
if (!url && !hash) {
|
|
||||||
this.props.history.replace({pathname: '/'});
|
|
||||||
} else if (this.isNewData(hash, url)) {
|
|
||||||
const loadedData = hash
|
|
||||||
? loadGedcom(hash, gedcom)
|
|
||||||
: loadFromUrl(url!, handleCors);
|
|
||||||
loadedData.then(
|
|
||||||
(data) => {
|
|
||||||
// Set state with data.
|
|
||||||
this.setState(
|
|
||||||
Object.assign({}, this.state, {
|
|
||||||
data,
|
|
||||||
hash,
|
|
||||||
selection: getSelection(data, indi, generation),
|
|
||||||
error: undefined,
|
|
||||||
loading: false,
|
|
||||||
url,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
(error) => {
|
|
||||||
// Set error state.
|
|
||||||
this.setState(
|
|
||||||
Object.assign({}, this.state, {
|
|
||||||
error: error.message,
|
|
||||||
loading: false,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
// Set loading state.
|
|
||||||
this.setState(
|
|
||||||
Object.assign({}, this.state, {
|
|
||||||
data: undefined,
|
|
||||||
selection: undefined,
|
|
||||||
hash,
|
|
||||||
error: undefined,
|
|
||||||
loading: true,
|
|
||||||
url,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else if (this.state.data && this.state.selection) {
|
|
||||||
// Update selection if it has changed in the URL.
|
|
||||||
const selection = getSelection(this.state.data, indi, generation);
|
|
||||||
if (
|
|
||||||
this.state.selection.id !== selection.id ||
|
|
||||||
this.state.selection.generation !== selection.generation
|
|
||||||
) {
|
|
||||||
this.setState(
|
|
||||||
Object.assign({}, this.state, {
|
|
||||||
selection,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.data && this.state.selection) {
|
|
||||||
return (
|
|
||||||
<Chart
|
|
||||||
data={this.state.data}
|
|
||||||
onSelection={this.onSelection}
|
|
||||||
selection={this.state.selection}
|
|
||||||
ref={(ref) => (this.chartRef = ref)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (this.state.error) {
|
|
||||||
return <ErrorMessage message={this.state.error!} />;
|
|
||||||
}
|
|
||||||
return <Loader active size="large" />;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Shows the print dialog to print the currently displayed chart. */
|
|
||||||
print() {
|
|
||||||
if (this.chartRef) {
|
|
||||||
this.chartRef.print();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadSvg() {
|
|
||||||
if (this.chartRef) {
|
|
||||||
this.chartRef.downloadSvg();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
downloadPng() {
|
|
||||||
if (this.chartRef) {
|
|
||||||
this.chartRef.downloadPng();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ import messages_pl from './translations/pl.json';
|
|||||||
import {addLocaleData} from 'react-intl';
|
import {addLocaleData} from 'react-intl';
|
||||||
import {App} from './app';
|
import {App} from './app';
|
||||||
import {detect} from 'detect-browser';
|
import {detect} from 'detect-browser';
|
||||||
|
import {HashRouter as Router, Route} from 'react-router-dom';
|
||||||
import {IntlProvider} from 'react-intl';
|
import {IntlProvider} from 'react-intl';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
import 'semantic-ui-css/semantic.min.css';
|
import 'semantic-ui-css/semantic.min.css';
|
||||||
@@ -30,7 +31,9 @@ if (browser && browser.name === 'ie') {
|
|||||||
} else {
|
} else {
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
<IntlProvider locale={language} messages={messages[language]}>
|
<IntlProvider locale={language} messages={messages[language]}>
|
||||||
<App />
|
<Router>
|
||||||
|
<Route component={App} />
|
||||||
|
</Router>
|
||||||
</IntlProvider>,
|
</IntlProvider>,
|
||||||
document.querySelector('#root'),
|
document.querySelector('#root'),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface State {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
showingChart: boolean;
|
||||||
onPrint: () => void;
|
onPrint: () => void;
|
||||||
onDownloadSvg: () => void;
|
onDownloadSvg: () => void;
|
||||||
onDownloadPng: () => void;
|
onDownloadPng: () => void;
|
||||||
@@ -144,6 +145,33 @@ export class TopBar extends React.Component<
|
|||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const chartMenus = this.props.showingChart ? (
|
||||||
|
<>
|
||||||
|
<Menu.Item as="a" onClick={() => this.props.onPrint()}>
|
||||||
|
<Icon name="print" />
|
||||||
|
<FormattedMessage id="menu.print" defaultMessage="Print" />
|
||||||
|
</Menu.Item>
|
||||||
|
<Dropdown
|
||||||
|
trigger={
|
||||||
|
<div>
|
||||||
|
<Icon name="download" />
|
||||||
|
<FormattedMessage id="menu.download" defaultMessage="Download" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
className="item"
|
||||||
|
>
|
||||||
|
<Dropdown.Menu>
|
||||||
|
<Dropdown.Item onClick={() => this.props.onDownloadPng()}>
|
||||||
|
<FormattedMessage id="menu.png_file" defaultMessage="PNG file" />
|
||||||
|
</Dropdown.Item>
|
||||||
|
<Dropdown.Item onClick={() => this.props.onDownloadSvg()}>
|
||||||
|
<FormattedMessage id="menu.svg_file" defaultMessage="SVG file" />
|
||||||
|
</Dropdown.Item>
|
||||||
|
</Dropdown.Menu>
|
||||||
|
</Dropdown>
|
||||||
|
</>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu attached="top" inverted color="blue" size="large">
|
<Menu attached="top" inverted color="blue" size="large">
|
||||||
<Link to="/">
|
<Link to="/">
|
||||||
@@ -174,28 +202,7 @@ export class TopBar extends React.Component<
|
|||||||
/>
|
/>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</label>
|
</label>
|
||||||
<Menu.Item as="a" onClick={() => this.props.onPrint()}>
|
{chartMenus}
|
||||||
<Icon name="print" />
|
|
||||||
<FormattedMessage id="menu.print" defaultMessage="Print" />
|
|
||||||
</Menu.Item>
|
|
||||||
<Dropdown
|
|
||||||
trigger={
|
|
||||||
<div>
|
|
||||||
<Icon name="download" />
|
|
||||||
<FormattedMessage id="menu.download" defaultMessage="Download" />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
className="item"
|
|
||||||
>
|
|
||||||
<Dropdown.Menu>
|
|
||||||
<Dropdown.Item onClick={() => this.props.onDownloadPng()}>
|
|
||||||
<FormattedMessage id="menu.png_file" defaultMessage="PNG file" />
|
|
||||||
</Dropdown.Item>
|
|
||||||
<Dropdown.Item onClick={() => this.props.onDownloadSvg()}>
|
|
||||||
<FormattedMessage id="menu.svg_file" defaultMessage="SVG file" />
|
|
||||||
</Dropdown.Item>
|
|
||||||
</Dropdown.Menu>
|
|
||||||
</Dropdown>
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
as="a"
|
as="a"
|
||||||
href="https://github.com/PeWu/topola-viewer"
|
href="https://github.com/PeWu/topola-viewer"
|
||||||
|
|||||||
Reference in New Issue
Block a user