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' 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, 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 [immichAllowedUrls, setImmichAllowedUrls] = useState([]) const [immichApiUrl, setImmichApiUrl] = 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) setImmichAllowedUrls([]) setImmichApiUrl('') setImmichApiKey('') setImmichStatus(null) fetchImmichConfig(treeId, authToken) .then(({ config, allowedApiUrls }) => { if (!active) return setImmichConfig(config) setImmichAllowedUrls(allowedApiUrls) setImmichApiUrl(allowedApiUrls.includes(config?.apiUrl) ? config.apiUrl : '') }) .catch(() => { // Configuration non chargée : l'onglet Immich affichera l'état inconnu }) 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(t('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' ? 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) } } 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(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) } 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 apiUrl = immichApiUrl if (!apiUrl) { setErrorMessage(t('Choisissez l’adresse de 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.apiUrl && immichConfig.apiUrl !== apiUrl) 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, { apiUrl, apiKey }, authToken) setImmichConfig(config || { configured: true, apiUrl, 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) 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, apiUrl: null, updatedAt: null }) setImmichApiUrl('') 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.')}

{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 === 'immich' && (
{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 === null ? t('État inconnu') : immichConfig.configured ? t('Configurée') : t('Non configurée')} {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 && (
)}
{immichAllowedUrls.length === 0 ? (

{immichConfig === null ? t('État inconnu') : t('Aucune instance Immich n’est autorisée par l’opérateur de ce serveur. La reconnaissance des visages reste désactivée.')}

) : (
setImmichApiKey(event.target.value)} placeholder={immichConfig?.configured ? (immichConfig.apiUrl && immichApiUrl !== immichConfig.apiUrl ? 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 && ( )}
)}
)} {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