Merge remote-tracking branch 'origin/main' into feat/barre-c

# Conflicts:
#	frontend/src/components/navbar/ContextualNavbar.css
This commit is contained in:
IliesAllali committed 2026-10-19 10:27:03 +02:00
commit dfc73858af
9 files changed
+182 -31

No files matched your search

+57 -18
View File
@@ -4,6 +4,7 @@ import Galaxy from './components/Galaxy'
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 { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
import MediaViewer from './components/MediaViewer'
import MediaManagerPanel from './components/MediaManagerPanel'
@@ -563,6 +564,10 @@ function App() {
const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false)
const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false)
const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null)
// Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries
const [draftNotice, setDraftNotice] = useState('')
// Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef)
const personSaveOnLeaveRef = useRef(null)
useEffect(() => {
if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) {
@@ -811,29 +816,55 @@ function App() {
updateDraft: editMode.updateDraft,
})
const handlePersonSelect = useCallback((personLike) => {
// Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un
// souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte
// avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas.
const leaveSelectedPerson = useCallback(async () => {
const current = tree.selectedPerson
const saveOnLeave = personSaveOnLeaveRef.current
if (!current || !saveOnLeave || isDemoMode) return true
const outcome = await saveOnLeave()
if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) }))
return outcome !== 'blocked'
}, [tree.selectedPerson, isDemoMode])
const chooseSelectedPerson = useCallback(async (next) => {
if (!(await leaveSelectedPerson())) return false
tree.setSelectedPerson(next)
return true
}, [leaveSelectedPerson, tree.setSelectedPerson])
const deactivateEditMode = useCallback(async () => {
if (!(await leaveSelectedPerson())) return
editMode.deactivate()
}, [leaveSelectedPerson, editMode.deactivate])
const handlePersonSelect = useCallback(async (personLike) => {
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
if (personId === null || personId === undefined) {
tree.setSelectedPerson(null)
await chooseSelectedPerson(null)
return
}
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
if (!(await leaveSelectedPerson())) return
// Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ;
// sa saisie reste en mémoire et reprend quand on rouvre « Ajouter »
setAddPersonPanelVisible(false)
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
tree.setSelectedPerson(freshPerson)
trackTreeInteraction('text')
trackDemoInteraction('person')
}, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
}, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
const handleMediaSelectFresh = useCallback((media, personLike) => {
const handleMediaSelectFresh = useCallback(async (media, personLike) => {
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
const freshPerson = personId !== null && personId !== undefined
? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
: personLike || null
if (!(await leaveSelectedPerson())) return
tree.handleMediaSelect(media, freshPerson)
trackTreeInteraction(toInteractionMediaType(media?.type))
trackDemoInteraction('media')
@@ -895,22 +926,26 @@ function App() {
const handleEditToggle = useCallback(() => {
if (editMode.isActive && editMode.hasDraft) {
setShowConfirmModal(true)
} else if (editMode.isActive) {
deactivateEditMode()
} else {
if (!editMode.isActive) trackDemoInteraction('edit')
trackDemoInteraction('edit')
editMode.toggle()
}
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
// Actions de la modal de confirmation
const handleConfirmContinue = useCallback(() => {
setShowConfirmModal(false)
}, [])
const handleConfirmSaveAndQuit = useCallback(() => {
const handleConfirmSaveAndQuit = useCallback(async () => {
const left = await leaveSelectedPerson()
setShowConfirmModal(false)
if (!left) return
editMode.saveDraft()
editMode.deactivate()
setShowConfirmModal(false)
}, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
const handleConfirmDiscard = useCallback(() => {
editMode.clearDraft()
@@ -966,11 +1001,11 @@ function App() {
setSouvenirPickerVisible(true)
}, [tree.treeContext.treeId])
const handleSouvenirPick = useCallback((person) => {
const handleSouvenirPick = useCallback(async (person) => {
if (!(await chooseSelectedPerson(person))) return
setSouvenirPickerVisible(false)
tree.setSelectedPerson(person)
setPendingMediaPersonId(person.id)
}, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
}, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSouvenirPickerAddPerson = useCallback(() => {
setSouvenirPickerVisible(false)
@@ -984,6 +1019,8 @@ function App() {
const handleAddPersonSubmit = useCallback(async (formData) => {
if (!tree.treeContext.treeId && !isDemoMode) return
// La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace
if (!(await leaveSelectedPerson())) return
setAddPersonLoading(true)
setAddPersonError('')
@@ -1193,7 +1230,6 @@ function App() {
const [contribSubmitLoading, setContribSubmitLoading] = useState(false)
const [contribSubmitError, setContribSubmitError] = useState('')
const [contribSuccess, setContribSuccess] = useState(false)
const [draftNotice, setDraftNotice] = useState('')
// Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite.
// L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit.
@@ -1233,6 +1269,8 @@ function App() {
// Soumission des modifications
const handleEditSubmit = useCallback(async () => {
// Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi
if (!(await leaveSelectedPerson())) return
if (isDemoMode) {
// Démo: édition locale uniquement, jamais de persistance serveur.
editMode.saveDraft()
@@ -2967,11 +3005,11 @@ function App() {
hasResults={search.hasResults}
onSearch={search.handleSearch}
onClear={search.clearSearch}
onResultClick={(person) => {
onResultClick={async (person) => {
const fullPerson = persons.find(p => p.id === person.id)
if (!fullPerson) return
trackDemoInteraction('search')
tree.setSelectedPerson(fullPerson)
if (!(await chooseSelectedPerson(fullPerson))) return
galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id)
}}
/>
@@ -2988,7 +3026,7 @@ function App() {
hasDraft={editMode.hasDraft}
draftTimestamp={editMode.draftTimestamp}
role={userRole.isAdmin ? 'admin' : 'contributor'}
onDeactivate={editMode.deactivate}
onDeactivate={deactivateEditMode}
onSubmit={handleEditSubmit}
/>
{/* Confirmation Modal (quitter mode édition avec draft) */}
@@ -3104,8 +3142,9 @@ function App() {
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
onClose={() => tree.setSelectedPerson(null)}
onClose={() => { chooseSelectedPerson(null) }}
onMediaSelect={handleMediaSelectFresh}
saveOnLeaveRef={personSaveOnLeaveRef}
editMode={editMode.isActive}
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
onManageMedia={media.handleOpenMediaManager}
+1
View File
@@ -270,6 +270,7 @@
.pc-unknown-first { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin: -4px 0 0; font: 400 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; white-space: nowrap; }
.pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
.pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); }
.pc-save-error { margin: 0; font: 500 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); }
/* Modification de l'onglet Infos : pastilles des champs vides (DA terracotta, sans nouvelle couleur) */
.pc-fill-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+39 -6
View File
@@ -17,6 +17,7 @@ import { formatPlace } from '../utils/place.js'
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js'
import { isYearOnly } from '../utils/dateInput.js'
import DateField from './DateField.jsx'
import { runOnce } from '../utils/saveOnLeave.js'
// Aucune modification en cours (référence stable)
const NO_EDITS = Object.freeze({})
@@ -70,6 +71,7 @@ const PersonCard = ({
onMoveSibling,
onUploadMomentFile,
onMomentSaved,
saveOnLeaveRef = null,
}) => {
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
@@ -114,6 +116,9 @@ const PersonCard = ({
const [lastNameMissing, setLastNameMissing] = useState(false)
const [saving, setSaving] = useState(false)
const [saveSuccess, setSaveSuccess] = useState(false)
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
@@ -149,6 +154,7 @@ const PersonCard = ({
setActiveRelationSearch(null)
setRelationSearchQuery('')
setSaveSuccess(false)
setSaveError('')
setShowSourceMenu(false)
setMomentSheet(null)
setLifeNotice('')
@@ -313,29 +319,53 @@ const PersonCard = ({
]
const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : []
const handleSave = async () => {
if (!hasChanges || !onSave) return
// Enregistre les modifications de cette fiche (bouton « Enregistrer » et départ de la fiche).
// Résultat : 'saved', 'unchanged' (rien à enregistrer) ou 'blocked' (validation, ou échec réseau :
// le message reste sur la fiche, la saisie n'est pas jetée).
const persistEdits = () => runOnce(saveInFlight, async () => {
if (!hasChanges || !onSave) return 'unchanged'
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
setFirstNameMissing(true)
return
return 'blocked'
}
// Prénom inconnu : il faut alors un nom de famille
if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) {
setLastNameMissing(true)
return
return 'blocked'
}
const savedFor = person.id
setSaving(true)
setSaveError('')
try {
const savedFor = person.id
await onSave(savedFor, edits)
setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev))
setOpenedFields([])
setSaveSuccess(true)
setTimeout(() => setSaveSuccess(false), 2000)
return 'saved'
} catch (error) {
// Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique
setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez."))
return 'blocked'
} finally {
setSaving(false)
}
}
})
// App enregistre la fiche avant de la quitter (changement de personne, fermeture, sortie du mode
// modification) : la référence suit toujours la dernière version de persistEdits
useEffect(() => {
if (!saveOnLeaveRef) return undefined
saveOnLeaveRef.current = persistEdits
return () => { if (saveOnLeaveRef.current === persistEdits) saveOnLeaveRef.current = null }
})
// Sortie du mode modification : les modifications restantes (abandonnées) ne reviennent pas à la prochaine entrée
useEffect(() => {
if (!editMode) setEditState({ personId: person.id, values: {}, changed: false })
}, [editMode]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSave = () => { persistEdits() }
const handleDelete = () => {
if (!onDelete) return
@@ -436,6 +466,8 @@ const PersonCard = ({
unions: personUnions.map((union) => ({
...union,
partner: persons.find((p) => p.id === (union.partner1Id === person.id ? union.partner2Id : union.partner1Id)) || null,
// Enfants du couple : placent dans la frise une union dont la date manque
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
})),
}
@@ -998,6 +1030,7 @@ const PersonCard = ({
</div>
{/* ── Actions d'édition ── */}
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
{editMode && (
<footer className="pc-foot">
{showDeleteConfirm ? (
+2
View File
@@ -101,4 +101,6 @@ export default {
'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).',
"Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.',
'Le serveur ne répond pas. Vérifiez la connexion et réessayez.': 'The server is not responding. Check your connection and try again.',
// App.jsx : enregistrement automatique en quittant une fiche
'Modifications de {name} enregistrées': 'Changes to {name} saved',
}
+1
View File
@@ -176,6 +176,7 @@ export default {
'Marie': 'Mary',
'Dupont': 'Smith',
'Il faut au moins un prénom.': 'A first name is required.',
"L'enregistrement a échoué. Vos modifications sont gardées, réessayez.": 'Saving failed. Your changes are kept, try again.',
'si différent': 'if different',
'Martin': 'Johnson',
'Place dans la famille': 'Place in the family',
+15 -7
View File
@@ -107,7 +107,7 @@ const siblingWord = (p) => {
/**
* @param person personne de la fiche (birthDate, deathDate, birthPlace, birthYear, deathYear)
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType }] }
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType, childIds }] }
* @param life réponse de GET /trees/:id/persons/:personId/life, ou null tant qu'elle n'est pas là
* @returns entrées triées { key, year, kind: 'mile' | 'minor' | 'fam', title, meta, note, sources, mediaIds,
* event (moment modifiable), milestone ({ type, unionId } d'une naissance, d'un mariage, d'un décès) }
@@ -144,10 +144,18 @@ export function buildLifeTimeline(person, family = {}, life = null) {
const partnerName = union.partner ? fullName(union.partner) : t('Conjoint inconnu')
const place = formatPlace(detail.startPlace || '')
const extra = sourcesOf('marriage', union.id)
// Union sans date : à sa place logique, juste avant le premier enfant du couple, sinon avant la fin de
// l'union ou le décès du conjoint. Aucune année n'est inventée, la colonne reste vide.
const childIds = new Set((union.childIds || []).map(String))
const childYears = (family.children || []).filter((c) => childIds.has(String(c.id))).map((c) => yearOf(c.birthDate) ?? c.birthYear).filter(within)
const partnerDeath = union.partner ? yearOf(union.partner.deathDate) ?? union.partner.deathYear : null
const anchor = year ? null : (childYears.length ? Math.min(...childYears) : null) ?? union.endYear ?? (within(partnerDeath) ? partnerDeath : null)
out.push({
key: `union-${union.id}`, year, order: 2, kind: 'mile',
key: `union-${union.id}`, year, sortYear: anchor, order: year ? 2 : 1, kind: 'mile',
title: union.unionType === 'union' ? t('Union avec {name}', { name: partnerName }) : t('Épouse {name}', { name: partnerName }),
meta: joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year)),
meta: year
? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year))
: joinMeta(place, t('date inconnue')),
note: extra?.notes || '', sources: extra?.sources || '',
...milestoneFields(extra, { type: 'marriage', unionId: union.id }),
})
@@ -171,7 +179,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
}
out.push({
key: `event-${event.id}`, year, order: 1, kind: 'minor', title,
meta: joinMeta(formatPlace(event.place || ''), describeDate(event.date, event.dateEnd, event.datePrecision), ageAt(birthYear, year)),
meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthYear, year)),
note: event.notes || '', sources: event.sources || '',
mediaIds: event.mediaIds || [],
// Seuls les moments propres à la personne se modifient depuis l'onglet (pas ceux d'une union)
@@ -206,9 +214,9 @@ export function buildLifeTimeline(person, family = {}, life = null) {
})
}
// Les entrées sans date restent en bas, dans l'ordre où elles sont arrivées
// Une union sans date se range à sa place logique (sortYear) ; le reste sans date va en bas, dans l'ordre d'arrivée
return out
.map((entry, index) => ({ ...entry, index }))
.sort((a, b) => (a.year ?? Infinity) - (b.year ?? Infinity) || a.order - b.order || a.index - b.index)
.map(({ order, index, ...entry }) => entry) // eslint-disable-line no-unused-vars
.sort((a, b) => (a.year ?? a.sortYear ?? Infinity) - (b.year ?? b.sortYear ?? Infinity) || a.order - b.order || a.index - b.index)
.map(({ order, index, sortYear, ...entry }) => entry) // eslint-disable-line no-unused-vars
}
+24
View File
@@ -66,6 +66,30 @@ describe('frise de vie', () => {
expect(entries.map((e) => [e.title, e.year, e.meta])).toEqual([['Naissance', 1900, ''], ['Décès', null, 'date inconnue']])
})
it('union sans date : avant le premier enfant du couple, sinon avant le décès du conjoint, sinon en bas', () => {
const lucie = { id: 1, firstName: 'Lucie', birthYear: 1940 }
const entries = buildLifeTimeline(lucie, {
children: [{ id: 7, firstName: 'Hugo', birthYear: 1966 }, { id: 8, firstName: 'Léa', birthYear: 1981 }],
unions: [
{ id: 'u1', partner: { id: 2, firstName: 'Marc', lastName: 'Roy', deathYear: 2013 }, unionType: 'mariage', childIds: [7] },
{ id: 'u2', partner: { id: 3, firstName: 'Paul', lastName: 'Vidal', deathYear: 2016 }, unionType: 'mariage', childIds: [] },
{ id: 'u3', partner: { id: 4, firstName: 'Yves', lastName: 'Mallet' }, unionType: 'mariage' },
],
}, null)
expect(entries.map((e) => e.title)).toEqual([
'Naissance', 'Épouse Marc Roy', 'Naissance de Hugo', 'Naissance de Léa', 'Décès de Marc', 'Épouse Paul Vidal', 'Décès de Paul', 'Épouse Yves Mallet',
])
// Aucune année inventée : colonne vide, « date inconnue » dit pourquoi
for (const key of ['union-u1', 'union-u2', 'union-u3']) {
expect(entries.find((e) => e.key === key)).toMatchObject({ year: undefined, meta: 'date inconnue' })
}
expect(entries.find((e) => e.key === 'union-u1')).not.toHaveProperty('sortYear')
})
it('un moment sans date dit « date inconnue »', () => {
expect(entries.find((e) => e.title === 'Résidence').meta).toBe('Rue des Écoles · date inconnue')
})
it('dit les dates comme on les dit', () => {
expect(describeDate('1879-01-01', null, 'about')).toBe('vers 1879')
expect(describeDate('1879-01-01', null, 'before')).toBe('avant 1879')
+8
View File
@@ -0,0 +1,8 @@
// Enregistrement d'une fiche en modification, partagé par le bouton « Enregistrer » et le départ de la fiche.
// Un appel pendant un enregistrement reçoit la même promesse : pas de double envoi.
export function runOnce(inFlightRef, run) {
if (inFlightRef.current) return inFlightRef.current
const promise = Promise.resolve().then(run).finally(() => { inFlightRef.current = null })
inFlightRef.current = promise
return promise
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, expect, it, vi } from 'vitest'
import { runOnce } from './saveOnLeave.js'
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
describe('runOnce (enregistrement de fiche)', () => {
it('un second appel pendant l’envoi reçoit le même résultat, sans second envoi', async () => {
const ref = { current: null }
const send = vi.fn(async () => { await tick(); return 'saved' })
const first = runOnce(ref, send)
const second = runOnce(ref, send)
expect(second).toBe(first)
expect(await first).toBe('saved')
expect(send).toHaveBeenCalledTimes(1)
})
it('une fois l’envoi terminé, un nouvel appel enregistre à nouveau (nouvelle modification)', async () => {
const ref = { current: null }
const send = vi.fn(async () => 'saved')
await runOnce(ref, send)
expect(ref.current).toBeNull()
await runOnce(ref, send)
expect(send).toHaveBeenCalledTimes(2)
})
it('un échec se propage au résultat et libère le verrou pour une nouvelle tentative', async () => {
const ref = { current: null }
const send = vi.fn()
.mockImplementationOnce(async () => { throw new Error('réseau') })
.mockImplementationOnce(async () => 'saved')
await expect(runOnce(ref, send)).rejects.toThrow('réseau')
expect(ref.current).toBeNull()
expect(await runOnce(ref, send)).toBe('saved')
})
})