mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-10 07:52:13 +00:00
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>
755 lines
40 KiB
React
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
|