mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-13 00:16:09 +00:00
- Colonne persons.immichPersonId (schéma + migration 20261012_001) et exposition dans le graphe pour l'afficher côté fiche. - Client Immich (src/lib/immich.ts) sur le contrat OpenAPI v3.1.0 : ping, /api/people paginé, vignette par personne ; erreurs distinctes entre injoignable, clé refusée et introuvable, identifiants validés comme UUID v4. - Routes /trees/:id/immich/* (status, people, thumbnail, assign, unassign), réservées aux administrateurs de l'arbre ; l'adresse et la clé d'Immich ne quittent jamais le backend. - La vignette passe par le pipeline des portraits (type lu dans les octets, métadonnées retirées, quota de l'arbre) : le fichier est écrit avant la transaction (personne + journal immich_assign) et l'ancien portrait est supprimé après ; un échec du nettoyage est journalisé sans faire échouer la requête. Le désassignement retire le portrait seulement s'il vient d'Immich (préfixe immich-) et préserve un portrait envoyé à la main. - Côté front : bouton « Depuis Immich » sur la fiche en édition, panneau de sélection avec aperçu et retrait de l'association, traductions anglaises, messages d'erreur distincts. - Tests : 16 tests backend (succès, 400/401/403/404/413/502/503, quota, échec de transaction, préservation du portrait manuel, serveur désactivé) et le mapping immichPersonId côté front. Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe <vibe@mistral.ai>
757 lines
32 KiB
React
757 lines
32 KiB
React
import { useState, useEffect, useRef, forwardRef } from 'react'
|
||
import { Save, Trash2, Plus, X, Search, Check, Play, Sparkles } from 'lucide-react'
|
||
import { getParents, getChildren, getPersonUnions, getPersonMedias, filiations, persons, unions } from '../data/mockData'
|
||
import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||
import ImmichFacePanel from './ImmichFacePanel.jsx'
|
||
import './PersonCard.css'
|
||
import PzBusy from './PzBusy.jsx'
|
||
import { t, dateLocale } from '../i18n/index.js'
|
||
|
||
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,
|
||
treeId = null,
|
||
immichToken = null,
|
||
onImmichChange,
|
||
avatarActionLoading = false,
|
||
avatarActionError = '',
|
||
isAdmin = false,
|
||
onSave,
|
||
onDelete,
|
||
onAddRelation,
|
||
onRemoveRelation,
|
||
relationActionLoading = false,
|
||
relationActionSuccess = '',
|
||
relationActionError = '',
|
||
}) => {
|
||
// 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'))
|
||
|
||
// État local pour les modifications en mode édition
|
||
const [edits, setEdits] = useState({})
|
||
const [hasChanges, setHasChanges] = useState(false)
|
||
const [saving, setSaving] = useState(false)
|
||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||
const [showImmichPanel, setShowImmichPanel] = useState(false)
|
||
|
||
// 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)
|
||
|
||
// Reset edits quand la personne change
|
||
useEffect(() => {
|
||
setEdits({})
|
||
setHasChanges(false)
|
||
setShowDeleteConfirm(false)
|
||
setActiveRelationSearch(null)
|
||
setRelationSearchQuery('')
|
||
setSaveSuccess(false)
|
||
}, [person.id])
|
||
|
||
// 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) => {
|
||
setEdits(prev => ({ ...prev, [field]: value }))
|
||
setHasChanges(true)
|
||
}
|
||
|
||
const handleSave = async () => {
|
||
if (!hasChanges || !onSave) return
|
||
setSaving(true)
|
||
try {
|
||
await onSave(person.id, edits)
|
||
setHasChanges(false)
|
||
setSaveSuccess(true)
|
||
setTimeout(() => setSaveSuccess(false), 2000)
|
||
} finally {
|
||
setSaving(false)
|
||
}
|
||
}
|
||
|
||
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
|
||
await onAvatarUpload(person.id, file)
|
||
}
|
||
|
||
// 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)
|
||
|
||
// 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)
|
||
return `${p1?.firstName || '?'} + ${p2?.firstName || '?'} (${unionTypeLabel(union.unionType)})`
|
||
}
|
||
}
|
||
|
||
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)', { name: parent?.firstName || '?' })
|
||
}
|
||
return null
|
||
}
|
||
|
||
const renderMediaIcon = (media) => {
|
||
if (media.type === 'photo') {
|
||
return media.url
|
||
? <img src={media.url} alt="" />
|
||
: <span>📷</span>
|
||
}
|
||
|
||
if (media.type === 'video') return <span>▶</span>
|
||
if (media.type === 'audio') return <span>♫</span>
|
||
if (media.type === 'document') return <span>📄</span>
|
||
if (media.type === 'citation') return <span>«</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' }) => {
|
||
if (!editMode) {
|
||
const isEmptyValue = value === null || value === undefined || value === ''
|
||
// Une date complète se lit « 22 octobre 1928 », pas « 1928-10-22 »
|
||
const shown = type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(String(value))
|
||
? new Date(`${String(value).slice(0, 10)}T00:00:00Z`).toLocaleDateString(dateLocale, { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
|
||
: value
|
||
return <span className="value">{isEmptyValue ? '-' : shown}</span>
|
||
}
|
||
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 ? `, ${person.birthPlace}` : ''}`)
|
||
if (!person.isAlive && died) 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 })
|
||
|
||
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?.firstName} {target?.lastName || ''}</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 })}
|
||
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: `${person.firstName} ${person.lastName || ''}` })}>
|
||
<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">
|
||
<div className="pc-photo">
|
||
{person.photo
|
||
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
|
||
: <span className="pc-photo-initials">{initials}</span>}
|
||
</div>
|
||
{editMode && onAvatarUpload && (
|
||
<div className="pc-photo-actions">
|
||
<input
|
||
ref={avatarInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
className="pc-hidden-input"
|
||
onChange={handleAvatarFileChange}
|
||
disabled={avatarActionLoading}
|
||
/>
|
||
<button type="button" className="pc-photo-btn" onClick={() => avatarInputRef.current?.click()} disabled={avatarActionLoading}>
|
||
{person.photo ? t('Changer') : t('Ajouter une photo')}
|
||
</button>
|
||
{person.photo && (
|
||
<button type="button" className="pc-photo-btn pc-photo-btn--danger" onClick={() => onAvatarDelete?.(person.id)} disabled={avatarActionLoading}>
|
||
{t('Retirer')}
|
||
</button>
|
||
)}
|
||
{treeId && onImmichChange && (
|
||
<button type="button" className="pc-photo-btn" onClick={() => setShowImmichPanel(true)} disabled={avatarActionLoading}>
|
||
<Sparkles size={14} aria-hidden="true" /> {person.immichPersonId ? t('Visage Immich') : t('Depuis Immich')}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</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) => handleFieldChange('firstName', e.target.value)}
|
||
placeholder={t('Prénom')}
|
||
aria-label={t('Prénom')}
|
||
/>
|
||
<input
|
||
type="text"
|
||
className="pc-input pc-input--name pc-input--last"
|
||
value={getEditValue('lastName', person.lastName)}
|
||
onChange={(e) => handleFieldChange('lastName', e.target.value)}
|
||
placeholder={t('Nom')}
|
||
aria-label={t('Nom')}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<h2 className="pc-name">
|
||
{person.firstName} <em>{person.lastName}</em>
|
||
</h2>
|
||
)}
|
||
{!editMode && lifeLine && <p className="pc-life">{lifeLine}</p>}
|
||
<div className="pc-chips">
|
||
{editMode && <span className="pz-tag pz-tag--accent pz-tag--dot">{t('Édition')}</span>}
|
||
{!editMode && ageLabel && <span className="pz-tag">{ageLabel}</span>}
|
||
{!editMode && person.profession && <span className="pz-tag">{person.profession}</span>}
|
||
</div>
|
||
</div>
|
||
</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 === '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: person.firstName })}</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>
|
||
)}
|
||
|
||
{/* ── Infos ── */}
|
||
{tab === 'infos' && (
|
||
<div className="pc-infos">
|
||
<section className="pc-section">
|
||
<h3 className="pz-eyebrow">{t('État civil')}</h3>
|
||
<div className="pc-rows">
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Naissance')}</span>
|
||
{renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' })}
|
||
</div>
|
||
{(person.birthPlace || editMode) && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Lieu')}</span>
|
||
{renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance') })}
|
||
</div>
|
||
)}
|
||
{(person.birthName || editMode) && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Nom de naissance')}</span>
|
||
{renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })}
|
||
</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') : t('Décédé(e)')}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{(!getEditValue('isAlive', person.isAlive) || (!editMode && !person.isAlive)) && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Décès')}</span>
|
||
{renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date' })}
|
||
</div>
|
||
)}
|
||
{!editMode && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Âge')}</span>
|
||
<span className="value">{ageLabel || '-'}</span>
|
||
</div>
|
||
)}
|
||
{(person.region || editMode) && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Région')}</span>
|
||
{renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') })}
|
||
</div>
|
||
)}
|
||
{(person.nationality || editMode) && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Nationalité')}</span>
|
||
{renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') })}
|
||
</div>
|
||
)}
|
||
{(person.profession || editMode) && (
|
||
<div className="pc-row">
|
||
<span className="pc-label">{t('Métier')}</span>
|
||
{renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') })}
|
||
</div>
|
||
)}
|
||
</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 => `${p.firstName} ${p.lastName}`).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?.firstName || '?'} ${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 => c.firstName).join(', ')}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{(person.note || editMode) && (
|
||
<section className="pc-section">
|
||
<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">
|
||
<h3 className="pz-eyebrow">{t('Parents')}</h3>
|
||
{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'))}
|
||
</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')) : <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 && (
|
||
<footer className="pc-foot">
|
||
{showDeleteConfirm ? (
|
||
<div className="pc-delete-confirm">
|
||
<span>{t("Supprimer {name} de l'arbre ?", { name: person.firstName })}</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>
|
||
)}
|
||
|
||
{/* ── Association d'un visage Immich (modale) ── */}
|
||
{showImmichPanel && treeId && (
|
||
<ImmichFacePanel
|
||
person={person}
|
||
treeId={treeId}
|
||
token={immichToken}
|
||
onClose={() => setShowImmichPanel(false)}
|
||
onImmichChange={onImmichChange}
|
||
/>
|
||
)}
|
||
</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">{p.firstName} <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
|