Files
parenthese/frontend/src/components/PersonCard.jsx
T
IliesAllaliandClaude Opus 5.5 eaecac8fa5 feat(personnes): saisir le sexe et nommer les liens de parenté en conséquence (#114)
- Champ Sexe (femme, homme, non précisé) dans la fiche en modification, à l'ajout
  d'une personne et dans une proposition de la famille ; relu « Sexe » dans les
  propositions à relire.
- Valeurs ramenées à male / female / null à l'écriture et à la lecture (anciennes
  valeurs M/F relues sans migration), valeur inconnue refusée (400). Export GEDCOM
  inchangé dans sa forme.
- Lien au survol genré quand le sexe est connu (Père, Tante, Cousine germaine,
  Belle-mère…), forme double sinon. Nommage déplacé dans utils/kinship : une
  fonction par langue reçoit le chemin complet (pas, personnes, sexe, année de
  naissance), pour brancher d'autres langues.
- Anglais : beau-parent distingué de parent par alliance selon l'ordre du chemin
  (Stepmother / Mother-in-law).
- Propositions à relire : le nom de la personne modifiée vient de l'avant lu en
  base quand la modification ne porte que le champ changé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 14:25:45 +02:00

1269 lines
60 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useRef, forwardRef } from 'react'
import { createPortal } from 'react-dom'
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
import { fetchPersonLife } from '../api/treeApi.js'
import LifeTimeline from './LifeTimeline.jsx'
import MomentSheet from './MomentSheet.jsx'
import { fetchImmichConfig } from '../api/immichApi.js'
import GeoMediaThumbnail from './GeoMediaThumbnail'
import ImmichFacePanel from './ImmichFacePanel.jsx'
import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
import PortraitCropModal from './PortraitCropModal.jsx'
import { imagePlacement } from '../utils/portraitCrop.js'
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'
import { runOnce } from '../utils/saveOnLeave.js'
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
// Aucune modification en cours (référence stable)
const NO_EDITS = Object.freeze({})
const TYPE_LABELS = {
photo: t('Photo'),
video: t('Vidéo'),
audio: t('Audio'),
document: t('Document'),
citation: t('Citation'),
geojson: t('Carte GPS'),
gpx: t('Trace GPX'),
}
// Type d'union affiché (la valeur stockée reste l'enum français)
const UNION_TYPE_LABELS = {
mariage: t('mariage'),
union: t('union'),
}
const unionTypeLabel = (type) => UNION_TYPE_LABELS[type] || type
// Hauteurs fixes de l'onde du lecteur vocal (pas d'aléatoire au rendu)
const VOICE_WAVE = [40, 64, 48, 82, 58, 92, 70, 46, 68, 86, 52, 74, 42, 62, 88, 56, 46, 72, 84, 50, 64, 42]
const PersonCard = ({
person,
onClose,
onMediaSelect,
editMode = false,
canManageMedia = false,
onManageMedia,
onAvatarUpload,
onAvatarDelete,
onAvatarCrop,
pendingPortraitCrop = null,
onPendingPortraitCropDone = null,
photoSourceRequest = null,
onPhotoSourceRequestDone = null,
treeId = null,
immichToken = null,
onImmichChange,
avatarActionLoading = false,
avatarActionError = '',
isAdmin = false,
onSave,
onDelete,
onAddRelation,
onRemoveRelation,
onSetParentage,
pendingParentage = null,
relationActionLoading = false,
relationActionSuccess = '',
relationActionError = '',
siblingPlace = null,
onMoveSibling,
onUploadMomentFile,
onMomentSaved,
saveOnLeaveRef = null,
}) => {
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
const [lifeState, setLifeState] = useState({ personId: null, data: null, loading: false, failed: false })
const lifeRequestedFor = useRef(null)
// Fiche « Ajouter un moment » : null fermée, {} pour ajouter, { event } pour modifier
const [momentSheet, setMomentSheet] = useState(null)
const [lifeRevision, setLifeRevision] = useState(0)
// Moment envoyé par la famille : on dit qu'il attend la relecture
const [lifeNotice, setLifeNotice] = useState('')
useEffect(() => {
if (tab !== 'vie' || lifeRequestedFor.current === person.id) return undefined
lifeRequestedFor.current = person.id
if (!treeId || !immichToken) {
setLifeState({ personId: person.id, data: getDemoLife(person.id), loading: false, failed: false })
return undefined
}
let cancelled = false
// Retour sur l'onglet : on garde la frise affichée pendant qu'elle se rafraîchit
setLifeState((prev) => (prev.personId === person.id && prev.data ? prev : { personId: person.id, data: null, loading: true, failed: false }))
fetchPersonLife(treeId, person.id, immichToken)
.then((data) => { if (!cancelled) setLifeState({ personId: person.id, data, loading: false, failed: false }) })
.catch(() => { if (!cancelled) setLifeState({ personId: person.id, data: null, loading: false, failed: true }) })
return () => {
cancelled = true
lifeRequestedFor.current = null
}
}, [tab, person.id, treeId, immichToken, lifeRevision])
// Modifications en cours, rattachées à la personne qu'elles concernent. Au changement de fiche,
// le premier rendu ne doit pas voir celles de la personne d'avant : un champ qui s'initialise à
// ce moment-là (DateField) les garderait, et la date d'une personne passait sur une autre.
const [editState, setEditState] = useState(() => ({ personId: person.id, values: {}, changed: false }))
const ownEdits = editState.personId === person.id
const edits = ownEdits ? editState.values : NO_EDITS
const hasChanges = ownEdits && editState.changed
// 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 [lastNameMissing, setLastNameMissing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
const [saveError, setSaveError] = useState('')
// Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse
const saveInFlight = useRef(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showImmichPanel, setShowImmichPanel] = useState(false)
// null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose
// qu'à qui peut agir (owner/admin de l'arbre, comme pour toute action Immich) et seulement
// si l'arbre est connecté ou qu'une association passée demande un nettoyage.
const [immichConfigured, setImmichConfigured] = useState(null)
const [showSourceMenu, setShowSourceMenu] = useState(false)
const [sourceMenuPos, setSourceMenuPos] = useState({ top: 0, left: 0 })
const sourceBtnRef = useRef(null)
// Menu du badge appareil photo (position, ou null fermé) et fenêtre de recadrage ({ src, mode, ownsUrl }, ou null)
const [photoMenuPos, setPhotoMenuPos] = useState(null)
const [cropState, setCropState] = useState(null)
// Taille naturelle de la photo affichée dans la fiche, pour placer le portrait recadré
const [photoSize, setPhotoSize] = useState(null)
// Inline relation search state
const [activeRelationSearch, setActiveRelationSearch] = useState(null) // 'parent' | 'spouse' | 'child' | null
const [relationSearchQuery, setRelationSearchQuery] = useState('')
const relationSearchRef = useRef(null)
const avatarInputRef = useRef(null)
// Infos en modification : champs ouverts depuis une pastille (et leur focus)
const [openedFields, setOpenedFields] = useState([])
const [focusField, setFocusField] = useState(null)
const bodyRef = useRef(null)
// Feuille du bas (téléphone) : on la ferme en la glissant par la poignée ou l'en-tête, comme la croix
const sheetMode = useSheetMode()
const drag = useSheetDrag({ enabled: sheetMode, zone: '.pc-grab, .pc-head, .pc-close', onClose })
// Reset edits quand la personne change
useEffect(() => {
setEditState({ personId: person.id, values: {}, changed: false })
setUnknownFirstName(isFirstNameUnknown(person))
setFirstNameMissing(false)
setLastNameMissing(false)
setShowDeleteConfirm(false)
setActiveRelationSearch(null)
setRelationSearchQuery('')
setSaveSuccess(false)
setSaveError('')
setShowSourceMenu(false)
setMomentSheet(null)
setLifeNotice('')
setPhotoMenuPos(null)
setCropState(null)
setPhotoSize(null)
setOpenedFields([])
setFocusField(null)
}, [person.id])
// Pastille cliquée : le champ s'ouvre à sa place et prend le focus
useEffect(() => {
if (!focusField) return
const target = bodyRef.current?.querySelector(`[data-pc-field="${focusField}"] input, [data-pc-field="${focusField}"] textarea`)
target?.focus()
setFocusField(null)
}, [focusField])
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
// traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source.
// Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur.
// Dépend de la présence de onImmichChange, pas de sa valeur : App le recrée à chaque rendu, ce qui
// relançait la lecture de la config et refermait le menu des sources juste après son ouverture
const canChangeImmich = Boolean(onImmichChange)
useEffect(() => {
let cancelled = false
setImmichConfigured(null)
setShowSourceMenu(false)
if (!editMode || !isAdmin || !treeId || !canChangeImmich || !immichToken) return undefined
fetchImmichConfig(treeId, immichToken)
.then(({ config }) => { if (!cancelled) setImmichConfigured(Boolean(config?.configured)) })
.catch(() => { if (!cancelled) setImmichConfigured(false) })
return () => { cancelled = true }
}, [editMode, isAdmin, treeId, immichToken, canChangeImmich])
useEffect(() => {
if (!showSourceMenu) return undefined
const onKeyDown = (event) => { if (event.key === 'Escape') setShowSourceMenu(false) }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [showSourceMenu])
// Escape ferme le menu du badge
useEffect(() => {
if (!photoMenuPos) return undefined
const onKeyDown = (event) => { if (event.key === 'Escape') setPhotoMenuPos(null) }
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [photoMenuPos])
const openPhotoMenu = () => {
if (avatarActionLoading) return
const rect = sourceBtnRef.current?.getBoundingClientRect()
if (rect) {
setPhotoMenuPos({
top: Math.round(rect.bottom) + 6,
left: Math.round(Math.min(rect.left, window.innerWidth - 232)),
})
}
}
// Fermer la fenêtre de recadrage ; l'aperçu envoyé est libéré
const closeCrop = () => {
if (cropState?.ownsUrl) URL.revokeObjectURL(cropState.src)
setCropState(null)
}
// Placement du portrait dans la fiche (carrée), comme le canevas
const photoPlacement = photoSize ? imagePlacement(photoSize.w, photoSize.h, 1, person.avatarCrop) : null
const photoStyle = photoPlacement
? { position: 'absolute', maxWidth: 'none', left: `${photoPlacement.left}%`, top: `${photoPlacement.top}%`, width: `${photoPlacement.width}%`, height: `${photoPlacement.height}%` }
: undefined
// Même forme de cadre que dans l'arbre (renderers.js)
const treeFrameType = getPersonById(person.id)?.frameType || person.frameType
const immichUsable = isAdmin && Boolean(immichConfigured || person.avatarFromImmich)
const openAvatarPicker = () => {
if (avatarActionLoading) return
if (immichUsable) {
const rect = sourceBtnRef.current?.getBoundingClientRect()
if (rect) {
setSourceMenuPos({
top: Math.round(rect.bottom) + 6,
left: Math.round(Math.min(rect.left, window.innerWidth - 252)),
})
}
setShowSourceMenu(true)
} else {
avatarInputRef.current?.click()
}
}
const chooseFileSource = () => { setShowSourceMenu(false); avatarInputRef.current?.click() }
const chooseImmichSource = () => { setShowSourceMenu(false); setShowImmichPanel(true) }
// Clic sur « + photo » dans l'arbre : la photo vient d'être envoyée, le recadrage s'ouvre tout de suite
useEffect(() => {
if (!pendingPortraitCrop || String(pendingPortraitCrop.personId) !== String(person.id)) return
setCropState({ src: pendingPortraitCrop.src, mode: 'new', ownsUrl: true })
onPendingPortraitCropDone?.()
}, [pendingPortraitCrop, person.id, onPendingPortraitCropDone])
// Clic sur « + photo » dans l'arbre, en modification avec Immich : même menu que le badge, sans clic de plus
useEffect(() => {
if (!photoSourceRequest || String(photoSourceRequest.personId) !== String(person.id)) return
if (immichConfigured === null) return
onPhotoSourceRequestDone?.()
if (immichUsable) openAvatarPicker()
}, [photoSourceRequest, person.id, immichConfigured, immichUsable, onPhotoSourceRequestDone]) // eslint-disable-line react-hooks/exhaustive-deps
// Focus search input when relation search opens
useEffect(() => {
if (activeRelationSearch) {
setTimeout(() => relationSearchRef.current?.focus(), 50)
}
}, [activeRelationSearch])
// Close relation search on click outside
useEffect(() => {
if (!activeRelationSearch) return
function handleClick(e) {
if (!e.target.closest('.relation-search-inline')) {
setActiveRelationSearch(null)
setRelationSearchQuery('')
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [activeRelationSearch])
const getEditValue = (field, fallback) => {
return field in edits ? edits[field] : (fallback ?? '')
}
const handleFieldChange = (field, value) => {
const personId = person.id
setEditState(prev => ({
personId,
values: { ...(prev.personId === personId ? prev.values : {}), [field]: value },
changed: true,
}))
}
// Onglet Infos en modification : seuls les champs remplis (ou ouverts) s'affichent, le reste est en pastilles
const isFilled = (value) => value !== null && value !== undefined && String(value).trim() !== ''
// Un champ ouvert par sa pastille, ou en cours de saisie, reste affiché jusqu'à l'enregistrement,
// même vidé : sinon il disparaît sous les doigts et la frappe part dans le champ suivant
const openedHas = (field) => openedFields.includes(field) || field in edits
const openField = (field) => {
setOpenedFields((prev) => (prev.includes(field) ? prev : [...prev, field]))
setFocusField(field)
}
const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear))
const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear))
const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive
const editSex = normalizeSex(getEditValue('sex', person.sex))
const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note'))
// En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli
const infoRow = (field, label, filled, content, always = false) => {
const shown = editMode ? (filled || openedHas(field)) : (always || filled)
if (!shown) return null
return (
<div className="pc-row" data-pc-field={field}>
<span className="pc-label">{label}</span>
{content}
</div>
)
}
const chipCandidates = [
{ field: 'nickname', label: t('Surnom'), filled: isFilled(getEditValue('nickname', person.nickname)), relevant: true },
{ field: 'birthYear', label: t('Date de naissance'), filled: birthFilled, relevant: true },
{ field: 'birthPlace', label: t('Lieu de naissance'), filled: isFilled(getEditValue('birthPlace', person.birthPlace)), relevant: true },
{ field: 'deathYear', label: t('Date de décès'), filled: deathFilled, relevant: deceasedNow },
{ field: 'deathPlace', label: t('Lieu de décès'), filled: isFilled(getEditValue('deathPlace', person.deathPlace)), relevant: deceasedNow },
{ field: 'birthName', label: t('Nom de naissance'), filled: isFilled(getEditValue('birthName', person.birthName)), relevant: true },
{ field: 'profession', label: t('Métier'), filled: isFilled(getEditValue('profession', person.profession)), relevant: true },
{ field: 'region', label: t('Région'), filled: isFilled(getEditValue('region', person.region)), relevant: true },
{ field: 'nationality', label: t('Nationalité'), filled: isFilled(getEditValue('nationality', person.nationality)), relevant: true },
{ field: 'note', label: t('Notes'), filled: isFilled(getEditValue('note', person.note)), relevant: true },
]
const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : []
// Enregistre les modifications de cette fiche (bouton « Enregistrer » et départ de la fiche).
// Résultat : 'saved', 'unchanged' (rien à enregistrer) ou 'blocked' (validation, ou échec réseau :
// le message reste sur la fiche, la saisie n'est pas jetée).
const persistEdits = () => runOnce(saveInFlight, async () => {
if (!hasChanges || !onSave) return 'unchanged'
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
setFirstNameMissing(true)
return 'blocked'
}
// Prénom inconnu : il faut alors un nom de famille
if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) {
setLastNameMissing(true)
return 'blocked'
}
const savedFor = person.id
setSaving(true)
setSaveError('')
try {
await onSave(savedFor, edits)
setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev))
setOpenedFields([])
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 2000)
return 'saved'
} catch (error) {
// Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique
setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez."))
return 'blocked'
} finally {
setSaving(false)
}
})
// App enregistre la fiche avant de la quitter (changement de personne, fermeture, sortie du mode
// modification) : la référence suit toujours la dernière version de persistEdits
useEffect(() => {
if (!saveOnLeaveRef) return undefined
saveOnLeaveRef.current = persistEdits
return () => { if (saveOnLeaveRef.current === persistEdits) saveOnLeaveRef.current = null }
})
// Sortie du mode modification : les modifications restantes (abandonnées) ne reviennent pas à la prochaine entrée
useEffect(() => {
if (!editMode) setEditState({ personId: person.id, values: {}, changed: false })
}, [editMode]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = () => { persistEdits() }
const handleDelete = () => {
if (!onDelete) return
onDelete(person.id)
setShowDeleteConfirm(false)
}
const handleAvatarFileChange = async (event) => {
const file = event.target.files?.[0]
event.target.value = ''
if (!file || !onAvatarUpload) return
const result = await onAvatarUpload(person.id, file)
// Juste après l'envoi, la fenêtre de recadrage s'ouvre (« Garder tel quel » laisse la photo entière)
if (result?.previewUrl) setCropState({ src: result.previewUrl, mode: 'new', ownsUrl: true })
}
// Relation actions
const handleRelationAdd = (targetPerson, relationType) => {
onAddRelation?.(person.id, targetPerson.id, relationType)
setActiveRelationSearch(null)
setRelationSearchQuery('')
}
const handleRelationRemove = (targetPersonId, relationType) => {
onRemoveRelation?.(person.id, targetPersonId, relationType)
}
const toYear = (val) => {
if (val === null || val === undefined) return Number.NaN
if (Number.isFinite(val)) return Number(val)
const m = String(val).match(/^(\d{4})/)
return m ? Number(m[1]) : Number.NaN
}
const birthYearNumber = toYear(person.birthYear ?? person.birthDate)
const deathYearNumber = toYear(person.deathYear ?? person.deathDate)
const hasBirthYear = Number.isFinite(birthYearNumber)
const hasDeathYear = Number.isFinite(deathYearNumber)
const age = hasBirthYear
? person.isAlive
? new Date().getFullYear() - birthYearNumber
: hasDeathYear
? deathYearNumber - birthYearNumber
: null
: null
const parentsList = getParents(person.id)
const childrenList = getChildren(person.id)
const personUnions = getPersonUnions(person.id)
const childFiliations = filiations
.filter((f) => String(f.childId) === String(person.id))
.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0))
const preferredFiliation = (() => {
if (childFiliations.length === 0) return null
// Prefer an explicit union-based parentage when present.
const viaUnion = childFiliations.find((f) => f.unionId && unions.some((u) => String(u.id) === String(f.unionId)))
if (viaUnion) return viaUnion
// If multiple parents exist, avoid stale "parent unique" metadata.
if (parentsList.length > 1) {
return childFiliations.find((f) => (f.parentageType || 'biologique') === 'biologique') || childFiliations[0]
}
return childFiliations[0]
})()
const personMedias = getPersonMedias(person.id)
// Filiation d'adoption entre un enfant et un parent (lien direct ou via une union)
const parentageAdopted = (childId, parentId) => filiations.some((f) => (
String(f.childId) === String(childId)
&& f.parentageType === 'adoption'
&& (String(f.parentId) === String(parentId) || (f.unionId && personUnions.some((u) => String(u.id) === String(f.unionId))))
))
// Un contributeur voit son choix en attente avant l'envoi ; sinon le lien enregistré
const parentsAdopted = pendingParentage ? pendingParentage === 'adoption' : filiations.some((f) => String(f.childId) === String(person.id) && f.parentageType === 'adoption')
const adoptedChildLabel = (child) => {
if (!parentageAdopted(child.id, person.id)) return null
return child.sex === 'female' ? t('adoptée') : t('adopté')
}
// Frise de vie : la famille vient de l'arbre déjà chargé, les événements de la route /life,
// demandée seulement à l'ouverture de l'onglet (la démo a ses propres données)
const siblingsList = (() => {
const seen = new Map()
for (const parent of parentsList) {
for (const child of getChildren(parent.id)) if (child.id !== person.id) seen.set(child.id, child)
}
return [...seen.values()]
})()
// Ajouter un moment : l'administrateur, et la famille qui peut proposer des souvenirs (relu ensuite).
// Modifier un moment ou compléter une naissance : l'administrateur. La démo reste en lecture.
const canAddMoment = Boolean(onUploadMomentFile && treeId && immichToken && (isAdmin || canManageMedia))
const canEditMoments = canAddMoment && isAdmin
const lifeFamily = {
parents: parentsList,
children: childrenList,
siblings: siblingsList,
unions: personUnions.map((union) => ({
...union,
partner: persons.find((p) => p.id === (union.partner1Id === person.id ? union.partner2Id : union.partner1Id)) || null,
// Enfants du couple : placent dans la frise une union dont la date manque
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
})),
}
// Filter persons for relation search (exclude current person + already linked)
const getFilteredPersonsForRelation = (relationType) => {
const excludeIds = new Set([person.id])
// Exclude persons already in that relation
if (relationType === 'parent') {
parentsList.forEach(p => excludeIds.add(p.id))
} else if (relationType === 'child') {
childrenList.forEach(c => excludeIds.add(c.id))
} else if (relationType === 'spouse') {
personUnions.forEach(u => {
excludeIds.add(u.partner1Id === person.id ? u.partner2Id : u.partner1Id)
})
// Basic guardrail: never propose direct parent/child as spouse.
parentsList.forEach(p => excludeIds.add(p.id))
childrenList.forEach(c => excludeIds.add(c.id))
}
return persons.filter(p => {
if (excludeIds.has(p.id)) return false
if (!relationSearchQuery) return true
const fullName = `${p.firstName} ${p.lastName || ''}`.toLowerCase()
return fullName.includes(relationSearchQuery.toLowerCase())
})
}
const getParentageLabel = () => {
if (!preferredFiliation) return null
if (preferredFiliation.unionId) {
const union = unions.find(u => u.id === preferredFiliation.unionId)
if (union) {
const p1 = persons.find(p => p.id === union.partner1Id)
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 ? 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 ? firstNameLabel(parent) : '?' })
}
return t('{name} (parent unique)', { name: parent ? firstNameLabel(parent) : '?' })
}
return null
}
const renderMediaIcon = (media) => {
if (media.type === 'photo') {
return media.url
? <img src={media.url} alt="" />
: <span>&#128247;</span>
}
if (media.type === 'video') return <span>&#9654;</span>
if (media.type === 'audio') return <span>&#9835;</span>
if (media.type === 'document') return <span>&#128196;</span>
if (media.type === 'citation') return <span>&laquo;</span>
if (media.type === 'geojson' || media.type === 'gpx') {
return <GeoMediaThumbnail media={media} className="person-card-media-thumb" />
}
return <span>{media.type?.toUpperCase?.() || '?'}</span>
}
// Rendu d'un champ éditable ou lecture seule
const renderEditableValue = ({ field, value, placeholder, type = 'text', format = null, emptyLabel = '-' }) => {
if (!editMode) {
const isEmptyValue = value === null || value === undefined || value === ''
// Une date complète se lit « 22 octobre 1928 », une année seule « 1928 », pas « 1 janvier 1928 »
let shown = format ? format(value) : value
if (type === 'date' && isYearOnly(value)) shown = String(value).slice(0, 4)
else if (type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(String(value))) {
shown = new Date(`${String(value).slice(0, 10)}T00:00:00Z`).toLocaleDateString(dateLocale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
}
return <span className="value">{isEmptyValue ? emptyLabel : shown}</span>
}
if (type === 'date') {
return (
<DateField
key={`${person.id}-${field}`}
className="pc-input value--editable"
value={getEditValue(field, value)}
onChange={(next) => handleFieldChange(field, next)}
/>
)
}
return (
<input
type={type}
className="pc-input value--editable"
value={getEditValue(field, value)}
onChange={(e) => handleFieldChange(field, e.target.value)}
placeholder={placeholder || '...'}
/>
)
}
// Souvenirs regroupés par nature : citation, voix, images, autres
const quoteMedia = personMedias.find((m) => m.type === 'citation' && m.label)
const voiceMedias = personMedias.filter((m) => m.type === 'audio')
const visualMedias = personMedias.filter((m) => m.type === 'photo' || m.type === 'video')
const otherMedias = personMedias.filter((m) => !['citation', 'audio', 'photo', 'video'].includes(m.type) || (m.type === 'citation' && m !== quoteMedia))
const initials = `${person.firstName?.[0] || ''}${person.lastName?.[0] || ''}` || '?'
const lifeLine = (() => {
const born = person.birthYear || (person.birthDate ? String(person.birthDate).slice(0, 4) : '')
const died = person.deathYear || (person.deathDate ? String(person.deathDate).slice(0, 4) : '')
const parts = []
if (born) parts.push(`${born}${person.birthPlace ? `, ${formatPlace(person.birthPlace)}` : ''}`)
if (!person.isAlive) parts.push(died || '?')
return parts.join(' – ')
})()
const ageLabel = age === null ? null : person.isAlive ? t('{age} ans', { age }) : t('{age} ans au décès', { age })
// Une seule ligne sous le nom : surnom, repères de vie, âge, séparés par un point médian
const metaItems = [
person.nickname && { key: 'nick', node: <em className="pc-nick">{person.nickname}</em>, nowrap: true },
lifeLine && { key: 'life', node: lifeLine },
ageLabel && { key: 'age', node: ageLabel, nowrap: true },
].filter(Boolean)
const renderRelationRow = (target, relationType, meta) => (
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
<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 ? 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 ? fullNameLabel(target) : '' })}
onClick={() => handleRelationRemove(target.id, relationType)}
disabled={relationActionLoading}
>
<X size={15} strokeWidth={2} />
</button>
</div>
)
const renderRelationAdd = (relationType, label, placeholder) => (
activeRelationSearch === relationType ? (
<RelationSearchInline
ref={relationSearchRef}
query={relationSearchQuery}
onQueryChange={setRelationSearchQuery}
results={getFilteredPersonsForRelation(relationType)}
onSelect={(p) => handleRelationAdd(p, relationType)}
onClose={() => { setActiveRelationSearch(null); setRelationSearchQuery('') }}
placeholder={placeholder}
/>
) : (
<button type="button" className="pc-rel-add" onClick={() => setActiveRelationSearch(relationType)} disabled={relationActionLoading}>
<Plus size={15} strokeWidth={2} />
{label}
</button>
)
)
return (
<div
className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`}
role="dialog"
aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}
style={drag.style}
data-dragging={drag.dragging || undefined}
{...drag.bind}
>
<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} />
</button>
{/* ── En-tête : visage, nom, repères ── */}
<header className="pc-head">
<div className="pc-photo-wrap">
{onAvatarUpload && (
<input
ref={avatarInputRef}
type="file"
accept="image/*"
className="pc-hidden-input"
onChange={handleAvatarFileChange}
disabled={avatarActionLoading}
/>
)}
{/* Portrait vide chez le propriétaire : le cadre lui-même invite à poser la photo, sans passer par l'édition */}
{!person.photo && onAvatarUpload && !editMode ? (
<button
type="button"
className="pc-photo pc-photo--add"
onClick={() => avatarInputRef.current?.click()}
disabled={avatarActionLoading}
aria-label={t('Ajouter une photo')}
title={t('Ajouter une photo')}
>
<ImagePlus size={22} strokeWidth={1.8} aria-hidden="true" />
</button>
) : (
<div className="pc-photo">
{person.photo
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })} style={photoStyle} />
: <span className="pc-photo-initials">{initials}</span>}
</div>
)}
{editMode && onAvatarUpload && (
<button
ref={sourceBtnRef}
type="button"
className="pc-photo-badge"
onClick={openPhotoMenu}
disabled={avatarActionLoading}
aria-label={t('Changer la photo')}
title={t('Changer la photo')}
aria-haspopup="menu"
aria-expanded={showSourceMenu}
>
<Camera size={14} strokeWidth={2} aria-hidden="true" />
</button>
)}
</div>
<div className="pc-id">
{editMode ? (
<div className="pc-name-edit">
<input
type="text"
className="pc-input pc-input--name"
value={getEditValue('firstName', person.firstName)}
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"
className="pc-input pc-input--name pc-input--last"
value={getEditValue('lastName', person.lastName)}
onChange={(e) => {
setLastNameMissing(false)
handleFieldChange('lastName', e.target.value)
}}
placeholder={t('Nom')}
aria-label={t('Nom')}
/>
{firstNameMissing && <p className="pc-name-error">{t('Il faut au moins un prénom.')}</p>}
{lastNameMissing && <p className="pc-name-error">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
</div>
) : (
<h2 className="pc-name">
{isFirstNameUnknown(person) ? <span className="pc-name-unknown">{firstNameLabel(person)}</span> : person.firstName}
{person.lastName && <>{' '}<em>{person.lastName}</em></>}
</h2>
)}
{!editMode && metaItems.length > 0 && (
<p className="pc-meta">
{metaItems.map((item, i) => (
<span key={item.key} className={`pc-meta-item${item.nowrap ? ' pc-meta-item--nowrap' : ''}`}>
{item.node}
{i < metaItems.length - 1 && <span className="pc-meta-sep" aria-hidden="true">·</span>}
</span>
))}
</p>
)}
<div className="pc-chips">
{editMode && siblingPlace && (
<span className="pz-tag pc-order" role="group" aria-label={t('Place {n} sur {total} parmi les frères et sœurs', { n: siblingPlace.index + 1, total: siblingPlace.count })}>
<button type="button" className="pc-order-btn" onClick={() => onMoveSibling?.(-1)} disabled={siblingPlace.index === 0} aria-label={t('Déplacer à gauche')} title={t('Déplacer à gauche')}>
<ChevronLeft size={15} strokeWidth={2} aria-hidden="true" />
</button>
<span className="pc-order-label" aria-hidden="true">{siblingPlace.index + 1}/{siblingPlace.count}</span>
<button type="button" className="pc-order-btn" onClick={() => onMoveSibling?.(1)} disabled={siblingPlace.index === siblingPlace.count - 1} aria-label={t('Déplacer à droite')} title={t('Déplacer à droite')}>
<ChevronRight size={15} strokeWidth={2} aria-hidden="true" />
</button>
</span>
)}
{!editMode && person.profession && <span className="pz-tag">{person.profession}</span>}
</div>
</div>
{editMode && ((unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim())) && (
<label className="pc-unknown-first">
<input
type="checkbox"
checked={unknownFirstName}
onChange={(e) => {
setUnknownFirstName(e.target.checked)
setFirstNameMissing(false)
setLastNameMissing(false)
handleFieldChange('firstName', '')
}}
/>
{t('Je ne connais pas son prénom')}
</label>
)}
</header>
{avatarActionError && <div className="pz-error pc-inline-msg">{avatarActionError}</div>}
{/* ── Onglets ── */}
<div className="pz-tabs pc-tabs" role="tablist">
<button type="button" role="tab" aria-selected={tab === 'medias'} className="pz-tab" onClick={() => setTab('medias')}>
{t('Souvenirs')}{personMedias.length > 0 ? ` · ${personMedias.length}` : ''}
</button>
<button type="button" role="tab" aria-selected={tab === 'vie'} className="pz-tab" onClick={() => setTab('vie')}>
{t('Vie')}
</button>
<button type="button" role="tab" aria-selected={tab === 'infos'} className="pz-tab" onClick={() => setTab('infos')}>
{t('Infos')}
</button>
{editMode && (
<button type="button" role="tab" aria-selected={tab === 'relations'} className="pz-tab" onClick={() => setTab('relations')}>
{t('Liens')}
</button>
)}
</div>
<div className="pc-body">
{/* ── Souvenirs ── */}
{tab === 'medias' && (
<div className="pc-souvenirs">
{personMedias.length === 0 && (
<div className="pc-empty">
<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>
)}
{quoteMedia && (
<button type="button" className="pc-quote" onClick={() => onMediaSelect?.(quoteMedia, person)}>
« {quoteMedia.label} »
{quoteMedia.source && <small>{quoteMedia.source}</small>}
</button>
)}
{voiceMedias.map((media) => (
<button key={media.id} type="button" className="pc-voice" onClick={() => onMediaSelect?.(media, person)}>
<span className="pc-voice-play" aria-hidden="true"><Play size={13} fill="currentColor" strokeWidth={0} /></span>
<span className="pc-voice-wave" aria-hidden="true">{VOICE_WAVE.map((h, i) => <i key={i} style={{ height: `${h}%` }} />)}</span>
<span className="pc-voice-label">{media.label || t('Enregistrement')}</span>
</button>
))}
{visualMedias.length > 0 && (
<div className="pc-grid">
{visualMedias.map((media) => (
<button
key={media.id}
type="button"
className={`pc-tile pc-tile--${media.type}`}
onClick={() => onMediaSelect?.(media, person)}
title={media.label || TYPE_LABELS[media.type]}
>
{media.type === 'photo' && media.url
? <img src={media.url} alt={media.label || ''} loading="lazy" />
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
{media.label && <span className="pc-tile-label">{media.label}</span>}
</button>
))}
{canManageMedia && (
<button type="button" className="pc-tile pc-tile--add" onClick={() => onManageMedia?.()} aria-label={t('Ajouter des souvenirs')}>
<Plus size={20} strokeWidth={1.8} />
</button>
)}
</div>
)}
{otherMedias.length > 0 && (
<div className="pc-list">
{otherMedias.map((media) => (
<button key={media.id} type="button" className="pc-list-item" onClick={() => onMediaSelect?.(media, person)}>
<span className={`pc-list-icon pc-list-icon--${media.type}`}>{renderMediaIcon(media)}</span>
<span className="pc-list-text">
<span className="pc-list-label">{media.label || TYPE_LABELS[media.type]}</span>
<span className="pc-list-type">{TYPE_LABELS[media.type] || media.type}</span>
</span>
</button>
))}
</div>
)}
{canManageMedia && (
<button type="button" className={`pz-btn pz-btn--block ${isAdmin ? 'pz-btn--soft' : 'pz-btn--primary'}`} onClick={() => onManageMedia?.()}>
<Plus size={16} strokeWidth={2} />
{isAdmin ? t('Ajouter ou ranger des souvenirs') : t('Ajouter un souvenir')}
</button>
)}
</div>
)}
{/* ── Vie ── */}
{tab === 'vie' && (
<LifeTimeline
person={person}
family={lifeFamily}
life={lifeState.personId === person.id ? lifeState.data : null}
loading={lifeState.personId !== person.id || lifeState.loading}
failed={lifeState.personId === person.id && lifeState.failed}
medias={personMedias}
canAdd={canAddMoment}
canEdit={canEditMoments}
notice={lifeNotice}
onAdd={() => { setLifeNotice(''); setMomentSheet({ entry: null }) }}
onEdit={(entry) => { setLifeNotice(''); setMomentSheet({ entry }) }}
onOpenMedia={(media) => onMediaSelect?.(media, person)}
/>
)}
{/* ── Infos ── */}
{tab === 'infos' && (
<div className="pc-infos" ref={bodyRef}>
<section className="pc-section">
<h3 className="pz-eyebrow">{t('État civil')}</h3>
<div className="pc-rows">
{infoRow('birthYear', t('Naissance'), birthFilled, renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' }), true)}
{infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))}
{infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))}
{infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))}
{editMode && (
<div className="pc-row pc-row--seg" data-pc-field="sex">
<span className="pc-label" id="pc-sex-label">{t('Sexe')}</span>
<div className="pz-tabs pc-seg" role="radiogroup" aria-labelledby="pc-sex-label">
{SEX_CHOICES.map(({ value, label }) => (
<button
key={label}
type="button"
role="radio"
aria-checked={editSex === value}
className={`pz-tab ${editSex === value ? 'is-active' : ''}`}
onClick={() => handleFieldChange('sex', value)}
>
{label}
</button>
))}
</div>
</div>
)}
{editMode && (
<div className="pc-row">
<span className="pc-label">{t('Statut')}</span>
<button
type="button"
className={`pc-alive ${getEditValue('isAlive', person.isAlive) ? 'is-alive' : 'is-deceased'}`}
onClick={() => handleFieldChange('isAlive', !getEditValue('isAlive', person.isAlive))}
>
<span className="pc-alive-dot" />
{getEditValue('isAlive', person.isAlive) ? t('En vie') : editSex === 'male' ? t('Décédé') : editSex === 'female' ? t('Décédée') : t('Décédé(e)')}
</button>
</div>
)}
{infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)}
{infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))}
{!editMode && (
<div className="pc-row">
<span className="pc-label">{t('Âge')}</span>
<span className="value">{ageLabel || '-'}</span>
</div>
)}
{infoRow('region', t('Région'), isFilled(getEditValue('region', person.region)), renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') }))}
{infoRow('nationality', t('Nationalité'), isFilled(getEditValue('nationality', person.nationality)), renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') }))}
{infoRow('profession', t('Métier'), isFilled(getEditValue('profession', person.profession)), renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') }))}
</div>
{/* Modification : une pastille par champ vide, qui ouvre le champ à sa place */}
{editMode && emptyChips.length > 0 && (
<div className="pc-fill-chips">
{emptyChips.map((chip) => (
<button key={chip.field} type="button" className="pc-fill-chip" onClick={() => openField(chip.field)}>
+ {chip.label}
</button>
))}
</div>
)}
</section>
{!editMode && (parentsList.length > 0 || personUnions.length > 0 || childrenList.length > 0) && (
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Famille')}</h3>
<div className="pc-rows">
{parentsList.length > 0 && (
<div className="pc-row">
<span className="pc-label">{t('Parents')}</span>
<span className="value">{getParentageLabel() || parentsList.map(p => fullNameLabel(p)).join(t(' et '))}</span>
</div>
)}
{preferredFiliation && preferredFiliation.parentageType !== 'biologique' && parentsList.length <= 1 && (
<div className="pc-row">
<span className="pc-label">{t('Filiation')}</span>
<span className="value">{preferredFiliation.parentageType === 'adoption' ? t('Adoption') : t('Parent inconnu')}</span>
</div>
)}
{personUnions.length > 0 && (
<div className="pc-row">
<span className="pc-label">{personUnions.length > 1 ? t('Unions') : t('Union')}</span>
<span className="value">
{personUnions.map(u => {
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 ? firstNameLabel(partner) : '?'} ${partner?.lastName || ''}${yearRange ? ` (${yearRange})` : ''}`
}).join(', ')}
</span>
</div>
)}
{childrenList.length > 0 && (
<div className="pc-row">
<span className="pc-label">{t('Enfants')}</span>
<span className="value">{childrenList.map(c => firstNameLabel(c)).join(', ')}</span>
</div>
)}
</div>
</section>
)}
{(editMode ? notesShown : person.note) && (
<section className="pc-section" data-pc-field="note">
<h3 className="pz-eyebrow">{t('Notes')}</h3>
{editMode ? (
<textarea
className="pc-input pc-note-input"
value={getEditValue('note', person.note || '')}
onChange={(e) => handleFieldChange('note', e.target.value)}
placeholder={t("Une anecdote, un trait de caractère, ce qu'on se raconte à son sujet")}
rows={3}
/>
) : (
<p className="pc-note">{person.note}</p>
)}
</section>
)}
</div>
)}
{/* ── Liens (édition) ── */}
{tab === 'relations' && editMode && (
<div className="pc-infos">
{(relationActionLoading || relationActionSuccess || relationActionError) && (
<div className={relationActionError ? 'pz-error pc-inline-msg' : relationActionSuccess ? 'pz-success pc-inline-msg' : 'pc-inline-msg pc-inline-msg--loading'}>
{relationActionLoading ? t('Mise à jour des liens…') : relationActionError || relationActionSuccess}
</div>
)}
<section className="pc-section">
<div className="pc-section-head">
<h3 className="pz-eyebrow">{t('Parents')}</h3>
{parentsAdopted && parentsList.length > 0 && <span className="pc-adopt-tag">{t('Parents adoptifs')}</span>}
</div>
{parentsList.length > 0 ? parentsList.map(p => renderRelationRow(p, 'parent')) : <p className="pc-rel-empty">{t("Aucun parent pour l'instant")}</p>}
{renderRelationAdd('parent', t('Ajouter un parent'), t('Rechercher un parent'))}
{parentsList.length > 0 && (
<div className="pc-parentage">
<span className="pc-label">{t('Lien avec ses parents')}</span>
<div className="pz-tabs pc-seg" role="radiogroup" aria-label={t('Lien avec ses parents')}>
<button type="button" role="radio" aria-checked={!parentsAdopted} className={`pz-tab ${!parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'biologique')}>{t('Naissance')}</button>
<button type="button" role="radio" aria-checked={parentsAdopted} className={`pz-tab ${parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'adoption')}>{t('Adoption')}</button>
</div>
</div>
)}
</section>
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Conjoint·e·s')}</h3>
{personUnions.length > 0 ? personUnions.map(u => {
const partnerId = u.partner1Id === person.id ? u.partner2Id : u.partner1Id
const partner = persons.find(p => p.id === partnerId)
return renderRelationRow(partner || { id: partnerId, firstName: '?' }, 'spouse', unionTypeLabel(u.unionType))
}) : <p className="pc-rel-empty">{t('Aucun·e conjoint·e')}</p>}
{renderRelationAdd('spouse', t('Ajouter un·e conjoint·e'), t('Rechercher un·e conjoint·e'))}
</section>
<section className="pc-section">
<h3 className="pz-eyebrow">{t('Enfants')}</h3>
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
{renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
</section>
</div>
)}
</div>
{/* ── Actions d'édition ── */}
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
{editMode && (
<footer className="pc-foot">
{showDeleteConfirm ? (
<div className="pc-delete-confirm">
<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>
</div>
</div>
) : (
<>
{isAdmin && (
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(true)}>
<Trash2 size={15} strokeWidth={2} />
{t('Supprimer')}
</button>
)}
<button
type="button"
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
disabled={(!hasChanges && !saveSuccess) || saving}
onClick={handleSave}
>
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
</button>
</>
)}
</footer>
)}
{/* ── Choix de la source du portrait (menu contextuel) ── */}
{showSourceMenu && (
createPortal(
<>
<div className="pc-source-backdrop" onClick={() => setShowSourceMenu(false)} />
<div className="pc-source-menu pz-menu" role="menu" aria-label={t('Source du portrait')} style={{ top: `${sourceMenuPos.top}px`, left: `${sourceMenuPos.left}px` }}>
<button type="button" role="menuitem" className="pz-menu-item" onClick={chooseFileSource}>
<ImagePlus size={16} aria-hidden="true" /> {t('Depuis un fichier')}
</button>
<button type="button" role="menuitem" className="pz-menu-item" onClick={chooseImmichSource}>
<Sparkles size={16} aria-hidden="true" /> {t('Depuis Immich')}
</button>
</div>
</>,
document.body,
)
)}
{/* ── Menu du badge : changer, recadrer, retirer ── */}
{photoMenuPos && (
createPortal(
<>
<div className="pc-source-backdrop" onClick={() => setPhotoMenuPos(null)} />
<div className="pc-source-menu pz-menu" role="menu" aria-label={t('Options du portrait')} style={{ top: `${photoMenuPos.top}px`, left: `${photoMenuPos.left}px` }}>
<button type="button" role="menuitem" className="pz-menu-item" onClick={() => { setPhotoMenuPos(null); openAvatarPicker() }}>
<ImagePlus size={16} aria-hidden="true" /> {t('Changer la photo')}
</button>
{person.photo && !person.avatarFromImmich && onAvatarCrop && (
<button type="button" role="menuitem" className="pz-menu-item" onClick={() => { setPhotoMenuPos(null); setCropState({ src: person.photo, mode: 'recrop', ownsUrl: false }) }}>
<Crop size={16} aria-hidden="true" /> {t('Recadrer')}
</button>
)}
{person.photo && (
<button type="button" role="menuitem" className="pz-menu-item pz-menu-item--danger" onClick={() => { setPhotoMenuPos(null); onAvatarDelete?.(person.id, person.avatarFromImmich) }} disabled={avatarActionLoading}>
<Trash2 size={16} aria-hidden="true" /> {t('Retirer')}
</button>
)}
</div>
</>,
document.body,
)
)}
{/* ── Recadrage du portrait (fenêtre pz) ── */}
{cropState && onAvatarCrop && (
<PortraitCropModal
src={cropState.src}
frameType={treeFrameType}
name={fullNameLabel(person)}
mode={cropState.mode}
initialCrop={cropState.mode === 'new' ? null : person.avatarCrop}
saving={avatarActionLoading}
error={avatarActionError}
onSave={async (crop) => {
const saved = await onAvatarCrop(person.id, crop)
if (saved) closeCrop()
}}
onClose={closeCrop}
/>
)}
{/* ── Association d'un visage Immich (modale) ── */}
{showImmichPanel && treeId && (
<ImmichFacePanel
person={person}
treeId={treeId}
token={immichToken}
onClose={() => setShowImmichPanel(false)}
onImmichChange={onImmichChange}
/>
)}
{/* ── Ajouter ou modifier un moment de l'onglet Vie (modale) ── */}
{momentSheet && canAddMoment && (
<MomentSheet
person={person}
entry={momentSheet.entry}
treeId={treeId}
token={immichToken}
medias={personMedias}
isAdmin={canEditMoments}
onUploadFile={onUploadMomentFile}
onClose={() => setMomentSheet(null)}
onSaved={({ pending, uploaded }) => {
setMomentSheet(null)
if (pending) setLifeNotice(t('Merci, c’est envoyé. Le moment apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.'))
// Nouveaux souvenirs : la fiche les recharge, puis la frise se relit
if (uploaded && !pending) onMomentSaved?.(person.id)
setLifeRevision((r) => r + 1)
}}
/>
)}
</div>
)
}
/**
* Recherche en ligne pour ajouter un lien (parent, conjoint·e, enfant)
*/
const RelationSearchInline = forwardRef(function RelationSearchInline(
{ query, onQueryChange, results, onSelect, onClose, placeholder },
ref
) {
const filtered = results.slice(0, 6)
return (
<div className="relation-search-inline pc-search">
<div className="pc-search-field">
<Search size={15} strokeWidth={2} className="pc-search-icon" />
<input
ref={ref}
type="text"
className="pc-search-input"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder={placeholder}
autoComplete="off"
onKeyDown={(e) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}}
/>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-search-close" onClick={onClose} aria-label={t('Fermer la recherche')}>
<X size={15} strokeWidth={2} />
</button>
</div>
{filtered.length > 0 ? (
<ul className="pc-search-list">
{filtered.map((p) => (
<li key={p.id}>
<button type="button" className="pc-search-item" onClick={() => onSelect(p)}>
<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">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{p.birthYear && <span className="pc-rel-meta">{p.birthYear}</span>}
</button>
</li>
))}
</ul>
) : query ? (
<p className="pc-rel-empty">{t('Aucune personne ne correspond')}</p>
) : null}
</div>
)
})
export default PersonCard