mirror of
https://github.com/PeWu/topola-viewer.git
synced 2025-12-23 18:50:04 +00:00
Initial version
This commit is contained in:
parent
12d9246add
commit
b5775ef687
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
build
|
||||
node_modules
|
||||
package-lock.json
|
||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"bracketSpacing": false,
|
||||
"arrowParens": "always",
|
||||
"trailingComma": "all"
|
||||
}
|
||||
39
package.json
Normal file
39
package.json
Normal file
@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "topola-viewer",
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.tsx",
|
||||
"dependencies": {
|
||||
"axios": "^0.18.0",
|
||||
"d3": "^5.7.0",
|
||||
"history": "^4.7.2",
|
||||
"md5": "^2.2.1",
|
||||
"query-string": "^6.2.0",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"react-router-dom": "^4.3.1",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^0.84.0",
|
||||
"topola": "^2.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3": "^5.5.0",
|
||||
"@types/history": "^4.7.2",
|
||||
"@types/md5": "^2.1.33",
|
||||
"@types/query-string": "^6.2.0",
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"@types/react-router-dom": "^4.3.1",
|
||||
"@types/jest": "*",
|
||||
"@types/node": "*",
|
||||
"prettier": "^1.15.3",
|
||||
"react-scripts-ts": "latest",
|
||||
"tslint-config-prettier": "^1.17.0",
|
||||
"typescript": "latest"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "react-scripts-ts start",
|
||||
"build": "react-scripts-ts build",
|
||||
"test": "react-scripts-ts test --env=jsdom",
|
||||
"prettier": "prettier --write src/**/*.{ts,tsx}"
|
||||
}
|
||||
}
|
||||
16
public/index.html
Normal file
16
public/index.html
Normal file
@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
|
||||
<title>Topola Genealogy Viewer</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>
|
||||
You need to enable JavaScript to run this app.
|
||||
</noscript>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
129
src/chart.tsx
Normal file
129
src/chart.tsx
Normal file
@ -0,0 +1,129 @@
|
||||
import * as d3 from 'd3';
|
||||
import * as React from 'react';
|
||||
import {
|
||||
JsonGedcomData,
|
||||
ChartHandle,
|
||||
IndiInfo,
|
||||
createChart,
|
||||
DetailedRenderer,
|
||||
HourglassChart,
|
||||
} from 'topola';
|
||||
|
||||
/** Called when the view is dragged with the mouse. */
|
||||
function zoomed() {
|
||||
const svg = d3.select('#chart');
|
||||
const parent = (svg.node() as HTMLElement).parentElement!;
|
||||
parent.scrollLeft = -d3.event.transform.x;
|
||||
parent.scrollTop = -d3.event.transform.y;
|
||||
}
|
||||
|
||||
/** Called when the scrollbars are used. */
|
||||
function scrolled() {
|
||||
const svg = d3.select('#chart');
|
||||
const parent = (svg.node() as HTMLElement).parentElement!;
|
||||
const x = parent.scrollLeft + parent.clientWidth / 2;
|
||||
const y = parent.scrollTop + parent.clientHeight / 2;
|
||||
d3.select(parent).call(d3.zoom().translateTo, x, y);
|
||||
}
|
||||
|
||||
export interface ChartProps {
|
||||
data: JsonGedcomData;
|
||||
selection: IndiInfo;
|
||||
onSelection: (indiInfo: IndiInfo) => void;
|
||||
}
|
||||
|
||||
/** Component showing the genealogy chart and handling transition animations. */
|
||||
export class Chart extends React.PureComponent<ChartProps, {}> {
|
||||
private chart?: ChartHandle;
|
||||
|
||||
/**
|
||||
* Renders the chart or performs a transition animation to a new state.
|
||||
* If indiInfo is not given, it means that it is the initial render and no
|
||||
* animation is performed.
|
||||
*/
|
||||
private renderChart(args: {initialRender: boolean} = {initialRender: false}) {
|
||||
if (args.initialRender) {
|
||||
(d3.select('#chart').node() as HTMLElement).innerHTML = '';
|
||||
this.chart = createChart({
|
||||
json: this.props.data,
|
||||
chartType: HourglassChart,
|
||||
renderer: DetailedRenderer,
|
||||
svgSelector: '#chart',
|
||||
indiCallback: (info) => this.props.onSelection(info),
|
||||
animate: true,
|
||||
updateSvgSize: false,
|
||||
});
|
||||
}
|
||||
const chartInfo = this.chart!.render({
|
||||
startIndi: this.props.selection.id,
|
||||
baseGeneration: this.props.selection.generation,
|
||||
});
|
||||
const svg = d3.select('#chart');
|
||||
const parent = (svg.node() as HTMLElement).parentElement!;
|
||||
|
||||
d3.select(parent)
|
||||
.on('scroll', scrolled)
|
||||
.call(
|
||||
d3
|
||||
.zoom()
|
||||
.scaleExtent([1, 1])
|
||||
.translateExtent([[0, 0], chartInfo.size])
|
||||
.on('zoom', zoomed),
|
||||
);
|
||||
|
||||
const scrollTopTween = (scrollTop: number) => {
|
||||
return () => {
|
||||
const i = d3.interpolateNumber(parent.scrollTop, scrollTop);
|
||||
return (t: number) => {
|
||||
parent.scrollTop = i(t);
|
||||
};
|
||||
};
|
||||
};
|
||||
const scrollLeftTween = (scrollLeft: number) => {
|
||||
return () => {
|
||||
const i = d3.interpolateNumber(parent.scrollLeft, scrollLeft);
|
||||
return (t: number) => {
|
||||
parent.scrollLeft = i(t);
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const dx = parent.clientWidth / 2 - chartInfo.origin[0];
|
||||
const dy = parent.clientHeight / 2 - chartInfo.origin[1];
|
||||
const offsetX = d3.max([0, (parent.clientWidth - chartInfo.size[0]) / 2]);
|
||||
const offsetY = d3.max([0, (parent.clientHeight - chartInfo.size[1]) / 2]);
|
||||
const svgTransition = svg
|
||||
.transition()
|
||||
.delay(200)
|
||||
.duration(500);
|
||||
const transition = args.initialRender ? svg : svgTransition;
|
||||
transition
|
||||
.attr('transform', `translate(${offsetX}, ${offsetY})`)
|
||||
.attr('width', chartInfo.size[0])
|
||||
.attr('height', chartInfo.size[1]);
|
||||
if (args.initialRender) {
|
||||
parent.scrollLeft = -dx;
|
||||
parent.scrollTop = -dy;
|
||||
} else {
|
||||
svgTransition
|
||||
.tween('scrollLeft', scrollLeftTween(-dx))
|
||||
.tween('scrollTop', scrollTopTween(-dy));
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.renderChart({initialRender: true});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ChartProps) {
|
||||
this.renderChart({initialRender: this.props.data !== prevProps.data});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div id="svgContainer">
|
||||
<svg id="chart" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
233
src/chart_view.tsx
Normal file
233
src/chart_view.tsx
Normal file
@ -0,0 +1,233 @@
|
||||
import * as md5 from 'md5';
|
||||
import * as queryString from 'query-string';
|
||||
import * as React from 'react';
|
||||
import axios from 'axios';
|
||||
import {Chart} from './chart';
|
||||
import {convertGedcom} from './gedcom_util';
|
||||
import {IndiInfo, JsonGedcomData} from 'topola';
|
||||
import {Loader, Message} from 'semantic-ui-react';
|
||||
import {RouteComponentProps} from 'react-router-dom';
|
||||
|
||||
/** 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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a valid IndiInfo object, either with the given indi and generation
|
||||
* or with an individual taken from the data and generation 0.
|
||||
*/
|
||||
function getSelection(
|
||||
data: JsonGedcomData,
|
||||
indi?: string,
|
||||
generation?: number,
|
||||
): IndiInfo {
|
||||
return {
|
||||
id: indi || data.indis[0].id,
|
||||
generation: generation || 0,
|
||||
};
|
||||
}
|
||||
|
||||
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. */
|
||||
loadedUrl?: 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};
|
||||
|
||||
/**
|
||||
* 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);
|
||||
};
|
||||
|
||||
/** Loads a GEDCOM file from the given URL. */
|
||||
loadFromUrl(
|
||||
url: string,
|
||||
options: {
|
||||
handleCors?: boolean;
|
||||
indi?: string;
|
||||
generation?: number;
|
||||
} = {},
|
||||
) {
|
||||
const cachedData = sessionStorage.getItem(url);
|
||||
if (cachedData) {
|
||||
const data = JSON.parse(cachedData);
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {
|
||||
data,
|
||||
selection: getSelection(data, options.indi, options.generation),
|
||||
loadedUrl: url,
|
||||
loading: false,
|
||||
error: undefined,
|
||||
hash: undefined,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {
|
||||
loading: true,
|
||||
loadedUrl: url,
|
||||
data: undefined,
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const urlToFetch = options.handleCors
|
||||
? 'https://cors-anywhere.herokuapp.com/' + url
|
||||
: url;
|
||||
|
||||
axios
|
||||
.get(urlToFetch)
|
||||
.then((response) =>
|
||||
this.setGedcom({
|
||||
gedcom: response.data,
|
||||
url,
|
||||
indi: options.indi,
|
||||
generation: options.generation,
|
||||
}),
|
||||
)
|
||||
.catch((e: Error) =>
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {error: e.message, loading: false}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts GEDCOM contents and sets the data in the state.
|
||||
* In case of an error reading the file, sets an error.
|
||||
*/
|
||||
setGedcom(input: {
|
||||
gedcom: string;
|
||||
url?: string;
|
||||
indi?: string;
|
||||
generation?: number;
|
||||
}) {
|
||||
try {
|
||||
const data = convertGedcom(input.gedcom);
|
||||
const hash = md5(input.gedcom);
|
||||
const serializedData = JSON.stringify(data);
|
||||
sessionStorage.setItem(input.url || hash, serializedData);
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {
|
||||
data,
|
||||
selection: getSelection(data, input.indi, input.generation),
|
||||
hash,
|
||||
loading: false,
|
||||
loadedUrl: input.url,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
this.setState(Object.assign({}, this.state, {error: e.message}));
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
if (hash && hash !== this.state.hash) {
|
||||
// New "load from file" data.
|
||||
if (gedcom) {
|
||||
this.setGedcom({gedcom, indi, generation});
|
||||
} else {
|
||||
// Data is not present. Try loading from cache.
|
||||
const cachedData = sessionStorage.getItem(hash);
|
||||
if (cachedData) {
|
||||
const data = JSON.parse(cachedData);
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {
|
||||
data,
|
||||
hash,
|
||||
selection: getSelection(data, indi, generation),
|
||||
error: undefined,
|
||||
loading: false,
|
||||
loadedUrl: undefined,
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
// No data available. Redirect to main page.
|
||||
this.props.history.replace({pathname: '/'});
|
||||
}
|
||||
}
|
||||
} else if (!this.state.loading && url && this.state.loadedUrl !== url) {
|
||||
// New URL to load data from.
|
||||
this.loadFromUrl(url, {
|
||||
indi,
|
||||
generation,
|
||||
handleCors: url.startsWith('http'),
|
||||
});
|
||||
} else if (!url && !gedcom && hash !== this.state.hash) {
|
||||
this.props.history.replace({pathname: '/'});
|
||||
} 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (this.state.error) {
|
||||
return <ErrorMessage message={this.state.error!} />;
|
||||
}
|
||||
return <Loader active size="large" />;
|
||||
}
|
||||
}
|
||||
109
src/gedcom_util.ts
Normal file
109
src/gedcom_util.ts
Normal file
@ -0,0 +1,109 @@
|
||||
import {gedcomToJson, JsonFam, JsonGedcomData, JsonIndi} from 'topola';
|
||||
|
||||
function strcmp(a: string, b: string) {
|
||||
if (a < b) {
|
||||
return -1;
|
||||
}
|
||||
if (a > b) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Birth date comparator for individuals. */
|
||||
function birthDatesComparator(gedcom: JsonGedcomData) {
|
||||
const idToIndiMap = new Map<string, JsonIndi>();
|
||||
gedcom.indis.forEach((indi) => {
|
||||
idToIndiMap[indi.id] = indi;
|
||||
});
|
||||
|
||||
return (indiId1: string, indiId2: string) => {
|
||||
const idComparison = strcmp(indiId1, indiId2);
|
||||
const indi1: JsonIndi = idToIndiMap[indiId1];
|
||||
const indi2: JsonIndi = idToIndiMap[indiId2];
|
||||
const birth1 = indi1 && indi1.birth;
|
||||
const birth2 = indi2 && indi2.birth;
|
||||
const date1 =
|
||||
birth1 && (birth1.date || (birth1.dateRange && birth1.dateRange.from));
|
||||
const date2 =
|
||||
birth2 && (birth2.date || (birth2.dateRange && birth2.dateRange.from));
|
||||
if (!date1 || !date1.year || !date2 || !date2.year) {
|
||||
return idComparison;
|
||||
}
|
||||
if (date1.year !== date2.year) {
|
||||
return date1.year - date2.year;
|
||||
}
|
||||
if (!date1.month || !date2.month) {
|
||||
return idComparison;
|
||||
}
|
||||
if (date1.month !== date2.month) {
|
||||
return date1.month - date2.month;
|
||||
}
|
||||
if (date1.day && date2.day && date1.day !== date2.day) {
|
||||
return date1.month - date2.month;
|
||||
}
|
||||
return idComparison;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts children by birth date in the given family.
|
||||
* Does not modify the input objects.
|
||||
*/
|
||||
function sortFamilyChildren(fam: JsonFam, gedcom: JsonGedcomData): JsonFam {
|
||||
if (!fam.children) {
|
||||
return fam;
|
||||
}
|
||||
const newChildren = fam.children.sort(birthDatesComparator(gedcom));
|
||||
return Object.assign({}, fam, {children: newChildren});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sorts children by birth date.
|
||||
* Does not modify the input object.
|
||||
*/
|
||||
function sortChildren(gedcom: JsonGedcomData): JsonGedcomData {
|
||||
const newFams = gedcom.fams.map((fam) => sortFamilyChildren(fam, gedcom));
|
||||
return Object.assign({}, gedcom, {fams: newFams});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes images that are not HTTP links.
|
||||
* Does not modify the input object.
|
||||
*/
|
||||
function filterImage(indi: JsonIndi): JsonIndi {
|
||||
if (!indi.imageUrl || indi.imageUrl.startsWith('http')) {
|
||||
return indi;
|
||||
}
|
||||
const newIndi = Object.assign({}, indi);
|
||||
delete newIndi.imageUrl;
|
||||
return newIndi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes images that are not HTTP links.
|
||||
* Does not modify the input object.
|
||||
*/
|
||||
function filterImages(gedcom: JsonGedcomData): JsonGedcomData {
|
||||
const newIndis = gedcom.indis.map(filterImage);
|
||||
return Object.assign({}, gedcom, {indis: newIndis});
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts GEDCOM file into JSON data performing additional transformations:
|
||||
* - sort children by birth date
|
||||
* - remove images that are not HTTP links.
|
||||
*/
|
||||
export function convertGedcom(gedcom: string): JsonGedcomData {
|
||||
const json = gedcomToJson(gedcom);
|
||||
if (
|
||||
!json ||
|
||||
!json.indis ||
|
||||
!json.indis.length ||
|
||||
!json.fams ||
|
||||
!json.fams.length
|
||||
) {
|
||||
throw new Error('Failed to read GEDCOM file');
|
||||
}
|
||||
return filterImages(sortChildren(json));
|
||||
}
|
||||
35
src/index.css
Normal file
35
src/index.css
Normal file
@ -0,0 +1,35 @@
|
||||
body, html, #root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.root {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#svgContainer {
|
||||
flex: 1 1 auto;
|
||||
overflow: scroll;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
p.comment {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
div.ui.negative.message {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
div.ui.card.intro {
|
||||
width: 600px;
|
||||
margin: auto;
|
||||
}
|
||||
21
src/index.tsx
Normal file
21
src/index.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import * as React from 'react';
|
||||
import * as ReactDOM from 'react-dom';
|
||||
import {ChartView} from './chart_view';
|
||||
import {HashRouter as Router, Route, Switch} from 'react-router-dom';
|
||||
import {Intro} from './intro';
|
||||
import {TopBar} from './top_bar';
|
||||
import 'semantic-ui-css/semantic.min.css';
|
||||
import './index.css';
|
||||
|
||||
ReactDOM.render(
|
||||
<Router>
|
||||
<div className="root">
|
||||
<Route path="/" component={TopBar} />
|
||||
<Switch>
|
||||
<Route exact path="/" component={Intro} />
|
||||
<Route exact path="/view" component={ChartView} />
|
||||
</Switch>
|
||||
</div>
|
||||
</Router>,
|
||||
document.querySelector('#root'),
|
||||
);
|
||||
79
src/intro.tsx
Normal file
79
src/intro.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import * as queryString from 'query-string';
|
||||
import * as React from 'react';
|
||||
import {Card} from 'semantic-ui-react';
|
||||
import {Link} from 'react-router-dom';
|
||||
|
||||
/** Link that loads a GEDCOM file from URL. */
|
||||
function GedcomLink(props: {url: string; text: string}) {
|
||||
return (
|
||||
<Link
|
||||
to={{pathname: '/view', search: queryString.stringify({url: props.url})}}
|
||||
>
|
||||
{props.text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/** The intro page. */
|
||||
export function Intro() {
|
||||
return (
|
||||
<Card className="intro">
|
||||
<Card.Content header="Topola Genealogy Viewer" />
|
||||
<Card.Content>
|
||||
<p>
|
||||
Topola Genealogy is a genealogy tree viewer that lets you browse the
|
||||
structure of the family.
|
||||
</p>
|
||||
<p>
|
||||
Use the LOAD FROM URL or LOAD FROM FILE buttons above to load a GEDCOM
|
||||
file. You can export a GEDCOM file from most of the existing genealogy
|
||||
programs and web sites.
|
||||
</p>
|
||||
<p>Here are some examples from the web that you can view:</p>
|
||||
<ul>
|
||||
<li>
|
||||
<GedcomLink
|
||||
url="http://genpol.com/module-Downloads-prep_hand_out-lid-32.html"
|
||||
text="Karol Wojtyła"
|
||||
/>{' '}
|
||||
(from{' '}
|
||||
<a href="http://genpol.com/module-Downloads-display-lid-32.html">
|
||||
GENPOL
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
<li>
|
||||
<GedcomLink
|
||||
url="https://webtreeprint.com/tp_downloader.php?path=famous_gedcoms/shakespeare.ged"
|
||||
text="Shakespeare"
|
||||
/>{' '}
|
||||
(from{' '}
|
||||
<a href="https://webtreeprint.com/tp_famous_gedcoms.php">
|
||||
webtreeprint.com
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
<li>
|
||||
<GedcomLink
|
||||
url="http://genealogyoflife.com/tng/gedcom/HarryPotter.ged"
|
||||
text="Harry Potter"
|
||||
/>{' '}
|
||||
(from{' '}
|
||||
<a href="http://famousfamilytrees.blogspot.com/">
|
||||
Famous Family Trees
|
||||
</a>
|
||||
)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
<b>Privacy</b>: When using the "load from file" option, this site does
|
||||
not send your data anywhere and files loaded from disk do not leave
|
||||
your computer. When using "load from URL", data is passed through the{' '}
|
||||
<a href="https://cors-anywhere.herokuapp.com/">cors-anywhere</a>{' '}
|
||||
service to deal with an issue with cross-site file loading in the
|
||||
browser.
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
145
src/top_bar.tsx
Normal file
145
src/top_bar.tsx
Normal file
@ -0,0 +1,145 @@
|
||||
import * as md5 from 'md5';
|
||||
import * as queryString from 'query-string';
|
||||
import * as React from 'react';
|
||||
import {Link} from 'react-router-dom';
|
||||
import {RouteComponentProps} from 'react-router-dom';
|
||||
import {
|
||||
Header,
|
||||
Button,
|
||||
Icon,
|
||||
Menu,
|
||||
Modal,
|
||||
Input,
|
||||
Form,
|
||||
} from 'semantic-ui-react';
|
||||
|
||||
/** Menus and dialogs state. */
|
||||
interface State {
|
||||
loadUrlDialogOpen: boolean;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export class TopBar extends React.Component<RouteComponentProps, State> {
|
||||
state: State = {loadUrlDialogOpen: false};
|
||||
inputRef?: Input;
|
||||
|
||||
/** Handles the "Upload file" button. */
|
||||
handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
|
||||
const files = (event.target as HTMLInputElement).files;
|
||||
if (!files || !files.length) {
|
||||
return;
|
||||
}
|
||||
const reader = new FileReader();
|
||||
reader.onload = (evt: ProgressEvent) => {
|
||||
const data = (evt.target as FileReader).result;
|
||||
const hash = md5(data as string);
|
||||
this.props.history.push({
|
||||
pathname: '/view',
|
||||
search: queryString.stringify({file: hash}),
|
||||
state: {data},
|
||||
});
|
||||
};
|
||||
reader.readAsText(files[0]);
|
||||
}
|
||||
|
||||
/** Opens the "Load from URL" dialog. */
|
||||
handleLoadFromUrl() {
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {loadUrlDialogOpen: true}),
|
||||
() => this.inputRef!.focus(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Cancels the "Load from URL" dialog. */
|
||||
handleClose() {
|
||||
this.setState(Object.assign({}, this.state, {loadUrlDialogOpen: false}));
|
||||
}
|
||||
|
||||
/** Upload button clicked in the "Load from URL" dialog. */
|
||||
handleLoad() {
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {
|
||||
loadUrlDialogOpen: false,
|
||||
}),
|
||||
);
|
||||
if (this.state.url) {
|
||||
this.props.history.push({
|
||||
pathname: '/view',
|
||||
search: queryString.stringify({url: this.state.url}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Called when the URL input is typed into. */
|
||||
handleUrlChange(event: React.SyntheticEvent) {
|
||||
this.setState(
|
||||
Object.assign({}, this.state, {
|
||||
url: (event.target as HTMLInputElement).value,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const loadFromUrlModal = (
|
||||
<Modal
|
||||
open={this.state.loadUrlDialogOpen}
|
||||
onClose={() => this.handleClose()}
|
||||
centered={false}
|
||||
>
|
||||
<Header icon="cloud download" content="Load from URL" />
|
||||
<Modal.Content>
|
||||
<Form onSubmit={() => this.handleLoad()}>
|
||||
<Input
|
||||
placeholder="https://"
|
||||
fluid
|
||||
onChange={(e) => this.handleUrlChange(e)}
|
||||
ref={(ref) => (this.inputRef = ref!)}
|
||||
/>
|
||||
<p className="comment">
|
||||
Data from the URL will be loaded through{' '}
|
||||
<a href="https://cors-anywhere.herokuapp.com/">
|
||||
cors-anywhere.herokuapp.com
|
||||
</a>{' '}
|
||||
to avoid CORS issues.
|
||||
</p>
|
||||
</Form>
|
||||
</Modal.Content>
|
||||
<Modal.Actions>
|
||||
<Button
|
||||
secondary
|
||||
content="Cancel"
|
||||
onClick={() => this.handleClose()}
|
||||
/>
|
||||
<Button primary content="Load" onClick={() => this.handleLoad()} />
|
||||
</Modal.Actions>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu attached="top" inverted color="blue" size="large">
|
||||
<Link to="/">
|
||||
<Menu.Item>
|
||||
<b>Topola Genealogy</b>
|
||||
</Menu.Item>
|
||||
</Link>
|
||||
<Menu.Item as="a" onClick={() => this.handleLoadFromUrl()}>
|
||||
<Icon name="cloud download" />
|
||||
Load from URL
|
||||
</Menu.Item>
|
||||
<input
|
||||
className="hidden"
|
||||
type="file"
|
||||
id="fileInput"
|
||||
onChange={(e) => this.handleUpload(e)}
|
||||
/>
|
||||
<label htmlFor="fileInput">
|
||||
<Menu.Item as="a">
|
||||
<Icon name="folder open" />
|
||||
Load from file
|
||||
</Menu.Item>
|
||||
</label>
|
||||
{loadFromUrlModal}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
}
|
||||
31
tsconfig.json
Normal file
31
tsconfig.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "build/dist",
|
||||
"module": "esnext",
|
||||
"target": "es5",
|
||||
"lib": ["es6", "dom"],
|
||||
"sourceMap": true,
|
||||
"allowJs": true,
|
||||
"jsx": "react",
|
||||
"moduleResolution": "node",
|
||||
"rootDir": "src",
|
||||
"types": [],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitReturns": true,
|
||||
"noImplicitThis": true,
|
||||
"noImplicitAny": true,
|
||||
"strictNullChecks": true,
|
||||
"suppressImplicitAnyIndexErrors": true,
|
||||
"noUnusedLocals": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"build",
|
||||
"scripts",
|
||||
"acceptance-tests",
|
||||
"webpack",
|
||||
"jest",
|
||||
"src/setupTests.ts"
|
||||
]
|
||||
}
|
||||
1
tsconfig.prod.json
Normal file
1
tsconfig.prod.json
Normal file
@ -0,0 +1 @@
|
||||
{ "extends": "./tsconfig.json" }
|
||||
59
tslint.json
Normal file
59
tslint.json
Normal file
@ -0,0 +1,59 @@
|
||||
{
|
||||
"extends": [
|
||||
"tslint-config-prettier"
|
||||
],
|
||||
"rules": {
|
||||
"array-type": [true, "array-simple"],
|
||||
"arrow-return-shorthand": true,
|
||||
"ban": [true,
|
||||
{"name": "parseInt", "message": "tsstyle#type-coercion"},
|
||||
{"name": "parseFloat", "message": "tsstyle#type-coercion"},
|
||||
{"name": "Array", "message": "tsstyle#array-constructor"}
|
||||
],
|
||||
"ban-types": [true,
|
||||
["Object", "Use {} instead."],
|
||||
["String", "Use 'string' instead."],
|
||||
["Number", "Use 'number' instead."],
|
||||
["Boolean", "Use 'boolean' instead."]
|
||||
],
|
||||
"class-name": true,
|
||||
"curly": [true, "ignore-same-line"],
|
||||
"deprecation": true,
|
||||
"forin": true,
|
||||
"interface-name": [true, "never-prefix"],
|
||||
"jsdoc-format": true,
|
||||
"label-position": true,
|
||||
"member-access": [true, "no-public"],
|
||||
"new-parens": true,
|
||||
"no-angle-bracket-type-assertion": true,
|
||||
"no-any": true,
|
||||
"no-arg": true,
|
||||
"no-conditional-assignment": true,
|
||||
"no-construct": true,
|
||||
"no-debugger": true,
|
||||
"no-default-export": true,
|
||||
"no-duplicate-variable": true,
|
||||
"no-inferrable-types": true,
|
||||
"no-namespace": [true, "allow-declarations"],
|
||||
"no-reference": true,
|
||||
"no-string-throw": true,
|
||||
"no-unused-expression": true,
|
||||
"no-var-keyword": true,
|
||||
"object-literal-shorthand": true,
|
||||
"only-arrow-functions": [true, "allow-declarations", "allow-named-functions"],
|
||||
"prefer-const": true,
|
||||
"radix": true,
|
||||
"semicolon": [true, "always", "ignore-bound-class-methods"],
|
||||
"switch-default": true,
|
||||
"triple-equals": [true, "allow-null-check"],
|
||||
"use-isnan": true,
|
||||
"variable-name": [
|
||||
true,
|
||||
"check-format",
|
||||
"ban-keywords",
|
||||
"allow-leading-underscore",
|
||||
"allow-trailing-underscore",
|
||||
"allow-pascal-case"
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user