Display photos in details panel (#100)

This commit is contained in:
czifumasa
2022-05-13 22:15:40 +02:00
committed by GitHub
parent d30c038406
commit 4ca0025438
7 changed files with 224 additions and 9 deletions

View File

@@ -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);

View File

@@ -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)}

View 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>
</>
);
}

View File

@@ -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%;
}

View File

@@ -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",

View File

@@ -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;

View File

@@ -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;
}