Files
parenthese/frontend/src/components/AdminPanel.jsx
T
IliesAllaliandClaude Opus 5.5 642b6020c3 onglets : les porteurs du nom dans leur famille, proches de l'autre côté réglables
Un enfant d'un membre qui porte le nom d'un onglet entre dans cet onglet (nom de naissance d'abord,
homonymes sans lien laissés dehors), avec son couple ; l'autre parent est estompé. Nouveau réglage
d'affichage : proches de l'autre côté à 0, 1, 2 ou 3 liens, 1 par défaut. Libellés des réglages
d'affichage réécrits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:21 +01:00

808 lines
35 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 TreeManagersSection from './TreeManagersSection.jsx'
import { removeManager } from '../api/managersApi.js'
import { t } from '../i18n/index.js'
import { readGedcomFile } from '../utils/gedcomText.js'
import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js'
import Skeleton from './Skeleton.jsx'
function normalizeSlug(value) {
return String(value || '')
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
// Icônes proposées pour un arbre (Patch, 25/10)
const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '📖', '🕊️', '🌊', '⛰️', '🍂', '🌸', '🐚', '☀️']
const AdminPanel = ({
familyTabs = true,
onFamilyTabsChange = null,
tabKinDepth = 1,
onTabKinDepthChange = null,
fullNames = false,
onFullNamesChange = null,
visible,
treeId,
authToken,
isAdmin = false,
// Propriétaire : supprime l'arbre et gère les co-gestionnaires. Un co-gestionnaire peut seulement quitter.
isOwner = false,
currentUserId = null,
oidcName = '',
onClose,
onTreeDeleted,
onLeftTree,
onOpenShare,
onTreeUpdated,
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('')
const [treeIcon, setTreeIcon] = useState(null)
// 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 [confirmLeave, setConfirmLeave] = 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 || '')
setTreeIcon(loadedTree?.icon || null)
})
.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,
icon: treeIcon,
}
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 || '')
setTreeIcon(updated?.icon || null)
setSuccessMessage(t('Modifications enregistrées.'))
onTreeUpdated?.(updated)
} 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)
}
}
// Co-gestionnaire qui quitte : l'arbre disparaît de ses arbres, comme supprimé pour lui
const handleLeave = async () => {
if (!treeId || !authToken || saving || !currentUserId) return
setSaving(true)
setErrorMessage('')
try {
await removeManager(treeId, currentUserId, authToken)
onLeftTree?.(treeId)
onClose?.()
} catch {
setErrorMessage(t('Impossible de quitter la gestion de 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 (
<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">{t("Paramètres de l'arbre")}</p>
<h1 id="treeSettingsTitle" className="pz-title">{tree?.name || t('Votre arbre')}</h1>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')}>
<X size={18} aria-hidden="true" />
</button>
<div className="pz-tabs ts-tabs" role="tablist">
<button type="button" role="tab" aria-selected={section === 'tree'} className="pz-tab" onClick={() => setSection('tree')}>{t('Votre arbre')}</button>
<button type="button" role="tab" aria-selected={section === 'managers'} className="pz-tab" onClick={() => setSection('managers')}>{t('Gestion')}</button>
<button type="button" role="tab" aria-selected={section === 'visits'} className="pz-tab" onClick={() => setSection('visits')}>{t('Visites')}</button>
<button type="button" role="tab" aria-selected={section === 'data'} className="pz-tab" onClick={() => setSection('data')}>{t('Données')}</button>
<button type="button" role="tab" aria-selected={section === 'danger'} className="pz-tab" onClick={() => setSection('danger')}>{t('Avancé')}</button>
</div>
<div className="ts-content">
{loading ? (
<Skeleton variant="lines" count={4} />
) : (
<>
{errorMessage && <div className="pz-error" role="alert">{errorMessage}</div>}
{successMessage && <div className="pz-success" role="status">{successMessage}</div>}
{section === 'visits' && <TreeVisitsSection treeId={treeId} authToken={authToken} />}
{section === 'managers' && <TreeManagersSection treeId={treeId} authToken={authToken} currentUserId={currentUserId} oidcName={oidcName} onOpenShare={onOpenShare} />}
{section === 'tree' && (
<form className="ts-panel" onSubmit={handleSaveTree}>
<div className="pz-field">
<label htmlFor="settingsTreeName">{t("Nom de l'arbre")}</label>
<input
id="settingsTreeName"
type="text"
value={treeName}
maxLength={120}
onChange={(event) => setTreeName(event.target.value)}
required
/>
</div>
{/* Icône de l'arbre : un emoji à la place de la première lettre dans les menus et sur la page d'invitation */}
<div className="pz-field">
<span className="pz-label" id="settingsTreeIconLabel">{t("Icône de l'arbre")}</span>
<div className="ts-icons" role="radiogroup" aria-labelledby="settingsTreeIconLabel">
<button type="button" role="radio" aria-checked={!treeIcon} className="ts-icon ts-icon--letter" onClick={() => setTreeIcon(null)}>
{t('Première lettre')}
</button>
{TREE_ICONS.map((icon) => (
<button key={icon} type="button" role="radio" aria-checked={treeIcon === icon} className="ts-icon" onClick={() => setTreeIcon(icon)}>
{icon}
</button>
))}
</div>
<p className="pz-hint">{t("Elle remplace la première lettre dans vos menus et sur la page d'invitation.")}</p>
</div>
<div className="pz-field">
<label htmlFor="settingsTreeDescription">{t('Description')} <span className="ts-optional">{t('facultatif')}</span></label>
<input
id="settingsTreeDescription"
type="text"
value={treeDescription}
maxLength={500}
onChange={(event) => setTreeDescription(event.target.value)}
placeholder={t('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={t('Enregistrement en cours')}>{t('Enregistrer')}</PzBusy>
</button>
</div>
<fieldset className="ts-policy" disabled={savingPolicy}>
<legend className="pz-eyebrow">{t('Contributions de la famille')}</legend>
<p className="pz-small">{t('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>{t('Je valide chaque proposition')}</strong>
<small>{t("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>{t("Les modifications s'appliquent tout de suite")}</strong>
<small>{t("La famille modifie l'arbre sans attendre. Vous gardez l'historique.")}</small>
</span>
</label>
</fieldset>
<fieldset className="ts-policy">
<legend className="pz-eyebrow">{t('Affichage')}</legend>
<p className="pz-small">{t('Réglages de ce navigateur.')}</p>
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
<input
type="checkbox"
name="familyTabs"
checked={familyTabs}
onChange={(event) => onFamilyTabsChange?.(event.target.checked)}
/>
<span className="ts-choice-text">
<strong>{t('Séparer les belles-familles en onglets')}</strong>
<small>{t("Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.")}</small>
</span>
</label>
<div className="pz-field">
<span className="pz-label" id="settingsTabKinLabel">{t("Dans un onglet, montrer aussi les proches de l'autre côté")}</span>
<div className="ts-icons" role="radiogroup" aria-labelledby="settingsTabKinLabel">
{[[0, t('Ma ligne seulement')], [1, t('1 lien')], [2, t('2 liens')], [3, t('3 liens')]].map(([d, text]) => (
<button key={d} type="button" role="radio" aria-checked={tabKinDepth === d} disabled={!familyTabs} className="ts-icon ts-icon--letter" onClick={() => onTabKinDepthChange?.(d)}>
{text}
</button>
))}
</div>
<p className="pz-hint">{t('Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.')}</p>
</div>
<label className={`ts-choice ${fullNames ? 'is-active' : ''}`}>
<input
type="checkbox"
name="fullNames"
checked={fullNames}
onChange={(event) => onFullNamesChange?.(event.target.checked)}
/>
<span className="ts-choice-text">
<strong>{t('Afficher le nom de famille')}</strong>
<small>{t("Sous chaque prénom. Le nom de naissance s'il est connu.")}</small>
</span>
</label>
</fieldset>
</form>
)}
{section === 'data' && (
<div className="ts-panel">
<div className="ts-row">
<div className="ts-row-text">
<strong>{t('Importer un fichier GEDCOM')}</strong>
<p className="pz-small">{t('Ajoute les personnes et les liens du fichier à cet arbre. Un fichier .ged, ou un zip qui contient le .ged et ses photos.')}</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,.zip,.gdz,text/x-gedcom,text/plain,application/zip"
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}>
{t('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={t('Import en cours')}>{t('Importer')}</PzBusy>
</button>
</div>
</div>
{onOpenPhotoStart && (
<div className="ts-row">
<div className="ts-row-text">
<strong>{t('Ajouter des portraits')}</strong>
<p className="pz-small">{t("Retrouver les photos de votre ancien logiciel, ou ajouter les premiers visages un par un.")}</p>
</div>
<div className="ts-row-actions">
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={onOpenPhotoStart} disabled={saving}>
<ImagePlus size={15} aria-hidden="true" />
{t('Ajouter des portraits')}
</button>
</div>
</div>
)}
<div className="ts-row">
<div className="ts-row-text">
<strong>{t('Télécharger en GEDCOM')}</strong>
<p className="pz-small">{t("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('Télécharger .ged')}
</button>
</div>
</div>
<div className="ts-row ts-row--muted">
<div className="ts-row-text">
<strong>{t('Sauvegarde complète')}</strong>
<p className="pz-small">{t('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('Télécharger .zip')}
</button>
</div>
</div>
</div>
)}
{section === 'danger' && immichInstances.length > 0 && (
<div className="ts-panel">
<div className="ts-row">
<div className="ts-row-text">
<strong>{t('Reconnaissance des visages')}</strong>
<p className="pz-small">{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.')}</p>
<span className={`pz-tag ${immichConfig?.configured ? 'pz-tag--accent' : ''}`}>
{immichConfig.configured
? t('Configurée')
: immichConfig.keyNeedsReentry
? t('Clé à ressaisir')
: t('Non configurée')}
</span>
{immichConfig?.keyNeedsReentry && (
<span className="pz-small">
{t('La clé enregistrée ne peut plus être lue (le secret du serveur a changé). Ressaisissez-la pour réactiver la connexion.')}
</span>
)}
{immichConfig?.configured && (
<span className="pz-small">
{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.')}
</span>
)}
</div>
{immichConfig?.configured && (
<div className="ts-row-actions">
<button type="button" className="pz-btn pz-btn--secondary pz-btn--sm" onClick={testImmichConnection} disabled={immichBusy}>
{t('Tester la connexion')}
</button>
</div>
)}
</div>
<form className="ts-panel" onSubmit={handleSaveImmichConfig}>
<div className="pz-field">
<label htmlFor="immichInstance">{t('Instance Immich')}</label>
<select
id="immichInstance"
value={immichInstanceId}
onChange={(event) => setImmichInstanceId(event.target.value)}
required
>
<option value="">{t('Choisir une instance…')}</option>
{immichInstances.map((instance) => (
<option key={instance.id} value={instance.id}>{instance.label}</option>
))}
</select>
</div>
<div className="pz-field">
<label htmlFor="immichApiKey">{t('Clé d’accès Immich')}</label>
<input
id="immichApiKey"
type="password"
value={immichApiKey}
maxLength={512}
autoComplete="new-password"
onChange={(event) => 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)')}
/>
</div>
<div className="ts-actions ts-row-actions">
<button type="submit" className="pz-btn pz-btn--primary pz-btn--sm" disabled={immichBusy}>
<PzBusy busy={immichBusy} busyLabel={t('Enregistrement en cours')}>{t('Enregistrer')}</PzBusy>
</button>
{(immichConfig?.configured || immichConfig?.keyNeedsReentry) && (
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={handleRemoveImmichConfig} disabled={immichBusy}>
<Unplug size={15} aria-hidden="true" />
{t('Supprimer la connexion')}
</button>
)}
</div>
</form>
</div>
)}
{section === 'danger' && !isOwner && (
<div className="ts-panel">
<div className="ts-danger">
<div className="ts-row-text">
<strong>{t('Quitter la gestion de cet arbre')}</strong>
<p className="pz-small">{t('Vous ne pourrez plus le modifier. Seul son propriétaire peut le supprimer.')}</p>
</div>
{confirmLeave ? (
<div className="ts-row-actions">
<button type="button" className="contrib-confirm-no" onClick={() => setConfirmLeave(false)}>{t('Rester')}</button>
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleLeave} disabled={saving}>
<PzBusy busy={saving} busyLabel={t('En cours')}>{t('Quitter')}</PzBusy>
</button>
</div>
) : (
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setConfirmLeave(true)}>{t('Quitter')}</button>
)}
</div>
</div>
)}
{section === 'danger' && isOwner && (
<div className="ts-panel">
<div className="ts-danger">
<div className="ts-row-text">
<strong>{t('Supprimer cet arbre')}</strong>
<p className="pz-small">{t("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" />
{t('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">{t('Dernière vérification')}</p>
<h3 id="treeDeleteTitle" className="pz-title">{t('Supprimer « {name} »', { name: tree?.name ?? '' })}</h3>
<p className="pz-sub">{t("Tapez le nom exact de l'arbre pour confirmer.")}</p>
</div>
<div className="pz-field">
<label htmlFor="treeDeleteConfirm">{t("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}>
{t('Annuler')}
</button>
<button type="button" className="pz-btn pz-btn--danger danger-action" onClick={handleDeleteTree} disabled={saving}>
<PzBusy busy={saving} busyLabel={t('Suppression en cours')}>{t('Supprimer définitivement')}</PzBusy>
</button>
</div>
</div>
</div>
)}
</div>
)
}
export default AdminPanel