import { useEffect, useRef, useState } from 'react' import { Download, ImagePlus, Trash2, Unplug, X } from 'lucide-react' import './SettingsPanels.css' import { deleteTree, exportGedcom, fetchTreeSettings, getTree, importGedcom, updateTree, updateTreeSettings, } from '../api/treeApi' import { checkImmichStatus, fetchImmichConfig, getImmichErrorMessage, removeImmichConfig, saveImmichConfig, } from '../api/immichApi.js' import PzBusy from './PzBusy.jsx' import TreeVisitsSection from './TreeVisitsSection.jsx' import { t } from '../i18n/index.js' import { readGedcomFile } from '../utils/gedcomText.js' import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js' 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, isAdmin = false, onClose, onTreeDeleted, onGedcomImported, onOpenPhotoStart, }) => { 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) // Connexion Immich propre à cet arbre : l'adresse se choisit parmi celles autorisées par // l'opérateur du serveur, la clé n'est jamais relue et le champ vide la conserve. const [immichConfig, setImmichConfig] = useState(null) const [immichInstances, setImmichInstances] = useState([]) const [immichInstanceId, setImmichInstanceId] = useState('') const [immichApiKey, setImmichApiKey] = useState('') const [immichStatus, setImmichStatus] = useState(null) // null : inconnu, 'ok', sinon le motif const [immichBusy, setImmichBusy] = useState(false) useEffect(() => { if (!visible || !treeId || !authToken) { return } let active = true setLoading(true) setErrorMessage('') setSuccessMessage('') setSection('tree') setShowDeleteModal(false) setDeleteConfirmName('') setGedcomFile(null) setImportingGedcom(false) setImmichConfig(null) setImmichInstances([]) setImmichInstanceId('') setImmichApiKey('') setImmichStatus(null) // Réservé aux administrateurs de l'arbre. Le panneau attend cette réponse avant de s'afficher : // la section Immich n'apparaît pas après coup et la zone de suppression ne bouge pas. const immichLoad = isAdmin ? fetchImmichConfig(treeId, authToken) .then(({ config, instances }) => { if (!active) return setImmichConfig(config) setImmichInstances(instances) setImmichInstanceId(instances.some((instance) => instance.id === config?.instanceId) ? config.instanceId : '') }) .catch(() => { // Configuration non chargée : la section Immich reste masquée }) : Promise.resolve() 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 }) const treeLoad = 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(t('Impossible de charger les paramètres de cet arbre.')) }) Promise.all([treeLoad, immichLoad]).finally(() => { if (active) setLoading(false) }) return () => { active = false } }, [visible, treeId, authToken, isAdmin]) 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' ? t('Les modifications de la famille sont désormais appliquées directement.') : t('Vous validez désormais chaque proposition de la famille.')) } catch { setContributionPolicy(previous) setErrorMessage(t("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(t("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( t('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(t('Modifications enregistrées.')) } catch { setErrorMessage(t("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(t('Échec du téléchargement GEDCOM.')) } finally { setSaving(false) } } // Lu avec le bon encodage (ANSI de Heredis, ANSEL, Mac…), sinon les accents arrivent cassés const readFileAsText = async (file) => (await readGedcomFile(file)).text const handleImportGedcom = async () => { if (!treeId || !authToken || saving || !gedcomFile) { return } setSaving(true) setImportingGedcom(true) setErrorMessage('') setSuccessMessage('') try { // Un zip apporte le .ged et ses photos : le .ged s'importe, les photos passent à l'étape des visages let bundle try { bundle = await readGedcomBundle(gedcomFile) } catch (error) { setErrorMessage(error?.code === 'no_gedcom' ? error.message : zipErrorMessage(error)) return } const fileContent = await readFileAsText(bundle.gedcomFile) if (!fileContent.trim()) { setErrorMessage(t('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( t('Import GEDCOM terminé : {persons} personnes, {unions} unions, {links} filiations.', { persons: personsCreated, unions: unionsCreated, links: linksCreated, }), ) setGedcomFile(null) if (gedcomInputRef.current) { gedcomInputRef.current.value = '' } await onGedcomImported?.(result, null, bundle.bundledFiles) } catch { setErrorMessage(t("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(t('Le nom saisi ne correspond pas.')) return } setSaving(true) setErrorMessage('') setSuccessMessage('') try { await deleteTree(treeId, authToken) setShowDeleteModal(false) onTreeDeleted?.(treeId) onClose?.() } catch { setErrorMessage(t('Impossible de supprimer cet arbre.')) } finally { setSaving(false) } } // ── Connexion Immich de l'arbre ────────────────────────────────────────────── const testImmichConnection = async () => { if (!treeId || !authToken) return setImmichStatus('testing') try { const status = await checkImmichStatus(treeId, authToken) setImmichStatus(status?.available ? 'ok' : (status?.reason || 'unreachable')) } catch (err) { setImmichStatus('error') setErrorMessage(getImmichErrorMessage(err, t)) } } const handleSaveImmichConfig = async (event) => { event.preventDefault() if (!treeId || !authToken || immichBusy) return const instanceId = immichInstanceId if (!instanceId) { setErrorMessage(t('Choisissez l’instance Immich.')) return } const apiKey = immichApiKey.trim() // Changer d'instance réclame une nouvelle clé : l'ancienne n'est ni conservée ni envoyée // au nouveau serveur. const changingInstance = Boolean(immichConfig?.configured && immichConfig.instanceId && immichConfig.instanceId !== instanceId) if (changingInstance && !apiKey) { setErrorMessage(t('L’instance Immich change : indiquez la clé d’accès de la nouvelle instance, l’ancienne n’est pas conservée.')) return } if (!immichConfig?.configured && !apiKey) { setErrorMessage(t('Indiquez la clé d’accès à Immich pour la première configuration.')) return } setImmichBusy(true) setErrorMessage('') setSuccessMessage('') try { const config = await saveImmichConfig(treeId, { instanceId, apiKey }, authToken) setImmichConfig(config || { configured: true, instanceId, label: null, updatedAt: null }) setImmichApiKey('') setSuccessMessage(t('Connexion Immich enregistrée.')) await testImmichConnection() } catch (err) { setErrorMessage(getImmichErrorMessage(err, t)) } finally { setImmichBusy(false) } } const handleRemoveImmichConfig = async () => { if (!treeId || !authToken || immichBusy || !(immichConfig?.configured || immichConfig?.keyNeedsReentry)) return if (!window.confirm(t('Supprimer la connexion Immich de cet arbre ? La reconnaissance des visages sera désactivée.'))) { return } setImmichBusy(true) setErrorMessage('') setSuccessMessage('') try { await removeImmichConfig(treeId, authToken) setImmichConfig({ configured: false, instanceId: null, label: null, updatedAt: null }) setImmichInstanceId('') setImmichApiKey('') setImmichStatus(null) setSuccessMessage(t('Connexion Immich supprimée.')) } catch (err) { setErrorMessage(getImmichErrorMessage(err, t)) } finally { setImmichBusy(false) } } if (!visible) return null return (
{ if (!showDeleteModal) onClose?.() }} >
event.stopPropagation()} role="dialog" aria-modal="true" aria-labelledby="treeSettingsTitle" >

{t("Paramètres de l'arbre")}

{tree?.name || t('Votre arbre')}

{loading ? (

{t('Chargement…')}

) : ( <> {errorMessage &&
{errorMessage}
} {successMessage &&
{successMessage}
} {section === 'visits' && } {section === 'tree' && (
setTreeName(event.target.value)} required />
setTreeDescription(event.target.value)} placeholder={t('Quelques mots pour accueillir la famille')} />
{t('Contributions de la famille')}

{t('Ce que deviennent les ajouts et corrections faits avec le mot de passe de contribution.')}

)} {section === 'data' && (
{t('Importer un fichier GEDCOM')}

{t('Ajoute les personnes et les liens du fichier à cet arbre. Un fichier .ged, ou un zip qui contient le .ged et ses photos.')}

{gedcomFile && {gedcomFile.name}}
setGedcomFile(event.target.files?.[0] || null)} />
{onOpenPhotoStart && (
{t('Ajouter des portraits')}

{t("Retrouver les photos de votre ancien logiciel, ou ajouter les premiers visages un par un.")}

)}
{t('Télécharger en GEDCOM')}

{t("Le format standard, lisible par les autres logiciels d'arbre.")}

{t('Sauvegarde complète')}

{t('Toutes les données et les médias. Bientôt disponible.')}

)} {section === 'danger' && immichInstances.length > 0 && (
{t('Reconnaissance des visages')}

{t('La connexion Immich est propre à cet arbre : ses administrateurs choisissent une instance autorisée par l’opérateur du serveur et fournissent leur propre clé d’accès.')}

{immichConfig.configured ? t('Configurée') : immichConfig.keyNeedsReentry ? t('Clé à ressaisir') : t('Non configurée')} {immichConfig?.keyNeedsReentry && ( {t('La clé enregistrée ne peut plus être lue (le secret du serveur a changé). Ressaisissez-la pour réactiver la connexion.')} )} {immichConfig?.configured && ( {immichStatus === 'ok' && t('Instance joignable.')} {immichStatus === 'testing' && t('Test en cours…')} {immichStatus === 'not_configured' && t('Immich n’est pas configuré pour cet arbre.')} {immichStatus === 'unreachable' && t('Immich est injoignable.')} {immichStatus === 'error' && t('Test impossible.')} {(immichStatus === null || immichStatus === undefined) && t('Connexion non testée depuis l’ouverture.')} )}
{immichConfig?.configured && (
)}
setImmichApiKey(event.target.value)} placeholder={immichConfig?.configured ? (immichConfig.instanceId && immichInstanceId !== immichConfig.instanceId ? t('Clé d’accès de la nouvelle instance requise') : t('Laisser vide pour conserver la clé actuelle')) : t('Clé créée dans Immich (Paramètres > Clés d’API)')} />
{(immichConfig?.configured || immichConfig?.keyNeedsReentry) && ( )}
)} {section === 'danger' && (
{t('Supprimer cet arbre')}

{t("Toutes les personnes, les souvenirs et les médias sont effacés, pour toute la famille. C'est définitif.")}

)} )}
{showDeleteModal && (
{ event.stopPropagation(); if (!saving) setShowDeleteModal(false) }}>
event.stopPropagation()}>

{t('Dernière vérification')}

{t('Supprimer « {name} »', { name: tree?.name ?? '' })}

{t("Tapez le nom exact de l'arbre pour confirmer.")}

setDeleteConfirmName(event.target.value)} placeholder={tree?.name || ''} autoComplete="off" />
)}
) } export default AdminPanel