mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
- 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>
764 lines
41 KiB
React
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
|