mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-02-18 02:55:48 +00:00
Refactoring: extracted load menus to separate files
This commit is contained in:
parent
a61b4848a5
commit
eb31f13030
32
src/menu/menu_item.tsx
Normal file
32
src/menu/menu_item.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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
113
src/menu/upload_menu.tsx
Normal 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
140
src/menu/url_menu.tsx
Normal 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
311
src/menu/wikitree_menu.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user