mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-08-03 17:41:48 +00:00
Display photos in details panel (#100)
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
|||||||
JsonEvent,
|
JsonEvent,
|
||||||
JsonFam,
|
JsonFam,
|
||||||
JsonGedcomData,
|
JsonGedcomData,
|
||||||
|
JsonImage,
|
||||||
JsonIndi,
|
JsonIndi,
|
||||||
} from 'topola';
|
} from 'topola';
|
||||||
import {GedcomData, normalizeGedcom, TopolaData} from '../util/gedcom_util';
|
import {GedcomData, normalizeGedcom, TopolaData} from '../util/gedcom_util';
|
||||||
@@ -80,6 +81,7 @@ interface Person {
|
|||||||
BirthDate: string;
|
BirthDate: string;
|
||||||
DeathDate: string;
|
DeathDate: string;
|
||||||
};
|
};
|
||||||
|
Photo: string;
|
||||||
PhotoData?: {
|
PhotoData?: {
|
||||||
path: string;
|
path: string;
|
||||||
url: string;
|
url: string;
|
||||||
@@ -348,6 +350,8 @@ export async function loadWikiTree(
|
|||||||
>();
|
>();
|
||||||
// Map from numerical id to human-readable id.
|
// Map from numerical id to human-readable id.
|
||||||
const idToName = new Map<number, string>();
|
const idToName = new Map<number, string>();
|
||||||
|
// Map from human-readable person id to fullSizeUrl of person photo.
|
||||||
|
const fullSizePhotoUrls: Map<string, string> = new Map();
|
||||||
|
|
||||||
everyone.forEach((person) => {
|
everyone.forEach((person) => {
|
||||||
idToName.set(person.Id, person.Name);
|
idToName.set(person.Id, person.Name);
|
||||||
@@ -364,6 +368,7 @@ export async function loadWikiTree(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const indis: JsonIndi[] = [];
|
const indis: JsonIndi[] = [];
|
||||||
|
|
||||||
const converted = new Set<number>();
|
const converted = new Set<number>();
|
||||||
everyone.forEach((person) => {
|
everyone.forEach((person) => {
|
||||||
if (converted.has(person.Id)) {
|
if (converted.has(person.Id)) {
|
||||||
@@ -371,6 +376,12 @@ export async function loadWikiTree(
|
|||||||
}
|
}
|
||||||
converted.add(person.Id);
|
converted.add(person.Id);
|
||||||
const indi = convertPerson(person, intl);
|
const indi = convertPerson(person, intl);
|
||||||
|
if (person.PhotoData?.path) {
|
||||||
|
fullSizePhotoUrls.set(
|
||||||
|
person.Name,
|
||||||
|
`https://www.wikitree.com${person.PhotoData.path}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (person.Spouses) {
|
if (person.Spouses) {
|
||||||
Object.values(person.Spouses).forEach((spouse) => {
|
Object.values(person.Spouses).forEach((spouse) => {
|
||||||
const famId = getFamilyId(person.Id, spouse.Id);
|
const famId = getFamilyId(person.Id, spouse.Id);
|
||||||
@@ -417,7 +428,7 @@ export async function loadWikiTree(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const chartData = normalizeGedcom({indis, fams});
|
const chartData = normalizeGedcom({indis, fams});
|
||||||
const gedcom = buildGedcom(chartData);
|
const gedcom = buildGedcom(chartData, fullSizePhotoUrls);
|
||||||
return {chartData, gedcom};
|
return {chartData, gedcom};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -481,7 +492,12 @@ function convertPerson(person: Person, intl: IntlShape): JsonIndi {
|
|||||||
indi.death = Object.assign({}, date, {place: person.DeathLocation});
|
indi.death = Object.assign({}, date, {place: person.DeathLocation});
|
||||||
}
|
}
|
||||||
if (person.PhotoData) {
|
if (person.PhotoData) {
|
||||||
indi.images = [{url: `https://www.wikitree.com${person.PhotoData.url}`}];
|
indi.images = [
|
||||||
|
{
|
||||||
|
url: `https://www.wikitree.com${person.PhotoData.url}`,
|
||||||
|
title: person.Photo,
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return indi;
|
return indi;
|
||||||
}
|
}
|
||||||
@@ -589,7 +605,40 @@ function eventToGedcom(event: JsonEvent): GedcomEntry[] {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function indiToGedcom(indi: JsonIndi): GedcomEntry {
|
function imageToGedcom(
|
||||||
|
image: JsonImage,
|
||||||
|
fullSizePhotoUrl: string | undefined,
|
||||||
|
): GedcomEntry[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
level: 2,
|
||||||
|
pointer: '',
|
||||||
|
tag: 'FILE',
|
||||||
|
data: fullSizePhotoUrl || image.url,
|
||||||
|
tree: [
|
||||||
|
{
|
||||||
|
level: 3,
|
||||||
|
pointer: '',
|
||||||
|
tag: 'FORM',
|
||||||
|
data: image.title?.split('.').pop() || '',
|
||||||
|
tree: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
level: 3,
|
||||||
|
pointer: '',
|
||||||
|
tag: 'TITL',
|
||||||
|
data: image.title?.split('.')[0] || '',
|
||||||
|
tree: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function indiToGedcom(
|
||||||
|
indi: JsonIndi,
|
||||||
|
fullSizePhotoUrl: Map<string, string>,
|
||||||
|
): GedcomEntry {
|
||||||
// WikiTree URLs replace spaces with underscores.
|
// WikiTree URLs replace spaces with underscores.
|
||||||
const escapedId = indi.id.replace(/ /g, '_');
|
const escapedId = indi.id.replace(/ /g, '_');
|
||||||
const record: GedcomEntry = {
|
const record: GedcomEntry = {
|
||||||
@@ -652,6 +701,15 @@ function indiToGedcom(indi: JsonIndi): GedcomEntry {
|
|||||||
tree: [],
|
tree: [],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
(indi.images || []).forEach((image) => {
|
||||||
|
record.tree.push({
|
||||||
|
level: 1,
|
||||||
|
pointer: '',
|
||||||
|
tag: 'OBJE',
|
||||||
|
data: '',
|
||||||
|
tree: imageToGedcom(image, fullSizePhotoUrl.get(indi.id)),
|
||||||
|
});
|
||||||
|
});
|
||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -706,11 +764,14 @@ function famToGedcom(fam: JsonFam): GedcomEntry {
|
|||||||
* Creates a GEDCOM structure for the purpose of displaying the details
|
* Creates a GEDCOM structure for the purpose of displaying the details
|
||||||
* panel.
|
* panel.
|
||||||
*/
|
*/
|
||||||
function buildGedcom(data: JsonGedcomData): GedcomData {
|
function buildGedcom(
|
||||||
|
data: JsonGedcomData,
|
||||||
|
fullSizePhotoUrls: Map<string, string>,
|
||||||
|
): GedcomData {
|
||||||
const gedcomIndis: {[key: string]: GedcomEntry} = {};
|
const gedcomIndis: {[key: string]: GedcomEntry} = {};
|
||||||
const gedcomFams: {[key: string]: GedcomEntry} = {};
|
const gedcomFams: {[key: string]: GedcomEntry} = {};
|
||||||
data.indis.forEach((indi) => {
|
data.indis.forEach((indi) => {
|
||||||
gedcomIndis[indi.id] = indiToGedcom(indi);
|
gedcomIndis[indi.id] = indiToGedcom(indi, fullSizePhotoUrls);
|
||||||
});
|
});
|
||||||
data.fams.forEach((fam) => {
|
data.fams.forEach((fam) => {
|
||||||
gedcomFams[fam.id] = famToGedcom(fam);
|
gedcomFams[fam.id] = famToGedcom(fam);
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
import flatMap from 'array.prototype.flatmap';
|
import flatMap from 'array.prototype.flatmap';
|
||||||
import {dereference, GedcomData, getData} from '../util/gedcom_util';
|
import {
|
||||||
|
dereference,
|
||||||
|
GedcomData,
|
||||||
|
getData,
|
||||||
|
getFileName,
|
||||||
|
isImageFile,
|
||||||
|
} from '../util/gedcom_util';
|
||||||
import {Events} from './events';
|
import {Events} from './events';
|
||||||
import {GedcomEntry} from 'parse-gedcom';
|
import {GedcomEntry} from 'parse-gedcom';
|
||||||
import {MultilineText} from './multiline-text';
|
import {MultilineText} from './multiline-text';
|
||||||
import {TranslatedTag} from './translated-tag';
|
import {TranslatedTag} from './translated-tag';
|
||||||
import {Header, Item} from 'semantic-ui-react';
|
import {Header, Item} from 'semantic-ui-react';
|
||||||
|
import {WrappedImage} from './wrapped-image';
|
||||||
|
|
||||||
const EXCLUDED_TAGS = [
|
const EXCLUDED_TAGS = [
|
||||||
'BIRT',
|
'BIRT',
|
||||||
@@ -47,6 +54,24 @@ function dataDetails(entry: GedcomEntry) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fileDetails(objectEntry: GedcomEntry) {
|
||||||
|
const imageFileEntry = objectEntry.tree.find(
|
||||||
|
(entry) =>
|
||||||
|
entry.tag === 'FILE' &&
|
||||||
|
entry.data.startsWith('http') &&
|
||||||
|
isImageFile(entry.data),
|
||||||
|
);
|
||||||
|
|
||||||
|
return imageFileEntry ? (
|
||||||
|
<div className="person-image">
|
||||||
|
<WrappedImage
|
||||||
|
url={imageFileEntry.data}
|
||||||
|
filename={getFileName(imageFileEntry) || ''}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function noteDetails(entry: GedcomEntry) {
|
function noteDetails(entry: GedcomEntry) {
|
||||||
return (
|
return (
|
||||||
<MultilineText
|
<MultilineText
|
||||||
@@ -128,6 +153,7 @@ export function Details(props: Props) {
|
|||||||
<div className="details">
|
<div className="details">
|
||||||
<Item.Group divided>
|
<Item.Group divided>
|
||||||
{getDetails(entries, ['NAME'], nameDetails)}
|
{getDetails(entries, ['NAME'], nameDetails)}
|
||||||
|
{getDetails(entriesWithData, ['OBJE'], fileDetails)}
|
||||||
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
|
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
|
||||||
{getOtherDetails(entriesWithData)}
|
{getOtherDetails(entriesWithData)}
|
||||||
{getDetails(entriesWithData, ['NOTE'], noteDetails)}
|
{getDetails(entriesWithData, ['NOTE'], noteDetails)}
|
||||||
|
|||||||
87
src/details/wrapped-image.tsx
Normal file
87
src/details/wrapped-image.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
Container,
|
||||||
|
Icon,
|
||||||
|
Image,
|
||||||
|
Label,
|
||||||
|
Message,
|
||||||
|
Modal,
|
||||||
|
Placeholder,
|
||||||
|
} from 'semantic-ui-react';
|
||||||
|
import {SyntheticEvent, useState} from 'react';
|
||||||
|
import {FormattedMessage} from 'react-intl';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
title?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WrappedImage(props: Props) {
|
||||||
|
const [imageOpen, setImageOpen] = useState(false);
|
||||||
|
const [imageLoaded, setImageLoaded] = useState(false);
|
||||||
|
const [imageFailed, setImageFailed] = useState(false);
|
||||||
|
const [imageSrc, setImageSrc] = useState('');
|
||||||
|
|
||||||
|
if (imageLoaded && imageSrc !== props.url) {
|
||||||
|
setImageLoaded(false);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Image
|
||||||
|
className={imageLoaded ? 'loaded-image-thumbnail' : 'hidden-image'}
|
||||||
|
onClick={() => setImageOpen(true)}
|
||||||
|
onLoad={() => {
|
||||||
|
setImageLoaded(true);
|
||||||
|
setImageSrc(props.url);
|
||||||
|
setImageFailed(false);
|
||||||
|
}}
|
||||||
|
onError={(e: SyntheticEvent<HTMLImageElement, Event>) => {
|
||||||
|
setImageLoaded(true);
|
||||||
|
setImageSrc(props.url);
|
||||||
|
setImageFailed(true);
|
||||||
|
e.currentTarget.alt = '';
|
||||||
|
}}
|
||||||
|
src={props.url}
|
||||||
|
alt={props.title || props.filename}
|
||||||
|
centered={true}
|
||||||
|
/>
|
||||||
|
<Placeholder
|
||||||
|
className={!imageLoaded ? 'image-placeholder' : 'hidden-image'}
|
||||||
|
>
|
||||||
|
<Placeholder.Image square />
|
||||||
|
</Placeholder>
|
||||||
|
{imageFailed && (
|
||||||
|
<Container fluid textAlign="center">
|
||||||
|
<Message negative compact>
|
||||||
|
<Message.Header>
|
||||||
|
<FormattedMessage
|
||||||
|
id="error.failed_to_load_image"
|
||||||
|
defaultMessage={'Failed to load image file'}
|
||||||
|
/>
|
||||||
|
</Message.Header>
|
||||||
|
</Message>
|
||||||
|
</Container>
|
||||||
|
)}
|
||||||
|
<Modal
|
||||||
|
basic
|
||||||
|
size="large"
|
||||||
|
closeIcon={<Icon name="close" color="red" />}
|
||||||
|
open={imageOpen}
|
||||||
|
onClose={() => setImageOpen(false)}
|
||||||
|
onOpen={() => setImageOpen(true)}
|
||||||
|
centered={false}
|
||||||
|
>
|
||||||
|
<Modal.Header>{props.title}</Modal.Header>
|
||||||
|
<Modal.Content image>
|
||||||
|
<Image
|
||||||
|
className="modal-image"
|
||||||
|
src={props.url}
|
||||||
|
alt={props.title || props.filename}
|
||||||
|
label={<Label attached="bottom" content={props.filename} />}
|
||||||
|
wrapped
|
||||||
|
/>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ body, html {
|
|||||||
flex: 0 0 320px;
|
flex: 0 0 320px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
border-left: solid #ccc 1px;
|
border-left: solid #ccc 1px;
|
||||||
|
overflow-x: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hidden {
|
.hidden {
|
||||||
@@ -164,6 +165,12 @@ div.zoom {
|
|||||||
min-width: 40%;
|
min-width: 40%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.details .person-image {
|
||||||
|
max-width: 289px;
|
||||||
|
width: 289px;
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.ui.form .field.no-margin {
|
.ui.form .field.no-margin {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
@@ -176,3 +183,26 @@ div.zoom {
|
|||||||
height: 300px;
|
height: 300px;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.loaded-image-thumbnail {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden-image {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-image {
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-image .ui.attached.label {
|
||||||
|
width: auto;
|
||||||
|
min-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-placeholder {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,7 @@
|
|||||||
"error.WIKITREE_ID_NOT_PROVIDED": "Identyfikator WikiTree nie został podany",
|
"error.WIKITREE_ID_NOT_PROVIDED": "Identyfikator WikiTree nie został podany",
|
||||||
"error.WIKITREE_PROFILE_NOT_ACCESSIBLE": "Profil WikiTree {id} nie jest dostępny",
|
"error.WIKITREE_PROFILE_NOT_ACCESSIBLE": "Profil WikiTree {id} nie jest dostępny",
|
||||||
"error.WIKITREE_PROFILE_NOT_FOUND": "Profil WikiTree {id} nie istnieje",
|
"error.WIKITREE_PROFILE_NOT_FOUND": "Profil WikiTree {id} nie istnieje",
|
||||||
|
"error.failed_to_load_image": "Błąd podczas pobierania pliku ze zdjęciem",
|
||||||
"wikitree.private": "Prywatne",
|
"wikitree.private": "Prywatne",
|
||||||
"tab.info": "Info",
|
"tab.info": "Info",
|
||||||
"tab.settings": "Ustawienia",
|
"tab.settings": "Ustawienia",
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ function calcDateDifferenceInYears(
|
|||||||
const startYear = firstDateObject.getUTCFullYear();
|
const startYear = firstDateObject.getUTCFullYear();
|
||||||
|
|
||||||
let yearDiff = secondDateObject.getUTCFullYear() - startYear;
|
let yearDiff = secondDateObject.getUTCFullYear() - startYear;
|
||||||
let monthDiff = secondDateObject.getUTCMonth() - firstDateObject.getUTCMonth();
|
let monthDiff =
|
||||||
|
secondDateObject.getUTCMonth() - firstDateObject.getUTCMonth();
|
||||||
if (monthDiff < 0) {
|
if (monthDiff < 0) {
|
||||||
yearDiff--;
|
yearDiff--;
|
||||||
monthDiff += 12;
|
monthDiff += 12;
|
||||||
|
|||||||
@@ -192,10 +192,10 @@ export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
|
|||||||
return sortSpouses(sortChildren(gedcom));
|
return sortSpouses(sortChildren(gedcom));
|
||||||
}
|
}
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = ['.jpg', '.png', '.gif'];
|
const IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.gif'];
|
||||||
|
|
||||||
/** Returns true if the given file name has a known image extension. */
|
/** Returns true if the given file name has a known image extension. */
|
||||||
function isImageFile(fileName: string): boolean {
|
export function isImageFile(fileName: string): boolean {
|
||||||
const lowerName = fileName.toLowerCase();
|
const lowerName = fileName.toLowerCase();
|
||||||
return IMAGE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
return IMAGE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
||||||
}
|
}
|
||||||
@@ -282,3 +282,12 @@ export function getName(person: GedcomEntry): string | undefined {
|
|||||||
const name = notMarriedName || names[0];
|
const name = notMarriedName || names[0];
|
||||||
return name?.data.replace(/\//g, '');
|
return name?.data.replace(/\//g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getFileName(fileEntry: GedcomEntry): string | undefined {
|
||||||
|
const fileTitle = fileEntry?.tree.find((entry) => entry.tag === 'TITL')?.data;
|
||||||
|
|
||||||
|
const fileExtension = fileEntry?.tree.find((entry) => entry.tag === 'FORM')
|
||||||
|
?.data;
|
||||||
|
|
||||||
|
return fileTitle && fileExtension && fileTitle + '.' + fileExtension;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user