Refactoring: extracted load menus to separate files

This commit is contained in:
Przemek Wiech 2020-05-04 21:37:09 +02:00
parent a61b4848a5
commit eb31f13030
5 changed files with 625 additions and 547 deletions

32
src/menu/menu_item.tsx Normal file
View File

@ -0,0 +1,32 @@
import * as React from 'react';
import {
Menu,
Dropdown,
MenuItemProps,
DropdownItemProps,
} from 'semantic-ui-react';
export enum MenuType {
Menu,
Dropdown,
}
interface Props {
menuType: MenuType;
}
export class MenuItem extends React.Component<
Props & MenuItemProps & DropdownItemProps
> {
render() {
return (
<>
{this.props.menuType === MenuType.Menu ? (
<Menu.Item {...this.props}>{this.props.children}</Menu.Item>
) : (
<Dropdown.Item {...this.props}>{this.props.children}</Dropdown.Item>
)}
</>
);
}
}

View File

@ -1,24 +1,20 @@
import * as queryString from 'query-string';
import * as React from 'react';
import debounce from 'debounce';
import md5 from 'md5';
import wikitreeLogo from './wikitree.png';
import {analyticsEvent} from '../util/analytics';
import {buildSearchIndex, SearchIndex} from './search_index';
import {displaySearchResult} from './search_util';
import {FormattedMessage, intlShape} from 'react-intl';
import {getLoggedInUserName} from '../datasource/wikitree';
import {IndiInfo, JsonGedcomData} from 'topola';
import {Link} from 'react-router-dom';
import {MenuType} from './menu_item';
import {RouteComponentProps} from 'react-router-dom';
import {UploadMenu} from './upload_menu';
import {UrlMenu} from './url_menu';
import {WikiTreeLoginMenu, WikiTreeMenu} from './wikitree_menu';
import {
Header,
Button,
Icon,
Menu,
Modal,
Input,
Form,
Dropdown,
Search,
SearchProps,
@ -26,12 +22,6 @@ import {
Responsive,
} from 'semantic-ui-react';
enum WikiTreeLoginState {
UNKNOWN,
NOT_LOGGED_IN,
LOGGED_IN,
}
enum ScreenSize {
LARGE,
SMALL,
@ -39,12 +29,6 @@ enum ScreenSize {
/** Menus and dialogs state. */
interface State {
loadUrlDialogOpen: boolean;
wikiTreeIdDialogOpen: boolean;
url?: string;
wikiTreeId?: string;
wikiTreeLoginState: WikiTreeLoginState;
wikiTreeLoginUsername?: string;
searchResults: SearchResultProps[];
}
@ -69,179 +53,21 @@ interface Props {
showWikiTreeMenus: boolean;
}
function loadFileAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (evt: ProgressEvent) => {
resolve((evt.target as FileReader).result as string);
};
reader.readAsText(file);
});
}
function isImageFileName(fileName: string) {
const lower = fileName.toLowerCase();
return lower.endsWith('.jpg') || lower.endsWith('.png');
}
export class TopBar extends React.Component<
RouteComponentProps & Props,
State
> {
state: State = {
loadUrlDialogOpen: false,
wikiTreeIdDialogOpen: false,
searchResults: [],
wikiTreeLoginState: WikiTreeLoginState.UNKNOWN,
};
/** Make intl appear in this.context. */
static contextTypes = {
intl: intlShape,
};
urlInputRef: React.RefObject<Input> = React.createRef();
wikiTreeIdInputRef: React.RefObject<Input> = React.createRef();
wikiTreeLoginFormRef: React.RefObject<HTMLFormElement> = React.createRef();
wikiTreeReturnUrlRef: React.RefObject<HTMLInputElement> = React.createRef();
searchRef?: {setValue(value: string): void};
searchIndex?: SearchIndex;
/** Handles the "Upload file" button. */
private async handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
const files = (event.target as HTMLInputElement).files;
if (!files || !files.length) {
return;
}
const filesArray = Array.from(files);
(event.target as HTMLInputElement).value = ''; // Reset the file input.
analyticsEvent('upload_files_selected', {
event_value: files.length,
});
const gedcomFile =
filesArray.length === 1
? filesArray[0]
: filesArray.find((file) => file.name.toLowerCase().endsWith('.ged')) ||
filesArray[0];
// Convert uploaded images to object URLs.
const images = filesArray
.filter(
(file) => file.name !== gedcomFile.name && isImageFileName(file.name),
)
.map((file) => ({
name: file.name,
url: URL.createObjectURL(file),
}));
const imageMap = new Map(
images.map((entry) => [entry.name, entry.url] as [string, string]),
);
const data = await loadFileAsText(gedcomFile);
const imageFileNames = images
.map((image) => image.name)
.sort()
.join('|');
// Hash GEDCOM contents with uploaded image file names.
const hash = md5(md5(data) + imageFileNames);
// Use history.replace() when reuploading the same file and history.push() when loading
// a new file.
const search = queryString.parse(this.props.location.search);
const historyPush =
search.file === hash
? this.props.history.replace
: this.props.history.push;
historyPush({
pathname: '/view',
search: queryString.stringify({file: hash}),
state: {data, images: imageMap},
});
}
/** Opens the "Load from URL" dialog. */
private openLoadUrlDialog() {
this.setState(
Object.assign({}, this.state, {loadUrlDialogOpen: true}),
() => this.urlInputRef.current!.focus(),
);
}
private openWikiTreeIdDialog() {
this.setState(
Object.assign({}, this.state, {wikiTreeIdDialogOpen: true}),
() => this.wikiTreeIdInputRef.current!.focus(),
);
}
/** Cancels any of the open dialogs. */
private handleClose() {
this.setState(
Object.assign({}, this.state, {
loadUrlDialogOpen: false,
wikiTreeIdDialogOpen: false,
}),
);
}
/** Load button clicked in the "Load from URL" dialog. */
private handleLoad() {
this.setState(
Object.assign({}, this.state, {
loadUrlDialogOpen: false,
}),
);
if (this.state.url) {
analyticsEvent('url_selected');
this.props.history.push({
pathname: '/view',
search: queryString.stringify({url: this.state.url}),
});
}
}
/** Select button clicked in the "Select WikiTree ID" dialog. */
private handleSelectWikiTreeId() {
this.setState(
Object.assign({}, this.state, {
wikiTreeIdDialogOpen: false,
}),
);
if (this.state.wikiTreeId) {
analyticsEvent('wikitree_id_selected');
const search = queryString.parse(this.props.location.search);
const standalone =
search.standalone !== undefined ? search.standalone : true;
this.props.history.push({
pathname: '/view',
search: queryString.stringify({
indi: this.state.wikiTreeId,
source: 'wikitree',
standalone,
}),
});
}
}
/** Called when the URL input is typed into. */
private handleUrlChange(value: string) {
this.setState(
Object.assign({}, this.state, {
url: value,
}),
);
}
/** Called when the URL input is typed into. */
private handleWikiTreeIdChange(value: string) {
this.setState(
Object.assign({}, this.state, {
wikiTreeId: value,
}),
);
}
/** On search input change. */
private handleSearch(input: string | undefined) {
if (!input) {
@ -266,7 +92,7 @@ export class TopBar extends React.Component<
}
}
changeView(view: string) {
private changeView(view: string) {
const location = this.props.location;
const search = queryString.parse(location.search);
if (search.view !== view) {
@ -276,192 +102,16 @@ export class TopBar extends React.Component<
}
}
/**
* Redirect to the WikiTree Apps login page with a return URL pointing to
* Topola Viewer hosted on apps.wikitree.com.
*/
private wikiTreeLogin() {
const wikiTreeTopolaUrl =
'https://apps.wikitree.com/apps/wiech13/topola-viewer';
// Append '&' because the login page appends '?authcode=...' to this URL.
// TODO: remove ?authcode if it is in the current URL.
const returnUrl = `${wikiTreeTopolaUrl}${window.location.hash}&`;
this.wikiTreeReturnUrlRef.current!.value = returnUrl;
this.wikiTreeLoginFormRef.current!.submit();
}
private checkWikiTreeLoginState() {
const wikiTreeLoginUsername = getLoggedInUserName();
const wikiTreeLoginState = wikiTreeLoginUsername
? WikiTreeLoginState.LOGGED_IN
: WikiTreeLoginState.NOT_LOGGED_IN;
if (this.state.wikiTreeLoginState !== wikiTreeLoginState) {
this.setState(
Object.assign({}, this.state, {
wikiTreeLoginState,
wikiTreeLoginUsername,
}),
);
}
}
async componentDidMount() {
this.checkWikiTreeLoginState();
componentDidMount() {
this.initializeSearchIndex();
}
componentDidUpdate(prevProps: Props) {
this.checkWikiTreeLoginState();
if (prevProps.data !== this.props.data) {
this.initializeSearchIndex();
}
}
private loadFromUrlModal() {
return (
<Modal
open={this.state.loadUrlDialogOpen}
onClose={() => this.handleClose()}
centered={false}
>
<Header>
<Icon name="cloud download" />
<FormattedMessage
id="load_from_url.title"
defaultMessage="Load from URL"
children={(txt) => txt}
/>
</Header>
<Modal.Content>
<Form onSubmit={() => this.handleLoad()}>
<Input
placeholder="https://"
fluid
onChange={(e, data) => this.handleUrlChange(data.value)}
ref={this.urlInputRef}
/>
<p>
<FormattedMessage
id="load_from_url.comment"
defaultMessage={
'Data from the URL will be loaded through {link} to avoid CORS issues.'
}
values={{
link: (
<a href="https://cors-anywhere.herokuapp.com/">
cors-anywhere.herokuapp.com
</a>
),
}}
/>
</p>
</Form>
</Modal.Content>
<Modal.Actions>
<Button secondary onClick={() => this.handleClose()}>
<FormattedMessage
id="load_from_url.cancel"
defaultMessage="Cancel"
/>
</Button>
<Button primary onClick={() => this.handleLoad()}>
<FormattedMessage id="load_from_url.load" defaultMessage="Load" />
</Button>
</Modal.Actions>
</Modal>
);
}
private enterWikiTreeId(event: React.MouseEvent, id: string) {
event.preventDefault(); // Do not follow link in href.
((this.wikiTreeIdInputRef.current as unknown) as {
inputRef: HTMLInputElement;
}).inputRef.value = id;
this.handleWikiTreeIdChange(id);
this.wikiTreeIdInputRef.current!.focus();
}
private wikiTreeIdModal() {
return (
<Modal
open={this.state.wikiTreeIdDialogOpen}
onClose={() => this.handleClose()}
centered={false}
>
<Header>
<img
src={wikitreeLogo}
alt="WikiTree logo"
style={{width: '32px', height: '32px'}}
/>
<FormattedMessage
id="select_wikitree_id.title"
defaultMessage="Select WikiTree ID"
children={(txt) => txt}
/>
</Header>
<Modal.Content>
<Form onSubmit={() => this.handleSelectWikiTreeId()}>
<p>
<FormattedMessage
id="select_wikitree_id.comment"
defaultMessage={
'Enter a {wikiTreeLink} profile ID. Examples: {example1}, {example2}.'
}
values={{
wikiTreeLink: (
<a
href="https://wikitree.com/"
target="_blank"
rel="noopener noreferrer"
>
WikiTree
</a>
),
example1: (
<span
onClick={(e) => this.enterWikiTreeId(e, 'Wojtyla-13')}
className="link-span"
>
Wojtyla-13
</span>
),
example2: (
<span
onClick={(e) => this.enterWikiTreeId(e, 'Skłodowska-2')}
className="link-span"
>
Skłodowska-2
</span>
),
}}
/>
</p>
<Input
fluid
onChange={(e, data) => this.handleWikiTreeIdChange(data.value)}
ref={this.wikiTreeIdInputRef}
/>
</Form>
</Modal.Content>
<Modal.Actions>
<Button secondary onClick={() => this.handleClose()}>
<FormattedMessage
id="select_wikitree_id.cancel"
defaultMessage="Cancel"
/>
</Button>
<Button primary onClick={() => this.handleSelectWikiTreeId()}>
<FormattedMessage
id="select_wikitree_id.load"
defaultMessage="Load"
/>
</Button>
</Modal.Actions>
</Modal>
);
}
private search() {
return (
<Search
@ -641,36 +291,16 @@ export class TopBar extends React.Component<
}
private fileMenus(screenSize: ScreenSize) {
const loadWikiTreeItem = (
<>
<img src={wikitreeLogo} alt="WikiTree logo" className="menu-icon" />
<FormattedMessage
id="menu.select_wikitree_id"
defaultMessage="Select WikiTree ID"
/>
</>
);
// In standalone WikiTree mode, show only the "Select WikiTree ID" menu.
if (!this.props.standalone && this.props.showWikiTreeMenus) {
switch (screenSize) {
case ScreenSize.LARGE:
return (
<>
<Menu.Item onClick={() => this.openWikiTreeIdDialog()}>
{loadWikiTreeItem}
</Menu.Item>
{this.wikiTreeIdModal()}
</>
);
return <WikiTreeMenu menuType={MenuType.Menu} {...this.props} />;
case ScreenSize.SMALL:
return (
<>
<Dropdown.Item onClick={() => this.openWikiTreeIdDialog()}>
{loadWikiTreeItem}
</Dropdown.Item>
<WikiTreeMenu menuType={MenuType.Dropdown} {...this.props} />
<Dropdown.Divider />
{this.wikiTreeIdModal()}
</>
);
}
@ -681,35 +311,6 @@ export class TopBar extends React.Component<
return null;
}
const openFileItem = (
<>
<Icon name="folder open" />
<FormattedMessage id="menu.open_file" defaultMessage="Open file" />
</>
);
const loadUrlItem = (
<>
<Icon name="cloud download" />
<FormattedMessage
id="menu.load_from_url"
defaultMessage="Load from URL"
/>
</>
);
const commonElements = (
<>
{this.loadFromUrlModal()}
{this.wikiTreeIdModal()}
<input
className="hidden"
type="file"
accept=".ged,image/*"
id="fileInput"
multiple
onChange={(e) => this.handleUpload(e)}
/>
</>
);
switch (screenSize) {
case ScreenSize.LARGE:
// Show dropdown if chart is shown, otherwise show individual menu
@ -725,51 +326,27 @@ export class TopBar extends React.Component<
className="item"
>
<Dropdown.Menu>
<Dropdown.Item as="label" htmlFor="fileInput">
{openFileItem}
</Dropdown.Item>
<Dropdown.Item onClick={() => this.openLoadUrlDialog()}>
{loadUrlItem}
</Dropdown.Item>
<Dropdown.Item onClick={() => this.openWikiTreeIdDialog()}>
{loadWikiTreeItem}
</Dropdown.Item>
<UploadMenu menuType={MenuType.Dropdown} {...this.props} />
<UrlMenu menuType={MenuType.Dropdown} {...this.props} />
<WikiTreeMenu menuType={MenuType.Dropdown} {...this.props} />
</Dropdown.Menu>
</Dropdown>
) : (
<>
<label htmlFor="fileInput">
<Menu.Item as="a">{openFileItem}</Menu.Item>
</label>
<Menu.Item onClick={() => this.openLoadUrlDialog()}>
{loadUrlItem}
</Menu.Item>
<Menu.Item onClick={() => this.openWikiTreeIdDialog()}>
{loadWikiTreeItem}
</Menu.Item>
</>
);
return (
<>
{menus}
{commonElements}
<UploadMenu menuType={MenuType.Menu} {...this.props} />
<UrlMenu menuType={MenuType.Menu} {...this.props} />
<WikiTreeMenu menuType={MenuType.Menu} {...this.props} />
</>
);
return menus;
case ScreenSize.SMALL:
return (
<>
<Dropdown.Item as="label" htmlFor="fileInput">
{openFileItem}
</Dropdown.Item>
<Dropdown.Item onClick={() => this.openLoadUrlDialog()}>
{loadUrlItem}
</Dropdown.Item>
<Dropdown.Item onClick={() => this.openWikiTreeIdDialog()}>
{loadWikiTreeItem}
</Dropdown.Item>
<UploadMenu menuType={MenuType.Dropdown} {...this.props} />
<UrlMenu menuType={MenuType.Dropdown} {...this.props} />
<WikiTreeMenu menuType={MenuType.Dropdown} {...this.props} />
<Dropdown.Divider />
{commonElements}
</>
);
}
@ -779,112 +356,17 @@ export class TopBar extends React.Component<
if (!this.props.showWikiTreeMenus) {
return null;
}
switch (this.state.wikiTreeLoginState) {
case WikiTreeLoginState.NOT_LOGGED_IN:
const loginForm = (
<form
action="https://api.wikitree.com/api.php"
method="POST"
style={{display: 'hidden'}}
ref={this.wikiTreeLoginFormRef}
>
<input type="hidden" name="action" value="clientLogin" />
<input
type="hidden"
name="returnURL"
ref={this.wikiTreeReturnUrlRef}
/>
</form>
);
switch (screenSize) {
case ScreenSize.LARGE:
return (
<Menu.Item onClick={() => this.wikiTreeLogin()}>
<img
src={wikitreeLogo}
alt="WikiTree logo"
className="menu-icon"
/>
<FormattedMessage
id="menu.wikitree_login"
defaultMessage="Log in to WikiTree"
/>
{loginForm}
</Menu.Item>
);
case ScreenSize.SMALL:
return (
<>
<Dropdown.Item onClick={() => this.wikiTreeLogin()}>
<img
src={wikitreeLogo}
alt="WikiTree logo"
className="menu-icon"
/>
<FormattedMessage
id="menu.wikitree_login"
defaultMessage="Log in to WikiTree"
/>
{loginForm}
</Dropdown.Item>
<Dropdown.Divider />
</>
);
}
break;
case WikiTreeLoginState.LOGGED_IN:
const tooltip = this.state.wikiTreeLoginUsername
? this.context.intl.formatMessage(
{
id: 'menu.wikitree_popup_username',
defaultMessage: 'Logged in to WikiTree as {username}',
},
{username: this.state.wikiTreeLoginUsername},
)
: this.context.intl.formatMessage({
id: 'menu.wikitree_popup',
defaultMessage: 'Logged in to WikiTree',
});
switch (screenSize) {
case ScreenSize.LARGE:
return (
<Menu.Item title={tooltip}>
<img
src={wikitreeLogo}
alt="WikiTree logo"
className="menu-icon"
/>
<FormattedMessage
id="menu.wikitree_logged_in"
defaultMessage="Logged in"
/>
</Menu.Item>
);
case ScreenSize.SMALL:
return (
<>
<Menu.Item title={tooltip}>
<img
src={wikitreeLogo}
alt="WikiTree logo"
className="menu-icon"
/>
<FormattedMessage
id="menu.wikitree_logged_in"
defaultMessage="Logged in"
/>
</Menu.Item>
<Dropdown.Divider />
</>
);
default:
return null;
}
}
return (
<>
<WikiTreeLoginMenu
menuType={
screenSize === ScreenSize.SMALL ? MenuType.Dropdown : MenuType.Menu
}
{...this.props}
/>
{screenSize === ScreenSize.SMALL ? <Dropdown.Divider /> : null}
</>
);
}
private mobileMenus() {

113
src/menu/upload_menu.tsx Normal file
View File

@ -0,0 +1,113 @@
import * as queryString from 'query-string';
import * as React from 'react';
import md5 from 'md5';
import {analyticsEvent} from '../util/analytics';
import {Dropdown, Icon, Menu} from 'semantic-ui-react';
import {FormattedMessage} from 'react-intl';
import {MenuType} from './menu_item';
import {RouteComponentProps} from 'react-router-dom';
function loadFileAsText(file: File): Promise<string> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = (evt: ProgressEvent) => {
resolve((evt.target as FileReader).result as string);
};
reader.readAsText(file);
});
}
function isImageFileName(fileName: string) {
const lower = fileName.toLowerCase();
return lower.endsWith('.jpg') || lower.endsWith('.png');
}
interface Props {
menuType: MenuType;
}
/** Displays and handles the "Open file" menu. */
export class UploadMenu extends React.Component<RouteComponentProps & Props> {
private async handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
const files = (event.target as HTMLInputElement).files;
if (!files || !files.length) {
return;
}
const filesArray = Array.from(files);
(event.target as HTMLInputElement).value = ''; // Reset the file input.
analyticsEvent('upload_files_selected', {
event_value: files.length,
});
const gedcomFile =
filesArray.length === 1
? filesArray[0]
: filesArray.find((file) => file.name.toLowerCase().endsWith('.ged')) ||
filesArray[0];
// Convert uploaded images to object URLs.
const images = filesArray
.filter(
(file) => file.name !== gedcomFile.name && isImageFileName(file.name),
)
.map((file) => ({
name: file.name,
url: URL.createObjectURL(file),
}));
const imageMap = new Map(
images.map((entry) => [entry.name, entry.url] as [string, string]),
);
const data = await loadFileAsText(gedcomFile);
const imageFileNames = images
.map((image) => image.name)
.sort()
.join('|');
// Hash GEDCOM contents with uploaded image file names.
const hash = md5(md5(data) + imageFileNames);
// Use history.replace() when reuploading the same file and history.push() when loading
// a new file.
const search = queryString.parse(this.props.location.search);
const historyPush =
search.file === hash
? this.props.history.replace
: this.props.history.push;
historyPush({
pathname: '/view',
search: queryString.stringify({file: hash}),
state: {data, images: imageMap},
});
}
render() {
const content = (
<>
<Icon name="folder open" />
<FormattedMessage id="menu.open_file" defaultMessage="Open file" />
</>
);
return (
<>
{this.props.menuType === MenuType.Menu ? (
<label htmlFor="fileInput">
<Menu.Item as="a">{content}</Menu.Item>
</label>
) : (
<Dropdown.Item as="label" htmlFor="fileInput">
{content}
</Dropdown.Item>
)}
<input
className="hidden"
type="file"
accept=".ged,image/*"
id="fileInput"
multiple
onChange={(e) => this.handleUpload(e)}
/>
</>
);
}
}

140
src/menu/url_menu.tsx Normal file
View File

@ -0,0 +1,140 @@
import * as queryString from 'query-string';
import * as React from 'react';
import {analyticsEvent} from '../util/analytics';
import {Button, Form, Header, Icon, Input, Modal} from 'semantic-ui-react';
import {FormattedMessage} from 'react-intl';
import {MenuItem, MenuType} from './menu_item';
import {RouteComponentProps} from 'react-router-dom';
interface Props {
menuType: MenuType;
}
interface State {
dialogOpen: boolean;
url?: string;
}
/** Displays and handles the "Open URL" menu. */
export class UrlMenu extends React.Component<
RouteComponentProps & Props,
State
> {
state: State = {dialogOpen: false};
inputRef: React.RefObject<Input> = React.createRef();
/** Opens the "Load from URL" dialog. */
private openDialog() {
this.setState(Object.assign({}, this.state, {dialogOpen: true}), () =>
this.inputRef.current!.focus(),
);
}
/** Cancels any of the open dialogs. */
private handleClose() {
this.setState(
Object.assign({}, this.state, {
dialogOpen: false,
}),
);
}
/** Load button clicked in the "Load from URL" dialog. */
private handleLoad() {
this.setState(
Object.assign({}, this.state, {
dialogOpen: false,
}),
);
if (this.state.url) {
analyticsEvent('url_selected');
this.props.history.push({
pathname: '/view',
search: queryString.stringify({url: this.state.url}),
});
}
}
/** Called when the URL input is typed into. */
private handleUrlChange(value: string) {
this.setState(
Object.assign({}, this.state, {
url: value,
}),
);
}
private loadFromUrlModal() {
return (
<Modal
open={this.state.dialogOpen}
onClose={() => this.handleClose()}
centered={false}
>
<Header>
<Icon name="cloud download" />
<FormattedMessage
id="load_from_url.title"
defaultMessage="Load from URL"
children={(txt) => txt}
/>
</Header>
<Modal.Content>
<Form onSubmit={() => this.handleLoad()}>
<Input
placeholder="https://"
fluid
onChange={(e, data) => this.handleUrlChange(data.value)}
ref={this.inputRef}
/>
<p>
<FormattedMessage
id="load_from_url.comment"
defaultMessage={
'Data from the URL will be loaded through {link} to avoid CORS issues.'
}
values={{
link: (
<a href="https://cors-anywhere.herokuapp.com/">
cors-anywhere.herokuapp.com
</a>
),
}}
/>
</p>
</Form>
</Modal.Content>
<Modal.Actions>
<Button secondary onClick={() => this.handleClose()}>
<FormattedMessage
id="load_from_url.cancel"
defaultMessage="Cancel"
/>
</Button>
<Button primary onClick={() => this.handleLoad()}>
<FormattedMessage id="load_from_url.load" defaultMessage="Load" />
</Button>
</Modal.Actions>
</Modal>
);
}
render() {
return (
<>
<MenuItem
onClick={() => this.openDialog()}
menuType={this.props.menuType}
>
<Icon name="cloud download" />
<FormattedMessage
id="menu.load_from_url"
defaultMessage="Load from URL"
/>
</MenuItem>
{this.loadFromUrlModal()}
</>
);
}
}

311
src/menu/wikitree_menu.tsx Normal file
View File

@ -0,0 +1,311 @@
import * as queryString from 'query-string';
import * as React from 'react';
import wikitreeLogo from './wikitree.png';
import {analyticsEvent} from '../util/analytics';
import {FormattedMessage, intlShape} from 'react-intl';
import {getLoggedInUserName} from '../datasource/wikitree';
import {MenuItem, MenuType} from './menu_item';
import {RouteComponentProps} from 'react-router-dom';
import {Header, Button, Modal, Input, Form} from 'semantic-ui-react';
enum WikiTreeLoginState {
UNKNOWN,
NOT_LOGGED_IN,
LOGGED_IN,
}
interface Props {
menuType: MenuType;
}
interface State {
dialogOpen: boolean;
wikiTreeId?: string;
}
/** Displays and handles the "Select WikiTree ID" menu. */
export class WikiTreeMenu extends React.Component<
RouteComponentProps & Props,
State
> {
state: State = {
dialogOpen: false,
};
inputRef: React.RefObject<Input> = React.createRef();
private openDialog() {
this.setState(Object.assign({}, this.state, {dialogOpen: true}), () =>
this.inputRef.current!.focus(),
);
}
/** Cancels any of the open dialogs. */
private handleClose() {
this.setState(
Object.assign({}, this.state, {
dialogOpen: false,
}),
);
}
/** Select button clicked in the "Select WikiTree ID" dialog. */
private handleSelectId() {
this.setState(
Object.assign({}, this.state, {
dialogOpen: false,
}),
);
if (this.state.wikiTreeId) {
analyticsEvent('wikitree_id_selected');
const search = queryString.parse(this.props.location.search);
const standalone =
search.standalone !== undefined ? search.standalone : true;
this.props.history.push({
pathname: '/view',
search: queryString.stringify({
indi: this.state.wikiTreeId,
source: 'wikitree',
standalone,
}),
});
}
}
/** Called when the WikiTree ID input is typed into. */
private handleIdChange(value: string) {
this.setState(
Object.assign({}, this.state, {
wikiTreeId: value,
}),
);
}
private enterId(event: React.MouseEvent, id: string) {
event.preventDefault(); // Do not follow link in href.
((this.inputRef.current as unknown) as {
inputRef: HTMLInputElement;
}).inputRef.value = id;
this.handleIdChange(id);
this.inputRef.current!.focus();
}
private wikiTreeIdModal() {
return (
<Modal
open={this.state.dialogOpen}
onClose={() => this.handleClose()}
centered={false}
>
<Header>
<img
src={wikitreeLogo}
alt="WikiTree logo"
style={{width: '32px', height: '32px'}}
/>
<FormattedMessage
id="select_wikitree_id.title"
defaultMessage="Select WikiTree ID"
children={(txt) => txt}
/>
</Header>
<Modal.Content>
<Form onSubmit={() => this.handleSelectId()}>
<p>
<FormattedMessage
id="select_wikitree_id.comment"
defaultMessage={
'Enter a {wikiTreeLink} profile ID. Examples: {example1}, {example2}.'
}
values={{
wikiTreeLink: (
<a
href="https://wikitree.com/"
target="_blank"
rel="noopener noreferrer"
>
WikiTree
</a>
),
example1: (
<span
onClick={(e) => this.enterId(e, 'Wojtyla-13')}
className="link-span"
>
Wojtyla-13
</span>
),
example2: (
<span
onClick={(e) => this.enterId(e, 'Skłodowska-2')}
className="link-span"
>
Skłodowska-2
</span>
),
}}
/>
</p>
<Input
fluid
onChange={(e, data) => this.handleIdChange(data.value)}
ref={this.inputRef}
/>
</Form>
</Modal.Content>
<Modal.Actions>
<Button secondary onClick={() => this.handleClose()}>
<FormattedMessage
id="select_wikitree_id.cancel"
defaultMessage="Cancel"
/>
</Button>
<Button primary onClick={() => this.handleSelectId()}>
<FormattedMessage
id="select_wikitree_id.load"
defaultMessage="Load"
/>
</Button>
</Modal.Actions>
</Modal>
);
}
render() {
return (
<>
<MenuItem
menuType={this.props.menuType}
onClick={() => this.openDialog()}
>
<img src={wikitreeLogo} alt="WikiTree logo" className="menu-icon" />
<FormattedMessage
id="menu.select_wikitree_id"
defaultMessage="Select WikiTree ID"
/>
</MenuItem>
{this.wikiTreeIdModal()}
</>
);
}
}
interface LoginState {
wikiTreeLoginState: WikiTreeLoginState;
wikiTreeLoginUsername?: string;
}
/** Displays and handles the "Log in to WikiTree" menu. */
export class WikiTreeLoginMenu extends React.Component<
RouteComponentProps & Props,
LoginState
> {
state: LoginState = {
wikiTreeLoginState: WikiTreeLoginState.UNKNOWN,
};
/** Make intl appear in this.context. */
static contextTypes = {
intl: intlShape,
};
wikiTreeLoginFormRef: React.RefObject<HTMLFormElement> = React.createRef();
wikiTreeReturnUrlRef: React.RefObject<HTMLInputElement> = React.createRef();
/**
* Redirect to the WikiTree Apps login page with a return URL pointing to
* Topola Viewer hosted on apps.wikitree.com.
*/
private wikiTreeLogin() {
const wikiTreeTopolaUrl =
'https://apps.wikitree.com/apps/wiech13/topola-viewer';
// Append '&' because the login page appends '?authcode=...' to this URL.
// TODO: remove ?authcode if it is in the current URL.
const returnUrl = `${wikiTreeTopolaUrl}${window.location.hash}&`;
this.wikiTreeReturnUrlRef.current!.value = returnUrl;
this.wikiTreeLoginFormRef.current!.submit();
}
private checkWikiTreeLoginState() {
const wikiTreeLoginUsername = getLoggedInUserName();
const wikiTreeLoginState = wikiTreeLoginUsername
? WikiTreeLoginState.LOGGED_IN
: WikiTreeLoginState.NOT_LOGGED_IN;
if (this.state.wikiTreeLoginState !== wikiTreeLoginState) {
this.setState(
Object.assign({}, this.state, {
wikiTreeLoginState,
wikiTreeLoginUsername,
}),
);
}
}
componentDidMount() {
this.checkWikiTreeLoginState();
}
componentDidUpdate() {
this.checkWikiTreeLoginState();
}
render() {
switch (this.state.wikiTreeLoginState) {
case WikiTreeLoginState.NOT_LOGGED_IN:
return (
<>
<MenuItem
menuType={this.props.menuType}
onClick={() => this.wikiTreeLogin()}
>
<img
src={wikitreeLogo}
alt="WikiTree logo"
className="menu-icon"
/>
<FormattedMessage
id="menu.wikitree_login"
defaultMessage="Log in to WikiTree"
/>
</MenuItem>
<form
action="https://api.wikitree.com/api.php"
method="POST"
style={{display: 'hidden'}}
ref={this.wikiTreeLoginFormRef}
>
<input type="hidden" name="action" value="clientLogin" />
<input
type="hidden"
name="returnURL"
ref={this.wikiTreeReturnUrlRef}
/>
</form>
</>
);
case WikiTreeLoginState.LOGGED_IN:
const tooltip = this.state.wikiTreeLoginUsername
? this.context.intl.formatMessage(
{
id: 'menu.wikitree_popup_username',
defaultMessage: 'Logged in to WikiTree as {username}',
},
{username: this.state.wikiTreeLoginUsername},
)
: this.context.intl.formatMessage({
id: 'menu.wikitree_popup',
defaultMessage: 'Logged in to WikiTree',
});
return (
<MenuItem menuType={this.props.menuType} title={tooltip}>
<img src={wikitreeLogo} alt="WikiTree logo" className="menu-icon" />
<FormattedMessage
id="menu.wikitree_logged_in"
defaultMessage="Logged in"
/>
</MenuItem>
);
}
return null;
}
}