Files
parenthese/frontend/src/components/ContributionPanel.jsx
T
IliesAllaliandClaude Opus 5.5 42e4ed63bb Lien d'un onglet de famille : ?famille=<charnière> ouvre l'arbre sur cette famille, et le lien de « La famille participe » suit l'onglet ouvert (#167)
- Galaxy : l'onglet du lien s'ouvre dès que l'arbre et ses onglets sont connus, et de nouveau si la personne de départ arrive après (visiteur), tant qu'on n'a pas changé d'onglet soi-même ; onglets coupés dans ce navigateur, l'arbre entier s'ouvre sur la charnière
- App : l'adresse de la page porte l'onglet ouvert, le lien à envoyer aussi, avec le nom de la famille dans le titre
- Même mot de passe, même accès : le lien choisit l'onglet d'arrivée, il ne restreint rien (Patch, 26/10)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:21:36 +01:00

764 lines
41 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'),
toVerify: t('À vérifier'),
}
function displayVal(v, key) {
// Sexe : « Non précisé » plutôt que « vide »
if (key === 'sex') return sexLabel(v)
if (key === 'toVerify') return v ? t('Oui') : t('Non')
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,
// Onglet de famille ouvert : le lien ouvre l'arbre sur cette famille
inviteFamilyName = '',
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">{inviteFamilyName ? t('Le lien à envoyer à la famille {name}', { name: inviteFamilyName }) : 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' : ''}`}
>
{/* Étiquette posée sur le bord haut de la carte. Une fiche changée depuis la proposition dit « À vérifier »
à la place de « Modification » ; toute autre action garde son mot (une suppression reste lisible) */}
{change.conflictState === 'needs_review' ? (
<span className="contrib-action-badge contrib-action-badge--conflict" title={t('La fiche a changé depuis cette proposition')}>
{change.action === 'update' ? t('À vérifier') : t('{action}, à vérifier', { action: formatAction(change.action) })}
</span>
) : (
<span className={`contrib-action-badge contrib-action-badge--${change.action}`}>
{formatAction(change.action)}
</span>
)}
<div className="contrib-change-card-head">
<span className="contrib-change-entity-name">
{personName || formatEntityType(change.entityType)}
</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