Merge pull request #87 from IliesAllali/fix/ajout-prenom-inconnu

Prénom inconnu, et fermer l'ajout au clic sur une personne
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-18 20:54:11 +02:00
commit efca97d9cd
18 files changed
+179 -50

No files matched your search

+2 -2
View File
@@ -68,7 +68,7 @@ const reviewSessionSchema = z.object({
})
const personCreateSchema = z.object({
firstName: z.string().min(1).max(120),
firstName: z.string().trim().max(120),
lastName: z.string().min(1).max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
@@ -85,7 +85,7 @@ const personCreateSchema = z.object({
const personUpdateSchema = z
.object({
firstName: z.string().min(1).max(120).optional(),
firstName: z.string().trim().max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
+11 -4
View File
@@ -258,8 +258,14 @@ function pushLifeEvent(lines: string[], event: ExportedEvent, tags: Record<strin
// ─── Name Parsing ────────────────────────────────────────────────────────────
// Valeur de « 1 NAME ». Prénom inconnu (chaîne vide) : « /Dupont/ », sans espace de tête
export function gedcomNameValue(firstName: string, lastName: string): string {
const given = firstName ? `${oneLine(firstName)} ` : ''
return `${given}/${oneLine(lastName).replace(/\//g, '')}/`
}
// « Jean /Dupont/ » : lu par indexOf, l'ancienne expression régulière était quadratique sur les espaces
function parseGedcomName(value: string): { firstName: string; lastName: string } {
export function parseGedcomName(value: string): { firstName: string; lastName: string } {
const open = value.indexOf('/')
const close = open >= 0 ? value.indexOf('/', open + 1) : -1
if (open >= 0 && close > open) {
@@ -453,7 +459,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
for (const person of persons) {
const xref = sanitizeXref(person.id)
lines.push(`0 @I${xref}@ INDI`)
lines.push(`1 NAME ${oneLine(person.firstName)} /${oneLine(person.lastName).replace(/\//g, '')}/`)
lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`)
if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`)
if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M')
@@ -655,7 +661,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const nameNode = findChild(indi, 'NAME')
const { firstName, lastName } = nameNode
? parseGedcomName(nameNode.value)
: { firstName: '?', lastName: '' }
: { firstName: '', lastName: '' }
const sexNode = findChild(indi, 'SEX')
const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null
@@ -671,7 +677,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
const notes = clip(personNotes(indi, records), 2000)
const personFirstName = (firstName || '?').slice(0, 120)
// Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre
const personFirstName = (firstName || '').slice(0, 120)
const personLastName = (lastName || '').slice(0, 120)
const birthDate = isoDateToDbDate(birthDateIso)
const deathDate = isoDateToDbDate(deathDateIso)
+2 -1
View File
@@ -273,6 +273,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
select: {
id: true,
firstName: true,
lastName: true,
},
})
@@ -362,7 +363,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
mode: contribution.mode,
status: contribution.autoStatus,
title: `Souvenir pour ${person.firstName}`.slice(0, 120),
title: `Souvenir pour ${person.firstName || person.lastName}`.slice(0, 120),
reviewedAt: contribution.autoStatus === 'approved' ? now : undefined,
reviewedBy: contribution.autoStatus === 'approved' ? contribution.submittedByUserId : undefined,
},
+4 -4
View File
@@ -84,7 +84,7 @@ const listAuditQuerySchema = z.object({
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
const createPersonSchema = z.object({
firstName: z.string().min(1).max(120),
firstName: z.string().trim().max(120),
lastName: z.string().min(1).max(120),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
@@ -101,7 +101,7 @@ const createPersonSchema = z.object({
const patchPersonSchema = z
.object({
firstName: z.string().min(1).max(120).optional(),
firstName: z.string().trim().max(120).optional(),
lastName: z.string().min(1).max(120).optional(),
birthName: z.string().max(120).optional().nullable(),
nickname: z.string().max(120).optional().nullable(),
@@ -2125,7 +2125,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
const person = await app.prisma.person.findFirst({
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
select: { id: true, firstName: true },
select: { id: true, firstName: true, lastName: true },
})
if (!person) return reply.code(404).send({ error: 'person_not_found' })
@@ -2164,7 +2164,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
mode: contribution.mode,
status: contribution.autoStatus,
title: `Moment pour ${person.firstName}`.slice(0, 120),
title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120),
reviewedAt: isPending ? undefined : new Date(),
reviewedBy: isPending ? undefined : contribution.submittedByUserId,
},
+21
View File
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { gedcomNameValue, parseGedcomName } from '../src/routes/gedcom'
// Prénom inconnu : enregistré vide. L'export écrit « /Nom/ », l'import relit un prénom vide (et non « ? »).
describe('prénom inconnu, GEDCOM', () => {
it('export : sans prénom, la valeur NAME commence par le nom', () => {
expect(gedcomNameValue('', 'Dupont')).toBe('/Dupont/')
expect(gedcomNameValue('Marie', 'Dupont')).toBe('Marie /Dupont/')
expect(gedcomNameValue('Marie', 'Du/pont')).toBe('Marie /Dupont/')
})
it('import : un NAME sans prénom donne un prénom vide', () => {
expect(parseGedcomName('/Dupont/')).toEqual({ firstName: '', lastName: 'Dupont' })
expect(parseGedcomName('Marie /Dupont/')).toEqual({ firstName: 'Marie', lastName: 'Dupont' })
})
it('aller-retour export puis import garde le prénom vide', () => {
expect(parseGedcomName(gedcomNameValue('', 'Martin'))).toEqual({ firstName: '', lastName: 'Martin' })
})
})
+7 -2
View File
@@ -227,7 +227,8 @@ function buildContributionChangesFromDraft(draft = {}) {
const addedPersons = Array.isArray(draft.addedPersons) ? draft.addedPersons : []
for (const person of addedPersons) {
if (!person || typeof person !== 'object') continue
if (!person.firstName || !person.lastName) continue
// Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant
if (!person.lastName) continue
// Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens
const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null
@@ -803,6 +804,10 @@ function App() {
return
}
// Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ;
// sa saisie reste en mémoire et reprend quand on rouvre « Ajouter »
setAddPersonPanelVisible(false)
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
tree.setSelectedPerson(freshPerson)
trackTreeInteraction('text')
@@ -1091,7 +1096,7 @@ function App() {
// La personne n'apparaît dans l'arbre qu'après envoi : on passe tout de suite à l'envoi
// (prénom, un mot), la famille n'a pas à chercher le bouton
setAddPersonPanelVisible(false)
setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: formData.firstName }))
setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
setContribSubmitError('')
setContribChanges(buildContribChangesList(editMode.restoreDraft()))
setShowContribModal(true)
+24 -8
View File
@@ -4,6 +4,7 @@ import { UserPlus, X, Search, ChevronDown } from 'lucide-react'
import './AddPersonPanel.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
@@ -32,6 +33,8 @@ function withEmphasis(text) {
*/
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) {
const [firstName, setFirstName] = useState('')
// Prénom inconnu : enregistré vide, affiché « Prénom inconnu » dans l'arbre et la fiche
const [unknownFirstName, setUnknownFirstName] = useState(false)
const [lastName, setLastName] = useState('')
const [birthName, setBirthName] = useState('')
const [nickname, setNickname] = useState('')
@@ -90,6 +93,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
useEffect(() => {
if (justAdded && !loading && !error) {
setFirstName('')
setUnknownFirstName(false)
setLastName('')
setBirthName('')
setNickname('')
@@ -116,7 +120,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
setRelations((prev) => [
...prev,
{ personId: person.id, personName: `${person.firstName} ${person.lastName || ''}`.trim(), type: selectedRelationType },
{ personId: person.id, personName: `${firstNameLabel(person)} ${person.lastName || ''}`.trim(), type: selectedRelationType },
])
setRelationSearch('')
setRelationDropdownOpen(false)
@@ -129,7 +133,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
const handleSubmit = (e) => {
e.preventDefault()
if (!firstName.trim()) {
if (!unknownFirstName && !firstName.trim()) {
setTouched({ firstName: true })
firstNameRef.current?.focus()
return
@@ -138,7 +142,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
setJustAdded(true)
onSubmit({
firstName: firstName.trim(),
firstName: unknownFirstName ? '' : firstName.trim(),
lastName: lastName.trim(),
birthName: birthName.trim() || null,
nickname: nickname.trim() || null,
@@ -189,12 +193,13 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
ref={firstNameRef}
id="ap-firstName"
type="text"
className={touched.firstName && !firstName.trim() ? 'apn-input--error' : ''}
className={touched.firstName && !unknownFirstName && !firstName.trim() ? 'apn-input--error' : ''}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={t('Marie')}
autoComplete="off"
aria-invalid={touched.firstName && !firstName.trim()}
disabled={unknownFirstName}
aria-invalid={touched.firstName && !unknownFirstName && !firstName.trim()}
/>
</div>
<div className="pz-field">
@@ -209,7 +214,18 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
/>
</div>
</div>
{touched.firstName && !firstName.trim() && (
<label className="apn-deceased">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setTouched({})
}}
/>
{t('Je ne connais pas son prénom')}
</label>
{touched.firstName && !unknownFirstName && !firstName.trim() && (
<p className="apn-error-text">{t('Il faut au moins un prénom.')}</p>
)}
@@ -298,9 +314,9 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<li key={p.id}>
<button type="button" className="apn-dropdown-item" onClick={() => handleAddRelation(p)}>
<span className="apn-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : (p.firstName || '?').charAt(0)}
{p.photo ? <img src={p.photo} alt="" /> : (p.firstName || '').trim().charAt(0) || '?'}
</span>
<span className="apn-dropdown-name">{p.firstName} <em>{p.lastName || ''}</em></span>
<span className="apn-dropdown-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{(p.birthYear || p.birthDate) && (
<span className="apn-dropdown-date">{p.birthYear || String(p.birthDate).slice(0, 4)}</span>
)}
+2 -1
View File
@@ -9,6 +9,7 @@ import {
} from '../utils/geoMedia'
import './MediaViewer.css'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
const HTTP_URL_RE = /^https?:\/\/\S+$/i
@@ -396,7 +397,7 @@ const MediaViewer = ({ media, person, onClose }) => {
) : (
<span className="mv-person-avatar mv-person-initial">{person?.firstName?.[0] || '?'}</span>
)}
<span className="mv-person-name">{person?.firstName} <em>{person?.lastName}</em></span>
<span className="mv-person-name">{person ? firstNameLabel(person) : ''} <em>{person?.lastName}</em></span>
{personYears && <span className="mv-person-years">{personYears}</span>}
</span>
<span className="pz-tag">{typeLabels[mediaType] || mediaType}</span>
+3 -2
View File
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { X, Paperclip, Images, FileText, Mic, Play, Check } from 'lucide-react'
import { t } from '../i18n/index.js'
import { firstNameLabel } from '../utils/personName.js'
import { createLifeEvent, updateLifeEvent, deleteLifeEvent } from '../api/treeApi.js'
import { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js'
import { inferMediaTypeFromMimeType } from '../utils/mediaUpload.js'
@@ -169,7 +170,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
}
}
const title = milestone ? entry.title : editing ? t('Modifier ce moment') : t('Ajouter à la vie de {name}', { name: person.firstName })
const title = milestone ? entry.title : editing ? t('Modifier ce moment') : t('Ajouter à la vie de {name}', { name: firstNameLabel(person) })
return createPortal(
<div className="pz-overlay ms-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}>
@@ -251,7 +252,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
</div>
<input ref={fileRef} type="file" accept={ACCEPTED_FILES} multiple hidden onChange={(e) => { addFiles(e.target.files || []); e.target.value = '' }} />
{picking && (
<div className="ms-pick" role="group" aria-label={t('Souvenirs de {name}', { name: person.firstName })}>
<div className="ms-pick" role="group" aria-label={t('Souvenirs de {name}', { name: firstNameLabel(person) })}>
{pickable.map((media) => {
const on = linkedIds.includes(String(media.id))
return (
+6
View File
@@ -250,3 +250,9 @@
@media (prefers-reduced-motion: reduce) {
.person-card { animation: pz-fade 200ms ease both; }
}
/* Prénom inconnu (issue Anaïs, 18/10) : libellé en italique, case à cocher sous les champs de nom */
.pc-name-unknown { font-style: italic; font-weight: 300; }
.pc-unknown-first { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; }
.pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
.pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); }
+43 -15
View File
@@ -12,6 +12,7 @@ import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { t, dateLocale } from '../i18n/index.js'
import { formatPlace } from '../utils/place.js'
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js'
import { isYearOnly } from '../utils/dateInput.js'
import DateField from './DateField.jsx'
@@ -94,6 +95,9 @@ const PersonCard = ({
// État local pour les modifications en mode édition
const [edits, setEdits] = useState({})
// Prénom inconnu (case cochée) et saisie de prénom vide sans case : le prénom est demandé avant l'enregistrement
const [unknownFirstName, setUnknownFirstName] = useState(() => isFirstNameUnknown(person))
const [firstNameMissing, setFirstNameMissing] = useState(false)
const [hasChanges, setHasChanges] = useState(false)
const [saving, setSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
@@ -116,6 +120,8 @@ const PersonCard = ({
// Reset edits quand la personne change
useEffect(() => {
setEdits({})
setUnknownFirstName(isFirstNameUnknown(person))
setFirstNameMissing(false)
setHasChanges(false)
setShowDeleteConfirm(false)
setActiveRelationSearch(null)
@@ -199,6 +205,10 @@ const PersonCard = ({
const handleSave = async () => {
if (!hasChanges || !onSave) return
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
setFirstNameMissing(true)
return
}
setSaving(true)
try {
await onSave(person.id, edits)
@@ -333,16 +343,16 @@ const PersonCard = ({
const p2 = persons.find(p => p.id === union.partner2Id)
// Union sans type (fréquent après un import GEDCOM) : pas de « (null) » à côté des prénoms
const kind = union.unionType ? ` (${unionTypeLabel(union.unionType)})` : ''
return `${p1?.firstName || '?'} + ${p2?.firstName || '?'}${kind}`
return `${p1 ? firstNameLabel(p1) : '?'} + ${p2 ? firstNameLabel(p2) : '?'}${kind}`
}
}
if (preferredFiliation.parentId && parentsList.length <= 1) {
const parent = persons.find(p => p.id === preferredFiliation.parentId)
if (preferredFiliation.parentageType === 'inconnu') {
return t('{name} (parent unique, autre parent inconnu)', { name: parent?.firstName || '?' })
return t('{name} (parent unique, autre parent inconnu)', { name: parent ? firstNameLabel(parent) : '?' })
}
return t('{name} (parent unique)', { name: parent?.firstName || '?' })
return t('{name} (parent unique)', { name: parent ? firstNameLabel(parent) : '?' })
}
return null
}
@@ -421,12 +431,12 @@ const PersonCard = ({
<span className="pc-rel-avatar" aria-hidden="true">
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
</span>
<span className="pc-rel-name">{target?.firstName} {target?.lastName || ''}</span>
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
{meta && <span className="pc-rel-meta">{meta}</span>}
<button
type="button"
className="pz-btn pz-btn--ghost pz-btn--icon pc-rel-remove"
aria-label={t('Retirer {name}', { name: target?.firstName })}
aria-label={t('Retirer {name}', { name: target ? fullNameLabel(target) : '' })}
onClick={() => handleRelationRemove(target.id, relationType)}
disabled={relationActionLoading}
>
@@ -455,7 +465,7 @@ const PersonCard = ({
)
return (
<div className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`} role="dialog" aria-label={t('Fiche de {name}', { name: `${person.firstName} ${person.lastName || ''}` })}>
<div className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`} role="dialog" aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}>
<span className="pc-grab" aria-hidden="true" />
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer la fiche')}>
<X size={18} strokeWidth={2} />
@@ -488,7 +498,7 @@ const PersonCard = ({
) : (
<div className="pc-photo">
{person.photo
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
? <img src={person.photo} alt={fullNameLabel(person)} />
: <span className="pc-photo-initials">{initials}</span>}
</div>
)}
@@ -526,9 +536,14 @@ const PersonCard = ({
type="text"
className="pc-input pc-input--name"
value={getEditValue('firstName', person.firstName)}
onChange={(e) => handleFieldChange('firstName', e.target.value)}
onChange={(e) => {
setFirstNameMissing(false)
handleFieldChange('firstName', e.target.value)
}}
placeholder={t('Prénom')}
aria-label={t('Prénom')}
disabled={unknownFirstName}
aria-invalid={firstNameMissing}
/>
<input
type="text"
@@ -538,10 +553,23 @@ const PersonCard = ({
placeholder={t('Nom')}
aria-label={t('Nom')}
/>
<label className="pc-unknown-first">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setFirstNameMissing(false)
handleFieldChange('firstName', '')
}}
/>
{t('Je ne connais pas son prénom')}
</label>
{firstNameMissing && <p className="pc-name-error">{t('Il faut au moins un prénom.')}</p>}
</div>
) : (
<h2 className="pc-name">
{person.firstName} <em>{person.lastName}</em>
{isFirstNameUnknown(person) ? <span className="pc-name-unknown">{firstNameLabel(person)}</span> : person.firstName} <em>{person.lastName}</em>
</h2>
)}
{!editMode && person.nickname && <p className="pc-nick">{person.nickname}</p>}
@@ -591,7 +619,7 @@ const PersonCard = ({
<div className="pc-souvenirs">
{personMedias.length === 0 && (
<div className="pc-empty">
<p className="pz-sub">{t('Pas encore de souvenir pour {name}.', { name: person.firstName })}</p>
<p className="pz-sub">{t('Pas encore de souvenir pour {name}.', { name: firstNameLabel(person) })}</p>
<p className="pz-small">{canManageMedia ? t('Une photo, une anecdote, sa voix. Vous pouvez ajouter le premier.') : t('Une photo, une anecdote, sa voix.')}</p>
</div>
)}
@@ -757,7 +785,7 @@ const PersonCard = ({
{parentsList.length > 0 && (
<div className="pc-row">
<span className="pc-label">{t('Parents')}</span>
<span className="value">{getParentageLabel() || parentsList.map(p => `${p.firstName} ${p.lastName}`).join(t(' et '))}</span>
<span className="value">{getParentageLabel() || parentsList.map(p => fullNameLabel(p)).join(t(' et '))}</span>
</div>
)}
{preferredFiliation && preferredFiliation.parentageType !== 'biologique' && parentsList.length <= 1 && (
@@ -774,7 +802,7 @@ const PersonCard = ({
const partnerId = u.partner1Id === person.id ? u.partner2Id : u.partner1Id
const partner = persons.find(p => p.id === partnerId)
const yearRange = u.startYear ? (u.endYear ? `${u.startYear}–${u.endYear}` : t('depuis {year}', { year: u.startYear })) : ''
return `${partner?.firstName || '?'} ${partner?.lastName || ''}${yearRange ? ` (${yearRange})` : ''}`
return `${partner ? firstNameLabel(partner) : '?'} ${partner?.lastName || ''}${yearRange ? ` (${yearRange})` : ''}`
}).join(', ')}
</span>
</div>
@@ -782,7 +810,7 @@ const PersonCard = ({
{childrenList.length > 0 && (
<div className="pc-row">
<span className="pc-label">{t('Enfants')}</span>
<span className="value">{childrenList.map(c => c.firstName).join(', ')}</span>
<span className="value">{childrenList.map(c => firstNameLabel(c)).join(', ')}</span>
</div>
)}
</div>
@@ -847,7 +875,7 @@ const PersonCard = ({
<footer className="pc-foot">
{showDeleteConfirm ? (
<div className="pc-delete-confirm">
<span>{t("Supprimer {name} de l'arbre ?", { name: person.firstName })}</span>
<span>{t("Supprimer {name} de l'arbre ?", { name: firstNameLabel(person) })}</span>
<div className="pc-delete-actions">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(false)}>{t('Annuler')}</button>
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleDelete}>{t('Supprimer')}</button>
@@ -967,7 +995,7 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
<span className="pc-rel-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : <span>{p.firstName[0]}</span>}
</span>
<span className="pc-rel-name">{p.firstName} <em>{p.lastName || ''}</em></span>
<span className="pc-rel-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{p.birthYear && <span className="pc-rel-meta">{p.birthYear}</span>}
</button>
</li>
+6 -3
View File
@@ -11,6 +11,7 @@ import {
} from './constants'
import { getEntranceProgress, drawPartialBezier } from './utils'
import { fitNickname } from '../../utils/nickname.js'
import { firstNameLabel, isFirstNameUnknown } from '../../utils/personName.js'
// Largeur maximale du surnom sur le nœud (px canevas) : au-delà, coupé avec …
const NICKNAME_MAX_W = 150
@@ -589,13 +590,15 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
// Libellés du petit portrait : un peu réduits, pas autant que le portrait pour rester lisibles
if (node._compact) ctx.scale(0.85, 0.85)
ctx.font = '400 13px "Newsreader", Georgia, serif'
// Prénom inconnu : libellé en italique (pas de prénom à afficher sinon)
const nameUnknown = isFirstNameUnknown(person)
const nameLabel = nameUnknown ? firstNameLabel(person) : person.firstName
ctx.font = `${nameUnknown ? 'italic ' : ''}400 13px "Newsreader", Georgia, serif`
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
const nameLabel = person.firstName || ''
if (nameLabel) {
if (withDetail) {
const nameMetrics = measureTextCached(ctx, nameLabel, 'name')
const nameMetrics = measureTextCached(ctx, nameLabel, nameUnknown ? 'name-unknown' : 'name')
const nameBlockH = Math.max(
18,
(nameMetrics.actualBoundingBoxAscent || 7) + (nameMetrics.actualBoundingBoxDescent || 4) + 8,
@@ -1,5 +1,6 @@
import './SearchResultsDropdown.css'
import { t } from '../../i18n/index.js'
import { firstNameLabel } from '../../utils/personName.js'
// Raison du match renvoyée par usePersonSearch (valeur française), traduite à l'affichage
const REASON_LABELS = {
@@ -67,7 +68,7 @@ function SearchResultsDropdown({
{/* Info personne */}
<div className="search-result-info">
<div className="search-result-name">
{person.firstName} <span className="search-result-lastname">{person.lastName}</span>
{firstNameLabel(person)} <span className="search-result-lastname">{person.lastName}</span>
</div>
<div className="search-result-meta">
{person.birthYear && <span className="search-result-year">{person.birthYear}{person.deathYear ? `–${person.deathYear}` : ''}</span>}
+2 -1
View File
@@ -100,7 +100,8 @@ function mapRemotePersonToLocal(person, current = null) {
return {
id: person?.id ?? current?.id,
firstName: person?.firstName || current?.firstName || t('Inconnu'),
// Prénom inconnu = chaîne vide renvoyée par l'API (pas de repli sur l'ancien nom lors d'un rafraîchissement)
firstName: person ? (person.firstName ?? '') : (current?.firstName ?? ''),
lastName: person?.lastName || current?.lastName || '',
birthName: person?.birthName ?? current?.birthName ?? null,
nickname: person?.nickname ?? null,
+2
View File
@@ -405,4 +405,6 @@ export default {
'Date inconnue': 'Date unknown',
'date inconnue': 'date unknown',
'Décédé(e), date inconnue': 'Deceased, date unknown',
'Prénom inconnu': 'Unknown first name',
'Je ne connais pas son prénom': "I don't know their first name",
}
+7 -6
View File
@@ -6,6 +6,7 @@
// enfant), limitée aux années où la personne a vécu. Une fiche sans aucun événement a donc déjà une frise.
import { t, dateLocale } from '../i18n/index.js'
import { firstNameLabel } from './personName.js'
import { formatPlace } from './place.js'
const EVENT_LABELS = {
@@ -93,15 +94,15 @@ const ageAt = (birthYear, year) => (birthYear && year && year > birthYear ? t('{
const joinMeta = (...parts) => parts.filter(Boolean).join(' · ')
const parentWord = (p) => {
if (p.sex === 'male' || p.sex === 'M') return t('Décès de son père {name}', { name: p.firstName })
if (p.sex === 'female' || p.sex === 'F') return t('Décès de sa mère {name}', { name: p.firstName })
return t('Décès de {name}, son parent', { name: p.firstName })
if (p.sex === 'male' || p.sex === 'M') return t('Décès de son père {name}', { name: firstNameLabel(p) })
if (p.sex === 'female' || p.sex === 'F') return t('Décès de sa mère {name}', { name: firstNameLabel(p) })
return t('Décès de {name}, son parent', { name: firstNameLabel(p) })
}
const siblingWord = (p) => {
if (p.sex === 'male' || p.sex === 'M') return t('Naissance de son frère {name}', { name: p.firstName })
if (p.sex === 'female' || p.sex === 'F') return t('Naissance de sa sœur {name}', { name: p.firstName })
return t('Naissance de {name}, sa fratrie', { name: p.firstName })
if (p.sex === 'male' || p.sex === 'M') return t('Naissance de son frère {name}', { name: firstNameLabel(p) })
if (p.sex === 'female' || p.sex === 'F') return t('Naissance de sa sœur {name}', { name: firstNameLabel(p) })
return t('Naissance de {name}, sa fratrie', { name: firstNameLabel(p) })
}
/**
+15
View File
@@ -0,0 +1,15 @@
import { t } from '../i18n/index.js'
// Prénom inconnu : le backend stocke une chaîne vide. L'affichage dit « Prénom inconnu » (en italique).
export function isFirstNameUnknown(person) {
return !String(person?.firstName ?? '').trim()
}
export function firstNameLabel(person) {
return isFirstNameUnknown(person) ? t('Prénom inconnu') : String(person.firstName).trim()
}
// « Prénom Nom » pour une phrase ou une liste ; le prénom inconnu s'affiche comme son libellé
export function fullNameLabel(person) {
return [firstNameLabel(person), person?.lastName].filter(Boolean).join(' ').trim()
}
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest'
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from './personName.js'
describe('prénom inconnu', () => {
it('un prénom vide ou blanc est inconnu', () => {
expect(isFirstNameUnknown({ firstName: '' })).toBe(true)
expect(isFirstNameUnknown({ firstName: ' ' })).toBe(true)
expect(isFirstNameUnknown({ firstName: 'Marie' })).toBe(false)
})
it('le libellé affiché remplace le prénom vide', () => {
expect(firstNameLabel({ firstName: '' })).toBe('Prénom inconnu')
expect(firstNameLabel({ firstName: ' Marie ' })).toBe('Marie')
})
it('le nom complet garde le nom de famille à côté du libellé', () => {
expect(fullNameLabel({ firstName: '', lastName: 'Dupont' })).toBe('Prénom inconnu Dupont')
expect(fullNameLabel({ firstName: 'Marie', lastName: '' })).toBe('Marie')
})
})