mirror of
https://github.com/PeWu/topola-viewer.git
synced 2025-12-24 03:00:05 +00:00
Display photos in details panel (#100)
This commit is contained in:
parent
d30c038406
commit
4ca0025438
@ -7,6 +7,7 @@ import {
|
||||
JsonEvent,
|
||||
JsonFam,
|
||||
JsonGedcomData,
|
||||
JsonImage,
|
||||
JsonIndi,
|
||||
} from 'topola';
|
||||
import {GedcomData, normalizeGedcom, TopolaData} from '../util/gedcom_util';
|
||||
@ -80,6 +81,7 @@ interface Person {
|
||||
BirthDate: string;
|
||||
DeathDate: string;
|
||||
};
|
||||
Photo: string;
|
||||
PhotoData?: {
|
||||
path: string;
|
||||
url: string;
|
||||
@ -348,6 +350,8 @@ export async function loadWikiTree(
|
||||
>();
|
||||
// Map from numerical id to human-readable id.
|
||||
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) => {
|
||||
idToName.set(person.Id, person.Name);
|
||||
@ -364,6 +368,7 @@ export async function loadWikiTree(
|
||||
});
|
||||
|
||||
const indis: JsonIndi[] = [];
|
||||
|
||||
const converted = new Set<number>();
|
||||
everyone.forEach((person) => {
|
||||
if (converted.has(person.Id)) {
|
||||
@ -371,6 +376,12 @@ export async function loadWikiTree(
|
||||
}
|
||||
converted.add(person.Id);
|
||||
const indi = convertPerson(person, intl);
|
||||
if (person.PhotoData?.path) {
|
||||
fullSizePhotoUrls.set(
|
||||
person.Name,
|
||||
`https://www.wikitree.com${person.PhotoData.path}`,
|
||||
);
|
||||
}
|
||||
if (person.Spouses) {
|
||||
Object.values(person.Spouses).forEach((spouse) => {
|
||||
const famId = getFamilyId(person.Id, spouse.Id);
|
||||
@ -417,7 +428,7 @@ export async function loadWikiTree(
|
||||
});
|
||||
|
||||
const chartData = normalizeGedcom({indis, fams});
|
||||
const gedcom = buildGedcom(chartData);
|
||||
const gedcom = buildGedcom(chartData, fullSizePhotoUrls);
|
||||
return {chartData, gedcom};
|
||||
}
|
||||
|
||||
@ -481,7 +492,12 @@ function convertPerson(person: Person, intl: IntlShape): JsonIndi {
|
||||
indi.death = Object.assign({}, date, {place: person.DeathLocation});
|
||||
}
|
||||
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;
|
||||
}
|
||||
@ -589,7 +605,40 @@ function eventToGedcom(event: JsonEvent): GedcomEntry[] {
|
||||
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.
|
||||
const escapedId = indi.id.replace(/ /g, '_');
|
||||
const record: GedcomEntry = {
|
||||
@ -652,6 +701,15 @@ function indiToGedcom(indi: JsonIndi): GedcomEntry {
|
||||
tree: [],
|
||||
});
|
||||
}
|
||||
(indi.images || []).forEach((image) => {
|
||||
record.tree.push({
|
||||
level: 1,
|
||||
pointer: '',
|
||||
tag: 'OBJE',
|
||||
data: '',
|
||||
tree: imageToGedcom(image, fullSizePhotoUrl.get(indi.id)),
|
||||
});
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
@ -706,11 +764,14 @@ function famToGedcom(fam: JsonFam): GedcomEntry {
|
||||
* Creates a GEDCOM structure for the purpose of displaying the details
|
||||
* panel.
|
||||
*/
|
||||
function buildGedcom(data: JsonGedcomData): GedcomData {
|
||||
function buildGedcom(
|
||||
data: JsonGedcomData,
|
||||
fullSizePhotoUrls: Map<string, string>,
|
||||
): GedcomData {
|
||||
const gedcomIndis: {[key: string]: GedcomEntry} = {};
|
||||
const gedcomFams: {[key: string]: GedcomEntry} = {};
|
||||
data.indis.forEach((indi) => {
|
||||
gedcomIndis[indi.id] = indiToGedcom(indi);
|
||||
gedcomIndis[indi.id] = indiToGedcom(indi, fullSizePhotoUrls);
|
||||
});
|
||||
data.fams.forEach((fam) => {
|
||||
gedcomFams[fam.id] = famToGedcom(fam);
|
||||
|
||||
@ -1,10 +1,17 @@
|
||||
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 {GedcomEntry} from 'parse-gedcom';
|
||||
import {MultilineText} from './multiline-text';
|
||||
import {TranslatedTag} from './translated-tag';
|
||||
import {Header, Item} from 'semantic-ui-react';
|
||||
import {WrappedImage} from './wrapped-image';
|
||||
|
||||
const EXCLUDED_TAGS = [
|
||||
'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) {
|
||||
return (
|
||||
<MultilineText
|
||||
@ -128,6 +153,7 @@ export function Details(props: Props) {
|
||||
<div className="details">
|
||||
<Item.Group divided>
|
||||
{getDetails(entries, ['NAME'], nameDetails)}
|
||||
{getDetails(entriesWithData, ['OBJE'], fileDetails)}
|
||||
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
|
||||
{getOtherDetails(entriesWithData)}
|
||||
{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;
|
||||
overflow: auto;
|
||||
border-left: solid #ccc 1px;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@ -164,6 +165,12 @@ div.zoom {
|
||||
min-width: 40%;
|
||||
}
|
||||
|
||||
.details .person-image {
|
||||
max-width: 289px;
|
||||
width: 289px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.ui.form .field.no-margin {
|
||||
margin: 0;
|
||||
}
|
||||
@ -176,3 +183,26 @@ div.zoom {
|
||||
height: 300px;
|
||||
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_PROFILE_NOT_ACCESSIBLE": "Profil WikiTree {id} nie jest dostępny",
|
||||
"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",
|
||||
"tab.info": "Info",
|
||||
"tab.settings": "Ustawienia",
|
||||
|
||||
@ -118,7 +118,8 @@ function calcDateDifferenceInYears(
|
||||
const startYear = firstDateObject.getUTCFullYear();
|
||||
|
||||
let yearDiff = secondDateObject.getUTCFullYear() - startYear;
|
||||
let monthDiff = secondDateObject.getUTCMonth() - firstDateObject.getUTCMonth();
|
||||
let monthDiff =
|
||||
secondDateObject.getUTCMonth() - firstDateObject.getUTCMonth();
|
||||
if (monthDiff < 0) {
|
||||
yearDiff--;
|
||||
monthDiff += 12;
|
||||
|
||||
@ -192,10 +192,10 @@ export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
|
||||
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. */
|
||||
function isImageFile(fileName: string): boolean {
|
||||
export function isImageFile(fileName: string): boolean {
|
||||
const lowerName = fileName.toLowerCase();
|
||||
return IMAGE_EXTENSIONS.some((ext) => lowerName.endsWith(ext));
|
||||
}
|
||||
@ -282,3 +282,12 @@ export function getName(person: GedcomEntry): string | undefined {
|
||||
const name = notMarriedName || names[0];
|
||||
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;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user