Files
parenthese/frontend/src/components/AdminPanel.jsx
T
IliesAllaliandClaude Opus 5.5 5447965243 feat(import): importer un zip qui contient le GEDCOM et ses photos
Le zip est lu dans le navigateur, sans bibliotheque (table des matieres puis
DecompressionStream, fichier par fichier) : le .ged le plus haut s'importe, GEDZIP
compris, et les photos et actes passent directement a l'etape des visages, rapproches
comme un dossier et decompresses seulement a l'envoi. Messages clairs pour un zip sans
.ged, protege par mot de passe ou trop gros. Corrige aussi « citait 1 autres fichiers ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 12:40:32 +02:00

688 lines
28 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 { 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 (
<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" 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 === '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 ? (
<p className="pz-small ts-loading">{t('Chargement…')}</p>
) : (
<>
{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 === '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>
<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>
</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' && (
<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