mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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>
808 lines
35 KiB
React
808 lines
35 KiB
React
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
|