From dbc6338f9b0128833619f2d81c6a4a063ec44bcd Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Tue, 27 Oct 2026 14:16:30 +0100 Subject: [PATCH] =?UTF-8?q?Souvenirs=20et=20moments=20supprim=C3=A9s=20:?= =?UTF-8?q?=20Annuler,=20comme=20pour=20une=20personne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.jsx | 219 ++++++++++-------------- frontend/src/api/treeApi.js | 6 +- frontend/src/components/MomentSheet.jsx | 9 +- frontend/src/components/PersonCard.jsx | 22 ++- frontend/src/data/mockData.js | 9 + frontend/src/hooks/useUndoable.js | 64 +++++++ frontend/src/i18n/en/fiche.js | 2 + 7 files changed, 200 insertions(+), 131 deletions(-) create mode 100644 frontend/src/hooks/useUndoable.js diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a52dad8..f061845 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 && (
- {undoDelete.name - ? t("{name} est supprimé·e de l'arbre.", { name: undoDelete.name }) - : t("La personne est supprimée de l'arbre.")} - + {undoable.message} +
)} - {draftNotice && !undoDelete && !contribSuccess && !showContribModal && ( + {draftNotice && !undoable.message && !contribSuccess && !showContribModal && (
{draftNotice}
)} {contribSuccess && ( @@ -3526,6 +3492,7 @@ function App() { {tree.selectedPerson && ( 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} /> } -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 ) : ( - + ))} diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index 15b72d8..5cf55b8 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -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é.')) diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index 2b4e0b6..c7c2be8 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -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)) diff --git a/frontend/src/hooks/useUndoable.js b/frontend/src/hooks/useUndoable.js new file mode 100644 index 0000000..7247c63 --- /dev/null +++ b/frontend/src/hooks/useUndoable.js @@ -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 } +} diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index c64cddb..8ffbe6c 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -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',