mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Merge pull request #169 from IliesAllali/feat/annuler-suppressions
Suppression d'une personne annulable (Annuler à la place de la confirmation)
This commit is contained in:
7 files changed
+182
-82
No files matched your search
+144
-48
@@ -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 && (
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
{draftNotice && !undoDelete && !contribSuccess && !showContribModal && (
|
||||
<div className="contrib-success-toast" role="status">{draftNotice}</div>
|
||||
)}
|
||||
{contribSuccess && (
|
||||
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 && <p className="pc-save-error" role="alert">{saveError}</p>}
|
||||
{editMode && (
|
||||
<footer className="pc-foot">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="pc-delete-confirm">
|
||||
<span>{t("Supprimer {name} de l'arbre ?", { name: firstNameLabel(person) })}</span>
|
||||
<div className="pc-delete-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(false)}>{t('Annuler')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleDelete}>{t('Supprimer')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isAdmin && (
|
||||
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(true)}>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
{t('Supprimer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
|
||||
disabled={(!hasChanges && !saveSuccess) || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
|
||||
</button>
|
||||
</>
|
||||
{isAdmin && (
|
||||
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={handleDelete}>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
{t('Supprimer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
|
||||
disabled={(!hasChanges && !saveSuccess) || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in new issue
Block a user