diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index a0a36c6..a52dad8 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -507,6 +507,61 @@ function readInvitationToken() { const INVITATION_TOKEN = readInvitationToken() 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) + } + } + + 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() @@ -711,7 +766,7 @@ function App() { const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode // Recherche de personnes avec scoring - const search = usePersonSearch(persons) + const search = usePersonSearch(persons, tree.graphRevision) // Mode édition avec auto-save const editMode = useEditMode({ @@ -1919,59 +1974,92 @@ function App() { } }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) - // Suppression personne (mode édition, admin) + // 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) + + 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 + useEffect(() => { + const pending = pendingDeleteRef.current + if (pending && removePersonFromGraph(pending.personId)) 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 - if (isDemoMode) { - for (let idx = persons.length - 1; idx >= 0; idx -= 1) { - if (String(persons[idx].id) === String(personId)) { - persons.splice(idx, 1) - } - } + const ownTree = !isDemoMode && tree.canEditCurrentTree + if (isDemoMode || ownTree) { + const accessToken = ownTree ? (tree.treeContext.accessToken || auth.userAuth.token) : null + if (ownTree && !accessToken) return - 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) - } - } + // 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) tree.setSelectedPerson(null) tree.bumpGraphRevision() - return - } + if (!snapshot) return - if (tree.canEditCurrentTree) { - const accessToken = tree.treeContext.accessToken || auth.userAuth.token - if (!accessToken) return - - await deletePerson(tree.treeContext.treeId, personId, accessToken) - await refreshTreeAndKeepSelection(null, { relayout: true }) - tree.setSelectedPerson(null) + 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 }) return } @@ -1981,7 +2069,7 @@ function App() { editMode.updateDraft({ deletedPersons }) tree.setSelectedPerson(null) - }, [auth.userAuth.token, refreshTreeAndKeepSelection, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + }, [auth.userAuth.token, commitPendingDelete, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps // Ajout d'une relation (mode édition) const handleAddRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => { @@ -3350,7 +3438,15 @@ function App() { onAddPerson={handleSouvenirPickerAddPerson} onClose={() => setSouvenirPickerVisible(false)} /> - {draftNotice && !contribSuccess && !showContribModal && ( + {undoDelete && ( +
+ {undoDelete.name + ? t("{name} est supprimé·e de l'arbre.", { name: undoDelete.name }) + : t("La personne est supprimée de l'arbre.")} + +
+ )} + {draftNotice && !undoDelete && !contribSuccess && !showContribModal && (
{draftNotice}
)} {contribSuccess && ( diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 7c1ccf3..63c3fb8 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -178,9 +178,10 @@ export async function updatePerson(treeId, personId, payload, token) { return data.person } -export async function deletePerson(treeId, personId, token) { +export async function deletePerson(treeId, personId, token, { keepalive = false } = {}) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, { method: 'DELETE', + keepalive, headers: { Authorization: `Bearer ${token}`, }, diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css index a8f5290..e1f5b55 100644 --- a/frontend/src/components/PersonCard.css +++ b/frontend/src/components/PersonCard.css @@ -236,8 +236,6 @@ .pc-foot { flex: none; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--pz-line); } .pc-foot .pc-save { margin-left: auto; } .pc-save.is-saved { background: var(--pz-success); } -.pc-delete-confirm { display: grid; gap: 10px; width: 100%; font: 500 14px var(--pz-sans); color: var(--pz-danger); } -.pc-delete-actions { display: flex; justify-content: flex-end; gap: 8px; } /* ── Téléphone : panneau qui monte par le bas ── */ @media (max-width: 768px) { diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx index f0a04de..15b72d8 100644 --- a/frontend/src/components/PersonCard.jsx +++ b/frontend/src/components/PersonCard.jsx @@ -130,7 +130,6 @@ const PersonCard = ({ const [saveError, setSaveError] = useState('') // Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse const saveInFlight = useRef(null) - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showImmichPanel, setShowImmichPanel] = useState(false) // null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose // qu'à qui peut agir (owner/admin de l'arbre, comme pour toute action Immich) et seulement @@ -164,7 +163,6 @@ const PersonCard = ({ setUnknownFirstName(isFirstNameUnknown(person)) setFirstNameMissing(false) setLastNameMissing(false) - setShowDeleteConfirm(false) setActiveRelationSearch(null) setRelationSearchQuery('') setSaveSuccess(false) @@ -393,7 +391,6 @@ const PersonCard = ({ const handleDelete = () => { if (!onDelete) return onDelete(person.id) - setShowDeleteConfirm(false) } const handleAvatarFileChange = async (event) => { @@ -1154,32 +1151,20 @@ const PersonCard = ({ {editMode && saveError &&

{saveError}

} {editMode && ( )} diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index 98f72e1..7afe13b 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -276,6 +276,22 @@ transform: translateX(-50%); } /* Sur deux lignes, une pilule se déforme : des coins arrondis simples */ +/* Suppression annulable : le bouton Annuler doit rester cliquable */ +.undo-toast { display: flex; align-items: center; gap: 14px; padding-right: 8px; pointer-events: auto; } +.undo-toast-btn { + position: relative; + padding: 6px 12px; + border: 0; + border-radius: 999px; + background: color-mix(in srgb, var(--pz-bg) 16%, transparent); + color: var(--pz-bg); + font: 600 14px/1.2 var(--pz-sans); + cursor: pointer; +} +/* Zone de clic d'au moins 44 px sans grossir le bouton */ +.undo-toast-btn::before { content: ''; position: absolute; inset: -8px; } +.undo-toast-btn:hover { background: color-mix(in srgb, var(--pz-bg) 26%, transparent); } +.undo-toast-btn:focus-visible { outline: 2px solid var(--pz-bg); outline-offset: 2px; } @media (max-width: 768px) { .contrib-success-toast { bottom: 78px; padding: 11px 16px; border-radius: 16px; font-size: 13px; } } @media (max-width: 560px) { diff --git a/frontend/src/hooks/usePersonSearch.js b/frontend/src/hooks/usePersonSearch.js index 5e56062..6f325fe 100644 --- a/frontend/src/hooks/usePersonSearch.js +++ b/frontend/src/hooks/usePersonSearch.js @@ -261,7 +261,8 @@ function getMatchDetail(query, person) { * @param {Array} persons - Liste de toutes les personnes * @returns {Object} API de recherche */ -export function usePersonSearch(persons = []) { +// revision : le tableau des personnes est modifié sur place (ajout, suppression) ; sa révision relance le calcul +export function usePersonSearch(persons = [], revision = 0) { const [query, setQuery] = useState('') const [isActive, setIsActive] = useState(false) @@ -291,7 +292,7 @@ export function usePersonSearch(persons = []) { .slice(0, 50) // Limiter à 50 résultats max return scored - }, [query, persons]) + }, [query, persons, revision]) // eslint-disable-line react-hooks/exhaustive-deps // Liste des IDs de personnes matchées (pour highlight galaxie) const matchedPersonIds = useMemo(() => { diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index 763869e..c64cddb 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -92,7 +92,10 @@ export default { 'Aucun enfant': 'No children', 'Ajouter un enfant': 'Add a child', 'Rechercher un enfant': 'Search for a child', - "Supprimer {name} de l'arbre ?": 'Delete {name} from the tree?', + "{name} est supprimé·e de l'arbre.": '{name} was deleted from the tree.', + "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)', 'Annuler': 'Cancel', 'Supprimer': 'Delete', 'Enregistré': 'Saved',