Files
parenthese/frontend/src/components/AdminPanel.jsx
T
IliesAllaliandClaude Opus 5.5 afe4a6a6d1 Le rond qui attend : le symbole du logo devient l'indicateur de chargement
Pendant une attente, le rond se divise en trois points qui respirent,
puis se referme avec un léger rebond quand c'est prêt.

- App : écran de démarrage (BootLoader, visible seulement au-delà de 400 ms,
  au moins 900 ms pour ne pas clignoter), remplace « Chargement de l'arbre
  généalogique... ».
- App : les boutons en attente (Création…, Envoi…, Un instant…) montrent
  trois points à la place du libellé, largeur figée, texte gardé pour les
  lecteurs d'écran (PzBusy).
- Landing : chargement de la démo intégrée.
- Réduire les animations : points sur place, fondu seul.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-25 12:12:58 +02:00

458 lines
17 KiB
React

import { useEffect, useRef, useState } from 'react'
import { Download, Trash2, X } from 'lucide-react'
import './SettingsPanels.css'
import {
deleteTree,
exportGedcom,
fetchTreeSettings,
getTree,
importGedcom,
updateTree,
updateTreeSettings,
} from '../api/treeApi'
import PzBusy from './PzBusy.jsx'
function normalizeSlug(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const AdminPanel = ({
visible,
treeId,
authToken,
onClose,
onTreeDeleted,
onGedcomImported,
}) => {
const [section, setSection] = useState('tree')
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [errorMessage, setErrorMessage] = useState('')
const [successMessage, setSuccessMessage] = useState('')
const [tree, setTree] = useState(null)
const [treeName, setTreeName] = useState('')
const [treeDescription, setTreeDescription] = useState('')
// Contributions de la famille : 'pending' = validées par l'admin, 'direct' = appliquées immédiatement
const [contributionPolicy, setContributionPolicy] = useState('pending')
const [savingPolicy, setSavingPolicy] = useState(false)
const [showDeleteModal, setShowDeleteModal] = useState(false)
const [deleteConfirmName, setDeleteConfirmName] = useState('')
const [gedcomFile, setGedcomFile] = useState(null)
const [importingGedcom, setImportingGedcom] = useState(false)
const gedcomInputRef = useRef(null)
useEffect(() => {
if (!visible || !treeId || !authToken) {
return
}
let active = true
setLoading(true)
setErrorMessage('')
setSuccessMessage('')
setSection('tree')
setShowDeleteModal(false)
setDeleteConfirmName('')
setGedcomFile(null)
setImportingGedcom(false)
fetchTreeSettings(treeId, authToken)
.then((settings) => {
if (!active || !settings) return
setContributionPolicy(settings.contributorPolicy === 'direct' ? 'direct' : 'pending')
})
.catch(() => {
// Réglage non chargé : on laisse la valeur par défaut affichée
})
getTree(treeId, authToken)
.then((result) => {
if (!active) return
const loadedTree = result?.tree || null
setTree(loadedTree)
setTreeName(loadedTree?.name || '')
setTreeDescription(loadedTree?.description || '')
})
.catch(() => {
if (!active) return
setErrorMessage("Impossible de charger les paramètres de cet arbre.")
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [visible, treeId, authToken])
const handlePolicyChange = async (nextPolicy) => {
if (!treeId || !authToken || savingPolicy || nextPolicy === contributionPolicy) return
const previous = contributionPolicy
setContributionPolicy(nextPolicy)
setSavingPolicy(true)
setErrorMessage('')
setSuccessMessage('')
try {
// Un seul réglage pour la famille, avec ou sans compte
await updateTreeSettings(treeId, { contributorPolicy: nextPolicy, memberContributionPolicy: nextPolicy }, authToken)
setSuccessMessage(nextPolicy === 'direct'
? 'Les modifications de la famille sont désormais appliquées directement.'
: 'Vous validez désormais chaque proposition de la famille.')
} catch {
setContributionPolicy(previous)
setErrorMessage("Impossible d'enregistrer ce réglage.")
} finally {
setSavingPolicy(false)
}
}
const handleSaveTree = async (event) => {
event.preventDefault()
if (!treeId || !authToken || saving || !tree) return
const nextName = treeName.trim()
if (nextName.length < 2) {
setErrorMessage("Le nom de l'arbre est trop court.")
return
}
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
const payload = {
name: nextName,
description: treeDescription.trim() || null,
}
const normalized = normalizeSlug(nextName)
const slugWillChange = normalized && normalized !== tree.slug
if (slugWillChange) {
const confirmSlugUpdate = window.confirm(
'Souhaitez-vous mettre à jour aussi le lien de partage avec ce nouveau nom ?',
)
if (confirmSlugUpdate) {
payload.slug = normalized
}
}
const updated = await updateTree(treeId, payload, authToken)
setTree(updated)
setTreeName(updated?.name || nextName)
setTreeDescription(updated?.description || '')
setSuccessMessage('Modifications enregistrées.')
} catch {
setErrorMessage("Impossible d'enregistrer les modifications.")
} finally {
setSaving(false)
}
}
const handleExportGedcom = async () => {
if (!treeId || !authToken || saving) return
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
const { blob, filename } = await exportGedcom(treeId, authToken)
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
URL.revokeObjectURL(url)
} catch {
setErrorMessage('Échec du téléchargement GEDCOM.')
} finally {
setSaving(false)
}
}
const readFileAsText = (file) => new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(String(reader.result || ''))
reader.onerror = () => reject(new Error('read_failed'))
reader.readAsText(file)
})
const handleImportGedcom = async () => {
if (!treeId || !authToken || saving || !gedcomFile) {
return
}
setSaving(true)
setImportingGedcom(true)
setErrorMessage('')
setSuccessMessage('')
try {
const fileContent = await readFileAsText(gedcomFile)
if (!fileContent.trim()) {
setErrorMessage("Le fichier GEDCOM est vide.")
return
}
const result = await importGedcom(treeId, fileContent, authToken)
const personsCreated = Number(result?.personsCreated || 0)
const unionsCreated = Number(result?.unionsCreated || 0)
const linksCreated = Number(result?.linksCreated || 0)
setSuccessMessage(
`Import GEDCOM terminé : ${personsCreated} personnes, ${unionsCreated} unions, ${linksCreated} filiations.`,
)
setGedcomFile(null)
if (gedcomInputRef.current) {
gedcomInputRef.current.value = ''
}
await onGedcomImported?.(result)
} catch {
setErrorMessage("Impossible d'importer ce fichier GEDCOM.")
} finally {
setImportingGedcom(false)
setSaving(false)
}
}
const handleDeleteTree = async () => {
if (!treeId || !authToken || saving || !tree) return
if (deleteConfirmName.trim() !== tree.name) {
setErrorMessage('Le nom saisi ne correspond pas.')
return
}
setSaving(true)
setErrorMessage('')
setSuccessMessage('')
try {
await deleteTree(treeId, authToken)
setShowDeleteModal(false)
onTreeDeleted?.(treeId)
onClose?.()
} catch {
setErrorMessage('Impossible de supprimer cet arbre.')
} finally {
setSaving(false)
}
}
if (!visible) return null
return (
<div
className="pz-overlay tree-settings-overlay"
onClick={() => {
if (!showDeleteModal) onClose?.()
}}
>
<div
className="pz-modal pz-modal--wide tree-settings-card"
onClick={(event) => event.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="treeSettingsTitle"
>
<div className="pz-modal-head">
<p className="pz-eyebrow">Paramètres de l'arbre</p>
<h1 id="treeSettingsTitle" className="pz-title">{tree?.name || 'Votre arbre'}</h1>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label="Fermer">
<X size={18} aria-hidden="true" />
</button>
<div className="pz-tabs" role="tablist">
<button type="button" role="tab" aria-selected={section === 'tree'} className="pz-tab" onClick={() => setSection('tree')}>Votre arbre</button>
<button type="button" role="tab" aria-selected={section === 'data'} className="pz-tab" onClick={() => setSection('data')}>Données</button>
<button type="button" role="tab" aria-selected={section === 'danger'} className="pz-tab" onClick={() => setSection('danger')}>Avancé</button>
</div>
<div className="ts-content">
{loading ? (
<p className="pz-small ts-loading">Chargement…</p>
) : (
<>
{errorMessage && <div className="pz-error" role="alert">{errorMessage}</div>}
{successMessage && <div className="pz-success" role="status">{successMessage}</div>}
{section === 'tree' && (
<form className="ts-panel" onSubmit={handleSaveTree}>
<div className="pz-field">
<label htmlFor="settingsTreeName">Nom de l'arbre</label>
<input
id="settingsTreeName"
type="text"
value={treeName}
maxLength={120}
onChange={(event) => setTreeName(event.target.value)}
required
/>
</div>
<div className="pz-field">
<label htmlFor="settingsTreeDescription">Description <span className="ts-optional">facultatif</span></label>
<input
id="settingsTreeDescription"
type="text"
value={treeDescription}
maxLength={500}
onChange={(event) => setTreeDescription(event.target.value)}
placeholder="Quelques mots pour accueillir la famille"
/>
</div>
<div className="ts-actions">
<button type="submit" className="pz-btn pz-btn--primary" disabled={saving}>
<PzBusy busy={saving} busyLabel="Enregistrement en cours">Enregistrer</PzBusy>
</button>
</div>
<fieldset className="ts-policy" disabled={savingPolicy}>
<legend className="pz-eyebrow">Contributions de la famille</legend>
<p className="pz-small">Ce que deviennent les ajouts et corrections faits avec le mot de passe de contribution.</p>
<label className={`ts-choice ${contributionPolicy === 'pending' ? 'is-active' : ''}`}>
<input
type="radio"
name="contributionPolicy"
value="pending"
checked={contributionPolicy === 'pending'}
onChange={() => handlePolicyChange('pending')}
/>
<span className="ts-choice-text">
<strong>Je valide chaque proposition</strong>
<small>Rien n'apparaît dans l'arbre avant votre accord.</small>
</span>
</label>
<label className={`ts-choice ${contributionPolicy === 'direct' ? 'is-active' : ''}`}>
<input
type="radio"
name="contributionPolicy"
value="direct"
checked={contributionPolicy === 'direct'}
onChange={() => handlePolicyChange('direct')}
/>
<span className="ts-choice-text">
<strong>Les modifications s'appliquent tout de suite</strong>
<small>La famille modifie l'arbre sans attendre. Vous gardez l'historique.</small>
</span>
</label>
</fieldset>
</form>
)}
{section === 'data' && (
<div className="ts-panel">
<div className="ts-row">
<div className="ts-row-text">
<strong>Importer un fichier GEDCOM</strong>
<p className="pz-small">Ajoute les personnes et les liens du fichier à cet arbre.</p>
{gedcomFile && <span className="pz-tag pz-tag--accent ts-file">{gedcomFile.name}</span>}
</div>
<div className="ts-row-actions">
<input
ref={gedcomInputRef}
className="ts-file-input"
type="file"
accept=".ged,text/x-gedcom,text/plain"
onChange={(event) => setGedcomFile(event.target.files?.[0] || null)}
/>
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={() => gedcomInputRef.current?.click()} disabled={saving}>
Choisir un fichier
</button>
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={handleImportGedcom} disabled={saving || !gedcomFile}>
<PzBusy busy={importingGedcom} busyLabel="Import en cours">Importer</PzBusy>
</button>
</div>
</div>
<div className="ts-row">
<div className="ts-row-text">
<strong>Télécharger en GEDCOM</strong>
<p className="pz-small">Le format standard, lisible par les autres logiciels d'arbre.</p>
</div>
<div className="ts-row-actions">
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={handleExportGedcom} disabled={saving}>
<Download size={15} aria-hidden="true" />
Télécharger .ged
</button>
</div>
</div>
<div className="ts-row ts-row--muted">
<div className="ts-row-text">
<strong>Sauvegarde complète</strong>
<p className="pz-small">Toutes les données et les médias. Bientôt disponible.</p>
</div>
<div className="ts-row-actions">
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" disabled>
Télécharger .zip
</button>
</div>
</div>
</div>
)}
{section === 'danger' && (
<div className="ts-panel">
<div className="ts-danger">
<div className="ts-row-text">
<strong>Supprimer cet arbre</strong>
<p className="pz-small">Toutes les personnes, les souvenirs et les médias sont effacés, pour toute la famille. C'est définitif.</p>
</div>
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm danger-action" onClick={() => setShowDeleteModal(true)}>
<Trash2 size={15} aria-hidden="true" />
Supprimer cet arbre
</button>
</div>
</div>
)}
</>
)}
</div>
</div>
{showDeleteModal && (
<div className="pz-overlay ts-delete-overlay" onClick={(event) => { event.stopPropagation(); if (!saving) setShowDeleteModal(false) }}>
<div className="pz-modal settings-delete-modal" role="dialog" aria-modal="true" aria-labelledby="treeDeleteTitle" onClick={(event) => event.stopPropagation()}>
<div className="pz-modal-head">
<p className="pz-eyebrow">Dernière vérification</p>
<h3 id="treeDeleteTitle" className="pz-title">Supprimer « {tree?.name} »</h3>
<p className="pz-sub">Tapez le nom exact de l'arbre pour confirmer.</p>
</div>
<div className="pz-field">
<label htmlFor="treeDeleteConfirm">Nom de l'arbre</label>
<input
id="treeDeleteConfirm"
type="text"
value={deleteConfirmName}
onChange={(event) => setDeleteConfirmName(event.target.value)}
placeholder={tree?.name || ''}
autoComplete="off"
/>
</div>
<div className="pz-modal-actions">
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setShowDeleteModal(false)} disabled={saving}>
Annuler
</button>
<button type="button" className="pz-btn pz-btn--danger danger-action" onClick={handleDeleteTree} disabled={saving}>
<PzBusy busy={saving} busyLabel="Suppression en cours">Supprimer définitivement</PzBusy>
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminPanel