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 (
onClose?.()}>
e.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="contribPanelTitle" >

{familyTitleBefore}{t('participe')}{familyTitleAfter}

{canModerate ? (
{/* ── Section invitation ── */}
{inviteFamilyName ? t('Le lien à envoyer à la famille {name}', { name: inviteFamilyName }) : t('Le lien à envoyer à la famille')} {copyFeedback && {copyFeedback}}
{inviteShareUrl || t('Lien indisponible')}
{t('Mot de passe')}{t('pour regarder et ajouter')} {passwordMode ? (
setNewPassword(e.target.value)} />
) : ( <> {knownShare && } {!knownShare && } )}

{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.")}

{defaultInviteText && (
{showInviteMessage && ( <>