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 67d87cd..c505450 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' @@ -81,6 +81,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, @@ -1252,6 +1254,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',