mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
7 files changed
+200
-131
No files matched your search
+93
-126
@@ -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}
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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é.'))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user