mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 07:50:58 +00:00
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:
18 files changed
+179
-50
No files matched your search
@@ -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(),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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); }
|
||||
@@ -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>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user