Files
parenthese/frontend/src/components/ContributionPanel.jsx
T
IliesAllaliandClaude Opus 5.5 f65273e95a Personnes identifiées sur un souvenir
Sur une photo ou une vidéo, on ajoute les personnes de l'arbre qui y figurent et, sur une photo,
on touche leur visage pour poser un point. Le souvenir apparaît aussi sur la fiche de chaque
personne identifiée (« Avec Odette »), un prénom ouvre sa fiche et recentre l'arbre.

L'administrateur enregistre directement. La famille propose, l'administrateur relit dans
« Propositions à relire ». Suppression d'une personne ou d'un souvenir : identifications retirées.

Table media_person_tags (migration additive), route PUT /trees/:id/media/:mediaId/people.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:32:22 +02:00

755 lines
40 KiB
React

import { useEffect, useState } from 'react'
import { buildMediaUrl } from '../utils/mediaToken.js'
import { API_BASE_URL } from '../api/client'
import { persons as treePersons } from '../data/mockData'
import { Check, ChevronDown, Copy, RefreshCw, X } from 'lucide-react'
import './SettingsPanels.css'
import PzBusy from './PzBusy.jsx'
import { HELP_URL } from '../utils/siteLinks.js'
import { t, dateLocale } from '../i18n/index.js'
import { sexLabel } from '../utils/personSex.js'
// ─── helpers ────────────────────────────────────────────────────────────────
function formatAction(action) {
if (action === 'create') return t('Création')
if (action === 'update') return t('Modification')
if (action === 'delete') return t('Suppression')
if (action === 'reorder') return t('Réorganisation')
return t('Changement')
}
function formatEntityType(entityType) {
if (entityType === 'person') return t('personne')
if (entityType === 'union') return t('union')
if (entityType === 'parent_child_link') return t('lien parent-enfant')
if (entityType === 'annotation') return t('annotation')
if (entityType === 'media') return t('souvenir')
if (entityType === 'life_event') return t('moment')
if (entityType === 'media_people') return t('personnes sur un souvenir')
return t('élément')
}
const MEDIA_TYPE_LABELS = {
photo: t('Photo'),
video: t('Vidéo'),
audio: t('Voix'),
document: t('Document'),
citation: t('Citation'),
geojson: t('Carte'),
gpx: t('Trajet'),
}
// Souvenir envoyé par la famille : de quoi juger avant d'accepter
function getMediaPreview(change, treeId) {
const data = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
const label = MEDIA_TYPE_LABELS[data.type] || t('Souvenir')
const caption = typeof data.caption === 'string' ? data.caption : ''
const isYoutube = data.mimeType === 'video/youtube'
const url = change.entityId && treeId && !isYoutube
? buildMediaUrl(`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(change.entityId)}`, API_BASE_URL)
: null
return { type: data.type, label, caption, url, isYoutube }
}
// Personnes identifiées sur un souvenir : la photo, qui y était, qui est proposé
function getMediaPeoplePreview(change, treeId) {
const after = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : {}
const names = (list) => (Array.isArray(list) ? list : []).map((tag) => nameOfPersonRef(tag?.personId)).filter(Boolean)
const url = change.entityId && treeId
? buildMediaUrl(`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(change.entityId)}`, API_BASE_URL)
: null
return { url, before: names(before.people), after: names(after.people) }
}
// Moment proposé par la famille : quand, où, et ce qu'elle en dit. Ses fichiers sont dans « Souvenirs »
function getMomentPreview(change) {
const data = (change.afterJson && typeof change.afterJson === 'object') ? change.afterJson : {}
const year = String(data.date || '').slice(0, 4)
const end = String(data.dateEnd || '').slice(0, 4)
let when = year
if (year && end) when = data.datePrecision === 'between' ? t('entre {year} et {end}', { year, end }) : t('de {year} à {end}', { year, end })
else if (year && data.datePrecision === 'about') when = t('vers {year}', { year })
return { when, place: data.place || '', notes: data.notes || '', sources: data.sources || '' }
}
function formatAnnotationType(type) {
if (type === 'sticker') return t('Sticker')
if (type === 'drawing') return t('Dessin')
if (type === 'text') return t('Texte')
if (type === 'photo') return t('Photo canvas')
return t('Annotation')
}
const PERSON_FIELD_LABELS = {
firstName: t('Prénom'),
lastName: t('Nom'),
birthName: t('Nom de naissance'),
nickname: t('Surnom'),
sex: t('Sexe'),
birthDate: t('Date de naissance'),
deathDate: t('Date de décès'),
deathPlace: t('Lieu de décès'),
birthPlace: t('Lieu de naissance'),
profession: t('Métier'),
nationality: t('Nationalité'),
region: t('Région'),
notes: t('Notes'),
}
function displayVal(v, key) {
// Sexe : « Non précisé » plutôt que « vide »
if (key === 'sex') return sexLabel(v)
if (v === null || v === undefined || v === '') return null
return String(v)
}
// Nom d'une personne citée par un lien : dans l'arbre, ou ajoutée dans la même contribution (id provisoire « tmp: »)
function nameOfPersonRef(id, sessionChanges = []) {
if (!id) return null
const key = String(id)
if (key.startsWith('tmp:')) {
const added = sessionChanges.find((c) => c.entityType === 'person' && c.afterJson?.ref === key)
const name = `${added?.afterJson?.firstName || ''} ${added?.afterJson?.lastName || ''}`.trim()
return name || t('la nouvelle personne')
}
const person = treePersons.find((candidate) => String(candidate.id) === key)
const name = `${person?.firstName || ''} ${person?.lastName || ''}`.trim()
return name || null
}
function getRelationLabel(change, sessionChanges) {
const data = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
if (!data) return null
if (change.entityType === 'parent_child_link') {
const child = nameOfPersonRef(data.childPersonId, sessionChanges)
const parent = nameOfPersonRef(data.parentPersonId, sessionChanges)
return child && parent ? t('{child}, enfant de {parent}', { child, parent }) : null
}
if (change.entityType === 'union') {
const a = nameOfPersonRef(data.partner1PersonId, sessionChanges)
const b = nameOfPersonRef(data.partner2PersonId, sessionChanges)
return a && b ? t('{a} et {b}', { a, b }) : null
}
return null
}
function getPersonDisplayName(change, sessionChanges = []) {
const relationLabel = getRelationLabel(change, sessionChanges)
if (relationLabel) return relationLabel
if (change.entityType === 'annotation') {
const data = change.afterJson || change.after || {}
return formatAnnotationType(data.type)
}
if (change.entityType === 'media') {
const data = change.afterJson || {}
return MEDIA_TYPE_LABELS[data.type] || t('Souvenir')
}
if (change.entityType === 'life_event') {
const data = change.afterJson || {}
return data.title || t('Moment')
}
if (change.entityType === 'media_people') {
const owner = nameOfPersonRef(change.afterJson?.ownerPersonId)
return owner ? t('Photo de {name}', { name: owner }) : t('Personnes sur un souvenir')
}
// Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base)
const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null
if (!after && !before) return null
const data = { ...before, ...after }
const name = `${data.firstName || ''} ${data.lastName || ''}`.trim()
return name || null
}
function getFieldRows(change) {
if (change.entityType !== 'person') return []
const after = change.afterJson
if (!after || typeof after !== 'object') return []
const before = (change.beforeJson && typeof change.beforeJson === 'object') ? change.beforeJson : null
return Object.entries(after)
.filter(([key]) => PERSON_FIELD_LABELS[key])
.map(([key, newVal]) => ({
key,
label: PERSON_FIELD_LABELS[key],
oldVal: before !== null ? before[key] : undefined,
newVal,
}))
.filter(({ newVal, oldVal }) => {
if (oldVal !== undefined) return true
return newVal !== null && newVal !== undefined && newVal !== ''
})
}
function getAnnotationPreview(change, treeId) {
const data = change.afterJson || change.after || {}
const type = data.type
if (!type) return null
if (type === 'sticker') return { type, emoji: data.content || '?' }
if (type === 'text') return { type, text: data.content || '' }
if (type === 'drawing') return { type, color: data.style?.color || '#2A2622' }
if (type === 'photo') {
try {
// Seulement une photo de cet arbre, reconstruite depuis son chemin : une adresse fournie par
// l'auteur de la contribution pourrait pister celui qui relit
const parsed = typeof data.content === 'string' ? JSON.parse(data.content) : data.content
const prefix = `/trees/${treeId}/annotation-photos/`
const photoPath = typeof parsed?.photoPath === 'string' ? parsed.photoPath : ''
const isOwnPhoto = photoPath.startsWith(prefix) && /^[A-Za-z0-9-]+$/.test(photoPath.slice(prefix.length))
return { type, url: isOwnPhoto ? buildMediaUrl(photoPath, API_BASE_URL) : null }
} catch { return { type } }
}
return { type }
}
function formatSessionDate(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
return date.toLocaleString(dateLocale, {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit',
})
}
// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp
function buildInviteText(url, password) {
return [
t('Bonjour à tous,'),
'',
t("Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter."),
'',
t("L'arbre est ici {url}", { url }),
t("Le mot de passe pour l'ouvrir est {password}", { password }),
'',
t("En bas de l'écran, vous pouvez ajouter une photo, un souvenir ou une personne qui manque."),
t("Le mode d'emploi, si besoin {url}", { url: HELP_URL }),
].join('\n')
}
// ─── component ──────────────────────────────────────────────────────────────
const ContributionPanel = ({
visible,
treeId,
canModerate,
loading,
errorMessage,
inviteShareUrl,
inviteKnownPasswords,
inviteSaving,
inviteError,
inviteMessage,
sessions,
reviewingSessionId,
onClose,
onRefresh,
onRotatePasswords,
onRememberPassword,
onReviewSession,
}) => {
const [decisionMap, setDecisionMap] = useState({})
const [collapsedSessions, setCollapsedSessions] = useState(new Set())
const [confirmApproveAll, setConfirmApproveAll] = useState(null)
const [showPassword, setShowPassword] = useState(false)
// '' | 'change' (nouveau mot de passe) | 'remember' (arbre d'avant : saisir celui déjà donné)
const [passwordMode, setPasswordMode] = useState('')
const [newPassword, setNewPassword] = useState('')
const [inviteLocalError, setInviteLocalError] = useState('')
const [copyFeedback, setCopyFeedback] = useState('')
// Le message d'invitation reste replié : une ligne, le texte ne s'ouvre que sur demande
const [showInviteMessage, setShowInviteMessage] = useState(false)
// Load persisted decisions from localStorage
useEffect(() => {
if (!treeId) return
try {
const saved = localStorage.getItem(`contrib_decisions_${treeId}`)
if (saved) setDecisionMap(JSON.parse(saved))
} catch { /* ignore */ }
}, [treeId])
// Persist decisions to localStorage on change
useEffect(() => {
if (!treeId) return
try {
localStorage.setItem(`contrib_decisions_${treeId}`, JSON.stringify(decisionMap))
} catch { /* ignore */ }
}, [treeId, decisionMap])
const toggleCollapse = (sessionId) => {
setCollapsedSessions((prev) => {
const next = new Set(prev)
next.has(sessionId) ? next.delete(sessionId) : next.add(sessionId)
return next
})
}
const toggleChangeDecision = (sessionId, changeId, decision) => {
setDecisionMap((current) => {
const prev = current[sessionId]?.[changeId] || ''
const next = prev === decision ? '' : decision
return { ...current, [sessionId]: { ...(current[sessionId] || {}), [changeId]: next } }
})
}
const getChangeDecision = (sessionId, changeId) =>
decisionMap?.[sessionId]?.[changeId] || ''
const buildChangeDecisions = (sessionId) => {
const raw = decisionMap[sessionId] || {}
return Object.entries(raw)
.filter(([, d]) => d === 'approved' || d === 'rejected')
.map(([changeId, decision]) => ({ changeId, decision }))
}
const getSessionSummary = (sessionId, changes) => {
const decisions = decisionMap[sessionId] || {}
let approved = 0, rejected = 0, pending = 0
for (const ch of (changes || [])) {
const d = decisions[ch.id] || ''
if (d === 'approved') approved++
else if (d === 'rejected') rejected++
else pending++
}
return { approved, rejected, pending, total: (changes || []).length }
}
const applyDecisions = (sessionId, changes) => {
const explicit = buildChangeDecisions(sessionId)
const explicitIds = new Set(explicit.map((d) => d.changeId))
const allDecisions = [
...explicit,
...(changes || [])
.filter((ch) => !explicitIds.has(ch.id))
.map((ch) => ({ changeId: ch.id, decision: 'rejected' })),
]
onReviewSession(sessionId, { decision: 'rejected', changeDecisions: allDecisions })
}
useEffect(() => {
if (!visible) return undefined
const handleKeyDown = (e) => { if (e.key === 'Escape') onClose?.() }
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [visible, onClose])
useEffect(() => {
if (!copyFeedback) return undefined
const timer = window.setTimeout(() => setCopyFeedback(''), 1800)
return () => window.clearTimeout(timer)
}, [copyFeedback])
useEffect(() => {
if (!visible) return
setPasswordMode('')
setNewPassword('')
setInviteLocalError('')
}, [inviteKnownPasswords?.share, visible])
const knownShare = inviteKnownPasswords?.share || ''
const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare) : ''
const [inviteText, setInviteText] = useState('')
useEffect(() => { setInviteText(defaultInviteText) }, [defaultInviteText])
const handleCopy = async (value, label) => {
const text = String(value || '').trim()
if (!text) return
try { await navigator.clipboard.writeText(text); setCopyFeedback(label) }
catch { setCopyFeedback(t('Copie indisponible')) }
}
const handleSavePassword = async (e) => {
e.preventDefault()
setInviteLocalError('')
if (passwordMode === 'remember') {
if (!newPassword.trim()) return
const result = await onRememberPassword?.({ password: newPassword.trim() })
if (result?.ok) { setPasswordMode(''); setNewPassword('') }
return
}
if (newPassword.trim().length < 8) { setInviteLocalError(t('Minimum 8 caractères.')); return }
const result = await onRotatePasswords?.({ password: newPassword.trim() })
if (result?.ok) { setPasswordMode(''); setNewPassword('') }
}
if (!visible) return null
const [familyTitleBefore, familyTitleAfter] = t('La famille {em}').split('{em}')
return (
<div className="pz-overlay contrib-overlay" onClick={() => onClose?.()}>
<div
className="pz-modal pz-modal--wide contrib-card"
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="contribPanelTitle"
>
<div className="pz-modal-head">
<h1 id="contribPanelTitle" className="pz-title">{familyTitleBefore}<em>{t('participe')}</em>{familyTitleAfter}</h1>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} strokeWidth={2} />
</button>
{canModerate ? (
<div className="contrib-list-section">
{/* ── Section invitation ── */}
<div className="contrib-invite">
<div className="contrib-invite-top">
<strong className="contrib-invite-title">{t('Le lien à envoyer à la famille')}</strong>
{copyFeedback && <span className="contrib-copy-feedback">{copyFeedback}</span>}
</div>
<div className="contrib-invite-link">
<code className="contrib-link-url">{inviteShareUrl || t('Lien indisponible')}</code>
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!inviteShareUrl} onClick={() => handleCopy(inviteShareUrl, t('Lien copié'))}><Copy size={14} strokeWidth={2} />{t('Copier')}</button>
</div>
<div className="contrib-invite-passwords">
<div className="contrib-role-row">
<span className="contrib-role-badge"><strong>{t('Mot de passe')}</strong><small>{t('pour regarder et ajouter')}</small></span>
{passwordMode ? (
<form className="contrib-pass-edit-inline" onSubmit={handleSavePassword}>
<input className="contrib-pass-edit-input" type="text" value={newPassword} placeholder={passwordMode === 'remember' ? t('Celui déjà donné à la famille') : t('8 caractères minimum')} aria-label={passwordMode === 'remember' ? t('Mot de passe actuel') : t('Nouveau mot de passe')} autoFocus autoComplete="off" onChange={(e) => setNewPassword(e.target.value)} />
<button type="submit" className="contrib-pill-btn contrib-pill-btn--save" disabled={inviteSaving}><PzBusy busy={inviteSaving} busyLabel={t('Enregistrement en cours')}>{passwordMode === 'remember' ? t('Vérifier') : t('Enregistrer')}</PzBusy></button>
<button type="button" className="contrib-pill-btn contrib-pill-btn--ghost" onClick={() => { setPasswordMode(''); setNewPassword(''); setInviteLocalError('') }}>{t('Annuler')}</button>
</form>
) : (
<>
<button type="button" className={`contrib-pass-reveal${knownShare && !showPassword ? ' contrib-pass-reveal--blurred' : ''}`} onClick={() => setShowPassword((v) => !v)} disabled={!knownShare} title={showPassword ? t('Cliquer pour masquer') : t('Cliquer pour révéler')}>{knownShare || t('Pas encore enregistré')}</button>
{knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(knownShare, t('Mot de passe copié'))}>{t('Copier')}</button>}
{!knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setPasswordMode('remember')}>{t("Saisir l'actuel")}</button>}
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setPasswordMode('change')}>{t('Changer')}</button>
</>
)}
</div>
<p className="contrib-pass-hint">
{passwordMode === 'remember' || (!knownShare && !passwordMode)
? t("Déjà donné à la famille ? Saisissez-le ici. Il est vérifié puis gardé, rien ne change pour ceux qui ont déjà ouvert l'arbre.")
: t("Changer le mot de passe oblige les personnes qui ont déjà ouvert l'arbre à saisir le nouveau.")}
</p>
</div>
{defaultInviteText && (
<div className="contrib-invite-message">
<div className="contrib-invite-message-row">
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(inviteText, t('Message copié'))}><Copy size={14} strokeWidth={2} />{t("Copier le message d'invitation")}</button>
<button type="button" className="contrib-link-btn" aria-expanded={showInviteMessage} aria-controls="contrib-invite-text" onClick={() => setShowInviteMessage((v) => !v)}>
{showInviteMessage ? t('Masquer le message') : t('Voir ou modifier le message')}
</button>
</div>
{showInviteMessage && (
<>
<label className="contrib-invite-message-label" htmlFor="contrib-invite-text">{t('Le message à envoyer')}</label>
<textarea id="contrib-invite-text" className="contrib-invite-textarea" rows={6} value={inviteText} onChange={(e) => setInviteText(e.target.value)} />
</>
)}
</div>
)}
{(inviteLocalError || inviteError) && <div className="contrib-error">{inviteLocalError || inviteError}</div>}
{inviteMessage && <div className="contrib-ok">{inviteMessage}</div>}
</div>
{/* ── En-tête sessions ── */}
<div className="contrib-list-header">
<div className="contrib-list-heading">
<h2 id="contrib-propositions">{t('Propositions à relire')}</h2>
<p>{t('Acceptez ou refusez chaque modification, puis appliquez vos choix.')}</p>
</div>
<div className="contrib-list-header-actions">
<span className="contrib-count">{sessions.length || 0}</span>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onRefresh} disabled={loading}><RefreshCw size={14} strokeWidth={2} />{t('Actualiser')}</button>
</div>
</div>
{errorMessage && <div className="contrib-error">{errorMessage}</div>}
{loading ? (
<div className="contrib-empty">{t('Chargement…')}</div>
) : sessions.length === 0 ? (
<div className="contrib-empty">{t("Rien à relire pour l'instant. Les propositions de la famille apparaîtront ici.")}</div>
) : (
<div className="contrib-list">
{sessions.map((session) => {
const summary = getSessionSummary(session.id, session.changes)
const isReviewing = reviewingSessionId === session.id
const hasDecisions = summary.approved + summary.rejected > 0
const isCollapsed = collapsedSessions.has(session.id)
return (
<div key={session.id} className="contrib-item contrib-item-stack">
{/* ── En-tête de session (cliquable pour collapse) ── */}
<div
className="contrib-item-main contrib-item-main--clickable"
onClick={() => toggleCollapse(session.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && toggleCollapse(session.id)}
>
<div className="contrib-item-top">
<strong className="contrib-session-title">{session.title || t('Proposition sans titre')}</strong>
<div className="contrib-session-top-right">
{formatSessionDate(session.createdAt) && (
<span className="contrib-session-date">{formatSessionDate(session.createdAt)}</span>
)}
<ChevronDown
size={16}
strokeWidth={2}
className={`contrib-collapse-icon${isCollapsed ? '' : ' contrib-collapse-icon--up'}`}
/>
</div>
</div>
<div className="contrib-item-meta">
<span className="contrib-meta-pill">{t('par {name}', { name: session.submittedByLabel || t('Contributeur') })}</span>
<span className="contrib-meta-sep">·</span>
<span className="contrib-meta-pill">{summary.total > 1 ? t('{n} modifications', { n: summary.total }) : t('{n} modification', { n: summary.total })}</span>
{hasDecisions && (
<>
<span className="contrib-meta-sep">·</span>
{summary.approved > 0 && <span className="contrib-decision-summary contrib-decision-summary--ok">{summary.approved > 1 ? t('{n} acceptées', { n: summary.approved }) : t('{n} acceptée', { n: summary.approved })}</span>}
{summary.rejected > 0 && <span className="contrib-decision-summary contrib-decision-summary--ko">{summary.rejected > 1 ? t('{n} refusées', { n: summary.rejected }) : t('{n} refusée', { n: summary.rejected })}</span>}
{summary.pending > 0 && <span className="contrib-decision-summary contrib-decision-summary--pending">{t('{n} en attente', { n: summary.pending })}</span>}
</>
)}
{summary.pending === 0 && summary.total > 0 && (
<span className="contrib-all-decided-badge">{t('Tout est relu')}</span>
)}
</div>
{session.comment && (
<p className="contrib-session-comment">{t('« {text} »', { text: session.comment })}</p>
)}
</div>
{/* ── Cartes de changement (collapsibles) ── */}
{!isCollapsed && (() => {
// Grouper les changements par entityType
const groupOrder = ['life_event', 'media', 'media_people', 'person', 'union', 'parent_child_link', 'annotation']
const groupLabels = { life_event: t('Moments'), media: t('Souvenirs'), media_people: t('Sur les photos'), person: t('Personnes'), union: t('Unions'), parent_child_link: t('Liens'), annotation: t('Annotations') }
const groupMap = {}
for (const ch of (session.changes || [])) {
const key = ch.entityType || 'other'
if (!groupMap[key]) groupMap[key] = []
groupMap[key].push(ch)
}
const orderedKeys = [
...groupOrder.filter((k) => groupMap[k]),
...Object.keys(groupMap).filter((k) => !groupOrder.includes(k)),
]
const hasMultipleGroups = orderedKeys.length > 1
const renderChangeCard = (change) => {
const decision = getChangeDecision(session.id, change.id)
const isApproved = decision === 'approved'
const isRejected = decision === 'rejected'
const personName = getPersonDisplayName(change, session.changes || [])
const fields = getFieldRows(change)
const annPreview = change.entityType === 'annotation' ? getAnnotationPreview(change, treeId) : null
const mediaPreview = change.entityType === 'media' ? getMediaPreview(change, treeId) : null
const momentPreview = change.entityType === 'life_event' ? getMomentPreview(change) : null
const peoplePreview = change.entityType === 'media_people' ? getMediaPeoplePreview(change, treeId) : null
return (
<div
key={change.id}
className={`contrib-change-card${isApproved ? ' contrib-change-card--approved' : ''}${isRejected ? ' contrib-change-card--rejected' : ''}`}
>
<div className="contrib-change-card-head">
<span className={`contrib-action-badge contrib-action-badge--${change.action}`}>
{formatAction(change.action)}
</span>
<span className="contrib-change-entity-name">
{personName || formatEntityType(change.entityType)}
</span>
{change.conflictState === 'needs_review' && (
<span className="contrib-conflict-badge">{t('À vérifier')}</span>
)}
<div className="contrib-card-decisions">
<button
type="button"
className={`contrib-decision-icon contrib-decision-icon--approve${isApproved ? ' active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleChangeDecision(session.id, change.id, 'approved') }}
disabled={isReviewing}
title={t('Accepter')} aria-label={t('Accepter')}
>
<Check size={13} strokeWidth={2.5} />
</button>
<button
type="button"
className={`contrib-decision-icon contrib-decision-icon--reject${isRejected ? ' active' : ''}`}
onClick={(e) => { e.stopPropagation(); toggleChangeDecision(session.id, change.id, 'rejected') }}
disabled={isReviewing}
title={t('Refuser')} aria-label={t('Refuser')}
>
<X size={13} strokeWidth={2.5} />
</button>
</div>
</div>
{annPreview && (
<div className="contrib-annotation-preview">
{annPreview.type === 'sticker' && <span className="contrib-ann-emoji">{annPreview.emoji}</span>}
{annPreview.type === 'text' && <span className="contrib-ann-text">{t('« {text} »', { text: (annPreview.text || '').slice(0, 120) })}</span>}
{annPreview.type === 'drawing' && (
<span className="contrib-ann-drawing" style={{ borderLeftColor: annPreview.color }}>{t('Dessin libre')}</span>
)}
{annPreview.type === 'photo' && annPreview.url && (
<img src={annPreview.url} className="contrib-ann-photo" alt={t('Photo canvas')} onError={(e) => { e.currentTarget.style.display = 'none' }} />
)}
{annPreview.type === 'photo' && !annPreview.url && (
<span className="contrib-ann-text">{t('Photo (aperçu indisponible)')}</span>
)}
</div>
)}
{mediaPreview && (
<div className="contrib-media-preview">
{mediaPreview.type === 'photo' && mediaPreview.url && (
<a href={mediaPreview.url} target="_blank" rel="noreferrer" className="contrib-media-photo">
<img src={mediaPreview.url} alt={mediaPreview.caption || t('Photo proposée')} onError={(e) => { e.currentTarget.style.display = 'none' }} />
</a>
)}
{mediaPreview.type !== 'photo' && mediaPreview.type !== 'citation' && mediaPreview.url && (
<a href={mediaPreview.url} target="_blank" rel="noreferrer" className="pz-link">{t('Ouvrir le fichier')}</a>
)}
{mediaPreview.caption && (
<p className="contrib-media-caption">{mediaPreview.type === 'citation' ? t('« {text} »', { text: mediaPreview.caption }) : mediaPreview.caption}</p>
)}
</div>
)}
{peoplePreview && (
<div className="contrib-media-preview">
{peoplePreview.url && (
<a href={peoplePreview.url} target="_blank" rel="noreferrer" className="contrib-media-photo">
<img src={peoplePreview.url} alt={t('Photo du souvenir')} onError={(e) => { e.currentTarget.style.display = 'none' }} />
</a>
)}
<p className="contrib-media-caption">
{peoplePreview.after.length ? t('Sur la photo, {names}', { names: peoplePreview.after.join(', ') }) : t('Plus personne sur la photo')}
</p>
{peoplePreview.before.length > 0 && (
<p className="contrib-media-caption">{t('Avant, {names}', { names: peoplePreview.before.join(', ') })}</p>
)}
</div>
)}
{momentPreview && (
<div className="contrib-moment-preview">
{momentPreview.when && <span>{momentPreview.when}</span>}
{momentPreview.place && <span>{momentPreview.place}</span>}
{momentPreview.notes && <p>{momentPreview.notes}</p>}
{momentPreview.sources && <p className="contrib-moment-source"><span>{t('Source')}</span> {momentPreview.sources}</p>}
</div>
)}
{fields.length > 0 && (
<div className="contrib-change-fields">
{fields.map(({ key, label, oldVal, newVal }) => (
<div key={key} className="contrib-field-row">
<span className="contrib-field-label">{label}</span>
{oldVal !== undefined ? (
<span className="contrib-field-diff">
<span className="contrib-field-old">{displayVal(oldVal, key) ?? t('vide')}</span>
<span className="contrib-field-arrow">→</span>
<span className="contrib-field-new">{displayVal(newVal, key) ?? t('vide')}</span>
</span>
) : (
<span className="contrib-field-new">{displayVal(newVal, key) ?? t('vide')}</span>
)}
</div>
))}
</div>
)}
</div>
)
}
return (
<>
<div className="contrib-change-list">
{orderedKeys.map((groupKey) => (
<div key={groupKey} className="contrib-change-group">
{hasMultipleGroups && (
<div className="contrib-group-header">{groupLabels[groupKey] || groupKey}</div>
)}
{groupMap[groupKey].map(renderChangeCard)}
</div>
))}
</div>
{/* ── Actions de session ── */}
<div className="contrib-session-footer">
{hasDecisions ? (
<>
<button
type="button"
className="contrib-apply-btn"
onClick={() => applyDecisions(session.id, session.changes)}
disabled={isReviewing}
>
<PzBusy busy={isReviewing} busyLabel={t('Application en cours')}>{summary.pending > 1
? t('Appliquer mes choix, {n} sans réponse seront refusées', { n: summary.pending })
: summary.pending === 1
? t('Appliquer mes choix, {n} sans réponse sera refusée', { n: summary.pending })
: t('Appliquer mes choix')}</PzBusy>
</button>
<div className="contrib-session-quick">
{confirmApproveAll === session.id ? (
<div className="contrib-confirm-approve">
<span>{t('Tout accepter ?')}</span>
<button type="button" className="contrib-confirm-yes" onClick={() => { setConfirmApproveAll(null); onReviewSession(session.id, { decision: 'approved' }) }} disabled={isReviewing}>{t('Oui')}</button>
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmApproveAll(null)}>{t('Non')}</button>
</div>
) : (
<button type="button" className="contrib-quick-btn" onClick={() => setConfirmApproveAll(session.id)} disabled={isReviewing}>{t('Tout accepter')}</button>
)}
<button type="button" className="contrib-quick-btn" onClick={() => onReviewSession(session.id, { decision: 'rejected' })} disabled={isReviewing}>{t('Tout refuser')}</button>
</div>
</>
) : (
<div className="contrib-session-quick contrib-session-quick--full">
{confirmApproveAll === session.id ? (
<div className="contrib-confirm-approve">
<span>{t('Accepter toutes les modifications de cette proposition ?')}</span>
<button type="button" className="contrib-confirm-yes" onClick={() => { setConfirmApproveAll(null); onReviewSession(session.id, { decision: 'approved' }) }} disabled={isReviewing}>{t('Oui, tout accepter')}</button>
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmApproveAll(null)}>{t('Annuler')}</button>
</div>
) : (
<button
type="button"
className="contrib-apply-btn contrib-apply-btn--approve"
onClick={() => setConfirmApproveAll(session.id)}
disabled={isReviewing}
>
<PzBusy busy={isReviewing} busyLabel={t('Application en cours')}>{t('Tout accepter')}</PzBusy>
</button>
)}
<button type="button" className="contrib-quick-btn" onClick={() => onReviewSession(session.id, { decision: 'rejected' })} disabled={isReviewing}>{t('Tout refuser')}</button>
</div>
)}
</div>
</>
)
})()}
</div>
)
})}
</div>
)}
</div>
) : (
<div className="contrib-readonly">
{t("Ce panneau est réservé à l'administration de l'arbre.")}
</div>
)}
</div>
</div>
)
}
export default ContributionPanel