mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 00:30:50 +00:00
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>
458 lines
17 KiB
React
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
|