diff --git a/package-lock.json b/package-lock.json index f2e5b3f0..4928224f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "gedcomx-js": "^2.8.0", "react": "^18.1.0", "react-dom": "^18.1.0", + "react-localization": "^1.0.19", "react-router-dom": "^6.10.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", @@ -10576,6 +10577,11 @@ "node": ">=8.9.0" } }, + "node_modules/localized-strings": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/localized-strings/-/localized-strings-0.2.4.tgz", + "integrity": "sha512-TKDhqFPkIIN/if2FSvVVZTaM/GP9TzfgdQ2uY65mr32xgFu5nqkKXprXbzy5rfx32DF5LDvS/y1UqYF/mAscYA==" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -12985,6 +12991,17 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "node_modules/react-localization": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/react-localization/-/react-localization-1.0.19.tgz", + "integrity": "sha512-f4E6T8xRis19K8qMOnnhjGV2quy1YH2lrSsnAiytvgt7uOSp6WgDhrZH6XZEaEFqupTOCFSf8uagIoIAkjl4JA==", + "dependencies": { + "localized-strings": "^0.2.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^17.0.0 || ^16.0.0 || ^15.6.0" + } + }, "node_modules/react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", @@ -23435,6 +23452,11 @@ "json5": "^2.1.2" } }, + "localized-strings": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/localized-strings/-/localized-strings-0.2.4.tgz", + "integrity": "sha512-TKDhqFPkIIN/if2FSvVVZTaM/GP9TzfgdQ2uY65mr32xgFu5nqkKXprXbzy5rfx32DF5LDvS/y1UqYF/mAscYA==" + }, "locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -25019,6 +25041,14 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" }, + "react-localization": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/react-localization/-/react-localization-1.0.19.tgz", + "integrity": "sha512-f4E6T8xRis19K8qMOnnhjGV2quy1YH2lrSsnAiytvgt7uOSp6WgDhrZH6XZEaEFqupTOCFSf8uagIoIAkjl4JA==", + "requires": { + "localized-strings": "^0.2.0" + } + }, "react-refresh": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz", diff --git a/package.json b/package.json index 20294ff9..d013a829 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "gedcomx-js": "^2.8.0", "react": "^18.1.0", "react-dom": "^18.1.0", + "react-localization": "^1.0.19", "react-router-dom": "^6.10.0", "react-scripts": "5.0.1", "typescript": "^4.9.5", diff --git a/src/App.css b/src/App.css index cb849bf2..6130ed7b 100644 --- a/src/App.css +++ b/src/App.css @@ -5,38 +5,81 @@ width: 100%; margin: 0; display: grid; - gap: 0; + gap: 1rem; justify-content: space-between; grid-template-columns: 1fr; + overflow: auto; - background: var(--background); + background: var(--background-lower); font-family: "Nunito", sans-serif; + + grid-template-areas: + "header" + "main" + "footer"; + grid-template-rows: auto 2fr auto; } #root > * { - width: 100%; box-sizing: border-box; } main { grid-area: main; - background: var(--background-lower); } main > * { background: var(--background); margin: 1rem auto 0; box-sizing: border-box; + border-radius: .5rem; +} + +main > :first-child { + margin-top: 0; +} + +footer { + color: var(--foreground-secondary); + text-align: left; + grid-area: footer; + padding: .5rem 1rem; + padding-top: 0; + width: 100%; + display: flex; + justify-content: space-between; +} + +footer a { + color: var(--foreground-secondary); +} + +footer * { + margin: 0 auto; +} + +li:not(:last-child) { + margin-bottom: .5rem; } @media (orientation: portrait) { - #root { + @media (max-width: 749px) { + #title { + display: none; + } + + footer :not(.important) { + display: none; + } + } + + #root.sidebar-visible { grid-template-areas: "header" "sidebar" "main" "footer"; - grid-template-rows: auto auto 1fr auto; + grid-template-rows: auto auto 2fr auto; } } @@ -55,6 +98,10 @@ main > * { grid-template-areas: "header header" "sidebar main" - "sidebar footer"; + "footer footer"; + } + + main { + margin-right: 1rem; } } diff --git a/src/App.tsx b/src/App.tsx index da0a3b78..3cd1d649 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,85 +1,30 @@ import * as React from "react"; -import {ReactNode} from "react"; import './App.css'; -import {localize} from "./main"; -import config from "./config"; -import Header from "./components/Header"; -import NavigationTutorial from "./components/NavigationTutorial"; -import Notification from "./components/Notification"; -import Uploader from "./components/Uploader"; +import {strings} from "./main"; import View from "./components/View"; -import {graphModel, loadData} from "./backend/ModelGraph"; import {BrowserRouter, Route, Routes} from "react-router-dom"; - -interface State { - notifications: ReactNode[] - dataAvailable: boolean -} - -class App extends React.Component { - constructor(props) { - super(props); - new URL(window.location.href); - let data = sessionStorage.getItem("familyData"); - if (data) { - loadData(JSON.parse(data)); - } - - this.state = { - notifications: [], - dataAvailable: graphModel !== undefined - }; - } - - render() { - return -
- {this.state.notifications} - - - - - - }/> - }/> - - - } - - onFileSelected(fileContent) { - sessionStorage.setItem("familyData", fileContent); - loadData(JSON.parse(fileContent)); - if (window.location.href.endsWith("/")) { - window.location.href += "view"; - } else { - window.location.href += "/view"; - } - } - - componentDidMount() { - localize(config.browserLang); - } - - componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { - let notifications = this.state.notifications; - - if (error.name.startsWith("Warning")) { - console.warn(error.message); - notifications.push( - - ); - } else { - console.error(error.message); - notifications.push( - - ); - } - - this.setState({ - notifications: notifications - }) - } +import {Home, Imprint} from "./components/Home"; + +function App() { + return + + }/> + }/> + }/> + + + } export default App; diff --git a/src/backend/ModelGraph.ts b/src/backend/ModelGraph.ts index c724e196..55f6f37a 100644 --- a/src/backend/ModelGraph.ts +++ b/src/backend/ModelGraph.ts @@ -1,5 +1,4 @@ import GedcomX, {setReferenceAge} from "./gedcomx-extensions"; -import {translationToString} from "../main"; import viewGraph, {ViewMode} from "./ViewGraph"; import config from "../config"; import {PersonFactTypes} from "./gedcomx-enums"; @@ -10,13 +9,7 @@ class ModelGraph extends GedcomX.Root { constructor(data) { super(data) if (!data || data.persons.length < 0 || data.relationships.length < 0) { - throw new Error( - translationToString({ - en: "The calculated graph is empty!" + - "Please check if your files are empty. If not, please contact the administrator!", - de: "Der berechnete Graph ist leer!" + - " Prüfe bitte, ob die Dateien leer sind. Sollte dies nicht der Fall sein, kontaktiere bitte den Administrator!" - })); + throw new Error("The calculated graph is empty! Please check if your files are empty. If not, please file a bug report!"); } console.log("Found", data.persons.length, "people"); @@ -30,6 +23,10 @@ class ModelGraph extends GedcomX.Root { return super.getPersonById(id); } + getSourceDescriptionById(id: string): GedcomX.SourceDescription { + return this.getSourceDescriptions().find(d => d.getId() === id); + } + getPersonByName = (name: string): GedcomX.Person => { return this.persons.find(person => person.getFullName().toLowerCase().includes(name)); } diff --git a/src/backend/gedcomx-enums.ts b/src/backend/gedcomx-enums.ts index 1b265811..e374ff3f 100644 --- a/src/backend/gedcomx-enums.ts +++ b/src/backend/gedcomx-enums.ts @@ -88,4 +88,11 @@ export enum OccupationCategories { King = "King" } +export enum KnownResourceTypes { + Collection = "http://gedcomx.org/Collection", + PhysicalArtifact ="http://gedcomx.org/PhysicalArtifact", + DigitalArtifact = "http://gedcomx.org/DigitalArtifact", + Record = "http://gedcomx.org/Record" +} + export const baseUri = "http://gedcomx.org/"; diff --git a/src/backend/gedcomx-extensions.ts b/src/backend/gedcomx-extensions.ts index 5bc2c056..f0867658 100644 --- a/src/backend/gedcomx-extensions.ts +++ b/src/backend/gedcomx-extensions.ts @@ -1,5 +1,5 @@ import * as GedcomX from "gedcomx-js"; -import {translationToString} from "../main"; +import {strings} from "../main"; import config from "../config"; import { baseUri, @@ -212,10 +212,9 @@ function extend(GedcomXExtend) { time = dateObject.toLocaleTimeString(config.browserLang, options); } - return translationToString({ - en: `${this.getFormal().length >= 11 ? "on" : "in"} ${date}${time ? " at " + time : ""}`, - de: `${this.getFormal().length >= 11 ? "am" : "in"} ${date}${time ? " um " + time : ""}` - }) + const length = this.getFormal().length; + + return `${strings.formatString(length >= 10 ? strings.gedcomX.day : (length >= 7 ? strings.gedcomX.month : strings.gedcomX.year), date)}${time ? " " + strings.formatString(strings.gedcomX.time, time) : ""}`; } @@ -227,69 +226,39 @@ function extend(GedcomXExtend) { switch (this.getType()) { case PersonFactTypes.Birth: - string = translationToString({ - en: "born", - de: "geboren" - }); + string = strings.gedcomX.born; break; case PersonFactTypes.Generation: - string = translationToString({ - en: "Generation", - de: "Generation" - }); + string = strings.gedcomX.generation; break; case PersonFactTypes.MaritalStatus: - string = translationToString({ - en: "", - de: "" - }); + string = ""; switch (value) { case "single": - value = translationToString({ - en: "single", - de: "ledig" - }); + value = strings.gedcomX.single; break; case "married": - value = translationToString({ - en: "married", - de: "verheiratet" - }); + value = strings.gedcomX.married; break; } break; case PersonFactTypes.Religion: - string = translationToString({ - en: "Religion:", - de: "Religion:" - }); + string = strings.gedcomX.religion; break; case PersonFactTypes.Occupation: - string = translationToString({ - en: "works as", - de: "arbeitet als" - }) + string = strings.gedcomX.worksAs; break; case PersonFactTypes.Death: - string = translationToString({ - en: "died", - de: "verstorben" - }); + string = strings.gedcomX.died; break; default: string = this.getType(); break; } - string += translationToString({ - en: (value || value === "0" ? ` ${value}` : "") + - (this.getDate() !== undefined ? ` ${this.getDate().toString()}` : "") + - (this.getPlace() && this.getPlace().toString() ? ` in ${this.getPlace().toString()}` : ""), - - de: (value || value === "0" ? " " + value : "") + - (this.getDate() !== undefined ? ` ${this.getDate().toString()}` : "") + - (this.getPlace() && this.getPlace().toString() ? " in " + this.getPlace().toString() : "") - }); + string += (value || value === "0" ? ` ${value}` : "") + + (this.getDate() !== undefined ? ` ${this.getDate().toString()}` : "") + + (this.getPlace() && this.getPlace().toString() ? " " + strings.formatString(strings.gedcomX.place, this.getPlace().toString()) : ""); if (this.getQualifiers() && this.getQualifiers().length > 0) { string += " " + this.getQualifiers().map(q => q.toString()).join(" "); @@ -325,10 +294,7 @@ function extend(GedcomXExtend) { let string; switch (this.getName()) { case PersonFactQualifiers.Age: - string = translationToString({ - en: `with ${this.getValue()} years old`, - de: `mit ${this.getValue()} Jahren` - }); + string = strings.formatString(strings.gedcomX.ageQualifier, this.getValue()); break; case PersonFactQualifiers.Cause: string = `(${this.getValue()})`; diff --git a/src/backend/gedcomx-js.d.ts b/src/backend/gedcomx-js.d.ts index 47dc8655..f841b716 100644 --- a/src/backend/gedcomx-js.d.ts +++ b/src/backend/gedcomx-js.d.ts @@ -19,16 +19,18 @@ declare module "gedcomx-js" { } export class Root extends ExtensibleData { + id: string lang: string + attribution: Attribution persons: Person[] relationships: Relationship[] - description: string - sourceDescriptions + sourceDescriptions: SourceDescription[] agents - events - documents + events: Event[] + documents: Document[] places - attribution + groups + description: string getPersons(): Person[] @@ -57,6 +59,8 @@ declare module "gedcomx-js" { setRelationships(relationships: Relationship[] | object[]): Root addRelationship(relationship: Relationship | object): Root + + getSourceDescriptions(): SourceDescription[] } export function GedcomX(json: any): Root; @@ -109,17 +113,17 @@ declare module "gedcomx-js" { setLang(lang); - getNotes(); + getNotes(): Note[]; - setNotes(notes: []); + setNotes(notes: Note[]); - addNote(note); + addNote(note: Note); - getSources(); + getSources(): SourceReference[]; - setSources(sources: []); + setSources(sources: SourceReference[]); - addSource(source); + addSource(source: SourceReference); } export class EvidenceReference extends ResourceReference { @@ -140,6 +144,100 @@ declare module "gedcomx-js" { getAttribution(): Attribution setAttribution(attribution: object | Attribution): SourceReference + + getQualifiers() + + setQualifiers(qualifiers: Qualifier[]) + } + + export class SourceDescription { + getId(): string + + setId(id: string) + + getResourceType() + + setResourceType() + + getCitations(): Citation[] + + setCitations(citations: any[]) + + getMediaType(): string + + setMediaType(mediaType: string) + + getAbout() + + setAbout(about: string) + + getMediator() + + setMediator() + + getPublisher() + + setPublisher() + + getAuthors() + + setAuthors() + + getSources(): SourceReference[] + + setSources(source : SourceReference[]) + + getAnalysis() + + setAnalysis() + + getComponentOf() + + setComponentOf() + + getTitles() + + setTitles() + + getNotes(): Note[] + + setNotes(notes: Note[]) + + getAttribution() + + setAttribution() + + getRights() + + setRights() + + getCoverage() + + setCoverage() + + getDescriptions() + + setDescriptions() + + getIdentifiers(): Identifier[] + + setIdentifiers() + + getCreated() + + setCreated() + + getModified() + + setModified() + + getPublished() + + setPublished() + + getRepository() + + setRepository() } export class Identifiers extends Base { @@ -455,4 +553,42 @@ declare module "gedcomx-js" { setFamiliesAsChild(families: FamilyView[]): DisplayProperties } + + export class Identifier { + getValue(): string + + setValue(value: string) + + getType(): string + + setType(type: string) + } + + export class Citation { + getValue(): string + + setValue(value: string) + + getLang(): string + + setLang(lang: string) + } + + export class Note { + getLang(): string + + setLang(lang: string) + + getSubject(): string + + setSubject(subject: string) + + getText(): string + + setText(text: string) + + getAttribution(): Attribution + + setAttribution(attribution: Attribution) + } } diff --git a/src/components/Article.css b/src/components/Article.css index db3f7da5..0d6fa464 100644 --- a/src/components/Article.css +++ b/src/components/Article.css @@ -1,5 +1,4 @@ article, .notification { - border-radius: .5rem; padding: .75rem 1rem; max-width: 50rem; width: 100%; @@ -12,6 +11,18 @@ article h1 { padding-bottom: .75rem; } +aside ul, aside ol { + padding-left: 1rem; +} + +article :first-child { + margin-top: 0; +} + +article :last-child { + margin-bottom: 0; +} + details { background: var(--background-lower); border-radius: .5rem; diff --git a/src/components/Article.tsx b/src/components/Article.tsx deleted file mode 100644 index 6d09c084..00000000 --- a/src/components/Article.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import './Article.css'; - -function Article(props) { - return ( -
-

{props.emoji} {props.title}

- {props.children} -
- ); -} - -export default Article; diff --git a/src/components/FamilyPath.css b/src/components/FamilyPath.css deleted file mode 100644 index 28fedc0c..00000000 --- a/src/components/FamilyPath.css +++ /dev/null @@ -1,28 +0,0 @@ -footer { - background: inherit; - border-top: solid var(--foreground-secondary) .1rem; - text-align: left; - grid-area: footer; - padding: .5rem 1rem; - width: 100%; - display: flex; - justify-content: space-between; -} - -#family-path { - margin: 0; - padding-left: 0; -} - -#family-path li { - display: inline; -} - -#family-path li:not(:last-child):after { - content: " > "; - font-weight: normal !important; -} - -#family-path li.focusPerson { - font-weight: bold; -} diff --git a/src/components/FamilyPath.tsx b/src/components/FamilyPath.tsx deleted file mode 100644 index ba3f7f74..00000000 --- a/src/components/FamilyPath.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import "./FamilyPath.css"; -import {graphModel} from "../backend/ModelGraph"; -import viewGraph from "../backend/ViewGraph"; -import {useState} from "react"; -import {GraphObject} from "../backend/graph"; -import {translationToString} from "../main"; -import config from "../config"; - -function FamilyPath(props) { - const updateValue = (e: CustomEvent) => setN(e.detail.nodes.filter((n: GraphObject) => n.type === "person").length) - - let [n, setN] = useState(viewGraph.nodes.filter(n => n.type === "person").length) - viewGraph.addEventListener("add", updateValue) - viewGraph.addEventListener("remove", updateValue) - - return ( -
-
    - {graphModel.getPersonPath(props.focus).map(p => -
  1. - {p.getFullName()} -
  2. )} -
-
- {translationToString({ - en: `Percentage of visible nodes:`, - de: `Sichtbarer Anteil:` - })} - -
-
- ); -} - -export default FamilyPath; diff --git a/src/components/Form.css b/src/components/Form.css index fd756889..4bca4d9e 100644 --- a/src/components/Form.css +++ b/src/components/Form.css @@ -27,19 +27,30 @@ input[type=submit], button, .button { font-weight: bold; border-radius: 2.33rem; padding: .66rem 1rem; + margin-left: 1rem; border: none; text-decoration: none; + cursor: pointer; +} + +.button { + display: inline-block; +} + +.button:visited { + color: inherit; } .button.inline, button.inline { - padding: .33rem 1rem; + padding: .33em 1em; } -.button.inactive, button.inactive { +.button.inactive, button.inactive, input[type=submit].inactive { background: var(--background); color: var(--primary); font-weight: normal; border: solid var(--primary) 2pt; + cursor: not-allowed; } @media (prefers-color-scheme: dark) { @@ -76,16 +87,14 @@ input[type=submit]:active, button:active, .button:not(select):active { } input[type=search] { + border-radius: 1.5em; border: none; - border-bottom: solid white; - border-radius: 0; - background: none; - color: white; - padding: 0 .25rem; + background: rgba(255, 255, 255, 0.5); + color: black; + padding: 0 .75em; min-width: 10rem; width: 100%; flex-shrink: 2; - margin-left: 1rem; } input[type=search].error, input[type=search]:invalid { @@ -110,6 +119,26 @@ input[type=search]:focus { text-shadow: 0 0 1rem var(--primary); } -button:hover, input[type=submit]:hover, input[type=file] { - cursor: pointer; +.card { + text-align: center; + background: var(--background-higher); + border-radius: 1rem; + padding: .66rem 1rem; + margin-left: auto; + margin-right: auto; +} + +.file-selected { + background: var(--positive-green); +} + +.file-upload *:not(:last-child) { + margin-bottom: .5rem; +} + +.icon-only { + background: none; + padding: 0; + border: none; + margin: 0; } diff --git a/src/components/Form.tsx b/src/components/Form.tsx index d727ca5d..559a7398 100644 --- a/src/components/Form.tsx +++ b/src/components/Form.tsx @@ -1,55 +1,20 @@ import './Form.css'; -import './FormInput.css'; -import {Component} from "react"; import * as React from "react"; -import {translationToString} from "../main"; +import {strings} from "../main"; +import {useState} from "react"; -class Form extends Component { - public input: React.RefObject; +function Form(props) { + const [focused, setFocused] = useState(false); + const [file, setFile] = useState(""); - constructor(props) { - super(props); - this.state = { - focused: false, - file: "" - } - this.input = React.createRef(); - } - - render() { - return ( -
-
-
- -
- - this.setState({file: e.target.files[0].name})} ref={this.input}/> -
-
- - -
- ); - } + let input = React.createRef(); - checkDropAllowed(e) { + function checkDropAllowed(e) { e.preventDefault(); if (e.dataTransfer.items[0].type === "application/json") { e.dataTransfer.effectAllowed = "copy"; e.dataTransfer.dropEffect = "copy"; - this.setState({ - focused: true - }); + setFocused(true); return; } @@ -58,48 +23,69 @@ class Form extends Component { e.dataTransfer.dropEffect = "none"; } - onDrop(e) { + function onDrop(e) { e.preventDefault(); - this.setState({ - focused: false, - file: e.dataTransfer.files[0].name - }); + setFocused(false); + setFile(e.dataTransfer.files[0].name); document.querySelector("#gedcom-file").files = e.dataTransfer.files; } - removeFocus() { - this.setState({ - focused: false - }) + function removeFocus() { + setFocused(false); } - onSubmit(event) { - event.preventDefault(); + return ( +
{ + event.preventDefault(); + parseFile(input.current.files[0]).then(saveDataAndRedirect); + }}> +
+
+ +
+ + setFile(e.target.files[0].name)} ref={input}/> +
+
- // load data from the file - let gedcomFile = this.input.current.files[0]; + {localStorage.getItem("familyData") && + {strings.form.continueSession} + } + +
+ ); +} - if (!gedcomFile) { - throw new Error(translationToString({ - en: "No gedcom file selected", - de: "Keine Datei ausgewählt" - })) - } +export async function parseFile(gedcomFile) { + if (!gedcomFile) { + throw new Error(strings.form.noFileError) + } - let readerGedcom = new FileReader(); - readerGedcom.onload = (file) => { - if (typeof file.target.result === "string") { - sessionStorage.setItem("familyData", file.target.result); - this.props.onSubmit(file.target.result); - } else { - throw new Error(translationToString({ - en: "The graph could not be loaded.", - de: "Der Graph konnte nicht geladen werden." - })) - } + let readerGedcom = new FileReader(); + readerGedcom.onload = (file) => { + if (typeof file.target.result === "string") { + localStorage.setItem("familyData", file.target.result); + return file.target.result; + } else { + throw new Error(strings.form.graphLoadingError) } - readerGedcom.readAsText(gedcomFile); } + readerGedcom.readAsText(gedcomFile); +} + +export function saveDataAndRedirect(fileContent) { + localStorage.setItem("familyData", fileContent); + let url = new URL(window.location.href); + url.pathname = "/family-tree/view"; + window.location.href = url.href; } export default Form; diff --git a/src/components/FormInput.css b/src/components/FormInput.css deleted file mode 100644 index 815c19aa..00000000 --- a/src/components/FormInput.css +++ /dev/null @@ -1,16 +0,0 @@ -.card { - text-align: center; - background: var(--background-higher); - border-radius: 1rem; - padding: .66rem 1rem; - margin-left: auto; - margin-right: auto; -} - -.file-selected { - background: var(--positive-green); -} - -.file-upload *:not(:last-child) { - margin-bottom: .5rem; -} diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx new file mode 100644 index 00000000..334307bb --- /dev/null +++ b/src/components/Gallery.tsx @@ -0,0 +1,19 @@ +import {useState} from "react"; + +interface Props { + children +} + +export function Gallery(props: Props) { + const [index, scroll] = useState(0); + + return
+ {props.children[index]} + {props.children.length > 1 && + {} + {} + } +
; +} diff --git a/src/components/Header.css b/src/components/Header.css index 991d37aa..79885c06 100644 --- a/src/components/Header.css +++ b/src/components/Header.css @@ -1,15 +1,13 @@ header { - padding: .75rem 1rem; + padding: .25rem 1rem; z-index: 1; -} - -header { box-shadow: 0 .25rem .25rem rgba(0, 0, 0, 0.33); display: flex; align-items: center; background: var(--primary); color: white; grid-area: header; + gap: 1rem; } header ::selection { @@ -21,6 +19,11 @@ header * { font-size: 2rem; } -header > *:not(:last-child) { - margin-right: 1rem; +header div { + flex-grow: 2; +} + +header input { + margin: 0; + font-size: 1.5rem; } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 0e368086..2badb771 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,29 +1,34 @@ +import * as React from "react"; import './Header.css'; import {Link} from 'react-router-dom'; -import {translationToString} from "../main"; +import {strings} from "../main"; +import {parseFile} from "./Form"; +import {saveDataAndRedirect} from "./Form"; function Header(props) { + let fileInput = React.createRef(); + return (
- - {translationToString({ + + {strings.header.imageAlt - {translationToString({ - en: "Family tree", - de: "Stammbaum" - })} + {strings.header.title}
{props.children}
+
); } -function removeData() { - window.sessionStorage.removeItem("familyData") -} - export default Header; diff --git a/src/components/Home.tsx b/src/components/Home.tsx new file mode 100644 index 00000000..76bb8c2a --- /dev/null +++ b/src/components/Home.tsx @@ -0,0 +1,80 @@ +import * as React from "react"; +import Header from "./Header"; +import {strings} from "../main"; +import Form from "./Form"; +import "./Article.css"; + +export function Home() { + return <> +
+
+ + +
+ ; +} + +function Uploader() { + let root = document.getElementById("root"); + root.classList.remove("sidebar-visible"); + + return ( +
+

+ {strings.home.uploadArticle.content} +

+
+
+ 🗒️ {strings.home.uploadArticle.detailSummary} +

+ {strings.formatString(strings.home.uploadArticle.detail, + + {strings.linkContent})} +

+
+
+ ); +} + +function Article(props) { + return ( +
+

{props.emoji} {props.title}

+ {props.children} +
+ ); +} + + +function NavigationTutorial() { + return
+ {strings.formatString(strings.home.navigationArticle.content, + {strings.ctrl})} +
+} + +export function Imprint() { + return <> +
+
+ + +
+ +} diff --git a/src/components/InfoPanel.css b/src/components/InfoPanel.css index 306b06a5..bd7910e5 100644 --- a/src/components/InfoPanel.css +++ b/src/components/InfoPanel.css @@ -1,59 +1,116 @@ aside { + overflow-y: scroll; + overflow-x: hidden; + display: flex; + gap: 1rem; + flex-flow: row; + max-width: 100vw; + flex-wrap: wrap; grid-area: sidebar; - background: rgba(70, 155, 24, 0.26); - padding: 1rem; - box-shadow: 0 .25rem .25rem rgba(0, 0, 0, 0.33); + max-height: 30vh; + padding: 0 1rem; } -#info-panel { - overflow: scroll; +aside > * { + flex: 1 1 0; } -#info-panel h1, #info-panel h2 { - text-align: center; +aside .title { + flex-basis: 100%; } -#info-panel h2 { - font-weight: normal; - margin: .25em 0; +aside .title :first-child { + margin-top: 0; } -#info-panel form { - margin: 0 auto; - width: fit-content; +aside .title :last-child { + margin-bottom: 0; } -#info-panel input[type=search] { - color: var(--foreground); - border-bottom: solid transparent; - font-size: 2em; - font-weight: bold; +aside h1, aside article h1 { + font-size: 1.5rem; +} + +aside .title > h1, aside .title > h2 { text-align: center; - transition: all .3s; } -#info-panel input[type=search]:hover, #info-panel input[type=search]:focus { - border-bottom: solid var(--foreground); +aside .title h2 { + font-weight: normal; } -#info-panel #factView { - background: var(--background); - padding: 1rem 1rem 1rem 2rem; +@media (orientation: landscape) { + aside { + flex-flow: column; + padding-right: 0; + max-height: unset; + } + + aside .title { + flex-basis: 0; + } +} + +aside > article { border-radius: 1rem; + padding: 1rem; + margin: 0; + box-sizing: border-box; + background: var(--background); box-shadow: inset 0 .25rem .25rem rgba(0, 0, 0, .33); + flex: 1 1 200px; } -#info-panel #factView li:not(:last-child) { - margin-bottom: .5rem; +article.gallery { + padding: 0; + z-index: 0; + position: relative; + box-shadow: none; } -.id::before { - content: "#"; +article.gallery div { + box-shadow: inset 0 .25rem .25rem rgba(0, 0, 0, .33); + border-radius: 1rem; + z-index: 0; +} + +.gallery img { + width: 100%; + border-top-left-radius: 1rem; + border-top-right-radius: 1rem; + position: relative; + /* z-index is needed for shadow effect */ + z-index: -1; +} + +.credits { + padding-top: .25rem; + padding-bottom: .25rem; + text-align: center; + display: block; + white-space: nowrap; + overflow: hidden; + font-size: .75rem; + z-index: 1; +} + +.gallery .buttons { + position: absolute; + bottom: .5rem; + width: 100%; + display: flex; + justify-content: space-between; + justify-items: center; + padding: 0 1em; + box-sizing: border-box; } -.id { - color: var(--foreground-secondary); - text-align: right; +.gallery .buttons button { margin: 0; - float: right; + font-size: .75rem; +} + +#confidence { + flex-grow: 2; + text-align: center; } diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx index d458507d..6e5ae252 100644 --- a/src/components/InfoPanel.tsx +++ b/src/components/InfoPanel.tsx @@ -1,24 +1,69 @@ import './InfoPanel.css'; -import {Component} from "react"; -import SearchField from "./SearchField"; import {Person} from "gedcomx-js"; -import {PersonFactTypes} from "../backend/gedcomx-enums"; +import {baseUri, PersonFactTypes} from "../backend/gedcomx-enums"; +import {strings} from "../main"; +import {graphModel} from "../backend/ModelGraph"; +import {Gallery} from "./Gallery"; interface Props { onRefocus: (newFocus: Person) => void, person: Person } -class InfoPanel extends Component { - render() { - let person = this.props.person; - return ( - + ); +} + +function getImages(person: Person) { + let mediaRefs = person.getMedia().map(media => media.getDescription()); + return mediaRefs.map(ref => { + let sourceDescription = graphModel.getSourceDescriptionById(ref.replace('#', '')); + if (!sourceDescription) throw Error(`Could not find a source description with id ${ref}`); + return sourceDescription; + }).filter(sourceDescription => { + let mediaType = sourceDescription.getMediaType(); + if (!mediaType) return false; + return mediaType.split('/')[0] === 'image' + }); } diff --git a/src/components/NavigationTutorial.tsx b/src/components/NavigationTutorial.tsx deleted file mode 100644 index 301e7ef9..00000000 --- a/src/components/NavigationTutorial.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import Article from "./Article"; -import {translationToString} from "../main"; - -function NavigationTutorial() { - return ( - <> -
- {translationToString({ - en:

- You can move the family tree with your mouse.While pressing Ctrl, you can zoom in and out - with your mouse wheel. - Select a person with your left mouse button to show their information. - Many people have a circle with "+" or "-" inside them, clicking on those displays their relatives. -

, - de:

- Man kann den Stammbaum durch Ziehen mit der Maus verschieben. Hält man Strg gedrückt, kann - man mit dem Mausrad rein- bzw. rauszoomen. - Klickt man auf eine Person werden weitere Informationen zu dieser angezeigt. - An vielen Personen hängen Kreise, in denen "+" oder "-" steht. Klickt man auf diese, werden weitere - Verwandte ein- oder ausgeblendet. -

- })} -
- - ); -} - -export default NavigationTutorial; diff --git a/src/components/Nodes.tsx b/src/components/Nodes.tsx index 48580988..d08fee2c 100644 --- a/src/components/Nodes.tsx +++ b/src/components/Nodes.tsx @@ -1,5 +1,5 @@ import config from "../config"; -import {translationToString} from "../main"; +import {strings} from "../main"; import viewGraph from "../backend/ViewGraph"; import {GraphPerson} from "../backend/graph"; @@ -17,20 +17,14 @@ export function Family(props) { {`💍 ${props.data.marriage}`} } {props.locked ? "🔒" : "➖"} - {props.locked ? translationToString({ - en: "This family cannot be hidden.", - de: "Diese Familie kann nicht ausgeblendet werden." - }) : translationToString({ - en: "Click to hide this family.", - de: "Klicke, um diese Familie auszublenden." - })} + {props.locked ? strings.nodes.lockedFamilyHint : strings.nodes.hideFamilyHint} ); } export function Etc(props) { return ( - viewGraph.showFamily(props.data)}> + props.graph.showFamily(props.data)}> @@ -45,10 +39,7 @@ export function Person(props) { x={graphPerson.x - graphPerson.width / 2} y={graphPerson.y - graphPerson.height / 2} width={graphPerson.width} height={graphPerson.height} onClick={() => props.onClick(graphPerson.data)}> -
+

{graphPerson.getName()}

diff --git a/src/components/Notification.css b/src/components/Notification.css deleted file mode 100644 index 85c12547..00000000 --- a/src/components/Notification.css +++ /dev/null @@ -1,11 +0,0 @@ -.warning { - color: var(--warning-foreground); - background: var(--warning-background); - border-color: var(--warning-foreground); -} - -.error { - color: var(--error-foreground); - background: var(--error-background); - border-color: var(--error-foreground); -} diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx deleted file mode 100644 index a84ddac2..00000000 --- a/src/components/Notification.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import {translationToString} from "../main"; -import "./Notification.css"; - -function Notification(props) { - if (props.type === "warning") { - return ( -
- ️ {translationToString({ - en: "Warning", - de: "Achtung" - })} | - {props.description} -
- ); - } - if (props.type === "error") { - return ( -
- 💥 {translationToString({ - en: "Error", - de: "Fehler" - })} | - {props.description} -
- ); - } -} - -export default Notification; diff --git a/src/components/SearchField.tsx b/src/components/SearchField.tsx index 54233dad..40c639b8 100644 --- a/src/components/SearchField.tsx +++ b/src/components/SearchField.tsx @@ -1,51 +1,16 @@ -import {Component} from "react"; -import {translationToString} from "../main"; +import {useEffect, useState} from "react"; +import {strings} from "../main"; import {graphModel} from "../backend/ModelGraph"; import {Person} from "gedcomx-js"; interface Props { - person: Person, onRefocus: (newFocus: Person) => void } -interface State { - hasError: boolean -} +function SearchField(props: Props) { + const [hasError, setHasError] = useState(false); -class SearchField extends Component { - constructor(props) { - super(props); - this.state = { - hasError: false - } - } - - render() { - return ( - - - - - - { - graphModel.persons.map(p => - - ) - } - - - ); - } - - componentDidMount() { + useEffect(() => { // add keyboard shortcuts document.addEventListener("keydown", event => { switch (event.key) { @@ -59,9 +24,9 @@ class SearchField extends Component { document.querySelector(":focus").blur(); } }); - } + }, []); - refocus(event) { + function refocus(event) { event.preventDefault(); let name = document.querySelector("#input-name").value; if (name) { @@ -69,26 +34,39 @@ class SearchField extends Component { let person = graphModel.getPersonByName(name.toLowerCase()); // if no person was found, throw error - this.setState({hasError: !person}); + setHasError(!person); if (!person) { - window.alert(translationToString({ - en: "No person with that name found!", - de: "Es konnte keine Person mit diesem Namen gefunden werden!" - })); + window.alert(strings.searchField.noPersonFound); return; } console.log(`Assuming the person is ${person.getFullName()} with id ${person.getId()}`); - this.props.onRefocus(person); + props.onRefocus(person); } } - resetError() { + function resetError() { let name = document.querySelector("#input-name").value; if (!name) { - this.setState({hasError: false}); + setHasError(false); } } + + return ( +
+ + + + + { + graphModel.persons.map(p => + + ) + } + +
+ ); } export default SearchField; diff --git a/src/components/Sidepanel.css b/src/components/Sidepanel.css deleted file mode 100644 index d4458407..00000000 --- a/src/components/Sidepanel.css +++ /dev/null @@ -1,10 +0,0 @@ -aside { - grid-area: sidebar; - background: rgba(70, 155, 24, 0.26); - padding: 1rem; - box-shadow: 0 .25rem .25rem rgba(0, 0, 0, 0.33); -} - -aside h1, aside h2 { - text-align: center; -} diff --git a/src/components/TreeView.tsx b/src/components/TreeView.tsx index 611b6075..15c15b5b 100644 --- a/src/components/TreeView.tsx +++ b/src/components/TreeView.tsx @@ -1,10 +1,10 @@ import {Etc, Family, Person} from "./Nodes"; -import {Component} from "react"; +import {useEffect, useState} from "react"; import config from "../config"; import * as d3 from "d3"; import * as cola from "webcola"; import * as GedcomX from "gedcomx-js"; -import viewGraph, {ColorMode, ViewGraph} from "../backend/ViewGraph"; +import {ColorMode, ViewGraph} from "../backend/ViewGraph"; import {GraphFamily, GraphPerson} from "../backend/graph"; let d3cola = cola.d3adaptor(d3); @@ -12,223 +12,211 @@ let d3cola = cola.d3adaptor(d3); interface Props { focus: GedcomX.Person focusHidden: boolean - onRefocus: (newFocus: GraphPerson) => void - colorMode: ColorMode | string -} - -interface State { + onRefocus: (newFocus: GedcomX.Person) => void + colorMode: ColorMode, graph: ViewGraph } -class TreeView extends Component { - mounted = false - - constructor(props) { - super(props); +function TreeView(props: Props) { + const [, updateGraph] = useState(props.graph.nodes.length); - viewGraph.addEventListener("add", this.onGraphChanged.bind(this)); - viewGraph.addEventListener("remove", this.onGraphChanged.bind(this)); - - this.state = { - graph: viewGraph - } + function onGraphChanged() { + updateGraph(props.graph.nodes.length); } - render() { - console.assert(viewGraph.nodes.length > 0, - "View graph has no nodes!"); - console.assert(viewGraph.links.length > 0, - "View graph has no links!"); - d3cola - .flowLayout("x", config.gridSize * 5) - .nodes(this.state.graph.nodes) - .links(this.state.graph.links) - .start(10, 0, 10); - - return ( - - - - - {this.state.graph.links.map((l, i) => - )} - - - {this.state.graph.nodes.filter(n => n.type === "family").map((r, i) => - )} - {this.state.graph.nodes.filter(n => n.type === "etc").map((r, i) => - )} - {this.state.graph.nodes.filter(n => n.type === "person").map((p, i) => - )} - + props.graph.addEventListener("add", onGraphChanged); + props.graph.addEventListener("remove", onGraphChanged); + + useEffect(() => { + setupCola(); + window.matchMedia("(prefers-color-scheme: dark)") + .addEventListener("change", () => animateTree(props.graph, props.colorMode)); + }, [props.graph, props.colorMode]) + + const focusId = props.focus.getId(); + const nodeLength = props.graph.nodes.length; + + useEffect(() => { + animateTree(props.graph, props.colorMode); + }, [focusId, nodeLength, props.graph, props.colorMode]); + + console.assert(props.graph.nodes.length > 0, + "View graph has no nodes!"); + console.assert(props.graph.links.length > 0, + "View graph has no links!"); + d3cola + .flowLayout("x", config.gridSize * 5) + .nodes(props.graph.nodes) + .links(props.graph.links) + .start(10, 0, 10); + + return ( + + + + + {props.graph.links.map((l, i) => + )} - - ); - } - - componentDidMount() { - let svg = d3.select("#family-tree"); - this.mounted = true; - - const viewportSize = [svg.node().getBBox().width, svg.node().getBBox().height]; - d3cola.size(viewportSize); - - // catch the transformation events - /* - I have changed the default zoom behavior to the following one: - - Nothing on double click - - Zoom with Ctrl + wheel - - Move with wheel (shift changes the axes) - */ - let svgZoom = d3.zoom() - .on("zoom", event => { - if (event.sourceEvent && event.sourceEvent.type === "wheel") { - if (event.sourceEvent.wheelDelta < 0) - svg.node().style.cursor = "zoom-out"; - else - svg.node().style.cursor = "zoom-in"; - } - svg.select("#vis").attr("transform", event.transform.toString()); - }) - .on("end", () => { - svg.node().style.cursor = ""; - }) - .filter(event => event.type !== "dblclick" && (event.type === "wheel" ? event.ctrlKey : true)) - .touchable(() => ('ontouchstart' in window) || Boolean(window.TouchEvent)); - // @ts-ignore FIXME - svg.select("rect").call(svgZoom); - - - this.animateTree() - window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", this.animateTree.bind(this)) - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { - this.animateTree() - } + + {props.graph.nodes.filter(n => n.type === "family").map((r, i) => + )} + {props.graph.nodes.filter(n => n.type === "etc").map((r, i) => + )} + {props.graph.nodes.filter(n => n.type === "person").map((p, i) => + )} + + + + ); +} - animateTree() { - d3cola - .flowLayout("x", d => d.target.type === "person" ? config.gridSize * 5 : config.gridSize * 3.5) - .symmetricDiffLinkLengths(config.gridSize) - .start(15, 0, 10); - - let nodesLayer = d3.select("#nodes"); - let linkLayer = d3.select("#links"); - - let personNode = nodesLayer.selectAll(".person") - .data(this.state.graph.nodes.filter(p => p.type === "person") as GraphPerson[]) - let partnerNode = nodesLayer.selectAll(".partnerNode") - .data(this.state.graph.nodes.filter(node => node.type === "family")); - let etcNode = nodesLayer.selectAll(".etc") - .data(this.state.graph.nodes.filter(n => n.type === "etc")); - let link = linkLayer.selectAll(".link") - .data(this.state.graph.links); - - // reset style - personNode.select(".bg").attr("style", null); - - const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; - switch (this.props.colorMode) { - case ColorMode.NAME: { - let last_names = viewGraph.nodes.filter(n => n.type === "person") - .map((p: GraphPerson) => p.getName().split(" ").reverse()[0]); - last_names = Array.from(new Set(last_names)); - const nameColor = d3.scaleOrdinal(last_names, d3.schemeSet3) - personNode - .select(".bg") - .style("background-color", d => nameColor(d.getName().split(" ").reverse()[0])) - .style("color", "black") - personNode - .select(".focused") - .style("box-shadow", d => `0 0 1rem ${nameColor(d.getName().split(" ").reverse()[0])}`); - break; - } - case ColorMode.AGE: { - const ageColor = d3.scaleSequential() - .domain([0, 120]) - .interpolator((d) => darkMode ? d3.interpolateYlGn(d) : d3.interpolateYlGn(1 - d)) - personNode - .select(".bg") - .style("background-color", (d: GraphPerson) => d.data.getLiving() ? ageColor(d.data.getAgeToday()) : "var(background-higher)") - .style("color", (d: GraphPerson) => - (d.data.getAgeToday() < 70 && d.data.getLiving()) ? "var(--background)" : "var(--foreground)") - .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : ageColor(d.data.getAgeToday())) - .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid"); - personNode - .select(".focused") - .style("box-shadow", d => `0 0 1rem ${ageColor(d.data.getAgeToday())}`); - break; - } - case ColorMode.GENDER: { - const genderColor = d3.scaleOrdinal(["female", "male", "intersex", "unknown"], d3.schemeSet1); - personNode - .select(".bg") - .style("background-color", (d: GraphPerson) => d.data.getLiving() ? genderColor(d.getGender()) : "var(--background-higher)") - .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : genderColor(d.getGender())) - .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid") - .style("color", (d: GraphPerson) => d.data.getLiving() && matchMedia("(prefers-color-scheme: light)").matches ? "var(--background)" : "var(--foreground)") - personNode - .select(".focused") - .style("box-shadow", d => `0 0 1rem ${genderColor(d.getGender())}`); - break; +function setupCola() { + let svg = d3.select("#family-tree"); + + const viewportSize = [svg.node().getBBox().width, svg.node().getBBox().height]; + d3cola.size(viewportSize); + + // catch the transformation events + /* + I have changed the default zoom behavior to the following one: + - Nothing on double click + - Zoom with Ctrl + wheel + - Move with wheel (shift changes the axes) + */ + let svgZoom = d3.zoom() + .on("zoom", event => { + if (event.sourceEvent && event.sourceEvent.type === "wheel") { + if (event.sourceEvent.wheelDelta < 0) + svg.node().style.cursor = "zoom-out"; + else + svg.node().style.cursor = "zoom-in"; } + svg.select("#vis").attr("transform", event.transform.toString()); + }) + .on("end", () => { + svg.node().style.cursor = ""; + }) + .filter(event => event.type !== "dblclick" && (event.type === "wheel" ? event.ctrlKey : true)) + .touchable(() => ('ontouchstart' in window) || Boolean(window.TouchEvent)); + // @ts-ignore FIXME + svg.select("rect").call(svgZoom); +} + +function animateTree(graph: ViewGraph, colorMode: ColorMode) { + d3cola + .flowLayout("x", d => d.target.type === "person" ? config.gridSize * 5 : config.gridSize * 3.5) + .symmetricDiffLinkLengths(config.gridSize) + .start(15, 0, 10); + + let nodesLayer = d3.select("#nodes"); + let linkLayer = d3.select("#links"); + + let personNode = nodesLayer.selectAll(".person") + .data(graph.nodes.filter(p => p.type === "person") as GraphPerson[]) + let partnerNode = nodesLayer.selectAll(".partnerNode") + .data(graph.nodes.filter(node => node.type === "family")); + let etcNode = nodesLayer.selectAll(".etc") + .data(graph.nodes.filter(n => n.type === "etc")); + let link = linkLayer.selectAll(".link") + .data(graph.links); + + // reset style + personNode.select(".bg").attr("style", null); + + const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches; + switch (colorMode) { + case ColorMode.NAME: { + let last_names = graph.nodes.filter(n => n.type === "person") + .map((p: GraphPerson) => p.getName().split(" ").reverse()[0]); + last_names = Array.from(new Set(last_names)); + const nameColor = d3.scaleOrdinal(last_names, d3.schemeSet3) + personNode + .select(".bg") + .style("background-color", d => nameColor(d.getName().split(" ").reverse()[0])) + .style("color", "black") + personNode + .select(".focused") + .style("box-shadow", d => `0 0 1rem ${nameColor(d.getName().split(" ").reverse()[0])}`); + break; + } + case ColorMode.AGE: { + const ageColor = d3.scaleSequential() + .domain([0, 120]) + .interpolator((d) => darkMode ? d3.interpolateYlGn(d) : d3.interpolateYlGn(1 - d)) + personNode + .select(".bg") + .style("background-color", (d: GraphPerson) => d.data.getLiving() ? ageColor(d.data.getAgeToday()) : "var(background-higher)") + .style("color", (d: GraphPerson) => + (d.data.getAgeToday() < 70 && d.data.getLiving()) ? "var(--background)" : "var(--foreground)") + .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : ageColor(d.data.getAgeToday())) + .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid"); + personNode + .select(".focused") + .style("box-shadow", d => `0 0 1rem ${ageColor(d.data.getAgeToday())}`); + break; } + case ColorMode.GENDER: { + const genderColor = d3.scaleOrdinal(["female", "male", "intersex", "unknown"], d3.schemeSet1); + personNode + .select(".bg") + .style("background-color", (d: GraphPerson) => d.data.getLiving() ? genderColor(d.getGender()) : "var(--background-higher)") + .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : genderColor(d.getGender())) + .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid") + .style("color", (d: GraphPerson) => d.data.getLiving() && matchMedia("(prefers-color-scheme: light)").matches ? "var(--background)" : "var(--foreground)") + personNode + .select(".focused") + .style("box-shadow", d => `0 0 1rem ${genderColor(d.getGender())}`); + break; + } + } + personNode + .transition() + .duration(300) + .style("opacity", "1") + + link + .transition() + .duration(600) + .style("opacity", "1") + etcNode + .transition() + .duration(300) + .style("opacity", "1") + + d3cola.on("tick", () => { personNode - .transition() - .duration(300) - .style("opacity", "1") - - link - .transition() - .duration(600) - .style("opacity", "1") + .attr("x", d => d.x - d.width / 2) + .attr("y", d => d.y - d.height / 2); + partnerNode + .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); etcNode - .transition() - .duration(300) - .style("opacity", "1") - - d3cola.on("tick", () => { - personNode - .attr("x", d => d.x - d.width / 2) - .attr("y", d => d.y - d.height / 2); - partnerNode - .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); - etcNode - .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); - - link.attr("d", d => { - // 1 or -1 - let flip = -(Number((d.source.y - d.target.y) > 0) * 2 - 1); - let radius = Math.min(config.gridSize / 2, Math.abs(d.target.x - d.source.x) / 2, Math.abs(d.target.y - d.source.y) / 2); - - if (d.target.type === "person") { - return `M${d.source.x},${d.source.y} ` + - `h${config.gridSize} ` + - `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` + - `V${d.target.y - (flip) * radius} ` + - `a${radius} ${radius} 0 0 ${(-flip + 1) / 2} ${radius} ${flip * radius} ` + - `H${d.target.x}`; - } else { - return `M${d.source.x} ${d.source.y} ` + - `H${d.target.x - radius} ` + - `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` + - `V${d.target.y}`; - } - }); + .attr("transform", d => "translate(" + d.x + "," + d.y + ")"); + + link.attr("d", d => { + // 1 or -1 + let flip = -(Number((d.source.y - d.target.y) > 0) * 2 - 1); + let radius = Math.min(config.gridSize / 2, Math.abs(d.target.x - d.source.x) / 2, Math.abs(d.target.y - d.source.y) / 2); + + if (d.target.type === "person") { + return `M${d.source.x},${d.source.y} ` + + `h${config.gridSize} ` + + `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` + + `V${d.target.y - (flip) * radius} ` + + `a${radius} ${radius} 0 0 ${(-flip + 1) / 2} ${radius} ${flip * radius} ` + + `H${d.target.x}`; + } else { + return `M${d.source.x} ${d.source.y} ` + + `H${d.target.x - radius} ` + + `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` + + `V${d.target.y}`; + } }); - } - - onGraphChanged() { - if (this.mounted) { - this.setState({ - graph: viewGraph - }); - } - } + }); } + export default TreeView; diff --git a/src/components/Uploader.tsx b/src/components/Uploader.tsx deleted file mode 100644 index 31aa41c8..00000000 --- a/src/components/Uploader.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import Article from "./Article"; -import Form from "./Form"; -import * as React from "react"; -import {translationToString} from "../main"; - -function UploadForm(props) { - return ( -
- ); -} - -class Uploader extends React.Component { - render() { - return ( - <> -
-

- {translationToString({ - en: "Select the file with the button below. " + - "Then click the green button to view the family tree.", - de: "Wähle die Datei über den unteren Knopf aus. " + - "Klicke dann auf den grünen Knopf, um den Stammbaum anzuzeigen." - })} -

- -
- 🗒️ {translationToString({ - en: "From where do I get the data?", - de: "Woher bekomme ich die Daten?" - })} -

- {translationToString({ - en: <>The file must be a valid GedcomX file in json format, - as described here, - de: <>Die Datei muss eine gültige GedcomX Datei im json Format sein, - wie hier beschrieben. - })} - -

-
- - {translationToString({ - en:

The source code is available on Github.

, - de:

Der Quellcode ist auf Github verfügbar.

- })} -
- - ); - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { - let root = document.getElementById("root"); - root.classList.remove("sidebar-visible"); - } - - componentDidMount() { - let root = document.getElementById("root"); - root.classList.remove("sidebar-visible"); - } -} - -export default Uploader; diff --git a/src/components/View.css b/src/components/View.css index 07d73e70..1fcf7c14 100644 --- a/src/components/View.css +++ b/src/components/View.css @@ -1,11 +1,13 @@ -meter { - margin-left: .5rem; +#family-tree-container { + height: 100%; + box-sizing: border-box; + display: flex; + flex-flow: column; } #family-tree { --grid-size: 16px; margin: 0; - border-radius: 0; grid-area: main; width: 100%; height: 100%; @@ -64,7 +66,7 @@ meter { } @media (prefers-color-scheme: dark) { - #family-tree { + #family-tree-container { background: black; } } @@ -111,18 +113,20 @@ meter { /*View option styles*/ #view-all { display: flex; - position: absolute; - background: none; align-items: baseline; max-width: 100vw; overflow-x: auto; - flex-wrap: wrap; + overflow-y: clip; + flex-wrap: nowrap; } #view-all > * { margin-left: 1rem; + display: flex; + align-items: baseline; } #view-all div label { margin-right: .5rem; + white-space: nowrap; } diff --git a/src/components/View.tsx b/src/components/View.tsx index e465bc9f..c4d59df0 100644 --- a/src/components/View.tsx +++ b/src/components/View.tsx @@ -1,142 +1,78 @@ -import {Component} from "react"; -import {translationToString} from "../main"; +import {useEffect, useState} from "react"; +import {strings} from "../main"; import "./View.css"; -import {graphModel} from "../backend/ModelGraph"; -import {ViewMode, ViewGraph, ColorMode} from "../backend/ViewGraph"; +import {graphModel, loadData} from "../backend/ModelGraph"; +import {ViewMode, ColorMode} from "../backend/ViewGraph"; import TreeView from "./TreeView"; import InfoPanel from "./InfoPanel"; +import Header from "./Header"; +import SearchField from "./SearchField"; import * as React from "react"; -import FamilyPath from "./FamilyPath"; import {Person} from "gedcomx-js"; -function ViewOptions() { +function ViewOptions(props) { return (
- - + + + + +
- - + + +
); } -interface State { - activeView: ViewMode | string, - colorMode: ColorMode | string, - viewGraph: ViewGraph - focusId: string - focusHidden: boolean -} +function View() { + loadData(JSON.parse(localStorage.getItem("familyData"))); + let url = new URL(window.location.href); -class View extends Component { - constructor(props) { - super(props); + const [view, setView] = useState((url.searchParams.get("view") as ViewMode) || ViewMode.DEFAULT); + const [colorMode, setColorMode] = useState((url.searchParams.get("colorMode") as ColorMode) || ColorMode.GENDER); + const [focusId, setFocus] = useState(url.hash.substring(1)); + const [focusHidden, hideFocus] = useState(false); - let url = new URL(window.location.href); - let view: string = url.searchParams.get("view-all") || ViewMode.DEFAULT; - console.debug(`View: ${view}`); - - let focusId = url.hash.substring(1); - let viewGraph = graphModel.buildViewGraph(focusId, ViewMode[view]); - console.assert(viewGraph.nodes.length > 0, - "View graph has no nodes!"); - console.assert(viewGraph.links.length > 0, - "View graph has no links!"); - this.state = { - activeView: view, - colorMode: ColorMode.GENDER, - viewGraph: viewGraph, - focusId: focusId, - focusHidden: false - } - } + console.debug(`View: ${view}`); + console.debug(`ColorMode: ${colorMode}`) - componentDidMount() { + useEffect(() => { let root = document.querySelector("#root"); root.classList.add("sidebar-visible"); + }); - let colorSelector = document.querySelector("#color-selector"); - colorSelector.addEventListener("change", e => this.onColorChanged.bind(this)((e.target as HTMLSelectElement).value)); - - let viewSelector = document.querySelector("#view-selector"); - viewSelector.addEventListener("change", e => this.onViewChanged.bind(this)((e.target as HTMLSelectElement).value)); - } - - componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) { + useEffect(() => { let root = document.querySelector("#root"); - if (this.state.focusHidden) { + if (focusHidden) { root.classList.remove("sidebar-visible"); } else { root.classList.add("sidebar-visible"); } - } + }, [focusHidden]) - render() { - let focus; - if (this.state.focusId) { - focus = graphModel.getPersonById(this.state.focusId); - } else { - focus = graphModel.persons[0]; - } - if (!focus) { - throw new Error(`No person with id ${this.state.focusId} could be found`) - } + let viewGraph = graphModel.buildViewGraph(focusId, view); + useEffect(() => { + graphModel.buildViewGraph(focusId, view); + }, [focusId, view]) - return ( - <> - {!this.state.focusHidden && } -
- - -
- - - ); - } + let focus = graphModel.getPersonById(focusId) || graphModel.persons[0]; + + function onViewChanged(e) { + let view = (e.target as HTMLSelectElement).value; - onViewChanged(view: string | ViewMode) { let url = new URL(window.location.href); if (view === ViewMode.DEFAULT) { url.searchParams.delete("view"); @@ -145,13 +81,12 @@ class View extends Component { } window.history.pushState({}, "", url.toString()); - this.setState({ - activeView: view, - viewGraph: graphModel.buildViewGraph(this.state.focusId, view) - }); + setView(view as ViewMode); } - onColorChanged(colorMode: string | ColorMode) { + function onColorChanged(e) { + let colorMode = (e.target as HTMLSelectElement).value; + let url = new URL(window.location.href); if (colorMode === ColorMode.GENDER) { url.searchParams.delete("colorMode"); @@ -160,30 +95,37 @@ class View extends Component { } window.history.pushState({}, "", url.toString()); - this.setState({ - colorMode: colorMode - }) + setColorMode(colorMode as ColorMode); } - onRefocus(newFocus: Person) { + function onRefocus(newFocus: Person) { let url = new URL(window.location.href); - url.hash =newFocus.getId(); - window.history.pushState({}, "", url.toString()) - + url.hash = newFocus.getId(); window.history.pushState({}, "", url.toString()); - if (newFocus.getId() === this.state.focusId) { - this.setState({ - focusHidden: !this.state.focusHidden - }) + if (newFocus.getId() === focusId) { + hideFocus(!focusHidden) return; } - this.setState({ - focusHidden: false, - focusId: newFocus.getId(), - viewGraph: graphModel.buildViewGraph(newFocus.getId(), this.state.activeView) - }); + hideFocus(false); + setFocus(newFocus.getId()); } + + return ( + <> +
+ +
+ {!focusHidden && } +
+
+ + +
+
+ + ); } export default View; diff --git a/src/config.ts b/src/config.ts index 381bf0fd..dbb46634 100644 --- a/src/config.ts +++ b/src/config.ts @@ -7,9 +7,8 @@ const config = { margin: 20, // length of the vertical line between families (-) and their children personDiff: 25, - supportedLanguages: ['de', 'en'], // max number of elements automatically added to the view graph; too high and it becomes slow - // this was chosen via trial and error on Firefox on a high performant machine + // this was chosen via trial and error on Firefox desktop maxElements: 70 } diff --git a/src/locales/de.json b/src/locales/de.json new file mode 100644 index 00000000..fd54bbd4 --- /dev/null +++ b/src/locales/de.json @@ -0,0 +1,93 @@ +{ + "footer": { + "sourceCode": "Der Quellcode ist auf {0} verfügbar.", + "imprint": "Impressum & Datenschutzerklärung", + "bugReport": "Ein Problem melden" + }, + "form": { + "fileInputLabel": "Gedcom Datei", + "continueSession": "Mit letzter Sitzung fortfahren", + "noFileError": "Keine Datei ausgewählt", + "graphLoadingError": "Der Graph konnte nicht geladen werden." + }, + "header": { + "imageAlt": "Ein lächelnder Baum.", + "title": "Stammbaum" + }, + "home": { + "uploadArticle": { + "title": "Datei-Upload", + "content": "Wähle die Datei über den unteren Knopf aus. Klicke dann auf den grünen Knopf, um den Stammbaum anzuzeigen.", + "openButton": "Stammbaum öffnen", + "detailSummary": "Woher bekomme ich die Daten?", + "detail": "Die Datei muss eine gültige GedcomX Datei im json Format sein, wie {0} beschrieben." + }, + "navigationArticle": { + "title": "Bedienung", + "content": "Man kann den Stammbaum durch Ziehen mit der Maus verschieben. Hält man {0} gedrückt, kann man mit dem Mausrad rein- bzw. rauszoomen. Klickt man auf eine Person werden weitere Informationen zu dieser angezeigt. An vielen Personen hängen Kreise, in denen \"+\" oder \"-\" steht. Klickt man auf diese, werden weitere Verwandte ein- oder ausgeblendet." + } + }, + "imprint": { + "privacyArticle": { + "title": "Datenschutzerklärung", + "content": "Alle Stammbaum Daten werden ausschließlich lokal verarbeitet und im local Storage des Browsers gespeichert. Der Service wird auf GitHub Pages gehostet, die entsprechende Datenschutzerklärung kann {0} aufgerufen werden." + }, + "imprintArticle": { + "title": "Impressum" + } + }, + "infoPanel": { + "born": "geb.: {0}", + "aka": "alias {0}", + "nickname": "Spitzname: {0}", + "personImageAlt": "Bild von {0}.", + "note": "Anmerkung", + "source": "Quelle", + "noSourceDescriptionError": "Source description {0} konnte nicht gefunden werden", + "confidenceExplanation": "Wie sehr kann den Daten vertraut werden", + "confidenceLabel": "Zuversicht: " + }, + "nodes": { + "lockedFamilyHint": "Diese Familie kann nicht ausgeblendet werden.", + "hideFamilyHint": "Klicke, um diese Familie auszublenden.", + "clickPersonHint": "Klicke für weitere Informationen" + }, + "gedcomX": { + "born": "geboren", + "generation": "Generation", + "single": "ledig", + "married": "verheiratet", + "religion": "Religion", + "worksAs": "arbeitet als", + "died": "verstorben", + "ageQualifier": "mit {0} Jahren", + "day": "am {0}", + "month": "im {0}", + "year": "{0}", + "time": "um {0}", + "place": "in {0}" + }, + "searchField": { + "noPersonFound": "Es konnte keine Person mit diesem Namen gefunden werden!", + "searchLabel": "Name:", + "searchHint": "Nach einer Person suchen" + }, + "viewOptions": { + "filter": { + "label": "Zeige:", + "default": "Standard", + "descendants": "Nachkommen", + "ancestors": "Vorfahren", + "living": "Lebende", + "all": "Alle" + }, + "color": { + "label": "Färbe nach:", + "gender": "Geschlecht", + "surname": "Nachname", + "age": "Alter" + } + }, + "ctrl": "Strg", + "linkContent": "hier" +} diff --git a/src/locales/en.json b/src/locales/en.json new file mode 100644 index 00000000..d2aa8672 --- /dev/null +++ b/src/locales/en.json @@ -0,0 +1,93 @@ +{ + "footer": { + "sourceCode": "The source code is available on {0}.", + "imprint": "Imprint & privacy policy", + "bugReport": "Report a problem" + }, + "form": { + "fileInputLabel": "Gedcom File", + "continueSession": "Continue with last session", + "noFileError": "No gedcom file selected", + "graphLoadingError": "The graph could not be loaded." + }, + "header": { + "imageAlt": "A smiling tree.", + "title": "Family tree" + }, + "home": { + "uploadArticle": { + "title": "File-Upload", + "content": "Select the file with the button below. Then click the green button to view the family tree.", + "openButton": "Open family tree", + "detailSummary": "From where do I get the data?", + "detail": "The file must be a valid GedcomX file in json format, as described {0}." + }, + "navigationArticle": { + "title": "Usage", + "content": "You can move the family tree with your mouse.While pressing {0}, you can zoom in and out with your mouse wheel. Select a person with your left mouse button to show their information. Many people have a circle with \"+\" or \"-\" inside them, clicking on those displays their relatives." + } + }, + "imprint": { + "privacyArticle": { + "title": "Privacy Policy", + "content": "All family tree data is processed locally only and stored in the browser's local storage. The service is hosted on GitHub Pages, the corresponding privacy policy can be found {0}." + }, + "imprintArticle": { + "title": "Imprint" + } + }, + "infoPanel": { + "born": "born: {0}", + "aka": "aka {0}", + "nickname": "Nickname: {0}", + "personImageAlt": "Image of {0}.", + "note": "Note", + "source": "Source", + "noSourceDescriptionError": "Source description {0} could not be found", + "confidenceExplanation": "How much can the data be trusted", + "confidenceLabel": "Confidence: " + }, + "nodes": { + "lockedFamilyHint": "This family cannot be hidden.", + "hideFamilyHint": "Click to hide this family.", + "clickPersonHint": "Click to show more information" + }, + "gedcomX": { + "born": "born", + "generation": "Generation", + "single": "single", + "married": "married", + "religion": "Religion", + "worksAs": "works as", + "died": "died", + "ageQualifier": "with {0} years old", + "day": "on {0}", + "month": "in {0}", + "year": "in {0}", + "time": "at {0}", + "place": "in {0}" + }, + "searchField": { + "noPersonFound": "No person with that name found!", + "searchLabel": "Name:", + "searchHint": "Search for a person" + }, + "viewOptions": { + "filter": { + "label": "Show:", + "default": "Default", + "descendants": "Descendants", + "ancestors": "Ancestors", + "living": "Living", + "all": "All" + }, + "color": { + "label": "Color by:", + "gender": "Gender", + "surname": "Surname", + "age": "Age" + } + }, + "ctrl": "Ctrl", + "linkContent": "here" +} diff --git a/src/main.ts b/src/main.ts index bbe14834..7aadc8c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,36 +1,8 @@ -import config from "./config"; - -/** - * Only show elements with the correct langauge - * @param language the language to show, e.g. window.navigator.language - */ -export function localize(language) { - // strip country-specific stuff - language = language.slice(0, 2); - - if (config.supportedLanguages.includes(language)) { - document.querySelector("html").setAttribute("lang", language) - // set the page title - document.title = document.querySelector("#title").innerHTML; - } else { - console.warn(`Language ${language} is not supported. Falling back to english.`); - localize("en"); - } -} - -/** - * Returns the correct translation for an translationObject - * The translationObject maps two-letter language strings to a message string. - * @param translationObject {object} - * @returns {string} - */ -export function translationToString(translationObject: {en: any, de?: any}) { - if (!("en" in translationObject)) - console.error(`${translationObject} has no translation into english, the default language!`); - - if (!(config.browserLang in translationObject)) { - console.debug(`${translationObject} has no translation for the currently used language!`) - return translationObject.en; - } - return translationObject[config.browserLang]; -} +import LocalizedStrings from "react-localization"; +import * as en from "./locales/en.json"; +import * as de from "./locales/de.json"; + +export let strings = new LocalizedStrings({ + en: en, + de: de +})