Added WikiTree login button

This commit is contained in:
Przemek Wiech
2020-02-02 00:11:14 +01:00
parent 0993af760d
commit 8e33f92a04
6 changed files with 229 additions and 61 deletions

10
package-lock.json generated
View File

@@ -1784,6 +1784,11 @@
"integrity": "sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==", "integrity": "sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA==",
"dev": true "dev": true
}, },
"@types/js-cookie": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-2.2.4.tgz",
"integrity": "sha512-WTfSE1Eauak/Nrg6cA9FgPTFvVawejsai6zXoq0QYTQ3mxONeRtGhKxa7wMlUzWWmzrmTeV+rwLjHgsCntdrsA=="
},
"@types/jspdf": { "@types/jspdf": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-1.2.2.tgz",
@@ -9354,6 +9359,11 @@
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.4.1.tgz",
"integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw==" "integrity": "sha512-36+AdBzCL+y6qjw5Tx7HgzeGCzC81MDDgaUP8ld2zhx58HdqXGoBd+tHdrBMiyjGQs0Hxs/MLZTu/eHNJJuWPw=="
}, },
"js-cookie": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz",
"integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ=="
},
"js-levenshtein": { "js-levenshtein": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz", "resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",

View File

@@ -3,6 +3,7 @@
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.tsx", "main": "src/index.tsx",
"dependencies": { "dependencies": {
"@types/js-cookie": "^2.2.4",
"array.prototype.flatmap": "^1.2.1", "array.prototype.flatmap": "^1.2.1",
"canvas-toBlob": "^1.0.0", "canvas-toBlob": "^1.0.0",
"d3": "^5.7.0", "d3": "^5.7.0",
@@ -11,6 +12,7 @@
"file-saver": "^2.0.1", "file-saver": "^2.0.1",
"history": "^4.7.2", "history": "^4.7.2",
"javascript-natural-sort": "^0.7.1", "javascript-natural-sort": "^0.7.1",
"js-cookie": "^2.2.1",
"jspdf": "^1.5.3", "jspdf": "^1.5.3",
"lunr": "^2.3.6", "lunr": "^2.3.6",
"md5": "^2.2.1", "md5": "^2.2.1",

View File

@@ -247,6 +247,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
const standalone = getParam('standalone') !== 'false'; // True by default. const standalone = getParam('standalone') !== 'false'; // True by default.
const view = getParam('view'); const view = getParam('view');
const source = getParam('source'); const source = getParam('source');
const authcode = getParam('?authcode');
const chartTypes = new Map<string | undefined, ChartType>([ const chartTypes = new Map<string | undefined, ChartType>([
['relatives', ChartType.Relatives], ['relatives', ChartType.Relatives],
@@ -278,7 +279,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
); );
const data = const data =
source === 'wikitree' source === 'wikitree'
? await loadWikiTree(indi!) ? await loadWikiTree(indi!, authcode)
: hash : hash
? await loadGedcom(hash, gedcom, images) ? await loadGedcom(hash, gedcom, images)
: await loadFromUrl(url!, handleCors); : await loadFromUrl(url!, handleCors);
@@ -485,6 +486,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
onDownloadPng: this.onDownloadPng, onDownloadPng: this.onDownloadPng,
onDownloadSvg: this.onDownloadSvg, onDownloadSvg: this.onDownloadSvg,
}} }}
showWikiTreeLogin={this.state.wikiTreeSource}
/> />
)} )}
/> />

View File

@@ -1,5 +1,6 @@
import * as queryString from 'query-string'; import * as queryString from 'query-string';
import * as React from 'react'; import * as React from 'react';
import Cookies from 'js-cookie';
import debounce from 'debounce'; import debounce from 'debounce';
import md5 from 'md5'; import md5 from 'md5';
import {analyticsEvent} from './analytics'; import {analyticsEvent} from './analytics';
@@ -24,10 +25,18 @@ import {
SearchResultProps, SearchResultProps,
} from 'semantic-ui-react'; } from 'semantic-ui-react';
enum WikiTreeLoginState {
UNKNOWN,
NOT_LOGGED_IN,
LOGGED_IN,
}
/** Menus and dialogs state. */ /** Menus and dialogs state. */
interface State { interface State {
loadUrlDialogOpen: boolean; loadUrlDialogOpen: boolean;
url?: string; url?: string;
wikiTreeLoginState: WikiTreeLoginState;
wikiTreeLoginUsername?: string;
searchResults: SearchResultProps[]; searchResults: SearchResultProps[];
} }
@@ -48,6 +57,8 @@ interface Props {
/** Whether to show the "All relatives" chart type in the menu. */ /** Whether to show the "All relatives" chart type in the menu. */
allowAllRelativesChart: boolean; allowAllRelativesChart: boolean;
eventHandlers: EventHandlers; eventHandlers: EventHandlers;
/** Whether to show the 'Log in to WikiTree' button. */
showWikiTreeLogin: boolean;
} }
function loadFileAsText(file: File): Promise<string> { function loadFileAsText(file: File): Promise<string> {
@@ -72,13 +83,21 @@ export class TopBar extends React.Component<
state: State = { state: State = {
loadUrlDialogOpen: false, loadUrlDialogOpen: false,
searchResults: [], searchResults: [],
wikiTreeLoginState: WikiTreeLoginState.UNKNOWN,
}; };
inputRef?: Input; /** Make intl appear in this.context. */
static contextTypes = {
intl: intlShape,
};
urlInputRef: React.RefObject<Input> = React.createRef();
wikiTreeLoginFormRef: React.RefObject<HTMLFormElement> = React.createRef();
wikiTreeReturnUrlRef: React.RefObject<HTMLInputElement> = React.createRef();
searchRef?: {setValue(value: string): void}; searchRef?: {setValue(value: string): void};
searchIndex?: SearchIndex; searchIndex?: SearchIndex;
/** Handles the "Upload file" button. */ /** Handles the "Upload file" button. */
async handleUpload(event: React.SyntheticEvent<HTMLInputElement>) { private async handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
const files = (event.target as HTMLInputElement).files; const files = (event.target as HTMLInputElement).files;
if (!files || !files.length) { if (!files || !files.length) {
return; return;
@@ -132,20 +151,20 @@ export class TopBar extends React.Component<
} }
/** Opens the "Load from URL" dialog. */ /** Opens the "Load from URL" dialog. */
handleLoadFromUrl() { private openLoadUrlDialog() {
this.setState( this.setState(
Object.assign({}, this.state, {loadUrlDialogOpen: true}), Object.assign({}, this.state, {loadUrlDialogOpen: true}),
() => this.inputRef!.focus(), () => this.urlInputRef.current!.focus(),
); );
} }
/** Cancels the "Load from URL" dialog. */ /** Cancels the "Load from URL" dialog. */
handleClose() { private handleClose() {
this.setState(Object.assign({}, this.state, {loadUrlDialogOpen: false})); this.setState(Object.assign({}, this.state, {loadUrlDialogOpen: false}));
} }
/** Upload button clicked in the "Load from URL" dialog. */ /** Upload button clicked in the "Load from URL" dialog. */
handleLoad() { private handleLoad() {
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
loadUrlDialogOpen: false, loadUrlDialogOpen: false,
@@ -161,7 +180,7 @@ export class TopBar extends React.Component<
} }
/** Called when the URL input is typed into. */ /** Called when the URL input is typed into. */
handleUrlChange(event: React.SyntheticEvent) { private handleUrlChange(event: React.SyntheticEvent) {
this.setState( this.setState(
Object.assign({}, this.state, { Object.assign({}, this.state, {
url: (event.target as HTMLInputElement).value, url: (event.target as HTMLInputElement).value,
@@ -170,7 +189,7 @@ export class TopBar extends React.Component<
} }
/** On search input change. */ /** On search input change. */
handleSearch(input: string | undefined) { private handleSearch(input: string | undefined) {
if (!input) { if (!input) {
return; return;
} }
@@ -181,13 +200,13 @@ export class TopBar extends React.Component<
} }
/** On search result selected. */ /** On search result selected. */
handleResultSelect(id: string) { private handleResultSelect(id: string) {
analyticsEvent('search_result_selected'); analyticsEvent('search_result_selected');
this.props.eventHandlers.onSelection({id, generation: 0}); this.props.eventHandlers.onSelection({id, generation: 0});
this.searchRef!.setValue(''); this.searchRef!.setValue('');
} }
initializeSearchIndex() { private initializeSearchIndex() {
if (this.props.gedcom) { if (this.props.gedcom) {
this.searchIndex = buildSearchIndex(this.props.gedcom); this.searchIndex = buildSearchIndex(this.props.gedcom);
} }
@@ -203,23 +222,50 @@ export class TopBar extends React.Component<
} }
} }
componentDidMount() { /**
* 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 wikiTreeLoginState =
Cookies.get('wikidb_wtb_UserID') !== undefined
? WikiTreeLoginState.LOGGED_IN
: WikiTreeLoginState.NOT_LOGGED_IN;
if (this.state.wikiTreeLoginState !== wikiTreeLoginState) {
const wikiTreeLoginUsername = Cookies.get('wikidb_wtb_UserName');
this.setState(
Object.assign({}, this.state, {
wikiTreeLoginState,
wikiTreeLoginUsername,
}),
);
}
}
async componentDidMount() {
this.checkWikiTreeLoginState();
this.initializeSearchIndex(); this.initializeSearchIndex();
} }
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
this.checkWikiTreeLoginState();
if (prevProps.gedcom !== this.props.gedcom) { if (prevProps.gedcom !== this.props.gedcom) {
this.initializeSearchIndex(); this.initializeSearchIndex();
} }
} }
/** Make intl appear in this.context. */ private loadFromUrlModal() {
static contextTypes = { return (
intl: intlShape,
};
render() {
const loadFromUrlModal = (
<Modal <Modal
open={this.state.loadUrlDialogOpen} open={this.state.loadUrlDialogOpen}
onClose={() => this.handleClose()} onClose={() => this.handleClose()}
@@ -239,7 +285,7 @@ export class TopBar extends React.Component<
placeholder="https://" placeholder="https://"
fluid fluid
onChange={(e) => this.handleUrlChange(e)} onChange={(e) => this.handleUrlChange(e)}
ref={(ref) => (this.inputRef = ref!)} ref={this.urlInputRef}
/> />
<p> <p>
<FormattedMessage <FormattedMessage
@@ -271,8 +317,13 @@ export class TopBar extends React.Component<
</Modal.Actions> </Modal.Actions>
</Modal> </Modal>
); );
}
const chartMenus = this.props.showingChart ? ( private chartMenus() {
if (!this.props.showingChart) {
return null;
}
return (
<> <>
<Menu.Item as="a" onClick={() => this.props.eventHandlers.onPrint()}> <Menu.Item as="a" onClick={() => this.props.eventHandlers.onPrint()}>
<Icon name="print" /> <Icon name="print" />
@@ -367,16 +418,21 @@ export class TopBar extends React.Component<
} }
/> />
</> </>
) : null; );
}
const fileMenus = this.props.standalone ? ( private fileMenus() {
if (!this.props.standalone) {
return null;
}
return (
<> <>
<Link to="/"> <Link to="/">
<Menu.Item> <Menu.Item>
<b>Topola Genealogy</b> <b>Topola Genealogy</b>
</Menu.Item> </Menu.Item>
</Link> </Link>
<Menu.Item as="a" onClick={() => this.handleLoadFromUrl()}> <Menu.Item as="a" onClick={() => this.openLoadUrlDialog()}>
<Icon name="cloud download" /> <Icon name="cloud download" />
<FormattedMessage <FormattedMessage
id="menu.load_from_url" id="menu.load_from_url"
@@ -401,44 +457,111 @@ export class TopBar extends React.Component<
</Menu.Item> </Menu.Item>
</label> </label>
</> </>
) : null;
const sourceLink = this.props.standalone ? (
<>
<Menu.Item
as="a"
href="https://github.com/PeWu/topola-viewer"
position="right"
target="_blank"
>
<FormattedMessage
id="menu.github"
defaultMessage="Source on GitHub"
/>
</Menu.Item>
</>
) : (
<>
<Menu.Item
as="a"
href="https://pewu.github.com/topola-viewer"
position="right"
target="_blank"
>
<FormattedMessage
id="menu.powered_by"
defaultMessage="Powered by Topola"
/>
</Menu.Item>
</>
); );
}
private wikiTreeLoginMenu() {
if (!this.props.showWikiTreeLogin) {
return null;
}
const wikiTreeLogoUrl =
'https://www.wikitree.com/photo.php/a/a5/WikiTree_Images.png';
switch (this.state.wikiTreeLoginState) {
case WikiTreeLoginState.NOT_LOGGED_IN:
return (
<Menu.Item as="a" onClick={() => this.wikiTreeLogin()}>
<img
src={wikiTreeLogoUrl}
alt="WikiTree logo"
style={{width: '24px', height: '24px'}}
/>
<FormattedMessage
id="menu.wikitree_login"
defaultMessage="Log in to WikiTree"
/>
<form
action="https://apps.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>
</Menu.Item>
);
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 (
<Menu.Item title={tooltip}>
<img
src={wikiTreeLogoUrl}
alt="WikiTree logo"
style={{width: '24px', height: '24px'}}
/>
<FormattedMessage
id="menu.wikitree_logged_in"
defaultMessage="Logged in"
/>
</Menu.Item>
);
default:
return null;
}
}
private sourceLink() {
return (
<Menu.Item
as="a"
href="https://github.com/PeWu/topola-viewer"
target="_blank"
>
<FormattedMessage id="menu.github" defaultMessage="Source on GitHub" />
</Menu.Item>
);
}
private poweredByLink() {
return (
<Menu.Item
as="a"
href="https://pewu.github.com/topola-viewer"
target="_blank"
>
<FormattedMessage
id="menu.powered_by"
defaultMessage="Powered by Topola"
/>
</Menu.Item>
);
}
render() {
return ( return (
<Menu attached="top" inverted color="blue" size="large"> <Menu attached="top" inverted color="blue" size="large">
{fileMenus} {this.fileMenus()}
{chartMenus} {this.chartMenus()}
{sourceLink} <Menu.Menu position="right">
{loadFromUrlModal} {this.wikiTreeLoginMenu()}
{this.props.standalone ? this.sourceLink() : this.poweredByLink()}
</Menu.Menu>
{this.loadFromUrlModal()}
</Menu> </Menu>
); );
} }

View File

@@ -10,6 +10,10 @@
"menu.hourglass": "Wykres klepsydrowy", "menu.hourglass": "Wykres klepsydrowy",
"menu.relatives": "Wszyscy krewni", "menu.relatives": "Wszyscy krewni",
"menu.fancy": "Ozdobne drzewo (eksperymentalne)", "menu.fancy": "Ozdobne drzewo (eksperymentalne)",
"menu.wikitree_login": "Zaloguj do WikiTree",
"menu.wikitree_logged_in": "Zalogowano",
"menu.wikitree_popup_username": "Zalogowano do WikiTree jako {username}",
"menu.wikitree_popup": "Zalogowano do WikiTree",
"menu.github": "Źródła na GitHub", "menu.github": "Źródła na GitHub",
"menu.powered_by": "Topola Genealogy", "menu.powered_by": "Topola Genealogy",
"menu.search.placeholder": "Szukaj osoby", "menu.search.placeholder": "Szukaj osoby",

View File

@@ -1,5 +1,6 @@
import {GedcomData, TopolaData} from './gedcom_util'; import Cookies from 'js-cookie';
import {Date, JsonFam, JsonIndi} from 'topola'; import {Date, JsonFam, JsonIndi} from 'topola';
import {GedcomData, TopolaData} from './gedcom_util';
import {GedcomEntry} from 'parse-gedcom'; import {GedcomEntry} from 'parse-gedcom';
/** WikiTree API getAncestors request. */ /** WikiTree API getAncestors request. */
@@ -17,7 +18,12 @@ interface GetRelatives {
getSpouses?: true; getSpouses?: true;
} }
type WikiTreeRequest = GetAncestorsRequest | GetRelatives; interface ClientLogin {
action: 'clientLogin';
authcode: string;
}
type WikiTreeRequest = GetAncestorsRequest | GetRelatives | ClientLogin;
/** Person structure returned from WikiTree API. */ /** Person structure returned from WikiTree API. */
interface Person { interface Person {
@@ -128,14 +134,35 @@ async function getRelatives(keys: string[], handleCors: boolean) {
return result.concat(fetchedResults); return result.concat(fetchedResults);
} }
export async function clientLogin(authcode: string) {
const response = await wikiTreeGet(
{
action: 'clientLogin',
authcode,
},
false,
);
return response.clientLogin;
}
/** /**
* Loads data from WikiTree to populate an hourglass chart starting from the * Loads data from WikiTree to populate an hourglass chart starting from the
* given person ID. * given person ID.
*/ */
export async function loadWikiTree(key: string): Promise<TopolaData> { export async function loadWikiTree(
key: string,
authcode?: string,
): Promise<TopolaData> {
// Work around CORS if not in apps.wikitree.com domain. // Work around CORS if not in apps.wikitree.com domain.
const handleCors = window.location.hostname !== 'apps.wikitree.com'; const handleCors = window.location.hostname !== 'apps.wikitree.com';
if (!handleCors && !Cookies.get('wikidb_wtb_UserID') && authcode) {
const loginResult = await clientLogin(authcode);
if (loginResult.result === 'Success') {
sessionStorage.clear();
}
}
const everyone: Person[] = []; const everyone: Person[] = [];
// Fetch the ancestors of the input person and ancestors of his/her spouses. // Fetch the ancestors of the input person and ancestors of his/her spouses.