Souvenirs et moments supprimés : Annuler, comme pour une personne

Un hook commun (hooks/useUndoable.js) porte les trois suppressions annulables : bandeau 6 s,
Ctrl+Z, requête envoyée à la fin du délai ou à la fermeture de la page.
- Souvenir (gestionnaire de souvenirs) : il n'y avait aucune confirmation, la suppression était immédiate.
- Moment (onglet Vie) : plus de « Supprimer / Garder ».

Corrige deux défauts de #169 :
- Les tableaux du graphe étaient mutés en place (splice, push), alors que les index par personne
  (getPersonById, getPersonMedias) ne se reconstruisent qu'au remplacement du tableau. Nouveau
  replaceGraph() dans mockData, utilisé pour la suppression et l'ajout d'une personne en démo.
- Le rechargement après l'envoi vidait la sélection : une fiche ouverte pendant le délai se fermait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-27 14:16:30 +01:00
1 parent 0b429e78a9
commit dbc6338f9b
7 files changed
+200 -131

No files matched your search

+93 -126
View File
@@ -5,6 +5,7 @@ import PersonCard from './components/PersonCard.jsx'
import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx'
import { HELP_URL } from './utils/siteLinks.js'
import { firstNameLabel } from './utils/personName.js'
import { useUndoable } from './hooks/useUndoable.js'
import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
import MediaViewer from './components/MediaViewer'
import MediaManagerPanel from './components/MediaManagerPanel'
@@ -38,6 +39,7 @@ import {
createUnion,
batchAnnotations,
deletePersonAvatar,
deletePersonMedia,
getTree,
importGedcom,
linkTreeAccess,
@@ -65,7 +67,7 @@ import { useEditMode } from './hooks/useEditMode'
import { useAnnotations } from './hooks/useAnnotations'
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
import TextInputOverlay from './components/annotations/TextInputOverlay'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, replaceGraph, unions } from './data/mockData'
import { readGedcomFile } from './utils/gedcomText'
import { readGedcomBundle, zipErrorMessage } from './utils/gedcomBundle.js'
import { materializeFile } from './utils/zipReader.js'
@@ -510,58 +512,25 @@ 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)
}
}
const id = String(personId)
if (!persons.some((candidate) => String(candidate.id) === id)) return null
const snapshot = { persons, unions, filiations, medias }
const removedUnionIds = new Set(unions
.filter((union) => String(union.partner1Id) === id || String(union.partner2Id) === id)
.map((union) => union.id))
replaceGraph({
persons: persons.filter((candidate) => String(candidate.id) !== id),
unions: unions.filter((union) => !removedUnionIds.has(union.id)),
filiations: filiations.filter((filiation) => (
String(filiation.childId) !== id
&& String(filiation.parentId) !== id
&& !(filiation.unionId && removedUnionIds.has(filiation.unionId))
)),
medias: medias.filter((media) => String(media.personId) !== id),
})
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()
@@ -1273,7 +1242,7 @@ function App() {
frameType: 'round',
}
persons.push(newPerson)
replaceGraph({ persons: [...persons, newPerson] })
for (const rel of formData.relations) {
if (rel.type === 'spouse') {
@@ -1974,70 +1943,23 @@ function App() {
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// 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)
// Suppressions annulables (personne, souvenir, moment) : l'écran change tout de suite, « Annuler » reste
// possible quelques secondes, et la requête ne part qu'ensuite (voir hooks/useUndoable.js)
const undoable = useUndoable()
// Un rechargement de l'arbre vide la sélection : la requête part après le délai, quand une autre fiche
// peut être ouverte, et c'est celle-là qu'on garde
const selectedPersonIdRef = useRef(null)
useEffect(() => { selectedPersonIdRef.current = tree.selectedPerson?.id ?? null }, [tree.selectedPerson])
const refreshKeepingSelection = useCallback(
(options) => refreshTreeAndKeepSelection(selectedPersonIdRef.current, options),
[refreshTreeAndKeepSelection],
)
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
// Un rechargement de l'arbre pendant le délai ramènerait ce qui vient d'être supprimé : on le retire à nouveau
useEffect(() => {
const pending = pendingDeleteRef.current
if (pending && removePersonFromGraph(pending.personId)) tree.bumpGraphRevision()
if (undoable.reapply()) 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
@@ -2046,9 +1968,6 @@ function App() {
const accessToken = ownTree ? (tree.treeContext.accessToken || auth.userAuth.token) : null
if (ownTree && !accessToken) return
// 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)
@@ -2056,10 +1975,31 @@ function App() {
tree.bumpGraphRevision()
if (!snapshot) return
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 })
const treeId = ownTree ? tree.treeContext.treeId : null
undoable.start({
message: name ? t("{name} est supprimé·e de l'arbre.", { name }) : t("La personne est supprimée de l'arbre."),
reapply: () => Boolean(removePersonFromGraph(personId)),
commit: async ({ keepalive = false } = {}) => {
if (!treeId) return
try {
await deletePerson(treeId, personId, accessToken, { keepalive })
if (!keepalive) await refreshKeepingSelection({ relayout: true })
} catch {
if (keepalive) return
await refreshKeepingSelection({ relayout: true })
setDraftNotice(t("{name} n'a pas pu être supprimé·e. Réessayez.", { name }))
}
},
undo: async () => {
if (treeId) {
await refreshTreeAndKeepSelection(personId, { relayout: true })
return
}
replaceGraph(snapshot)
tree.bumpGraphRevision()
tree.setSelectedPerson(persons.find((candidate) => String(candidate.id) === String(personId)) || null)
},
})
return
}
@@ -2069,7 +2009,35 @@ function App() {
editMode.updateDraft({ deletedPersons })
tree.setSelectedPerson(null)
}, [auth.userAuth.token, commitPendingDelete, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
}, [auth.userAuth.token, undoable.start, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
const handleMediaDelete = useCallback((mediaId) => {
const personId = tree.selectedPerson?.id
const { treeId, accessToken } = tree.treeContext
if (!tree.canEditCurrentTree || !personId || !treeId || !accessToken) return
const removeMedia = () => {
if (!medias.some((candidate) => String(candidate.id) === String(mediaId))) return false
replaceGraph({ medias: medias.filter((candidate) => String(candidate.id) !== String(mediaId)) })
return true
}
if (!removeMedia()) return
tree.bumpGraphRevision()
undoable.start({
message: t('Le souvenir est supprimé.'),
reapply: removeMedia,
commit: async ({ keepalive = false } = {}) => {
try {
await deletePersonMedia(treeId, personId, mediaId, accessToken, { keepalive })
if (!keepalive) await refreshKeepingSelection({ relayout: false })
} catch {
if (keepalive) return
await refreshKeepingSelection({ relayout: false })
setDraftNotice(t('La suppression n’a pas abouti. Réessayez dans un instant.'))
}
},
undo: () => refreshKeepingSelection({ relayout: false }),
})
}, [tree.selectedPerson, tree.canEditCurrentTree, tree.treeContext, undoable.start, refreshKeepingSelection]) // eslint-disable-line react-hooks/exhaustive-deps
// Ajout d'une relation (mode édition)
const handleAddRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
@@ -3438,15 +3406,13 @@ function App() {
onAddPerson={handleSouvenirPickerAddPerson}
onClose={() => setSouvenirPickerVisible(false)}
/>
{undoDelete && (
{undoable.message && (
<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>
<span>{undoable.message}</span>
<button type="button" className="undo-toast-btn" onClick={undoable.undo} title={t('Annuler (Ctrl+Z)')}>{t('Annuler')}</button>
</div>
)}
{draftNotice && !undoDelete && !contribSuccess && !showContribModal && (
{draftNotice && !undoable.message && !contribSuccess && !showContribModal && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{contribSuccess && (
@@ -3526,6 +3492,7 @@ function App() {
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
onUndoable={undoable.start}
onClose={() => chooseSelectedPerson(null)}
onMediaSelect={handleMediaSelectFresh}
saveOnLeaveRef={personSaveOnLeaveRef}
@@ -3611,7 +3578,7 @@ function App() {
onClose={media.handleCloseMediaManager}
onUpload={media.handleUploadPersonMedia}
onMove={media.handleMovePersonMedia}
onDelete={media.handleDeletePersonMedia}
onDelete={handleMediaDelete}
/>
<ContributionPanel
visible={contrib.contribState.visible}
+4 -2
View File
@@ -84,9 +84,10 @@ export async function updateLifeEvent(treeId, eventId, payload, token) {
})
}
export async function deleteLifeEvent(treeId, eventId, token) {
export async function deleteLifeEvent(treeId, eventId, token, { keepalive = false } = {}) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
keepalive,
headers: { Authorization: `Bearer ${token}` },
})
}
@@ -453,11 +454,12 @@ export async function reorderPersonMedia(treeId, personId, orderedMediaIds, toke
return data
}
export async function deletePersonMedia(treeId, personId, mediaId, token) {
export async function deletePersonMedia(treeId, personId, mediaId, token, { keepalive = false } = {}) {
const data = await apiRequest(
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
{
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
+7 -2
View File
@@ -36,7 +36,7 @@ function FileThumb({ type, url }) {
return <span className="ms-file-icon"><Icon size={18} strokeWidth={1.8} /></span>
}
export default function MomentSheet({ person, entry = null, treeId, token, medias = [], isAdmin = false, onUploadFile, onClose, onSaved }) {
export default function MomentSheet({ person, entry = null, treeId, token, medias = [], isAdmin = false, onUploadFile, onClose, onSaved, onDelete }) {
const milestone = entry?.milestone || null
const event = entry?.event || null
const editing = Boolean(event)
@@ -178,6 +178,11 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
}
const remove = async () => {
// Suppression annulable : la fiche s'en charge
if (onDelete) {
onDelete(event)
return
}
setBusy(true)
setError('')
try {
@@ -329,7 +334,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setConfirmDelete(false)} disabled={busy}>{t('Garder')}</button>
</span>
) : (
<button type="button" className="pz-btn pz-btn--ghost ms-delete" onClick={() => setConfirmDelete(true)} disabled={busy}>{t('Supprimer ce moment')}</button>
<button type="button" className="pz-btn pz-btn--ghost ms-delete" onClick={onDelete ? remove : () => setConfirmDelete(true)} disabled={busy}>{t('Supprimer ce moment')}</button>
))}
<span className="ms-actions-end">
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose} disabled={busy}>{t('Annuler')}</button>
+21 -1
View File
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, forwardRef } from 'react'
import { createPortal } from 'react-dom'
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight, Pencil } from 'lucide-react'
import { getParents, getChildren, getPersonUnions, getPersonMedias, getPersonTaggedMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
import { fetchPersonLife } from '../api/treeApi.js'
import { deleteLifeEvent, fetchPersonLife } from '../api/treeApi.js'
import LifeTimeline from './LifeTimeline.jsx'
import MomentSheet from './MomentSheet.jsx'
import { fetchImmichConfig } from '../api/immichApi.js'
@@ -80,6 +80,8 @@ const PersonCard = ({
onMoveSibling,
onUploadMomentFile,
onMomentSaved,
// Suppression annulable (App, hooks/useUndoable.js)
onUndoable,
saveOnLeaveRef = null,
// Administrateur, hors démo : la fiche passe en modification d'un clic, sans chercher le crayon de l'arbre
onQuickEdit,
@@ -1251,6 +1253,24 @@ const PersonCard = ({
isAdmin={canEditMoments}
onUploadFile={onUploadMomentFile}
onClose={() => setMomentSheet(null)}
onDelete={onUndoable && treeId && immichToken ? (event) => {
// Le moment quitte la frise tout de suite ; la requête part à la fin du délai d'annulation
setMomentSheet(null)
setLifeState((prev) => (prev.data?.events ? { ...prev, data: { ...prev.data, events: prev.data.events.filter((e) => e.id !== event.id) } } : prev))
onUndoable({
message: t('Le moment est supprimé.'),
commit: async ({ keepalive = false } = {}) => {
try {
await deleteLifeEvent(treeId, event.id, immichToken, { keepalive })
} catch {
if (keepalive) return
setLifeRevision((r) => r + 1)
setLifeNotice(t('La suppression n’a pas abouti. Réessayez dans un instant.'))
}
},
undo: () => setLifeRevision((r) => r + 1),
})
} : undefined}
onSaved={({ pending, uploaded }) => {
setMomentSheet(null)
if (pending) setLifeNotice(t('Merci, c’est envoyé. Le moment apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.'))
+9
View File
@@ -154,6 +154,15 @@ export function getDemoLife(personId) {
return DEMO_LIFE[personId] || null
}
// Remplace des tableaux du graphe. Ne jamais les muter en place (push, splice) : les index par personne
// ci-dessous ne se reconstruisent que quand le tableau change d'identité
export function replaceGraph(next) {
if (next.persons) persons = next.persons
if (next.unions) unions = next.unions
if (next.filiations) filiations = next.filiations
if (next.medias) medias = next.medias
}
export function resetToDemoData() {
persons = JSON.parse(JSON.stringify(INITIAL_PERSONS))
medias = JSON.parse(JSON.stringify(INITIAL_MEDIAS))
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Délai pendant lequel une suppression reste annulable avant de partir au serveur
export const UNDO_DELAY_MS = 6000
// Une suppression annulable à la fois. L'écran change tout de suite ; la requête ne part qu'à la fin du délai,
// à la suppression suivante, ou à la fermeture de la page (fetch keepalive).
// Une action : { message, commit({ keepalive }), undo(), reapply?() } ; reapply refait le retrait local
// après un rechargement de l'arbre pendant le délai, et renvoie true s'il a changé quelque chose.
export function useUndoable() {
const pendingRef = useRef(null)
const [message, setMessage] = useState('')
const commit = useCallback(async (action, options = {}) => {
if (pendingRef.current !== action) return
pendingRef.current = null
clearTimeout(action.timer)
setMessage('')
await action.commit(options)
}, [])
const start = useCallback((action) => {
// La précédente part maintenant
if (pendingRef.current) commit(pendingRef.current)
action.timer = setTimeout(() => commit(action), UNDO_DELAY_MS)
pendingRef.current = action
setMessage(action.message)
}, [commit])
const undo = useCallback(async () => {
const action = pendingRef.current
if (!action) return
pendingRef.current = null
clearTimeout(action.timer)
setMessage('')
await action.undo()
}, [])
const reapply = useCallback(() => Boolean(pendingRef.current?.reapply?.()), [])
// Page fermée pendant le délai : la suppression part quand même
useEffect(() => {
const flush = () => {
if (pendingRef.current) commit(pendingRef.current, { keepalive: true })
}
window.addEventListener('pagehide', flush)
return () => window.removeEventListener('pagehide', flush)
}, [commit])
// Ctrl+Z (Cmd+Z) annule, hors des champs de saisie
useEffect(() => {
if (!message) 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()
undo()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [message, undo])
return { message, start, undo, reapply }
}
+2
View File
@@ -96,6 +96,8 @@ export default {
"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)',
'Le souvenir est supprimé.': 'The memory was deleted.',
'Le moment est supprimé.': 'The moment was deleted.',
'Annuler': 'Cancel',
'Supprimer': 'Delete',
'Enregistré': 'Saved',