Add Immediate Family section to person details in side panel

This commit is contained in:
Przemek Więch
2026-05-12 00:33:42 +02:00
parent e86d13a00e
commit 708d26d561
19 changed files with 492 additions and 34 deletions

View File

@@ -162,7 +162,7 @@ div.zoom {
.details .item-header .header {
text-transform: uppercase;
margin: 0;
margin: 0 0 8px 0;
min-width: 40%;
}
@@ -335,6 +335,14 @@ div.zoom {
white-space: pre;
}
.parents-block {
margin-bottom: 7px;
}
.children-block {
display: flex;
}
@media (max-width: 450px) {
/* Hide the title from the top bar when the viewport is too small to display
it alongside the search box. */

View File

@@ -9,8 +9,10 @@ This directory contains React components for displaying detailed information abo
- [details.tsx](details.tsx): The main component that orchestrates the display of all details for an individual, including name, images, events, facts, notes, and sources.
- [event-extras.tsx](event-extras.tsx): Displays additional content for events (images, notes, sources, files) in a tabbed interface.
- [events.tsx](events.tsx): Handles the logic and display for all events related to an individual, sorting them by date and grouping them by life stages.
- [immediate-family.tsx](immediate-family.tsx): A dedicated side panel module for grouping and displaying parents, spouses, and children as rapid-navigation links.
- [linkify-new-tab.tsx](linkify-new-tab.tsx): A helper component that wraps content and makes URLs clickable, opening them in a new tab.
- [multiline-text.tsx](multiline-text.tsx): Helper component to display multi-line text with linkified URLs.
- [person-link.tsx](person-link.tsx): Component that renders a clickable link to navigate to a person's profile view.
- [sources.tsx](sources.tsx): Displays a list of sources cited for an entry.
- [translated-tag.tsx](translated-tag.tsx): Component to translate GEDCOM tags into human-readable labels.
- [wrapped-image.tsx](wrapped-image.tsx): Component to display images with loading placeholders, error fallback, and a click-to-enlarge modal.

View File

@@ -14,6 +14,7 @@ import {
import {Config, Ids} from '../config/config';
import {AdditionalFiles, FileEntry} from './additional-files';
import {ALL_SUPPORTED_EVENT_TYPES, Events} from './events';
import {ImmediateFamily} from './immediate-family';
import {MultilineText} from './multiline-text';
import {Sources} from './sources';
import {TranslatedTag} from './translated-tag';
@@ -353,6 +354,7 @@ export function Details(props: Props) {
['OBJE'],
imageDetails,
)}
<ImmediateFamily gedcom={props.gedcom} indi={props.indi} />
<Events gedcom={props.gedcom} entries={entries} indi={props.indi} />
{props.config.id === Ids.SHOW ? getSectionForId(props.indi) : null}
{getSectionForEachMatchingEntry(

View File

@@ -1,8 +1,6 @@
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 {IntlShape, useIntl} from 'react-intl';
import {Header, Item} from 'semantic-ui-react';
import {DateOrRange, getDate} from 'topola';
import {calcAge} from '../../util/age_util';
@@ -13,39 +11,17 @@ import {
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 {PersonLink} from './person-link';
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;

View File

@@ -0,0 +1,218 @@
import {GedcomEntry} from 'parse-gedcom';
import {FormattedMessage} from 'react-intl';
import {Header, Item} from 'semantic-ui-react';
import {getDate} from 'topola';
import {compareDates} from '../../util/date_util';
import {GedcomData, pointerToId, resolveDate} from '../../util/gedcom_util';
import {PersonLink} from './person-link';
interface Props {
gedcom: GedcomData;
indi: string;
}
function mapSpousalFamily(
familyRecord: GedcomEntry,
gedcom: GedcomData,
indi: string,
) {
const spouseSubEntry = familyRecord.tree?.find(
(sub) =>
['HUSB', 'WIFE'].includes(sub.tag) && pointerToId(sub.data) !== indi,
);
const spouseId = spouseSubEntry
? pointerToId(spouseSubEntry.data)
: undefined;
const spouseRecord = spouseId ? gedcom.indis[spouseId] : undefined;
const chilSubEntries =
familyRecord.tree?.filter((sub) => sub.tag === 'CHIL') || [];
const validChildren = chilSubEntries.filter(
(childSub) => !!gedcom.indis[pointerToId(childSub.data)],
);
// Pre-parse dates to avoid redundant O(N log N) parsing inside sort comparator
const mappedChildren = validChildren.map((childSub) => {
const childEntry = gedcom.indis[pointerToId(childSub.data)];
const birt = childEntry?.tree.find((sub) => sub.tag === 'BIRT');
const dateSub = birt ? resolveDate(birt) : undefined;
const parsedDate = dateSub ? getDate(dateSub.data) : undefined;
return {
childEntry,
parsedDate,
};
});
const sortedChildren = mappedChildren
.sort((a, b) => compareDates(a.parsedDate, b.parsedDate))
.map((item) => item.childEntry);
return {
familyId: pointerToId(familyRecord.pointer),
spouseTag: spouseSubEntry?.tag,
spouseRecord,
children: sortedChildren,
};
}
/**
* Renders the immediate family section of a person, displaying their parents,
* spouses, and children.
*/
export function ImmediateFamily(props: Props) {
const personEntry = props.gedcom.indis[props.indi];
if (!personEntry) {
return null;
}
const entries = personEntry.tree;
// --- Parents Block Renderer ---
const famcEntry = entries.find((sub) => sub.tag === 'FAMC');
const parentalFamily = famcEntry
? props.gedcom.fams[pointerToId(famcEntry.data)]
: undefined;
const isParentalFamilyValid = parentalFamily?.tag === 'FAM';
const husbEntry = isParentalFamilyValid
? parentalFamily?.tree.find((sub) => sub.tag === 'HUSB')
: undefined;
const wifeEntry = isParentalFamilyValid
? parentalFamily?.tree.find((sub) => sub.tag === 'WIFE')
: undefined;
const fatherId = husbEntry ? pointerToId(husbEntry.data) : undefined;
const fatherRecord = fatherId ? props.gedcom.indis[fatherId] : undefined;
const motherId = wifeEntry ? pointerToId(wifeEntry.data) : undefined;
const motherRecord = motherId ? props.gedcom.indis[motherId] : undefined;
const hasParents = fatherRecord || motherRecord;
// --- Spouses and Children Block Renderer ---
const famsEntries = entries.filter((sub) => sub.tag === 'FAMS');
const spousalFamilies = famsEntries.map(
(entry) => props.gedcom.fams[pointerToId(entry.data)],
);
const mappedFamilies = spousalFamilies.map((familyRecord) =>
mapSpousalFamily(familyRecord, props.gedcom, props.indi),
);
const validMappedFamilies = mappedFamilies.filter(
(group) => group.spouseRecord || group.children.length > 0,
);
const totalValidFams = validMappedFamilies.length;
const hasSpousesOrChildren = totalValidFams > 0;
if (!hasParents && !hasSpousesOrChildren) {
return null;
}
return (
<Item>
<Item.Content>
<div className="item-header">
<Header as="span" size="small">
<FormattedMessage
id="family.immediate_family"
defaultMessage="Immediate Family"
/>
</Header>
</div>
{hasParents && (
<div className="parents-block">
{fatherRecord && (
<div>
<strong>
<FormattedMessage
id="family.father"
defaultMessage="Father"
/>
</strong>
: <PersonLink person={fatherRecord} />
</div>
)}
{motherRecord && (
<div>
<strong>
<FormattedMessage
id="family.mother"
defaultMessage="Mother"
/>
</strong>
: <PersonLink person={motherRecord} />
</div>
)}
</div>
)}
{validMappedFamilies.map((group) => {
const showUnknownSpouse = !group.spouseRecord && totalValidFams > 1;
return (
<div key={group.familyId} className="spousal-group">
{group.spouseRecord ? (
<div>
<strong>
{group.spouseTag === 'HUSB' ? (
<FormattedMessage
id="family.husband"
defaultMessage="Husband"
/>
) : (
<FormattedMessage
id="family.wife"
defaultMessage="Wife"
/>
)}
</strong>
: <PersonLink person={group.spouseRecord} />
</div>
) : showUnknownSpouse ? (
<div>
<strong>
<FormattedMessage
id="family.unknown_spouse"
defaultMessage="Unknown Spouse"
/>
</strong>
</div>
) : null}
{group.children.length > 0 && (
<div className="children-block">
<div>
<strong>
{group.children.length === 1 ? (
<FormattedMessage
id="family.child"
defaultMessage="Child"
/>
) : (
<FormattedMessage
id="family.children"
defaultMessage="Children"
/>
)}
</strong>
:&nbsp;
</div>
<div>
{group.children.map((child) => (
<div key={child.pointer}>
<PersonLink person={child} />
</div>
))}
</div>
</div>
)}
</div>
);
})}
</Item.Content>
</Item>
);
}

View File

@@ -0,0 +1,31 @@
import {GedcomEntry} from 'parse-gedcom';
import queryString from 'query-string';
import {FormattedMessage} from 'react-intl';
import {Link, useLocation} from 'react-router';
import {getName, pointerToId} from '../../util/gedcom_util';
interface Props {
person: GedcomEntry;
}
/**
* Renders a clickable link to an individual's profile view, preserving existing query parameters.
*/
export function PersonLink(props: Props) {
const location = useLocation();
const name = getName(props.person);
const search = queryString.parse(location.search);
search['indi'] = pointerToId(props.person.pointer);
return (
<Link to={{pathname: '/view', search: queryString.stringify(search)}}>
{name ? (
name
) : (
<FormattedMessage id="name.unknown_name" defaultMessage="N.N." />
)}
</Link>
);
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Изображение",
"extras.notes": "Бележки",
"extras.sources": "Източници",
"extras.files": "Допълнителни файлове"
"extras.files": "Допълнителни файлове",
"family.immediate_family": "Най-близко семейство",
"family.parents": "Родители",
"family.father": "Баща",
"family.mother": "Майка",
"family.husband": "Съпруг",
"family.wife": "Съпруга",
"family.unknown_spouse": "Неизвестен съпруг/а",
"family.children": "Деца",
"family.child": "Дете"
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Obrázky",
"extras.notes": "Poznámky",
"extras.sources": "Zdroje",
"extras.files": "Další soubory"
"extras.files": "Další soubory",
"family.immediate_family": "Nejbližší rodina",
"family.parents": "Rodiče",
"family.father": "Otec",
"family.mother": "Matka",
"family.husband": "Manžel",
"family.wife": "Manželka",
"family.unknown_spouse": "Neznámý manžel/ka",
"family.children": "Děti",
"family.child": "Dítě"
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Bilder",
"extras.notes": "Notizen",
"extras.sources": "Quellen",
"extras.files": "Weitere Dateien"
"extras.files": "Weitere Dateien",
"family.immediate_family": "Engere Familie",
"family.parents": "Eltern",
"family.father": "Vater",
"family.mother": "Mutter",
"family.husband": "Ehemann",
"family.wife": "Ehefrau",
"family.unknown_spouse": "Unbekannter Ehepartner",
"family.children": "Kinder",
"family.child": "Kind"
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Images",
"extras.notes": "Notes",
"extras.sources": "Sources",
"extras.files": "Fichiers supplémentaires"
"extras.files": "Fichiers supplémentaires",
"family.immediate_family": "Famille proche",
"family.parents": "Parents",
"family.father": "Père",
"family.mother": "Mère",
"family.husband": "Mari",
"family.wife": "Femme",
"family.unknown_spouse": "Conjoint inconnu",
"family.children": "Enfants",
"family.child": "Enfant"
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Immagini",
"extras.notes": "Appunti",
"extras.sources": "Fonti",
"extras.files": "File aggiuntivi"
"extras.files": "File aggiuntivi",
"family.immediate_family": "Famiglia stretta",
"family.parents": "Genitori",
"family.father": "Padre",
"family.mother": "Madre",
"family.husband": "Marito",
"family.wife": "Moglie",
"family.unknown_spouse": "Coniuge sconosciuto",
"family.children": "Figli",
"family.child": "Figlio"
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Zdjęcia",
"extras.notes": "Notatki",
"extras.sources": "Źródła",
"extras.files": "Dodatkowe pliki"
"extras.files": "Dodatkowe pliki",
"family.immediate_family": "Najbliższa rodzina",
"family.parents": "Rodzice",
"family.father": "Ojciec",
"family.mother": "Matka",
"family.husband": "Mąż",
"family.wife": "Żona",
"family.unknown_spouse": "Nieznany współmałżonek",
"family.children": "Dzieci",
"family.child": "Dziecko"
}

View File

@@ -134,5 +134,14 @@
"extras.images": "Картинки",
"extras.notes": "Примечание",
"extras.sources": "Источники",
"extras.files": "Дополнительные файлы"
"extras.files": "Дополнительные файлы",
"family.immediate_family": "Ближайшие родственники",
"family.parents": "Родители",
"family.father": "Отец",
"family.mother": "Мать",
"family.husband": "Муж",
"family.wife": "Жена",
"family.unknown_spouse": "Неизвестный супруг(а)",
"family.children": "Дети",
"family.child": "Ребёнок"
}