Merge pull request #172 from IliesAllali/feat/annuler-moments-medias

Souvenirs et moments supprimés : Annuler, comme pour une personne
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-27 14:56:10 +01:00
commit e1b25265dd
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'
@@ -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é.'))
+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',