Merge pull request #169 from IliesAllali/feat/annuler-suppressions

Suppression d'une personne annulable (Annuler à la place de la confirmation)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-27 13:37:25 +01:00
commit 0b429e78a9
7 files changed
+182 -82

No files matched your search

+144 -48
View File
@@ -507,6 +507,61 @@ function readInvitationToken() {
const INVITATION_TOKEN = readInvitationToken()
let oidcSignIn = null
// Retire une personne du graphe en mémoire, avec ses unions, ses liens et ses médias. Renvoie les tableaux tels
// qu'ils étaient avant le retrait, ou null si la personne n'y était plus
function removePersonFromGraph(personId) {
if (!persons.some((candidate) => String(candidate.id) === String(personId))) return null
const snapshot = { persons: [...persons], unions: [...unions], filiations: [...filiations], medias: [...medias] }
for (let idx = persons.length - 1; idx >= 0; idx -= 1) {
if (String(persons[idx].id) === String(personId)) {
persons.splice(idx, 1)
}
}
const removedUnionIds = new Set()
for (let idx = unions.length - 1; idx >= 0; idx -= 1) {
const union = unions[idx]
if (
String(union.partner1Id) === String(personId)
|| String(union.partner2Id) === String(personId)
) {
removedUnionIds.add(union.id)
unions.splice(idx, 1)
}
}
for (let idx = filiations.length - 1; idx >= 0; idx -= 1) {
const filiation = filiations[idx]
const touchesPerson = (
String(filiation.childId) === String(personId)
|| String(filiation.parentId) === String(personId)
)
const touchesRemovedUnion = filiation.unionId && removedUnionIds.has(filiation.unionId)
if (touchesPerson || touchesRemovedUnion) {
filiations.splice(idx, 1)
}
}
for (let idx = medias.length - 1; idx >= 0; idx -= 1) {
if (String(medias[idx].personId) === String(personId)) {
medias.splice(idx, 1)
}
}
return snapshot
}
function restoreGraphSnapshot(snapshot) {
persons.splice(0, persons.length, ...snapshot.persons)
unions.splice(0, unions.length, ...snapshot.unions)
filiations.splice(0, filiations.length, ...snapshot.filiations)
medias.splice(0, medias.length, ...snapshot.medias)
}
// Délai pendant lequel une suppression reste annulable avant de partir au serveur
const UNDO_DELAY_MS = 6000
function App() {
const auth = useAuth()
const tree = useTreeAccess()
@@ -711,7 +766,7 @@ function App() {
const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode
// Recherche de personnes avec scoring
const search = usePersonSearch(persons)
const search = usePersonSearch(persons, tree.graphRevision)
// Mode édition avec auto-save
const editMode = useEditMode({
@@ -1919,59 +1974,92 @@ function App() {
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Suppression personne (mode édition, admin)
// Suppression d'une personne : elle disparaît tout de suite, et « Annuler » reste possible quelques secondes.
// Sur un vrai arbre, la requête ne part qu'à la fin du délai (ou à la fermeture de la page)
const pendingDeleteRef = useRef(null)
const [undoDelete, setUndoDelete] = useState(null)
const commitPendingDelete = useCallback(async (pending, { keepalive = false } = {}) => {
if (pendingDeleteRef.current !== pending) return
pendingDeleteRef.current = null
clearTimeout(pending.timer)
setUndoDelete(null)
if (!pending.treeId) return
try {
await deletePerson(pending.treeId, pending.personId, pending.accessToken, { keepalive })
if (!keepalive) await refreshTreeAndKeepSelection(null, { relayout: true })
} catch {
if (keepalive) return
await refreshTreeAndKeepSelection(null, { relayout: true })
setDraftNotice(t("{name} n'a pas pu être supprimé·e. Réessayez.", { name: pending.name }))
}
}, [refreshTreeAndKeepSelection])
const handleUndoDelete = useCallback(async () => {
const pending = pendingDeleteRef.current
if (!pending) return
pendingDeleteRef.current = null
clearTimeout(pending.timer)
setUndoDelete(null)
if (pending.treeId) {
await refreshTreeAndKeepSelection(pending.personId, { relayout: true })
return
}
restoreGraphSnapshot(pending.snapshot)
tree.bumpGraphRevision()
tree.setSelectedPerson(persons.find((candidate) => String(candidate.id) === String(pending.personId)) || null)
}, [refreshTreeAndKeepSelection]) // eslint-disable-line react-hooks/exhaustive-deps
// Un rechargement de l'arbre pendant le délai ramènerait la personne : on la retire à nouveau
useEffect(() => {
const pending = pendingDeleteRef.current
if (pending && removePersonFromGraph(pending.personId)) tree.bumpGraphRevision()
}, [tree.graphRevision]) // eslint-disable-line react-hooks/exhaustive-deps
// Page fermée pendant le délai : la suppression part quand même
useEffect(() => {
const flush = () => {
if (pendingDeleteRef.current) commitPendingDelete(pendingDeleteRef.current, { keepalive: true })
}
window.addEventListener('pagehide', flush)
return () => window.removeEventListener('pagehide', flush)
}, [commitPendingDelete])
// Ctrl+Z (Cmd+Z) annule, hors des champs de saisie
useEffect(() => {
if (!undoDelete) return undefined
const onKeyDown = (event) => {
if (!(event.ctrlKey || event.metaKey) || event.shiftKey || event.key.toLowerCase() !== 'z') return
if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return
event.preventDefault()
handleUndoDelete()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [undoDelete, handleUndoDelete])
const handlePersonDelete = useCallback(async (personId) => {
if (!tree.treeContext.treeId && !isDemoMode) return
if (isDemoMode) {
for (let idx = persons.length - 1; idx >= 0; idx -= 1) {
if (String(persons[idx].id) === String(personId)) {
persons.splice(idx, 1)
}
}
const ownTree = !isDemoMode && tree.canEditCurrentTree
if (isDemoMode || ownTree) {
const accessToken = ownTree ? (tree.treeContext.accessToken || auth.userAuth.token) : null
if (ownTree && !accessToken) return
const removedUnionIds = new Set()
for (let idx = unions.length - 1; idx >= 0; idx -= 1) {
const union = unions[idx]
if (
String(union.partner1Id) === String(personId)
|| String(union.partner2Id) === String(personId)
) {
removedUnionIds.add(union.id)
unions.splice(idx, 1)
}
}
for (let idx = filiations.length - 1; idx >= 0; idx -= 1) {
const filiation = filiations[idx]
const touchesPerson = (
String(filiation.childId) === String(personId)
|| String(filiation.parentId) === String(personId)
)
const touchesRemovedUnion = filiation.unionId && removedUnionIds.has(filiation.unionId)
if (touchesPerson || touchesRemovedUnion) {
filiations.splice(idx, 1)
}
}
for (let idx = medias.length - 1; idx >= 0; idx -= 1) {
if (String(medias[idx].personId) === String(personId)) {
medias.splice(idx, 1)
}
}
// Une seule suppression en attente : la précédente part maintenant
if (pendingDeleteRef.current) commitPendingDelete(pendingDeleteRef.current)
const target = persons.find((candidate) => String(candidate.id) === String(personId))
const name = target ? firstNameLabel(target) : ''
const snapshot = removePersonFromGraph(personId)
tree.setSelectedPerson(null)
tree.bumpGraphRevision()
return
}
if (!snapshot) return
if (tree.canEditCurrentTree) {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken) return
await deletePerson(tree.treeContext.treeId, personId, accessToken)
await refreshTreeAndKeepSelection(null, { relayout: true })
tree.setSelectedPerson(null)
const pending = { personId, name, snapshot, treeId: ownTree ? tree.treeContext.treeId : null, accessToken }
pending.timer = setTimeout(() => commitPendingDelete(pending), UNDO_DELAY_MS)
pendingDeleteRef.current = pending
setUndoDelete({ personId, name })
return
}
@@ -1981,7 +2069,7 @@ function App() {
editMode.updateDraft({ deletedPersons })
tree.setSelectedPerson(null)
}, [auth.userAuth.token, refreshTreeAndKeepSelection, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
}, [auth.userAuth.token, commitPendingDelete, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
// Ajout d'une relation (mode édition)
const handleAddRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
@@ -3350,7 +3438,15 @@ function App() {
onAddPerson={handleSouvenirPickerAddPerson}
onClose={() => setSouvenirPickerVisible(false)}
/>
{draftNotice && !contribSuccess && !showContribModal && (
{undoDelete && (
<div className="contrib-success-toast undo-toast" role="status">
<span>{undoDelete.name
? t("{name} est supprimé·e de l'arbre.", { name: undoDelete.name })
: t("La personne est supprimée de l'arbre.")}</span>
<button type="button" className="undo-toast-btn" onClick={handleUndoDelete} title={t('Annuler (Ctrl+Z)')}>{t('Annuler')}</button>
</div>
)}
{draftNotice && !undoDelete && !contribSuccess && !showContribModal && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{contribSuccess && (
+2 -1
View File
@@ -178,9 +178,10 @@ export async function updatePerson(treeId, personId, payload, token) {
return data.person
}
export async function deletePerson(treeId, personId, token) {
export async function deletePerson(treeId, personId, token, { keepalive = false } = {}) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
-2
View File
@@ -236,8 +236,6 @@
.pc-foot { flex: none; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--pz-line); }
.pc-foot .pc-save { margin-left: auto; }
.pc-save.is-saved { background: var(--pz-success); }
.pc-delete-confirm { display: grid; gap: 10px; width: 100%; font: 500 14px var(--pz-sans); color: var(--pz-danger); }
.pc-delete-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* ── Téléphone : panneau qui monte par le bas ── */
@media (max-width: 768px) {
+13 -28
View File
@@ -130,7 +130,6 @@ const PersonCard = ({
const [saveError, setSaveError] = useState('')
// Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse
const saveInFlight = useRef(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [showImmichPanel, setShowImmichPanel] = useState(false)
// null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose
// qu'à qui peut agir (owner/admin de l'arbre, comme pour toute action Immich) et seulement
@@ -164,7 +163,6 @@ const PersonCard = ({
setUnknownFirstName(isFirstNameUnknown(person))
setFirstNameMissing(false)
setLastNameMissing(false)
setShowDeleteConfirm(false)
setActiveRelationSearch(null)
setRelationSearchQuery('')
setSaveSuccess(false)
@@ -393,7 +391,6 @@ const PersonCard = ({
const handleDelete = () => {
if (!onDelete) return
onDelete(person.id)
setShowDeleteConfirm(false)
}
const handleAvatarFileChange = async (event) => {
@@ -1154,32 +1151,20 @@ const PersonCard = ({
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
{editMode && (
<footer className="pc-foot">
{showDeleteConfirm ? (
<div className="pc-delete-confirm">
<span>{t("Supprimer {name} de l'arbre ?", { name: firstNameLabel(person) })}</span>
<div className="pc-delete-actions">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(false)}>{t('Annuler')}</button>
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleDelete}>{t('Supprimer')}</button>
</div>
</div>
) : (
<>
{isAdmin && (
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(true)}>
<Trash2 size={15} strokeWidth={2} />
{t('Supprimer')}
</button>
)}
<button
type="button"
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
disabled={(!hasChanges && !saveSuccess) || saving}
onClick={handleSave}
>
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
</button>
</>
{isAdmin && (
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={handleDelete}>
<Trash2 size={15} strokeWidth={2} />
{t('Supprimer')}
</button>
)}
<button
type="button"
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
disabled={(!hasChanges && !saveSuccess) || saving}
onClick={handleSave}
>
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
</button>
</footer>
)}
@@ -276,6 +276,22 @@
transform: translateX(-50%);
}
/* Sur deux lignes, une pilule se déforme : des coins arrondis simples */
/* Suppression annulable : le bouton Annuler doit rester cliquable */
.undo-toast { display: flex; align-items: center; gap: 14px; padding-right: 8px; pointer-events: auto; }
.undo-toast-btn {
position: relative;
padding: 6px 12px;
border: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--pz-bg) 16%, transparent);
color: var(--pz-bg);
font: 600 14px/1.2 var(--pz-sans);
cursor: pointer;
}
/* Zone de clic d'au moins 44 px sans grossir le bouton */
.undo-toast-btn::before { content: ''; position: absolute; inset: -8px; }
.undo-toast-btn:hover { background: color-mix(in srgb, var(--pz-bg) 26%, transparent); }
.undo-toast-btn:focus-visible { outline: 2px solid var(--pz-bg); outline-offset: 2px; }
@media (max-width: 768px) { .contrib-success-toast { bottom: 78px; padding: 11px 16px; border-radius: 16px; font-size: 13px; } }
@media (max-width: 560px) {
+3 -2
View File
@@ -261,7 +261,8 @@ function getMatchDetail(query, person) {
* @param {Array} persons - Liste de toutes les personnes
* @returns {Object} API de recherche
*/
export function usePersonSearch(persons = []) {
// revision : le tableau des personnes est modifié sur place (ajout, suppression) ; sa révision relance le calcul
export function usePersonSearch(persons = [], revision = 0) {
const [query, setQuery] = useState('')
const [isActive, setIsActive] = useState(false)
@@ -291,7 +292,7 @@ export function usePersonSearch(persons = []) {
.slice(0, 50) // Limiter à 50 résultats max
return scored
}, [query, persons])
}, [query, persons, revision]) // eslint-disable-line react-hooks/exhaustive-deps
// Liste des IDs de personnes matchées (pour highlight galaxie)
const matchedPersonIds = useMemo(() => {
+4 -1
View File
@@ -92,7 +92,10 @@ export default {
'Aucun enfant': 'No children',
'Ajouter un enfant': 'Add a child',
'Rechercher un enfant': 'Search for a child',
"Supprimer {name} de l'arbre ?": 'Delete {name} from the tree?',
"{name} est supprimé·e de l'arbre.": '{name} was deleted from the tree.',
"La personne est supprimée de l'arbre.": 'The person was deleted from the tree.',
"{name} n'a pas pu être supprimé·e. Réessayez.": '{name} could not be deleted. Try again.',
'Annuler (Ctrl+Z)': 'Undo (Ctrl+Z)',
'Annuler': 'Cancel',
'Supprimer': 'Delete',
'Enregistré': 'Saved',