mirror of
https://github.com/PeWu/topola-viewer.git
synced 2026-07-26 05:31:48 +00:00
Sidebar improvements (Collapse/Expand, Mobile view) (#215)
* Extract sidebar to new component * Add sidebar toggle * Fix scrollbars sometimes appearing even at maximum zoom-out
This commit is contained in:
234
src/sidepanel/config/config.tsx
Normal file
234
src/sidepanel/config/config.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import {ParsedQuery} from 'query-string';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {Checkbox, Form, Header, Item} from 'semantic-ui-react';
|
||||
|
||||
export enum ChartColors {
|
||||
NO_COLOR,
|
||||
COLOR_BY_SEX,
|
||||
COLOR_BY_GENERATION,
|
||||
}
|
||||
|
||||
export enum Ids {
|
||||
HIDE,
|
||||
SHOW,
|
||||
}
|
||||
|
||||
export enum Sex {
|
||||
HIDE,
|
||||
SHOW,
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
color: ChartColors;
|
||||
id: Ids;
|
||||
sex: Sex;
|
||||
}
|
||||
|
||||
export const DEFALUT_CONFIG: Config = {
|
||||
color: ChartColors.COLOR_BY_GENERATION,
|
||||
id: Ids.SHOW,
|
||||
sex: Sex.SHOW,
|
||||
};
|
||||
|
||||
const COLOR_ARG = new Map<string, ChartColors>([
|
||||
['n', ChartColors.NO_COLOR],
|
||||
['g', ChartColors.COLOR_BY_GENERATION],
|
||||
['s', ChartColors.COLOR_BY_SEX],
|
||||
]);
|
||||
const COLOR_ARG_INVERSE = new Map<ChartColors, string>();
|
||||
COLOR_ARG.forEach((v, k) => COLOR_ARG_INVERSE.set(v, k));
|
||||
|
||||
const ID_ARG = new Map<string, Ids>([
|
||||
['h', Ids.HIDE],
|
||||
['s', Ids.SHOW],
|
||||
]);
|
||||
const ID_ARG_INVERSE = new Map<Ids, string>();
|
||||
ID_ARG.forEach((v, k) => ID_ARG_INVERSE.set(v, k));
|
||||
|
||||
const SEX_ARG = new Map<string, Sex>([
|
||||
['h', Sex.HIDE],
|
||||
['s', Sex.SHOW],
|
||||
]);
|
||||
const SEX_ARG_INVERSE = new Map<Sex, string>();
|
||||
SEX_ARG.forEach((v, k) => SEX_ARG_INVERSE.set(v, k));
|
||||
|
||||
export function argsToConfig(args: ParsedQuery<any>): Config {
|
||||
const getParam = (name: string) => {
|
||||
const value = args[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
color: COLOR_ARG.get(getParam('c') ?? '') ?? DEFALUT_CONFIG.color,
|
||||
id: ID_ARG.get(getParam('i') ?? '') ?? DEFALUT_CONFIG.id,
|
||||
sex: SEX_ARG.get(getParam('s') ?? '') ?? DEFALUT_CONFIG.sex,
|
||||
};
|
||||
}
|
||||
|
||||
export function configToArgs(config: Config): ParsedQuery<any> {
|
||||
return {
|
||||
c: COLOR_ARG_INVERSE.get(config.color),
|
||||
i: ID_ARG_INVERSE.get(config.id),
|
||||
s: SEX_ARG_INVERSE.get(config.sex),
|
||||
};
|
||||
}
|
||||
|
||||
export function ConfigPanel(props: {
|
||||
config: Config;
|
||||
onChange: (config: Config) => void;
|
||||
}) {
|
||||
return (
|
||||
<Form className="details">
|
||||
<Item.Group>
|
||||
<Item>
|
||||
<Item.Content>
|
||||
<Header sub>
|
||||
<FormattedMessage id="config.colors" defaultMessage="Colors" />
|
||||
</Header>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.colors.NO_COLOR"
|
||||
defaultMessage="none"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="none"
|
||||
checked={props.config.color === ChartColors.NO_COLOR}
|
||||
onClick={() =>
|
||||
props.onChange({
|
||||
...props.config,
|
||||
color: ChartColors.NO_COLOR,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.colors.COLOR_BY_GENERATION"
|
||||
defaultMessage="by generation"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="generation"
|
||||
checked={props.config.color === ChartColors.COLOR_BY_GENERATION}
|
||||
onClick={() =>
|
||||
props.onChange({
|
||||
...props.config,
|
||||
color: ChartColors.COLOR_BY_GENERATION,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.colors.COLOR_BY_SEX"
|
||||
defaultMessage="by sex"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="gender"
|
||||
checked={props.config.color === ChartColors.COLOR_BY_SEX}
|
||||
onClick={() =>
|
||||
props.onChange({
|
||||
...props.config,
|
||||
color: ChartColors.COLOR_BY_SEX,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Item.Content>
|
||||
</Item>
|
||||
<Item>
|
||||
<Item.Content>
|
||||
<Header sub>
|
||||
<FormattedMessage id="config.ids" defaultMessage="IDs" />
|
||||
</Header>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.ids.HIDE"
|
||||
defaultMessage="hide"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="hide"
|
||||
checked={props.config.id === Ids.HIDE}
|
||||
onClick={() => props.onChange({...props.config, id: Ids.HIDE})}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.ids.SHOW"
|
||||
defaultMessage="show"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="show"
|
||||
checked={props.config.id === Ids.SHOW}
|
||||
onClick={() => props.onChange({...props.config, id: Ids.SHOW})}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Item.Content>
|
||||
</Item>
|
||||
<Item>
|
||||
<Item.Content>
|
||||
<Header sub>
|
||||
<FormattedMessage id="config.sex" defaultMessage="Sex" />
|
||||
</Header>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.sex.HIDE"
|
||||
defaultMessage="hide"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="hide"
|
||||
checked={props.config.sex === Sex.HIDE}
|
||||
onClick={() => props.onChange({...props.config, sex: Sex.HIDE})}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.sex.SHOW"
|
||||
defaultMessage="show"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="show"
|
||||
checked={props.config.sex === Sex.SHOW}
|
||||
onClick={() => props.onChange({...props.config, sex: Sex.SHOW})}
|
||||
/>
|
||||
</Form.Field>
|
||||
</Item.Content>
|
||||
</Item>
|
||||
</Item.Group>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
29
src/sidepanel/details/additional-files.tsx
Normal file
29
src/sidepanel/details/additional-files.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import {List} from 'semantic-ui-react';
|
||||
|
||||
export interface FileEntry {
|
||||
url: string;
|
||||
filename?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
files?: FileEntry[];
|
||||
}
|
||||
|
||||
export function AdditionalFiles({files}: Props) {
|
||||
if (!files?.length) return null;
|
||||
|
||||
return (
|
||||
<List>
|
||||
{files.map((file, index) => (
|
||||
<List.Item key={index}>
|
||||
<List.Icon verticalAlign="middle" name="circle" size="tiny" />
|
||||
<List.Content>
|
||||
<a target="_blank" href={file.url} rel="noopener noreferrer">
|
||||
{file.filename || file.url.split('/').pop() || file.url}
|
||||
</a>
|
||||
</List.Content>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
26
src/sidepanel/details/collapsed-details.tsx
Normal file
26
src/sidepanel/details/collapsed-details.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {GedcomData} from '../../util/gedcom_util';
|
||||
|
||||
interface Props {
|
||||
gedcom: GedcomData;
|
||||
indi: string;
|
||||
}
|
||||
|
||||
export function CollapsedDetails(props: Props) {
|
||||
const entries = props.gedcom.indis[props.indi].tree;
|
||||
const nameEntry = entries.find((entry) => entry.tag === 'NAME');
|
||||
|
||||
const fullName = nameEntry?.data.replaceAll('/', '') ?? '';
|
||||
|
||||
return (
|
||||
<div className="collapsed-details">
|
||||
{fullName ? (
|
||||
<span className="vertical-name">{fullName}</span>
|
||||
) : (
|
||||
<span className="vertical-name">
|
||||
<FormattedMessage id="name.unknown_name" defaultMessage="N.N." />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
src/sidepanel/details/details.tsx
Normal file
295
src/sidepanel/details/details.tsx
Normal file
@@ -0,0 +1,295 @@
|
||||
import flatMap from 'array.prototype.flatmap';
|
||||
import {GedcomEntry} from 'parse-gedcom';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {Header, Item} from 'semantic-ui-react';
|
||||
import {
|
||||
dereference,
|
||||
GedcomData,
|
||||
getData,
|
||||
getFileName,
|
||||
getImageFileEntry,
|
||||
getNonImageFileEntry,
|
||||
mapToSource,
|
||||
} from '../../util/gedcom_util';
|
||||
import {AdditionalFiles} from './additional-files';
|
||||
import {ALL_SUPPORTED_EVENT_TYPES, Events} from './events';
|
||||
import {MultilineText} from './multiline-text';
|
||||
import {Sources} from './sources';
|
||||
import {TranslatedTag} from './translated-tag';
|
||||
import {WrappedImage} from './wrapped-image';
|
||||
|
||||
const EXCLUDED_TAGS = [
|
||||
...ALL_SUPPORTED_EVENT_TYPES,
|
||||
'NAME',
|
||||
'SEX',
|
||||
'FAMC',
|
||||
'FAMS',
|
||||
'NOTE',
|
||||
'SOUR',
|
||||
];
|
||||
|
||||
function dataDetails(entry: GedcomEntry) {
|
||||
const lines = [];
|
||||
if (entry.data) {
|
||||
lines.push(...getData(entry));
|
||||
}
|
||||
entry.tree
|
||||
.filter((subentry) => subentry.tag === 'NOTE')
|
||||
.forEach((note) =>
|
||||
getData(note).forEach((line) => lines.push(<i>{line}</i>)),
|
||||
);
|
||||
if (!lines.length) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Header sub>
|
||||
<TranslatedTag tag={entry.tag} />
|
||||
</Header>
|
||||
<span>
|
||||
<MultilineText lines={lines} />
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function imageDetails(objectEntryReference: GedcomEntry, gedcom: GedcomData) {
|
||||
const imageEntry = dereference(
|
||||
objectEntryReference,
|
||||
gedcom,
|
||||
(gedcom) => gedcom.other,
|
||||
);
|
||||
|
||||
const imageFileEntry = getImageFileEntry(imageEntry);
|
||||
|
||||
if (!imageFileEntry || !hasData(imageEntry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="person-image">
|
||||
<WrappedImage
|
||||
url={imageFileEntry.data}
|
||||
filename={getFileName(imageFileEntry) || ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function sourceDetails(
|
||||
sourceReferenceEntries: GedcomEntry[],
|
||||
gedcom: GedcomData,
|
||||
) {
|
||||
const sources = sourceReferenceEntries.map((sourceEntryReference) =>
|
||||
mapToSource(sourceEntryReference, gedcom),
|
||||
);
|
||||
|
||||
if (!sources.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="item-header">
|
||||
<Header as="span" size="small">
|
||||
<TranslatedTag tag="SOUR" />
|
||||
</Header>
|
||||
</div>
|
||||
<Sources sources={sources} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function fileDetails(objectEntries: GedcomEntry[], gedcom: GedcomData) {
|
||||
const files = objectEntries
|
||||
.map((objectEntry) =>
|
||||
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
|
||||
)
|
||||
.map((objectEntry) => getNonImageFileEntry(objectEntry))
|
||||
.filter((objectEntry): objectEntry is GedcomEntry => !!objectEntry)
|
||||
.map((fileEntry) => ({
|
||||
url: fileEntry.data,
|
||||
filename: getFileName(fileEntry),
|
||||
}));
|
||||
|
||||
if (!files.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="item-header">
|
||||
<Header as="span" size="small">
|
||||
<TranslatedTag tag="OBJE" />
|
||||
</Header>
|
||||
</div>
|
||||
<AdditionalFiles files={files} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function noteDetails(noteEntryReference: GedcomEntry, gedcom: GedcomData) {
|
||||
const noteEntry = dereference(
|
||||
noteEntryReference,
|
||||
gedcom,
|
||||
(gedcom) => gedcom.other,
|
||||
);
|
||||
|
||||
if (!noteEntry || !hasData(noteEntry)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<MultilineText
|
||||
lines={getData(noteEntry).map((line, index) => (
|
||||
<i key={index}>{line}</i>
|
||||
))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function nameDetails(entry: GedcomEntry) {
|
||||
const fullName = entry.data.replaceAll('/', '');
|
||||
|
||||
const nameType = entry.tree.find(
|
||||
(entry) => entry.tag === 'TYPE' && entry.data !== 'Unknown',
|
||||
)?.data;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header as="span" size="large">
|
||||
{fullName ? (
|
||||
fullName
|
||||
) : (
|
||||
<FormattedMessage id="name.unknown_name" defaultMessage="N.N." />
|
||||
)}
|
||||
</Header>
|
||||
{fullName && nameType && (
|
||||
<Item.Meta>
|
||||
<TranslatedTag tag={nameType} />
|
||||
</Item.Meta>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function getSectionForEachMatchingEntry(
|
||||
entries: GedcomEntry[],
|
||||
gedcom: GedcomData,
|
||||
tags: string[],
|
||||
detailsFunction: (
|
||||
entry: GedcomEntry,
|
||||
gedcom: GedcomData,
|
||||
) => React.ReactNode | null,
|
||||
): React.ReactNode[] {
|
||||
return flatMap(tags, (tag) =>
|
||||
entries
|
||||
.filter((entry) => entry.tag === tag)
|
||||
.map((entry) => detailsFunction(entry, gedcom)),
|
||||
)
|
||||
.filter((element) => element !== null)
|
||||
.map((element, index) => (
|
||||
<Item key={index}>
|
||||
<Item.Content>{element}</Item.Content>
|
||||
</Item>
|
||||
));
|
||||
}
|
||||
|
||||
function combineAllMatchingEntriesIntoSingleSection(
|
||||
entries: GedcomEntry[],
|
||||
gedcom: GedcomData,
|
||||
tags: string[],
|
||||
detailsFunction: (
|
||||
entries: GedcomEntry[],
|
||||
gedcom: GedcomData,
|
||||
) => React.ReactNode | null,
|
||||
): React.ReactNode {
|
||||
const entriesWithMatchingTag = flatMap(tags, (tag) =>
|
||||
entries.filter((entry) => entry.tag === tag),
|
||||
).filter((element) => element !== null);
|
||||
|
||||
const sectionWithDetails = entriesWithMatchingTag.length
|
||||
? detailsFunction(entriesWithMatchingTag, gedcom)
|
||||
: null;
|
||||
|
||||
if (!sectionWithDetails) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Item>
|
||||
<Item.Content>{sectionWithDetails}</Item.Content>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if there is displayable information in this entry.
|
||||
* Returns false if there is no data in this entry or this is only a reference
|
||||
* to another entry.
|
||||
*/
|
||||
function hasData(entry: GedcomEntry) {
|
||||
return entry.tree.length > 0 || (entry.data && !entry.data.startsWith('@'));
|
||||
}
|
||||
|
||||
function getOtherSections(entries: GedcomEntry[], gedcom: GedcomData) {
|
||||
return entries
|
||||
.filter((entry) => !EXCLUDED_TAGS.includes(entry.tag))
|
||||
.map((entry) => dereference(entry, gedcom, (gedcom) => gedcom.other))
|
||||
.filter(hasData)
|
||||
.map((entry) => dataDetails(entry))
|
||||
.filter((element) => element !== null)
|
||||
.map((element, index) => (
|
||||
<Item key={index}>
|
||||
<Item.Content>{element}</Item.Content>
|
||||
</Item>
|
||||
));
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gedcom: GedcomData;
|
||||
indi: string;
|
||||
}
|
||||
|
||||
export function Details(props: Props) {
|
||||
const entries = props.gedcom.indis[props.indi].tree;
|
||||
|
||||
return (
|
||||
<div className="details">
|
||||
<Item.Group divided>
|
||||
{getSectionForEachMatchingEntry(
|
||||
entries,
|
||||
props.gedcom,
|
||||
['NAME'],
|
||||
nameDetails,
|
||||
)}
|
||||
{getSectionForEachMatchingEntry(
|
||||
entries,
|
||||
props.gedcom,
|
||||
['OBJE'],
|
||||
imageDetails,
|
||||
)}
|
||||
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
|
||||
{getOtherSections(entries, props.gedcom)}
|
||||
{getSectionForEachMatchingEntry(
|
||||
entries,
|
||||
props.gedcom,
|
||||
['NOTE'],
|
||||
noteDetails,
|
||||
)}
|
||||
{combineAllMatchingEntriesIntoSingleSection(
|
||||
entries,
|
||||
props.gedcom,
|
||||
['OBJE'],
|
||||
fileDetails,
|
||||
)}
|
||||
{combineAllMatchingEntriesIntoSingleSection(
|
||||
entries,
|
||||
props.gedcom,
|
||||
['SOUR'],
|
||||
sourceDetails,
|
||||
)}
|
||||
</Item.Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
180
src/sidepanel/details/event-extras.tsx
Normal file
180
src/sidepanel/details/event-extras.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {
|
||||
Icon,
|
||||
Item,
|
||||
List,
|
||||
Menu,
|
||||
MenuItemProps,
|
||||
Popup,
|
||||
Tab,
|
||||
} from 'semantic-ui-react';
|
||||
import {Source} from '../../util/gedcom_util';
|
||||
import {AdditionalFiles, FileEntry} from './additional-files';
|
||||
import {MultilineText} from './multiline-text';
|
||||
import {Sources} from './sources';
|
||||
import {WrappedImage} from './wrapped-image';
|
||||
|
||||
export interface Image {
|
||||
url: string;
|
||||
filename: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
images?: Image[];
|
||||
notes?: string[][];
|
||||
sources?: Source[];
|
||||
indi: string;
|
||||
files?: FileEntry[];
|
||||
}
|
||||
|
||||
function eventImages(images: Image[] | undefined) {
|
||||
return (
|
||||
!!images &&
|
||||
images.map((image, index) => (
|
||||
<List key={index}>
|
||||
<List.Item>
|
||||
<WrappedImage
|
||||
url={image.url}
|
||||
filename={image.filename}
|
||||
title={image.title}
|
||||
/>
|
||||
</List.Item>
|
||||
</List>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
function eventNotes(notes: string[][] | undefined) {
|
||||
return (
|
||||
!!notes?.length &&
|
||||
notes.map((note, index) => (
|
||||
<div key={index}>
|
||||
<MultilineText
|
||||
lines={note.map((line, index) => (
|
||||
<i key={index}>{line}</i>
|
||||
))}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
export function EventExtras(props: Props) {
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [indi, setIndi] = useState('');
|
||||
|
||||
if (!indi || indi !== props.indi) {
|
||||
setActiveIndex(-1);
|
||||
setIndi(props.indi);
|
||||
}
|
||||
|
||||
function handleTabOnClick(
|
||||
_event: React.MouseEvent<HTMLAnchorElement>,
|
||||
menuItemProps: MenuItemProps,
|
||||
) {
|
||||
menuItemProps.index !== undefined && activeIndex !== menuItemProps.index
|
||||
? setActiveIndex(menuItemProps.index)
|
||||
: setActiveIndex(-1);
|
||||
}
|
||||
|
||||
const imageTab = props.images?.length && {
|
||||
menuItem: (
|
||||
<Menu.Item fitted key="images" onClick={handleTabOnClick}>
|
||||
<Popup
|
||||
content={
|
||||
<FormattedMessage id="extras.images" defaultMessage="Images" />
|
||||
}
|
||||
size="mini"
|
||||
position="bottom center"
|
||||
trigger={<Icon circular name="camera" />}
|
||||
/>
|
||||
</Menu.Item>
|
||||
),
|
||||
render: () => <Tab.Pane>{eventImages(props.images)}</Tab.Pane>,
|
||||
};
|
||||
|
||||
const noteTab = props.notes?.length && {
|
||||
menuItem: (
|
||||
<Menu.Item fitted key="notes" onClick={handleTabOnClick}>
|
||||
<Popup
|
||||
content={
|
||||
<FormattedMessage id="extras.notes" defaultMessage="Notes" />
|
||||
}
|
||||
size="mini"
|
||||
position="bottom center"
|
||||
trigger={<Icon circular name="sticky note outline" />}
|
||||
/>
|
||||
</Menu.Item>
|
||||
),
|
||||
render: () => <Tab.Pane>{eventNotes(props.notes)}</Tab.Pane>,
|
||||
};
|
||||
|
||||
const sourceTab = props.sources?.length && {
|
||||
menuItem: (
|
||||
<Menu.Item fitted key="sources" onClick={handleTabOnClick}>
|
||||
<Popup
|
||||
content={
|
||||
<FormattedMessage id="extras.sources" defaultMessage="Sources" />
|
||||
}
|
||||
size="mini"
|
||||
position="bottom center"
|
||||
trigger={<Icon circular name="quote right" />}
|
||||
/>
|
||||
</Menu.Item>
|
||||
),
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<Sources sources={props.sources} />
|
||||
</Tab.Pane>
|
||||
),
|
||||
};
|
||||
|
||||
const filesTab = props.files?.length && {
|
||||
menuItem: (
|
||||
<Menu.Item fitted key="files" onClick={handleTabOnClick}>
|
||||
<Popup
|
||||
content={
|
||||
<FormattedMessage
|
||||
id="extras.files"
|
||||
defaultMessage="Additonal files"
|
||||
/>
|
||||
}
|
||||
size="mini"
|
||||
position="bottom center"
|
||||
trigger={<Icon circular name="file alternate outline" />}
|
||||
/>
|
||||
</Menu.Item>
|
||||
),
|
||||
render: () => (
|
||||
<Tab.Pane>
|
||||
<AdditionalFiles files={props.files} />
|
||||
</Tab.Pane>
|
||||
),
|
||||
};
|
||||
|
||||
const panes = [imageTab, noteTab, sourceTab, filesTab].flatMap((tab) =>
|
||||
tab ? [tab] : [],
|
||||
);
|
||||
|
||||
if (panes.length) {
|
||||
return (
|
||||
<Item.Description>
|
||||
<Tab
|
||||
className="event-extras"
|
||||
activeIndex={activeIndex}
|
||||
renderActiveOnly={true}
|
||||
menu={{
|
||||
tabular: true,
|
||||
attached: true,
|
||||
compact: true,
|
||||
borderless: true,
|
||||
}}
|
||||
panes={panes}
|
||||
/>
|
||||
</Item.Description>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
361
src/sidepanel/details/events.tsx
Normal file
361
src/sidepanel/details/events.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import flatMap from 'array.prototype.flatmap';
|
||||
import {GedcomEntry} from 'parse-gedcom';
|
||||
import queryString from 'query-string';
|
||||
import {FormattedMessage, IntlShape, useIntl} from 'react-intl';
|
||||
import {Link, useLocation} from 'react-router';
|
||||
import {Header, Item} from 'semantic-ui-react';
|
||||
import {DateOrRange, getDate} from 'topola';
|
||||
import {calcAge} from '../../util/age_util';
|
||||
import {compareDates, formatDateOrRange} from '../../util/date_util';
|
||||
import {
|
||||
dereference,
|
||||
GedcomData,
|
||||
getData,
|
||||
getFileName,
|
||||
getImageFileEntry,
|
||||
getName,
|
||||
getNonImageFileEntry,
|
||||
mapToSource,
|
||||
pointerToId,
|
||||
resolveDate,
|
||||
resolveType,
|
||||
Source,
|
||||
} from '../../util/gedcom_util';
|
||||
import {FileEntry} from './additional-files';
|
||||
import {EventExtras, Image} from './event-extras';
|
||||
import {TranslatedTag} from './translated-tag';
|
||||
|
||||
function PersonLink(props: {person: GedcomEntry}) {
|
||||
const location = useLocation();
|
||||
|
||||
const name = getName(props.person);
|
||||
|
||||
const search = queryString.parse(location.search);
|
||||
search['indi'] = pointerToId(props.person.pointer);
|
||||
|
||||
return (
|
||||
<Item.Meta>
|
||||
<Link to={{pathname: '/view', search: queryString.stringify(search)}}>
|
||||
{name ? (
|
||||
name
|
||||
) : (
|
||||
<FormattedMessage id="name.unknown_name" defaultMessage="N.N." />
|
||||
)}
|
||||
</Link>
|
||||
</Item.Meta>
|
||||
);
|
||||
}
|
||||
|
||||
interface Props {
|
||||
gedcom: GedcomData;
|
||||
indi: string;
|
||||
entries: GedcomEntry[];
|
||||
}
|
||||
|
||||
interface EventData {
|
||||
tag: string;
|
||||
type?: string;
|
||||
date?: DateOrRange;
|
||||
age?: string;
|
||||
personLink?: GedcomEntry;
|
||||
place?: string[];
|
||||
images?: Image[];
|
||||
files?: FileEntry[];
|
||||
notes?: string[][];
|
||||
sources?: Source[];
|
||||
indi: string;
|
||||
}
|
||||
|
||||
const BIRTH_EVENT_TAGS = ['BIRT'];
|
||||
const INDI_EVENT_TAGS = [
|
||||
'ADOP',
|
||||
'BAPM',
|
||||
'BARM',
|
||||
'BASM',
|
||||
'BLES',
|
||||
'CENS',
|
||||
'CHR',
|
||||
'CHRA',
|
||||
'CONF',
|
||||
'EDUC',
|
||||
'EMIG',
|
||||
'EVEN',
|
||||
'FAMS',
|
||||
'FCOM',
|
||||
'GRAD',
|
||||
'IMMI',
|
||||
'NATU',
|
||||
'ORDN',
|
||||
'OCCU',
|
||||
'PROP',
|
||||
'RESI',
|
||||
'RETI',
|
||||
'WILL',
|
||||
'_DEG',
|
||||
'_ELEC',
|
||||
'_MDCL',
|
||||
'_MILT',
|
||||
];
|
||||
|
||||
const FAMILY_EVENT_TAGS = [
|
||||
'ANUL',
|
||||
'CENS',
|
||||
'DIV',
|
||||
'DIVF',
|
||||
'ENGA',
|
||||
'EVEN',
|
||||
'MARB',
|
||||
'MARC',
|
||||
'MARL',
|
||||
'MARR',
|
||||
'MARS',
|
||||
];
|
||||
const LIFE_EVENT_TAGS = [...INDI_EVENT_TAGS, ...FAMILY_EVENT_TAGS];
|
||||
const DEATH_EVENT_TAGS = ['DEAT'];
|
||||
const AFTER_DEATH_EVENT_TAGS = ['BURI', 'CREM', 'PROB'];
|
||||
const SORTED_EVENT_TYPE_GROUPS = [
|
||||
BIRTH_EVENT_TAGS,
|
||||
LIFE_EVENT_TAGS,
|
||||
DEATH_EVENT_TAGS,
|
||||
AFTER_DEATH_EVENT_TAGS,
|
||||
];
|
||||
|
||||
export const ALL_SUPPORTED_EVENT_TYPES = [
|
||||
...BIRTH_EVENT_TAGS,
|
||||
...LIFE_EVENT_TAGS,
|
||||
...DEATH_EVENT_TAGS,
|
||||
...AFTER_DEATH_EVENT_TAGS,
|
||||
];
|
||||
|
||||
function EventHeader(props: {event: EventData}) {
|
||||
const intl = useIntl();
|
||||
return (
|
||||
<div className="item-header">
|
||||
<Header as="span" size="small">
|
||||
<TranslatedTag tag={getEventTitle(props.event)} />
|
||||
</Header>
|
||||
{props.event.date ? (
|
||||
<Header as="span" textAlign="right" sub>
|
||||
{formatDateOrRange(props.event.date, intl)}
|
||||
</Header>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEventTitle(event: EventData) {
|
||||
if (event.tag === 'EVEN' && event.type) {
|
||||
return event.type;
|
||||
}
|
||||
return event.tag;
|
||||
}
|
||||
|
||||
function getSpouse(indi: string, familyEntry: GedcomEntry, gedcom: GedcomData) {
|
||||
const spouseReference = familyEntry.tree
|
||||
.filter((familySubEntry) => ['WIFE', 'HUSB'].includes(familySubEntry.tag))
|
||||
.find((familySubEntry) => !familySubEntry.data.includes(indi));
|
||||
|
||||
if (!spouseReference) {
|
||||
return undefined;
|
||||
}
|
||||
return dereference(spouseReference, gedcom, (gedcom) => gedcom.indis);
|
||||
}
|
||||
|
||||
function getAge(
|
||||
eventEntry: GedcomEntry,
|
||||
indi: string,
|
||||
gedcom: GedcomData,
|
||||
intl: IntlShape,
|
||||
): string | undefined {
|
||||
if (!DEATH_EVENT_TAGS.includes(eventEntry.tag)) {
|
||||
return undefined;
|
||||
}
|
||||
const deathDate = resolveDate(eventEntry);
|
||||
|
||||
const birthDate = gedcom.indis[indi].tree
|
||||
.filter((indiSubEntry) => BIRTH_EVENT_TAGS.includes(indiSubEntry.tag))
|
||||
.map((birthEvent) => resolveDate(birthEvent))
|
||||
.find((topolaDate) => topolaDate);
|
||||
|
||||
if (!birthDate || !deathDate) {
|
||||
return undefined;
|
||||
}
|
||||
return calcAge(birthDate?.data, deathDate?.data, intl);
|
||||
}
|
||||
|
||||
function eventPlace(entry: GedcomEntry) {
|
||||
const place = entry.tree.find((subEntry) => subEntry.tag === 'PLAC');
|
||||
return place?.data ? getData(place) : undefined;
|
||||
}
|
||||
|
||||
function eventImages(entry: GedcomEntry, gedcom: GedcomData): Image[] {
|
||||
return entry.tree
|
||||
.filter((subEntry) => 'OBJE' === subEntry.tag)
|
||||
.map((objectEntry) =>
|
||||
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
|
||||
)
|
||||
.map((objectEntry) => getImageFileEntry(objectEntry))
|
||||
.flatMap((imageFileEntry) =>
|
||||
imageFileEntry
|
||||
? [
|
||||
{
|
||||
url: imageFileEntry?.data || '',
|
||||
filename: getFileName(imageFileEntry) || '',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function eventFiles(entry: GedcomEntry, gedcom: GedcomData): Image[] {
|
||||
return entry.tree
|
||||
.filter((subEntry) => 'OBJE' === subEntry.tag)
|
||||
.map((objectEntry) =>
|
||||
dereference(objectEntry, gedcom, (gedcom) => gedcom.other),
|
||||
)
|
||||
.map((objectEntry) => getNonImageFileEntry(objectEntry))
|
||||
.flatMap((fileEntry) =>
|
||||
fileEntry
|
||||
? [
|
||||
{
|
||||
url: fileEntry?.data || '',
|
||||
filename: getFileName(fileEntry) || '',
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
}
|
||||
|
||||
function eventSources(entry: GedcomEntry, gedcom: GedcomData): Source[] {
|
||||
return entry.tree
|
||||
.filter((subEntry) => 'SOUR' === subEntry.tag)
|
||||
.map((sourceEntryReference) => mapToSource(sourceEntryReference, gedcom));
|
||||
}
|
||||
|
||||
function eventNotes(entry: GedcomEntry, gedcom: GedcomData): string[][] {
|
||||
const externalNotes = entry.tree
|
||||
.filter((subEntry) => subEntry.tag === 'NOTE')
|
||||
.map((note) => dereference(note, gedcom, (gedcom) => gedcom.other));
|
||||
|
||||
//for generic 'EVEN' tag 'TYPE is mandatory and is part of the header, for other types it can be worth it to display it as a note
|
||||
const type =
|
||||
entry.tag !== 'EVEN'
|
||||
? entry.tree.filter((subEntry) => subEntry.tag === 'TYPE')
|
||||
: [];
|
||||
|
||||
//entry.data contains event description, so it's also displayed in notes section
|
||||
return (
|
||||
[entry, ...type, ...externalNotes]
|
||||
.filter((entry) => !!entry.data)
|
||||
/* In Gedcom 'Y' only indicates event occurred, but it doesn't contain any valuable information
|
||||
like place, date or description, so it should be omitted when fetching entry data. */
|
||||
.filter((entry) => entry.data !== 'Y')
|
||||
.map((note) => getData(note))
|
||||
);
|
||||
}
|
||||
|
||||
function toEvent(
|
||||
entry: GedcomEntry,
|
||||
gedcom: GedcomData,
|
||||
indi: string,
|
||||
intl: IntlShape,
|
||||
): EventData[] {
|
||||
if (entry.tag === 'FAMS') {
|
||||
return toFamilyEvents(entry, gedcom, indi);
|
||||
}
|
||||
return toIndiEvent(entry, gedcom, indi, intl);
|
||||
}
|
||||
|
||||
function toIndiEvent(
|
||||
entry: GedcomEntry,
|
||||
gedcom: GedcomData,
|
||||
indi: string,
|
||||
intl: IntlShape,
|
||||
): EventData[] {
|
||||
const date = resolveDate(entry) || null;
|
||||
return [
|
||||
{
|
||||
tag: entry.tag,
|
||||
date: date ? getDate(date.data) : undefined,
|
||||
type: resolveType(entry),
|
||||
age: getAge(entry, indi, gedcom, intl),
|
||||
place: eventPlace(entry),
|
||||
images: eventImages(entry, gedcom),
|
||||
files: eventFiles(entry, gedcom),
|
||||
notes: eventNotes(entry, gedcom),
|
||||
sources: eventSources(entry, gedcom),
|
||||
indi: indi,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function toFamilyEvents(
|
||||
entry: GedcomEntry,
|
||||
gedcom: GedcomData,
|
||||
indi: string,
|
||||
): EventData[] {
|
||||
const family = dereference(entry, gedcom, (gedcom) => gedcom.fams);
|
||||
return flatMap(FAMILY_EVENT_TAGS, (tag) =>
|
||||
family.tree.filter((entry) => entry.tag === tag),
|
||||
).map((familyEvent) => {
|
||||
const date = resolveDate(familyEvent) || null;
|
||||
return {
|
||||
tag: familyEvent.tag,
|
||||
date: date ? getDate(date.data) : undefined,
|
||||
type: resolveType(familyEvent),
|
||||
personLink: getSpouse(indi, family, gedcom),
|
||||
place: eventPlace(familyEvent),
|
||||
images: eventImages(familyEvent, gedcom),
|
||||
files: eventFiles(familyEvent, gedcom),
|
||||
notes: eventNotes(familyEvent, gedcom),
|
||||
sources: eventSources(familyEvent, gedcom),
|
||||
indi: indi,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function Event(props: {event: EventData}) {
|
||||
return (
|
||||
<Item>
|
||||
<Item.Content>
|
||||
<EventHeader event={props.event} />
|
||||
{!!props.event.age && <Item.Meta>{props.event.age}</Item.Meta>}
|
||||
{!!props.event.personLink && (
|
||||
<PersonLink person={props.event.personLink} />
|
||||
)}
|
||||
{!!props.event.place && (
|
||||
<Item.Description>{props.event.place}</Item.Description>
|
||||
)}
|
||||
<EventExtras
|
||||
images={props.event.images}
|
||||
notes={props.event.notes}
|
||||
sources={props.event.sources}
|
||||
indi={props.event.indi}
|
||||
files={props.event.files}
|
||||
/>
|
||||
</Item.Content>
|
||||
</Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function Events(props: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
const events = flatMap(SORTED_EVENT_TYPE_GROUPS, (eventTypeGroup) =>
|
||||
props.entries
|
||||
.filter((entry) => eventTypeGroup.includes(entry.tag))
|
||||
.map((eventEntry) => toEvent(eventEntry, props.gedcom, props.indi, intl))
|
||||
.flatMap((events) => events)
|
||||
.sort((event1, event2) => compareDates(event1.date, event2.date)),
|
||||
);
|
||||
if (events.length) {
|
||||
return (
|
||||
<>
|
||||
{events.map((event, index) => (
|
||||
<Event event={event} key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
18
src/sidepanel/details/multiline-text.tsx
Normal file
18
src/sidepanel/details/multiline-text.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import Linkify from 'react-linkify';
|
||||
|
||||
interface Props {
|
||||
lines: (React.ReactNode | string)[];
|
||||
}
|
||||
|
||||
export function MultilineText(props: Props) {
|
||||
return (
|
||||
<>
|
||||
{props.lines.map((line, index) => (
|
||||
<div key={index}>
|
||||
<Linkify properties={{target: '_blank'}}>{line}</Linkify>
|
||||
<br />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/sidepanel/details/sources.tsx
Normal file
38
src/sidepanel/details/sources.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import {useIntl} from 'react-intl';
|
||||
import Linkify from 'react-linkify';
|
||||
import {List} from 'semantic-ui-react';
|
||||
import {formatDateOrRange} from '../../util/date_util';
|
||||
import {Source} from '../../util/gedcom_util';
|
||||
|
||||
interface Props {
|
||||
sources?: Source[];
|
||||
}
|
||||
|
||||
export function Sources({sources}: Props) {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!sources?.length) return null;
|
||||
|
||||
return (
|
||||
<List>
|
||||
{sources.map((source, index) => (
|
||||
<List.Item key={index}>
|
||||
<List.Icon verticalAlign="middle" name="circle" size="tiny" />
|
||||
<List.Content>
|
||||
<List.Header>
|
||||
<Linkify properties={{target: '_blank'}}>
|
||||
{[source.author, source.title, source.publicationInfo]
|
||||
.filter((sourceElement) => !!sourceElement)
|
||||
.join(', ')}
|
||||
</Linkify>
|
||||
</List.Header>
|
||||
<List.Description>
|
||||
<Linkify properties={{target: '_blank'}}>{source.page}</Linkify>
|
||||
{source.date && ` [${formatDateOrRange(source.date, intl)}]`}
|
||||
</List.Description>
|
||||
</List.Content>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
68
src/sidepanel/details/translated-tag.tsx
Normal file
68
src/sidepanel/details/translated-tag.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
const TAG_DESCRIPTIONS = new Map([
|
||||
['ADOP', 'Adoption'],
|
||||
['BAPM', 'Baptism'],
|
||||
['BARM', 'Bar Mitzvah'],
|
||||
['BASM', 'Bas Mitzvah'],
|
||||
['BIRT', 'Birth'],
|
||||
['BLES', 'Blessing'],
|
||||
['BURI', 'Burial'],
|
||||
['CENS', 'Census'],
|
||||
['CHR', 'Christening'],
|
||||
['CHRA', 'Adult christening'],
|
||||
['CONF', 'Confirmation'],
|
||||
['CREM', 'Cremation'],
|
||||
['DEAT', 'Death'],
|
||||
['DEG', 'Degree'],
|
||||
['DIV', 'Divorce'],
|
||||
['DIVF', 'Divorce filed'],
|
||||
['EDUC', 'Education'],
|
||||
['ELEC', 'Elected'],
|
||||
['EMAIL', 'E-mail'],
|
||||
['EMIG', 'Emigration'],
|
||||
['ENGA', 'Engagement'],
|
||||
['EVEN', 'Event'],
|
||||
['FACT', 'Fact'],
|
||||
['FCOM', 'First communion'],
|
||||
['GRAD', 'Graduation'],
|
||||
['IMMI', 'Immigration'],
|
||||
['MARB', 'Marriage bann'],
|
||||
['MARC', 'Marriage contract'],
|
||||
['MARL', 'Marriage license'],
|
||||
['MARR', 'Marriage'],
|
||||
['MARS', 'Marriage settlement'],
|
||||
['MDCL', 'Medical info'],
|
||||
['MILT', 'Military services'],
|
||||
['NATU', 'Naturalization'],
|
||||
['OBJE', 'Additional files'],
|
||||
['OCCU', 'Occupation'],
|
||||
['ORDN', 'Ordination'],
|
||||
['PROB', 'Probate'],
|
||||
['PROP', 'Property'],
|
||||
['RESI', 'Residence'],
|
||||
['RETI', 'Retirement'],
|
||||
['SOUR', 'Sources'],
|
||||
['TITL', 'Title'],
|
||||
['WILL', 'Will'],
|
||||
['WWW', 'WWW'],
|
||||
['birth', 'Birth name'],
|
||||
['married', 'Married name'],
|
||||
['maiden', 'Maiden name'],
|
||||
['immigrant', 'Immigrant name'],
|
||||
['aka', 'Also known as'],
|
||||
]);
|
||||
|
||||
interface Props {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
export function TranslatedTag(props: Props) {
|
||||
const normalizedTag = props.tag.replace(/_/g, '');
|
||||
return (
|
||||
<FormattedMessage
|
||||
id={`gedcom.${normalizedTag}`}
|
||||
defaultMessage={TAG_DESCRIPTIONS.get(normalizedTag) || normalizedTag}
|
||||
/>
|
||||
);
|
||||
}
|
||||
87
src/sidepanel/details/wrapped-image.tsx
Normal file
87
src/sidepanel/details/wrapped-image.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import {SyntheticEvent, useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {
|
||||
Container,
|
||||
Icon,
|
||||
Image,
|
||||
Label,
|
||||
Message,
|
||||
Modal,
|
||||
Placeholder,
|
||||
} from 'semantic-ui-react';
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/sidepanel/side-panel.tsx
Normal file
63
src/sidepanel/side-panel.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import {useIntl} from 'react-intl';
|
||||
import {Button, Icon, Sidebar, Tab} from 'semantic-ui-react';
|
||||
import {TopolaData} from '../util/gedcom_util';
|
||||
import {Config, ConfigPanel} from './config/config';
|
||||
import {CollapsedDetails} from './details/collapsed-details';
|
||||
import {Details} from './details/details';
|
||||
|
||||
interface SidePanelProps {
|
||||
data: TopolaData;
|
||||
selectedIndiId: string;
|
||||
config: Config;
|
||||
onConfigChange: (config: Config) => void;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
export function SidePanel({
|
||||
data,
|
||||
selectedIndiId,
|
||||
config,
|
||||
onConfigChange,
|
||||
expanded,
|
||||
onToggle,
|
||||
}: SidePanelProps) {
|
||||
const intl = useIntl();
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
menuItem: intl.formatMessage({
|
||||
id: 'tab.info',
|
||||
defaultMessage: 'Info',
|
||||
}),
|
||||
render: () => <Details gedcom={data.gedcom} indi={selectedIndiId} />,
|
||||
},
|
||||
{
|
||||
menuItem: intl.formatMessage({
|
||||
id: 'tab.settings',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
render: () => <ConfigPanel config={config} onChange={onConfigChange} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
id="sidebar"
|
||||
animation="overlay"
|
||||
icon="labeled"
|
||||
width={expanded ? 'wide' : 'very thin'}
|
||||
direction="right"
|
||||
visible={true}
|
||||
>
|
||||
{expanded ? (
|
||||
<Tab id="sideTabs" panes={tabs} />
|
||||
) : (
|
||||
<CollapsedDetails gedcom={data.gedcom} indi={selectedIndiId} />
|
||||
)}
|
||||
<Button id="sideToggle" icon size="mini" onClick={() => onToggle()}>
|
||||
<Icon size="large" name={expanded ? 'arrow right' : 'arrow left'} />
|
||||
</Button>
|
||||
</Sidebar>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user