diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index bbe67da..c95b3bd 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -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(), diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index ed675a9..5fe070c 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -258,8 +258,14 @@ function pushLifeEvent(lines: string[], event: ExportedEvent, tags: Record= 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) diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 9129744..0d917b4 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -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, }, diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index 27dd338..b82ae7b 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -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, }, diff --git a/backend/test/unknown-first-name.test.ts b/backend/test/unknown-first-name.test.ts new file mode 100644 index 0000000..730b531 --- /dev/null +++ b/backend/test/unknown-first-name.test.ts @@ -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' }) + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 421b8f9..b4a9e04 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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) diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index 4f65abb..cd62fc0 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -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 et 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()} />
@@ -209,7 +214,18 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on />
- {touched.firstName && !firstName.trim() && ( + + {touched.firstName && !unknownFirstName && !firstName.trim() && (

{t('Il faut au moins un prénom.')}

)} @@ -298,9 +314,9 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
  • @@ -967,7 +995,7 @@ const RelationSearchInline = forwardRef(function RelationSearchInline( - {p.firstName} {p.lastName || ''} + {firstNameLabel(p)} {p.lastName || ''} {p.birthYear && {p.birthYear}}
  • diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index ef150b8..e541346 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -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, diff --git a/frontend/src/components/search/SearchResultsDropdown.jsx b/frontend/src/components/search/SearchResultsDropdown.jsx index cadeba0..7810f55 100644 --- a/frontend/src/components/search/SearchResultsDropdown.jsx +++ b/frontend/src/components/search/SearchResultsDropdown.jsx @@ -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 */}
    - {person.firstName} {person.lastName} + {firstNameLabel(person)} {person.lastName}
    {person.birthYear && {person.birthYear}{person.deathYear ? `–${person.deathYear}` : ''}} diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 6e04583..ef4b1ed 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -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, diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 0e92719..62bd65a 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -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", } diff --git a/frontend/src/utils/lifeTimeline.js b/frontend/src/utils/lifeTimeline.js index 8457b20..ed684f9 100644 --- a/frontend/src/utils/lifeTimeline.js +++ b/frontend/src/utils/lifeTimeline.js @@ -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) }) } /** diff --git a/frontend/src/utils/personName.js b/frontend/src/utils/personName.js new file mode 100644 index 0000000..4f68de1 --- /dev/null +++ b/frontend/src/utils/personName.js @@ -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() +} diff --git a/frontend/src/utils/personName.test.js b/frontend/src/utils/personName.test.js new file mode 100644 index 0000000..bb6391e --- /dev/null +++ b/frontend/src/utils/personName.test.js @@ -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') + }) +})