Merge remote-tracking branch 'origin/main' into fix/generation-parente

This commit is contained in:
IliesAllali committed 2026-10-19 13:50:56 +02:00
commit ae8b473a25
31 files changed
+1377 -584

No files matched your search

+8 -5
View File
@@ -1,9 +1,10 @@
import { z } from 'zod'
// Cadrage d'un portrait : centre (proportions de l'image) et zoom. Zoom 1 = l'image entière recouvre le cadre.
// Les bornes qui empêchent le vide sont appliquées à l'affichage (frontend, portraitCrop.js), le serveur
// vérifie seulement la forme et les plages.
export type AvatarCrop = { x: number; y: number; zoom: number }
// Cadrage d'un portrait : centre (proportions de l'image), zoom, et forme du cadre si on l'a choisie.
// Zoom 1 = l'image entière recouvre le cadre. Les bornes qui empêchent le vide sont appliquées à
// l'affichage (frontend, portraitCrop.js), le serveur vérifie seulement la forme et les plages.
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] as const
export type AvatarCrop = { x: number; y: number; zoom: number; shape?: (typeof FRAME_SHAPES)[number] }
export const AVATAR_CROP_MAX_ZOOM = 4
@@ -13,16 +14,18 @@ export const avatarCropInputSchema = z.object({
x: z.number().min(0).max(1),
y: z.number().min(0).max(1),
zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM),
shape: z.enum(FRAME_SHAPES).optional(),
})
.nullable(),
})
const round4 = (value: number) => Math.round(value * 10000) / 10000
// Forme à écrire en base. Le cadrage par défaut se stocke comme absence de cadrage (null).
// Forme à écrire en base. Le cadrage par défaut, sans forme choisie, se stocke comme absence de cadrage (null).
export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null {
if (!crop) return null
const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) }
if (crop.shape) return { ...normalized, shape: crop.shape }
if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
return normalized
}
+13
View File
@@ -166,6 +166,19 @@ describe('PUT /trees/:id/persons/:personId/avatar/crop', () => {
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
})
it('garde la forme choisie, même avec un cadrage centré, et refuse une forme inconnue', async () => {
const app = createApp()
const token = await createUserToken(app)
const put = (crop: unknown) => app.inject({ method: 'PUT', url, headers: { authorization: `Bearer ${token}` }, payload: { crop } })
const saved = await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
expect(saved.statusCode).toBe(200)
expect(saved.json()).toEqual({ ok: true, avatarCrop: { x: 0.5, y: 0.5, zoom: 1, shape: 'round' } })
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
expect((await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'hexagone' })).statusCode).toBe(400)
})
it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => {
const app = createApp()
const token = await createUserToken(app)
+113 -21
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'
@@ -50,7 +51,7 @@ import {
reorderSiblings,
uploadPersonMedia,
} from './api/treeApi'
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { useAuth } from './hooks/useAuth'
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
import { useMediaManager } from './hooks/useMediaManager'
@@ -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()
@@ -1648,6 +1686,51 @@ function App() {
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// « + photo » dans l'arbre : le sélecteur part dans le clic (Safari iOS refuse un input file ouvert hors geste),
// puis la fenêtre de recadrage s'ouvre dans la fiche de la personne
const portraitInputRef = useRef(null)
const portraitPickerPersonIdRef = useRef(null)
const [pendingPortraitCrop, setPendingPortraitCrop] = useState(null)
const [photoSourceRequest, setPhotoSourceRequest] = useState(null)
const [immichSourceReady, setImmichSourceReady] = useState(false)
// Administrateur : Immich proposé dans le menu des sources seulement s'il est configuré
useEffect(() => {
let cancelled = false
const treeId = tree.treeContext.treeId
const token = tree.treeContext.accessToken || auth.userAuth.token
if (!userRole.isAdmin || !tree.canEditCurrentTree || !treeId || !token) {
setImmichSourceReady(false)
return undefined
}
fetchImmichConfig(treeId, token)
.then(({ config }) => { if (!cancelled) setImmichSourceReady(Boolean(config?.configured)) })
.catch(() => { if (!cancelled) setImmichSourceReady(false) })
return () => { cancelled = true }
}, [userRole.isAdmin, tree.canEditCurrentTree, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
const handlePhotoPlaceholderClick = useCallback((personId) => {
// En modification avec Immich : le menu des sources s'ouvre dans la fiche (le badge en est l'ancre)
if (editMode.isActive && immichSourceReady) {
setPhotoSourceRequest({ personId })
} else {
// Sans menu : le sélecteur de fichiers part tout de suite, dans le clic
portraitPickerPersonIdRef.current = personId
portraitInputRef.current?.click()
}
handlePersonSelect(personId)
}, [editMode.isActive, immichSourceReady, handlePersonSelect])
const handlePortraitPicked = useCallback(async (event) => {
const file = event.target.files?.[0]
event.target.value = ''
const personId = portraitPickerPersonIdRef.current
portraitPickerPersonIdRef.current = null
if (!file || personId === null) return
const result = await handlePersonAvatarUpload(personId, file)
if (result?.previewUrl) setPendingPortraitCrop({ personId, src: result.previewUrl })
}, [handlePersonAvatarUpload])
// Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant)
const handleImmichChange = useCallback(async (personId) => {
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
@@ -2967,11 +3050,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 +3071,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) */}
@@ -3057,8 +3140,8 @@ function App() {
activeTreeId={tree.treeContext.treeId}
onEditClick={handleEditToggle}
onAddPersonClick={handleOpenAddPerson}
onAddSouvenirClick={isFamilyVisitor ? handleOpenSouvenirPicker : undefined}
helpUrl={isFamilyVisitor ? HELP_URL : ''}
onAddSouvenirClick={isFamilyVisitor || userRole.isAdmin ? handleOpenSouvenirPicker : undefined}
helpUrl={HELP_URL}
onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined}
onTreeSelect={handleOpenTreeFromAccount}
onCreateTree={handleOpenTreeWizard}
@@ -3097,21 +3180,30 @@ function App() {
focusPersonId={tree.treeContext.rootPersonId}
assetRevision={tree.assetRevision}
photoHint={tree.canEditCurrentTree}
onPhotoPlaceholderClick={tree.canEditCurrentTree ? handlePhotoPlaceholderClick : null}
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{tree.canEditCurrentTree && (
<input ref={portraitInputRef} type="file" accept="image/*" className="pc-hidden-input" onChange={handlePortraitPicked} />
)}
{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}
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined}
pendingPortraitCrop={pendingPortraitCrop}
onPendingPortraitCropDone={() => setPendingPortraitCrop(null)}
photoSourceRequest={photoSourceRequest}
onPhotoSourceRequestDone={() => setPhotoSourceRequest(null)}
treeId={tree.treeContext.treeId || null}
immichToken={tree.treeContext.accessToken || auth.userAuth.token}
onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined}
@@ -434,7 +434,7 @@ const ContributionPanel = ({
{/* ── En-tête sessions ── */}
<div className="contrib-list-header">
<div className="contrib-list-heading">
<h2>{t('Propositions à relire')}</h2>
<h2 id="contrib-propositions">{t('Propositions à relire')}</h2>
<p>{t('Acceptez ou refusez chaque modification, puis appliquez vos choix.')}</p>
</div>
<div className="contrib-list-header-actions">
+2 -2
View File
@@ -40,7 +40,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
const centerX = (node) => node.x + node.width / 2
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, reorderEnabled = false, onReorderSiblings = null }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null }) => {
const canvasRef = useRef(null)
// Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours
const focusPersonIdRef = useRef(focusPersonId)
@@ -139,7 +139,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
canvasRef, transformRef, targetTransformRef,
layoutDataRef, nodeRandomDataRef, entranceRef,
hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos,
getAnimatedPos, onPersonSelect, onMediaSelect,
getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick,
annotationHandlers,
minScale: MIN_SCALE,
maxScale: MAX_SCALE,
+10 -1
View File
@@ -252,8 +252,15 @@
left: 0; right: 0; bottom: 0; width: auto;
max-height: 78dvh; padding: 10px 18px calc(18px + env(safe-area-inset-bottom));
border-radius: 26px 26px 0 0; border-bottom: 0;
animation: pc-sheet 400ms var(--pz-ease-drawer) both;
/* Pas de fill-mode « both » : une fois l'entrée finie, le glisser (style inline) reprend la main sur transform */
animation: pc-sheet 400ms var(--pz-ease-drawer) backwards;
transition: transform 240ms var(--pz-ease-drawer);
}
/* Pendant le glisser, la feuille suit le doigt sans transition */
.person-card[data-dragging] { transition: none; }
/* La poignée et l'en-tête portent le geste : le navigateur ne doit pas le prendre pour un défilement (les champs gardent le leur) */
.person-card .pc-grab, .person-card .pc-close, .person-card .pc-head { touch-action: none; }
.person-card .pc-head :is(input, textarea, select, label, [contenteditable]) { touch-action: auto; }
.pc-grab { display: block; width: 38px; height: 4px; margin: 0 auto 2px; border-radius: 4px; background: var(--pz-line); flex: none; }
.pc-close { top: 10px; right: 10px; }
.pc-photo { width: 68px; height: 68px; }
@@ -263,6 +270,7 @@
@media (prefers-reduced-motion: reduce) {
.person-card { animation: pz-fade 200ms ease both; }
.person-card { transition-duration: 1ms; }
}
/* Prénom inconnu (issue Anaïs, 18/10) : libellé en italique, case à cocher sous les champs de nom */
@@ -270,6 +278,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; }
+75 -9
View File
@@ -10,6 +10,7 @@ import GeoMediaThumbnail from './GeoMediaThumbnail'
import ImmichFacePanel from './ImmichFacePanel.jsx'
import './PersonCard.css'
import PzBusy from './PzBusy.jsx'
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
import PortraitCropModal from './PortraitCropModal.jsx'
import { imagePlacement } from '../utils/portraitCrop.js'
import { t, dateLocale } from '../i18n/index.js'
@@ -17,6 +18,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({})
@@ -51,6 +53,10 @@ const PersonCard = ({
onAvatarUpload,
onAvatarDelete,
onAvatarCrop,
pendingPortraitCrop = null,
onPendingPortraitCropDone = null,
photoSourceRequest = null,
onPhotoSourceRequestDone = null,
treeId = null,
immichToken = null,
onImmichChange,
@@ -70,6 +76,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 +121,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
@@ -138,6 +148,9 @@ const PersonCard = ({
const [openedFields, setOpenedFields] = useState([])
const [focusField, setFocusField] = useState(null)
const bodyRef = useRef(null)
// Feuille du bas (téléphone) : on la ferme en la glissant par la poignée ou l'en-tête, comme la croix
const sheetMode = useSheetMode()
const drag = useSheetDrag({ enabled: sheetMode, zone: '.pc-grab, .pc-head, .pc-close', onClose })
// Reset edits quand la personne change
useEffect(() => {
@@ -149,6 +162,7 @@ const PersonCard = ({
setActiveRelationSearch(null)
setRelationSearchQuery('')
setSaveSuccess(false)
setSaveError('')
setShowSourceMenu(false)
setMomentSheet(null)
setLifeNotice('')
@@ -170,16 +184,19 @@ const PersonCard = ({
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
// traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source.
// Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur.
// Dépend de la présence de onImmichChange, pas de sa valeur : App le recrée à chaque rendu, ce qui
// relançait la lecture de la config et refermait le menu des sources juste après son ouverture
const canChangeImmich = Boolean(onImmichChange)
useEffect(() => {
let cancelled = false
setImmichConfigured(null)
setShowSourceMenu(false)
if (!editMode || !isAdmin || !treeId || !onImmichChange || !immichToken) return undefined
if (!editMode || !isAdmin || !treeId || !canChangeImmich || !immichToken) return undefined
fetchImmichConfig(treeId, immichToken)
.then(({ config }) => { if (!cancelled) setImmichConfigured(Boolean(config?.configured)) })
.catch(() => { if (!cancelled) setImmichConfigured(false) })
return () => { cancelled = true }
}, [editMode, isAdmin, treeId, immichToken, onImmichChange])
}, [editMode, isAdmin, treeId, immichToken, canChangeImmich])
useEffect(() => {
if (!showSourceMenu) return undefined
@@ -240,6 +257,21 @@ const PersonCard = ({
const chooseFileSource = () => { setShowSourceMenu(false); avatarInputRef.current?.click() }
const chooseImmichSource = () => { setShowSourceMenu(false); setShowImmichPanel(true) }
// Clic sur « + photo » dans l'arbre : la photo vient d'être envoyée, le recadrage s'ouvre tout de suite
useEffect(() => {
if (!pendingPortraitCrop || String(pendingPortraitCrop.personId) !== String(person.id)) return
setCropState({ src: pendingPortraitCrop.src, mode: 'new', ownsUrl: true })
onPendingPortraitCropDone?.()
}, [pendingPortraitCrop, person.id, onPendingPortraitCropDone])
// Clic sur « + photo » dans l'arbre, en modification avec Immich : même menu que le badge, sans clic de plus
useEffect(() => {
if (!photoSourceRequest || String(photoSourceRequest.personId) !== String(person.id)) return
if (immichConfigured === null) return
onPhotoSourceRequestDone?.()
if (immichUsable) openAvatarPicker()
}, [photoSourceRequest, person.id, immichConfigured, immichUsable, onPhotoSourceRequestDone]) // eslint-disable-line react-hooks/exhaustive-deps
// Focus search input when relation search opens
useEffect(() => {
if (activeRelationSearch) {
@@ -313,29 +345,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 +492,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),
})),
}
@@ -602,7 +660,14 @@ const PersonCard = ({
)
return (
<div className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`} role="dialog" aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}>
<div
className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`}
role="dialog"
aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}
style={drag.style}
data-dragging={drag.dragging || undefined}
{...drag.bind}
>
<span className="pc-grab" aria-hidden="true" />
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer la fiche')}>
<X size={18} strokeWidth={2} />
@@ -998,6 +1063,7 @@ const PersonCard = ({
</div>
{/* ── Actions d'édition ── */}
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
{editMode && (
<footer className="pc-foot">
{showDeleteConfirm ? (
@@ -55,3 +55,16 @@
@media (prefers-reduced-motion: reduce) {
.pcrop-img { transition: none; }
}
/* Forme du cadre : chaque choix montre sa forme, son nom est dans aria-label et title */
.pcrop-shapes { width: min(100%, 300px); margin: 0 auto; }
.pcrop-shapes .pz-tab { display: grid; place-items: center; min-height: 40px; padding: 0; color: var(--pz-muted); }
.pcrop-shapes .pz-tab.is-active { color: var(--pz-a700, #93402A); }
.pcrop-shapes .pz-tab:disabled { cursor: default; opacity: .5; }
.pcrop-ico { display: block; border: 1.75px solid currentColor; }
.pcrop-ico--round { width: 20px; height: 20px; border-radius: 50%; }
.pcrop-ico--square { width: 20px; height: 20px; border-radius: 3px; }
/* Polaroïd : la marge du bas plus large que les côtés */
.pcrop-ico--polaroid { width: 20px; height: 22px; border-radius: 2px; border-bottom-width: 5px; }
.pcrop-ico--portrait { width: 16px; height: 22px; border-radius: 3px; }
.pcrop-ico--landscape { width: 26px; height: 16px; border-radius: 3px; }
+29 -4
View File
@@ -8,11 +8,13 @@ import {
DEFAULT_CROP,
clampCrop,
cropForSave,
cropShape,
cropViewport,
frameAspect,
imagePlacement,
normalizeCrop,
resolveFrameKind,
shapeOfKind,
} from '../utils/portraitCrop.js'
import './PortraitCropModal.css'
@@ -23,8 +25,11 @@ const KEY_STEP = 0.04
const KEY_STEP_LARGE = 0.16
const KEY_ZOOM_STEP = 0.2
const SHAPE_LABELS = { round: t('Rond'), square: t('Carré'), polaroid: t('Polaroïd'), portrait: t('Vertical'), landscape: t('Horizontal') }
// Recadrage d'un portrait. Le masque garde la forme du cadre de la personne dans l'arbre ; la photo
// glisse dessous. `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo).
// glisse dessous. La forme se choisit aussi ici : sans choix, elle suit la photo ou le tirage de la personne.
// `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo).
const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop = null, saving = false, error = '', onSave, onClose }) => {
const stageRef = useRef(null)
const dragRef = useRef(null)
@@ -33,11 +38,13 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop
const [size, setSize] = useState(null)
const [loadFailed, setLoadFailed] = useState(false)
const [crop, setCrop] = useState(() => normalizeCrop(initialCrop))
const [shape, setShape] = useState(() => cropShape(initialCrop))
const [dragging, setDragging] = useState(false)
const imageRatio = size ? size.w / size.h : 1
const frameKind = resolveFrameKind(frameType, imageRatio)
const aspect = frameAspect(frameKind, imageRatio)
const frameKind = resolveFrameKind(frameType, imageRatio, shape)
const aspect = frameAspect(frameKind, imageRatio, shape)
const shownShape = shape ?? shapeOfKind(frameKind, imageRatio)
const winW = aspect >= 1 ? WINDOW_SIZE : WINDOW_SIZE * aspect
const winH = aspect >= 1 ? WINDOW_SIZE / aspect : WINDOW_SIZE
@@ -159,7 +166,7 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop
const setZoom = (zoom) => apply({ ...current, zoom: Number(zoom) })
const save = () => {
if (!size || saving) return
onSave?.(cropForSave(size.w, size.h, aspect, current))
onSave?.(cropForSave(size.w, size.h, aspect, current, shape))
}
const isNew = mode === 'new'
@@ -208,6 +215,24 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop
{!size && !loadFailed && <div className="pcrop-loading"><PzBusy busy busyLabel={t('Chargement de la photo')} /></div>}
</div>
<div className="pz-tabs pcrop-shapes" role="radiogroup" aria-label={t('Forme du cadre')}>
{Object.entries(SHAPE_LABELS).map(([value, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={shownShape === value}
className={`pz-tab ${shownShape === value ? 'is-active' : ''}`}
onClick={() => setShape(value)}
disabled={!size || saving}
aria-label={label}
title={label}
>
<span className={`pcrop-ico pcrop-ico--${value}`} aria-hidden="true" />
</button>
))}
</div>
<p id="pcropHint" className="pz-small pcrop-hint">
{t('Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.')}
</p>
+15 -22
View File
@@ -1,6 +1,6 @@
import { t } from '../../i18n/index.js'
import { getPersonById, getPersonMedias } from '../../data/mockData'
import { cropViewport } from '../../utils/portraitCrop.js'
import { cropShape, cropViewport, isLandscapeRect, resolveFrameKind } from '../../utils/portraitCrop.js'
// Tracés Lucide dessinés sur le canvas : données publiques de @lucide/icons (Map renommé, il masquerait
// le Map global utilisé plus bas)
import { Map as mapIcon, MapPin as mapPinIcon, Route as routeIcon } from '@lucide/icons'
@@ -201,6 +201,8 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD
// useGalaxyInteractions.
// ============================================================
export const renvoiHitAreas = []
// Zones « + photo » : portraits vides, chez le propriétaire (cercle du nœud, rempli au dessin)
export const photoPlaceholderHitAreas = []
const renvoiPills = []
const RENVOI_FONT = '500 11px "DM Sans", system-ui, sans-serif'
// Pastilles collées aux personnes : juste sous le couple, juste au-dessus du portrait de l'enfant
@@ -450,6 +452,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
const { cx, cy } = pos
const person = getPersonById(node._data.id) || node._data
const rd = nodeRandomData.get(node.id)
// Zone « + photo » : seulement là où le cadre invite à poser une photo (même condition que le dessin)
if (photoHint && !person.photo && nodeProgress >= 0.5) {
photoPlaceholderHitAreas.push({ personId: node._data.id, cx, cy, r: nodeRadius(node) })
}
const img = person.photo ? imageCache.get(person.photo) : null
const frameType = resolveFrameType(person, img)
const frameRot = rd?.frameRotation || 0
@@ -891,26 +897,12 @@ function drawMediaContent(ctx, media, ms, imageCache) {
// ============================================================
// Dessiner le cadre photo (polaroid, rect, round)
// ============================================================
// Forme choisie au recadrage d'abord ; sinon une photo très large ou très haute impose la sienne, et une photo
// carrée ou presque garde la forme tirée au hasard pour la personne (pickFrameType), pour que l'arbre ne soit
// pas uniforme. Même règle que la fenêtre de recadrage (portraitCrop.js).
function resolveFrameType(person, img) {
const fallback = person.frameType || 'round'
if (!img || !img.naturalWidth || !img.naturalHeight) {
return fallback
}
const ratio = img.naturalWidth / img.naturalHeight
if (ratio >= 1.28) {
return 'rect'
}
if (ratio <= 0.78) {
return 'polaroid'
}
// Photo carrée ou presque : chaque personne garde sa forme tirée au hasard
// (person.frameType, voir pickFrameType), pour que l'arbre ne soit pas uniforme
return fallback
const ratio = img?.naturalWidth && img?.naturalHeight ? img.naturalWidth / img.naturalHeight : 1
return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop))
}
function drawFrame(ctx, frameType, person, img, hint = 0) {
@@ -952,8 +944,8 @@ function drawFrame(ctx, frameType, person, img, hint = 0) {
const ratio = img?.naturalWidth && img?.naturalHeight
? img.naturalWidth / img.naturalHeight
: 1
// Photo carrée : cadre vertical, qui garde le visage entier
const isLandscape = ratio > 1.05
// Couché ou debout selon la forme choisie, sinon selon la photo (carrée : debout, le visage reste entier)
const isLandscape = isLandscapeRect(ratio, cropShape(person.avatarCrop))
const rw = isLandscape ? PERSON_R + 12 : PERSON_R + 2
const rh = isLandscape ? PERSON_R - 8 : PERSON_R + 6
const pad = 3
@@ -1043,6 +1035,7 @@ function drawImageCover(ctx, img, x, y, w, h, crop = null) {
// ============================================================
export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, renderState) {
const view = renderState.view || null
photoPlaceholderHitAreas.length = 0
layoutData.children.forEach(node => {
const pos = posMap.get(node.id)
if (!pos) return
@@ -1,9 +1,10 @@
import { useRef, useCallback } from 'react'
import { getPersonMedias } from '../../data/mockData'
import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants'
import { nodeRadius, renvoiHitAreas } from './renderers'
import { nodeRadius, renvoiHitAreas, photoPlaceholderHitAreas } from './renderers'
const hitRenvoi = (x, y) => renvoiHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1)
const hitPhotoPlaceholder = (x, y) => photoPlaceholderHitAreas.find(a => Math.hypot(x - a.cx, y - a.cy) < a.r)
import { getEntranceProgress } from './utils'
const CURSOR_GRAB = 'default'
@@ -21,7 +22,7 @@ export function useGalaxyInteractions({
canvasRef, transformRef, targetTransformRef,
layoutDataRef, nodeRandomDataRef, entranceRef,
hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef,
getAnimatedPos, onPersonSelect, onMediaSelect,
getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null,
annotationHandlers,
minScale = 0.2,
maxScale = 4,
@@ -138,6 +139,7 @@ export function useGalaxyInteractions({
}
}
if (!isOverClickable && hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true
if (!isOverClickable && hitPhotoPlaceholder(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true
hoveredNodeIdRef.current = newHoveredNode
hoveredMediaKeyRef.current = newHoveredMedia
@@ -262,6 +264,13 @@ export function useGalaxyInteractions({
}
}
// « + photo » : le clic lance directement la photo (la fiche s'ouvre aussi, côté App)
const photoZone = hitPhotoPlaceholder(clickX, clickY)
if (photoZone && onPhotoPlaceholderClick) {
onPhotoPlaceholderClick(photoZone.personId)
return
}
for (const node of layoutData.children) {
if (node._type !== 'person') continue
if (ent && !ent.finished) {
@@ -286,7 +295,7 @@ export function useGalaxyInteractions({
y: rect2.height / 2 - renvoi.targetY * scale,
}
}
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, annotationHandlers])
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, annotationHandlers])
return {
isDragging, isZooming, zoomTimeoutRef, hasDragged,
+26 -34
View File
@@ -1,30 +1,25 @@
/* Menu du compte (DA terracotta), s'ouvre au-dessus de l'avatar en bas à droite */
.avatar-menu.account-menu {
position: absolute;
bottom: calc(100% + 10px);
right: 0;
width: min(320px, 88vw);
max-height: min(72vh, 540px);
overflow-y: auto;
transform-origin: bottom right;
scrollbar-width: thin;
scrollbar-color: var(--pz-line) transparent;
}
.am-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
/* Menu du compte : contenu propre, la coque (popover ou feuille) vit dans NavMenu.css */
.navm-head {
min-height: 60px;
padding: 10px;
border: 0;
border-radius: 12px;
background: transparent;
cursor: pointer;
text-align: left;
}
@media (hover: hover) and (pointer: fine) { .am-head:hover { background: var(--pz-line-soft); } }
.am-head-mono {
.navm-head strong {
display: block;
font: 400 18px/1.2 var(--pz-serif);
color: var(--pz-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navm-head small {
font: 400 12.5px var(--pz-sans);
color: var(--pz-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navm-head-mono {
width: 40px;
height: 40px;
flex: none;
@@ -35,15 +30,11 @@
color: var(--pz-a700);
font: 400 19px/1 var(--pz-serif);
}
.am-head-text { display: grid; gap: 2px; min-width: 0; flex: 1; }
.am-head-text strong { font: 400 18px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.am-head-text span { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.am-head-chevron { color: var(--pz-faint); flex: none; }
.navm-chevron { color: var(--pz-faint); flex: none; }
.am-tree-mono {
.navm-tree-mono {
width: 26px;
height: 26px;
flex: none;
display: grid;
place-items: center;
border-radius: 8px;
@@ -51,7 +42,8 @@
color: var(--pz-a700);
font: 400 14px/1 var(--pz-serif);
}
.am-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.am-check { color: var(--pz-a700) !important; }
.am-single { cursor: default; }
@media (hover: hover) and (pointer: fine) { .am-single:hover { background: transparent; } }
.navm-check { color: var(--pz-a700); }
.navm-current { cursor: default; }
.navm-current:hover { background: transparent !important; }
/* Arbre en cours : désactivé pour le clavier et le clic, mais texte à sa couleur normale (pas la mise en retrait de .navm-item:disabled) */
.navm-item.navm-current:disabled { opacity: 1; cursor: default; color: var(--pz-ink); }
+109 -68
View File
@@ -1,95 +1,136 @@
import { Check, ChevronRight, Globe, LogOut, Plus, Settings } from 'lucide-react'
import { Check, ChevronRight, Globe, HelpCircle, LogIn, LogOut, Plus, Settings, Share2, TreeDeciduous, UserPlus } from 'lucide-react'
import { t } from '../../i18n/index.js'
import LanguageSwitch from './LanguageSwitch'
import { NavMenu, NavMenuGroup, NavMenuItem, NavMenuSep } from './NavMenu'
import './AvatarMenu.css'
const ICON = { size: 18, strokeWidth: 2, 'aria-hidden': true }
/**
* Menu du rond de compte. Connecté : identité, propositions à relire, arbres, paramètres, aide, langue, déconnexion.
* Sans compte : se connecter, créer son arbre, aide, langue.
*/
function AvatarMenu({
phone = false,
triggerRef,
guest = false,
onClose,
userDisplayName,
userEmail,
linkedPersonName = '',
trees = [],
activeTreeId,
activeTreeName = '',
isOwner = false,
pendingContributions = 0,
onContributionsClick,
onShareClick,
helpUrl = '',
onAccountClick,
onSelectTree,
onCreateTree,
onSettings,
onLogin,
onRegister,
onLogout,
}) {
const hasSeveralTrees = trees.length > 1
const currentTreeName = (
trees.find((entry) => String(entry.id) === String(activeTreeId))?.name
|| activeTreeName
|| trees[0]?.name
|| t('Aucun arbre')
)
const displayName = linkedPersonName || userDisplayName || t('Mon compte')
const currentTreeName = trees.find((entry) => String(entry.id) === String(activeTreeId))?.name
|| activeTreeName || trees[0]?.name || t('Aucun arbre')
return (
<div className="avatar-menu account-menu pz-menu" role="menu">
<button type="button" className="am-head" onClick={onAccountClick} role="menuitem">
<span className="am-head-mono" aria-hidden="true">{displayName.trim().charAt(0).toUpperCase()}</span>
<span className="am-head-text">
<strong>{displayName}</strong>
<span>{userEmail || t('Mon compte')}</span>
</span>
<ChevronRight size={16} aria-hidden="true" className="am-head-chevron" />
</button>
<div className="pz-menu-sep" />
<p className="pz-menu-label">{hasSeveralTrees ? t('Vos arbres') : t('Arbre ouvert')}</p>
{hasSeveralTrees ? (
trees.map((tree) => {
const isActive = String(tree.id) === String(activeTreeId)
return (
<button
key={tree.id}
type="button"
role="menuitem"
className={`pz-menu-item ${isActive ? 'is-active' : ''}`}
onClick={() => onSelectTree?.(tree.id)}
>
<span className="am-tree-mono" aria-hidden="true">{(tree.name || '?').trim().charAt(0).toUpperCase()}</span>
<span className="am-tree-name">{tree.name}</span>
{isActive && <Check size={16} aria-hidden="true" className="am-check" />}
</button>
)
})
const helpRow = (
<div className="navm-row">
{helpUrl ? (
<NavMenuItem
icon={<HelpCircle {...ICON} />}
label={t('Aide')}
onSelect={() => { window.open(helpUrl, '_blank', 'noopener'); onClose() }}
/>
) : (
<div className="pz-menu-item am-single" aria-current="true">
<span className="am-tree-mono" aria-hidden="true">{currentTreeName.trim().charAt(0).toUpperCase()}</span>
<span className="am-tree-name">{currentTreeName}</span>
</div>
)}
<button type="button" role="menuitem" className="pz-menu-item" onClick={onCreateTree}>
<Plus size={16} aria-hidden="true" />
<span>{t('Créer un nouvel arbre')}</span>
</button>
<div className="pz-menu-sep" />
<div className="pz-lang-row">
<span className="pz-lang-row-label">
<Globe size={16} aria-hidden="true" />
<span className="navm-item navm-text" aria-hidden="true">
<span className="navm-ic"><Globe {...ICON} /></span>
<span>{t('Langue')}</span>
</span>
<LanguageSwitch />
</div>
<button type="button" role="menuitem" className="pz-menu-item" onClick={onSettings}>
<Settings size={16} aria-hidden="true" />
<span>{t("Paramètres de l'arbre")}</span>
</button>
<button type="button" role="menuitem" className="pz-menu-item pz-menu-item--danger" onClick={onLogout}>
<LogOut size={16} aria-hidden="true" />
<span>{t('Déconnexion')}</span>
</button>
)}
<LanguageSwitch />
</div>
)
const body = guest ? (
<>
<NavMenuItem icon={<LogIn {...ICON} />} label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} />
<NavMenuItem icon={<UserPlus {...ICON} />} label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister?.() }} />
<NavMenuSep />
{helpRow}
</>
) : (
<>
<button type="button" role="menuitem" className="navm-item navm-head" onClick={() => { onClose(); onAccountClick?.() }}>
<span className="navm-head-mono" aria-hidden="true">{displayName.trim().charAt(0).toUpperCase()}</span>
<span className="navm-text">
<strong>{displayName}</strong>
<small>{userEmail || t('Mon compte')}</small>
</span>
<ChevronRight size={16} aria-hidden="true" className="navm-chevron" />
</button>
{isOwner && onContributionsClick && pendingContributions > 0 && (
<NavMenuItem
icon={<Share2 {...ICON} />}
label={t('Propositions à relire')}
onSelect={() => { onClose(); onContributionsClick() }}
right={<span className="navm-badge">{pendingContributions > 99 ? '99+' : pendingContributions}</span>}
/>
)}
<NavMenuSep />
<NavMenuGroup label={t('Mes arbres')}>
{trees.length > 1 ? trees.map((tree) => {
// L'arbre en cours : affiché comme courant (coche terracotta), non cliquable, pas grisé
const isActive = String(tree.id) === String(activeTreeId)
return (
<NavMenuItem
key={tree.id}
icon={<span className="navm-tree-mono" aria-hidden="true">{(tree.name || '?').trim().charAt(0).toUpperCase()}</span>}
label={tree.name}
onSelect={isActive ? undefined : () => { onClose(); onSelectTree?.(tree.id) }}
disabled={isActive}
className={isActive ? 'navm-current' : undefined}
right={isActive ? <Check size={16} aria-hidden="true" className="navm-check" /> : null}
/>
)
}) : (
<NavMenuItem
icon={<TreeDeciduous {...ICON} />}
label={currentTreeName}
disabled
className="navm-current"
right={<Check size={16} aria-hidden="true" className="navm-check" />}
/>
)}
<NavMenuItem icon={<Plus {...ICON} />} label={t('Créer un nouvel arbre')} onSelect={() => { onClose(); onCreateTree?.() }} />
</NavMenuGroup>
{isOwner && onShareClick && (
<NavMenuItem icon={<Share2 {...ICON} />} label={t("Partager l'arbre")} onSelect={() => { onClose(); onShareClick() }} />
)}
{isOwner && (
<NavMenuItem icon={<Settings {...ICON} />} label={t("Paramètres de l'arbre")} onSelect={() => { onClose(); onSettings?.() }} />
)}
<NavMenuSep />
{helpRow}
<NavMenuSep />
<NavMenuItem danger icon={<LogOut {...ICON} />} label={t('Se déconnecter')} onSelect={() => { onClose(); onLogout?.() }} />
</>
)
return (
<NavMenu phone={phone} label={t('Menu compte')} onClose={onClose} triggerRef={triggerRef}>
{body}
</NavMenu>
)
}
export default AvatarMenu
@@ -1,4 +1,4 @@
/* Barre d'actions flottante en bas à droite (DA terracotta) */
/* Barre flottante en bas à droite : une seule pilule (+ et compte), le zoom reste à gauche (DA terracotta, maquette C) */
.contextual-navbar {
position: fixed;
bottom: 20px;
@@ -17,39 +17,70 @@
to { opacity: 1; transform: none; }
}
/* La pilule : ancre des menus (ils s'ouvrent au-dessus, calés à droite) */
.navbar-content {
position: relative;
display: flex;
align-items: center;
gap: 2px;
padding: 5px;
gap: 4px;
padding: 6px;
background: var(--pz-surface);
border: 1px solid var(--pz-line);
border-radius: 999px;
box-shadow: var(--pz-shadow-lg);
box-shadow: 0 1px 2px rgba(42, 38, 34, 0.05), 0 10px 30px rgba(42, 38, 34, 0.12);
}
.nav-dropdown-wrapper {
/* Bouton + : noir, rond ; « Ajouter » à côté quand il y a la place */
.nav-add {
position: relative;
margin-left: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--pz-ink);
color: #fff;
font: 500 14px/1 var(--pz-sans);
white-space: nowrap;
cursor: pointer;
transition: background-color 160ms ease, box-shadow 160ms ease, transform 140ms var(--pz-ease);
-webkit-tap-highlight-color: transparent;
}
.nav-add--label { padding: 0 18px 0 14px; }
.nav-add:active { transform: scale(0.96); }
.nav-add:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; }
@media (hover: hover) and (pointer: fine) { .nav-add:hover { background: #3a3530; } }
.nav-add.is-open,
.nav-add.is-editing { box-shadow: 0 0 0 4px var(--pz-a100); }
/* Avatar du compte */
/* Rond du compte */
.nav-dropdown-wrapper { position: static; }
.nav-avatar {
width: 44px;
height: 44px;
position: relative;
width: 46px;
height: 46px;
flex: none;
display: grid;
place-items: center;
overflow: hidden;
overflow: visible;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--pz-a100);
color: var(--pz-a700);
cursor: pointer;
transition: box-shadow 160ms ease, transform 140ms var(--pz-ease);
box-shadow: 0 0 0 2px var(--pz-surface), 0 0 0 3px var(--pz-line);
-webkit-tap-highlight-color: transparent;
}
.nav-avatar:active { transform: scale(.95); }
@media (hover: hover) and (pointer: fine) { .nav-avatar:hover { box-shadow: 0 0 0 2px var(--pz-surface), 0 0 0 3px var(--pz-a300); } }
.nav-avatar.active,
.nav-avatar.admin.active { box-shadow: 0 0 0 2px var(--pz-surface), 0 0 0 4px var(--pz-a500); }
.nav-avatar > img,
.nav-avatar-photo { border-radius: 50%; }
.nav-avatar--guest { background: var(--pz-line-soft); color: var(--pz-muted); }
.nav-avatar:active { transform: scale(0.95); }
@media (hover: hover) and (pointer: fine) { .nav-avatar:hover { box-shadow: 0 0 0 3px var(--pz-a300); } }
.nav-avatar.active { box-shadow: 0 0 0 4px var(--pz-a500); }
.nav-avatar:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; }
.nav-avatar-initial {
@@ -64,11 +95,30 @@
object-fit: cover;
}
@media (max-width: 768px) {
.contextual-navbar { bottom: 12px; right: 12px; }
.navbar-content { padding: 4px; }
/* Pastille : propositions à relire, sur le rond lui-même */
.nav-avatar-badge {
position: absolute;
top: -3px;
right: -3px;
min-width: 18px;
height: 18px;
box-sizing: border-box;
padding: 0 5px;
border: 2px solid #fff;
border-radius: 999px;
background: var(--pz-a500);
color: #fff;
font: 600 10.5px/14px var(--pz-sans);
text-align: center;
}
@media (max-width: 767px) {
.contextual-navbar { bottom: 12px; right: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.contextual-navbar { animation-duration: 1ms; }
}
/* ============================================================
Mobile Annotation Mode — Navbar Morph
The pill transforms to host annotation tools on mobile
@@ -279,95 +329,3 @@
transform: translateY(0) scale(1);
}
}
/* ============================================================
Famille arrivée par le lien : gestes écrits en toutes lettres
============================================================ */
.nav-family-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 0 18px;
border: 0;
border-radius: 999px;
background: transparent;
font: 500 15.5px/1 var(--pz-sans);
color: var(--pz-ink);
cursor: pointer;
white-space: nowrap;
transition: background 140ms ease, transform 140ms var(--pz-ease);
}
.nav-family-btn:active { transform: scale(.97); }
.nav-family-btn:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) { .nav-family-btn:hover { background: var(--pz-a50); } }
.nav-family-btn--primary { background: var(--pz-ink); color: #fff; }
@media (hover: hover) and (pointer: fine) { .nav-family-btn--primary:hover { background: #3a3530; } }
.nav-family-btn--more.is-open { background: var(--pz-a100); }
.nav-more-wrapper { position: relative; }
.nav-more-menu {
position: absolute;
right: 0;
bottom: calc(100% + 12px);
min-width: 260px;
display: grid;
gap: 2px;
padding: 6px;
background: var(--pz-surface);
border: 1px solid var(--pz-line);
border-radius: var(--pz-r-m);
box-shadow: var(--pz-shadow-lg);
animation: pz-rise 180ms var(--pz-ease) both;
}
.nav-more-menu button,
.nav-more-menu a {
display: flex;
align-items: center;
gap: 10px;
min-height: 48px;
padding: 0 14px;
border: 0;
border-radius: 10px;
background: transparent;
font: 500 15.5px/1.2 var(--pz-sans);
color: var(--pz-ink);
text-align: left;
text-decoration: none;
cursor: pointer;
}
@media (hover: hover) and (pointer: fine) { .nav-more-menu button:hover, .nav-more-menu a:hover { background: var(--pz-a50); } }
@media (max-width: 768px) {
/* Sur téléphone, la barre prend toute la largeur : trois cibles larges, icône et mot court */
.contextual-navbar--family { left: 12px; right: 12px; }
.contextual-navbar--family .navbar-content { justify-content: space-between; gap: 4px; }
.contextual-navbar--family .nav-family-btn {
flex: 1;
flex-direction: column;
justify-content: center;
gap: 4px;
min-height: 64px;
padding: 8px 6px;
border-radius: 22px;
font-size: 14px;
line-height: 1.15;
white-space: normal;
text-align: center;
}
.contextual-navbar--family .nav-family-label-short { display: none; }
/* Sous 420 px, trois libellés complets passent sur deux lignes : on prend les courts, sur une ligne */
@media (max-width: 419px) {
.contextual-navbar--family .nav-family-label-full { display: none; }
.contextual-navbar--family .nav-family-label-short { display: inline; white-space: nowrap; }
}
.contextual-navbar--family .nav-more-wrapper { flex: 1; display: flex; }
.contextual-navbar--family .nav-dropdown-wrapper { margin-left: 0; align-self: center; }
/* Le zoom passe au-dessus de la barre pleine largeur */
body:has(.contextual-navbar--family) .zoom-control { bottom: 104px; }
/* Les messages (merci, ajout) passent au-dessus de la barre, en gros */
body:has(.contextual-navbar--family) .contrib-success-toast { bottom: 108px; font-size: 15px; }
}
/* Choix de la langue dans le menu « Plus » de la famille : même gabarit que les autres lignes */
.nav-more-menu .pz-lang-row { min-height: 48px; padding-left: 14px; font-size: 15.5px; }
@@ -1,16 +1,15 @@
import { useState, useRef, useEffect } from 'react'
import {
Edit3, Share2, User, UserPlus,
X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle, Globe, ArrowLeft,
Brush, ImagePlus, Plus, UserPlus, User, X, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft,
} from 'lucide-react'
import NavButton from './NavButton'
import { NavMenu, NavMenuGroup, NavMenuItem } from './NavMenu'
import AvatarMenu from './AvatarMenu'
import LanguageSwitch from './LanguageSwitch'
import DrawingOptions from '../annotations/DrawingOptions'
import TextOptions from '../annotations/TextOptions'
import StickerPicker from '../annotations/StickerPicker'
import { toJourneyRole, toOpaqueTreeId, trackAppEvent } from '../../utils/analytics'
import { t } from '../../i18n/index.js'
import { PHONE_QUERY } from '../../utils/sheetDrag'
import './ContextualNavbar.css'
const ANNOT_TOOLS = [
@@ -21,7 +20,7 @@ const ANNOT_TOOLS = [
{ id: 'photo', label: t('Photo'), Icon: Image },
]
// Mobile : les outils de dessin attendent dans « Plus », la barre ne montre que l'essentiel
// Téléphone, en modification : les outils de dessin passent dans le menu « + »
const ANNOT_MENU = [
{ id: 'drawing', label: t('Dessiner'), Icon: Pencil },
{ id: 'text', label: t('Écrire un texte'), Icon: Type },
@@ -29,6 +28,9 @@ const ANNOT_MENU = [
{ id: 'photo', label: t('Poser une photo'), Icon: Image },
]
// Téléphone (ou pointeur grossier) : le « + » et le compte s'ouvrent en feuille, sinon en popover (PHONE_QUERY, partagé avec la fiche)
const ICON = { size: 18, strokeWidth: 2, 'aria-hidden': true }
function ContextualNavbar({
userRole,
pendingContributions = 0,
@@ -53,39 +55,35 @@ function ContextualNavbar({
onLogout,
annotationTools = null,
}) {
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false)
// Un seul menu ouvert à la fois : 'add' (le +), 'account' (le rond), ou null
const [openMenu, setOpenMenu] = useState(null)
const [annotPanel, setAnnotPanel] = useState(null)
const [annotMode, setAnnotMode] = useState(false)
const [moreOpen, setMoreOpen] = useState(false)
const moreRef = useRef(null)
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
const [isPhone, setIsPhone] = useState(() =>
typeof window !== 'undefined' && window.matchMedia(PHONE_QUERY).matches
)
const avatarMenuRef = useRef(null)
const navRef = useRef(null)
const addBtnRef = useRef(null)
const avatarBtnRef = useRef(null)
const photoInputRef = useRef(null)
useEffect(() => {
const mql = window.matchMedia('(max-width: 768px)')
const handler = (event) => setIsMobile(event.matches)
const mql = window.matchMedia(PHONE_QUERY)
const handler = (event) => setIsPhone(event.matches)
mql.addEventListener('change', handler)
return () => mql.removeEventListener('change', handler)
}, [])
// Clic dehors ferme le menu sur ordinateur (sur téléphone, c'est le voile)
useEffect(() => {
function handleClickOutside(event) {
if (avatarMenuRef.current && !avatarMenuRef.current.contains(event.target)) {
setAvatarMenuOpen(false)
}
if (moreRef.current && !moreRef.current.contains(event.target)) {
setMoreOpen(false)
}
if (isPhone) return
if (navRef.current && !navRef.current.contains(event.target)) setOpenMenu(null)
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
}, [isPhone])
useEffect(() => {
if (!annotPanel) return
@@ -97,15 +95,14 @@ function ContextualNavbar({
return () => document.removeEventListener('mousedown', handleClick)
}, [annotPanel])
// Échap ferme le menu ouvert et le panneau d'options
useEffect(() => {
function handleEscape(event) {
if (event.key === 'Escape') {
setAvatarMenuOpen(false)
setMoreOpen(false)
setOpenMenu(null)
setAnnotPanel(null)
}
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [])
@@ -118,13 +115,34 @@ function ContextualNavbar({
}, [editModeActive])
const { navbarType, authenticated } = userRole
const showMobileAnnot = isMobile && editModeActive && annotationTools
const isOwner = navbarType === 'admin'
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
const isDemo = userRole.accessMode === 'demo' && !['admin', 'contributor_auth', 'contributor_anon', 'visitor'].includes(navbarType)
const showMobileAnnot = isPhone && editModeActive && annotationTools
const accountRole = toJourneyRole(userRole.role) || 'visitor'
const accountClickProps = {
role: accountRole,
tree_id: toOpaqueTreeId(activeTreeId),
}
const closeMenus = () => setOpenMenu(null)
// « Propositions à relire » ouvre la fenêtre de partage, positionnée sur la section des propositions
const openProposals = () => {
onShareClick?.()
const scrollWhenReady = (retries) => {
const section = document.getElementById('contrib-propositions')
if (section) section.scrollIntoView({ block: 'start' })
else if (retries > 0) setTimeout(() => scrollWhenReady(retries - 1), 100)
}
scrollWhenReady(20)
}
// Chaque action ferme le menu avant de se lancer
const run = (action) => () => {
closeMenus()
action?.()
}
// Création de compte dans l'app
const handleCreateAccountClick = () => {
trackAppEvent('create_account_clicked', accountClickProps)
@@ -156,184 +174,69 @@ function ContextualNavbar({
</button>
)
// Famille arrivée par le lien : les deux gestes utiles écrits en toutes lettres, le reste dans « Plus »
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
const renderFamilyButtons = () => (
<>
{/* Le même crayon que le propriétaire : proposer des changements sans passer par « Plus » */}
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Proposer des changements')}
onClick={onEditClick}
active={editModeActive}
/>
{/* Sous 420 px, un libellé court tient sur une ligne ; le libellé complet reste en aria-label et en title */}
<button type="button" className="nav-family-btn nav-family-btn--primary" onClick={onAddSouvenirClick} aria-label={t('Ajouter un souvenir')} title={t('Ajouter un souvenir')}>
<ImagePlus size={20} strokeWidth={2} aria-hidden="true" />
<span className="nav-family-label-full">{t('Ajouter un souvenir')}</span>
<span className="nav-family-label-short" aria-hidden="true">{t('Souvenir')}</span>
</button>
<button type="button" className="nav-family-btn" onClick={onAddPersonClick} aria-label={t('Ajouter une personne')} title={t('Ajouter une personne')}>
<UserPlus size={20} strokeWidth={2} aria-hidden="true" />
<span className="nav-family-label-full">{t('Ajouter une personne')}</span>
<span className="nav-family-label-short" aria-hidden="true">{t('Personne')}</span>
</button>
<div ref={moreRef} className="nav-more-wrapper">
<button type="button" className={`nav-family-btn nav-family-btn--more${moreOpen ? ' is-open' : ''}`} onClick={() => setMoreOpen((v) => !v)} aria-expanded={moreOpen} aria-haspopup="menu">
<MoreHorizontal size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Plus')}</span>
</button>
{moreOpen && (
<div className="nav-more-menu" role="menu">
<button type="button" role="menuitem" onClick={() => { setMoreOpen(false); onEditClick?.() }}>
<Pencil size={18} strokeWidth={2} aria-hidden="true" />
{t("Dessiner ou écrire sur l'arbre")}
</button>
{helpUrl && (
<a role="menuitem" href={helpUrl} target="_blank" rel="noopener" onClick={() => setMoreOpen(false)}>
<HelpCircle size={18} strokeWidth={2} aria-hidden="true" />
{t("Le mode d'emploi")}
</a>
)}
{!authenticated && (
<button type="button" role="menuitem" onClick={() => { setMoreOpen(false); handleCreateAccountClick() }}>
<User size={18} strokeWidth={2} aria-hidden="true" />
{t('Créer un compte')}
</button>
)}
{!authenticated && (
<div className="pz-lang-row">
<span className="pz-lang-row-label">
<Globe size={18} strokeWidth={2} aria-hidden="true" />
<span>{t('Langue')}</span>
</span>
<LanguageSwitch />
</div>
)}
</div>
)}
</div>
</>
// Le « + » : ce qu'on met sur l'arbre, et ce qu'on corrige
const editLabel = editModeActive
? t('Terminer la modification')
: isFamily
? t('Proposer des changements')
: isDemo
? t('Modifier la démo')
: t("Modifier l'arbre")
let addGroups = []
if (showMobileAnnot) {
addGroups = [{
label: t('Ajouter'),
items: [
{ key: 'person', icon: <UserPlus {...ICON} />, label: t('Une personne'), onSelect: onAddPersonClick },
...ANNOT_MENU.map((tool) => ({
key: tool.id,
icon: <tool.Icon {...ICON} />,
label: tool.label,
onSelect: () => { setAnnotMode(true); handleAnnotToolClick(tool.id) },
})),
],
}]
} else {
const addItems = []
if (onAddSouvenirClick) {
addItems.push({ key: 'souvenir', icon: <ImagePlus {...ICON} />, label: t('Un souvenir'), sub: t('photo, vidéo, voix, document'), onSelect: onAddSouvenirClick })
}
if (isFamily || isOwner || isDemo) {
addItems.push({ key: 'person', icon: <UserPlus {...ICON} />, label: t('Une personne'), onSelect: onAddPersonClick })
addItems.push({ key: 'word', icon: <Brush {...ICON} />, label: t("Un mot ou un dessin sur l'arbre"), onSelect: onEditClick })
}
const fixItems = []
if (isFamily) {
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, sub: editModeActive ? null : t('dates, noms, liens, le propriétaire relit'), onSelect: onEditClick })
} else if (isOwner || isDemo) {
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, onSelect: onEditClick })
}
if (addItems.length) addGroups.push({ label: t('Ajouter'), items: addItems })
if (fixItems.length) addGroups.push({ label: t('Corriger'), items: fixItems })
}
const hasAddMenu = addGroups.length > 0
const renderAddMenu = () => (
<NavMenu phone={isPhone} label={t('Ajouter ou corriger')} onClose={closeMenus} triggerRef={addBtnRef}>
{addGroups.map((group) => (
<NavMenuGroup key={group.label} label={group.label}>
{group.items.map((item) => (
<NavMenuItem
key={item.key}
icon={item.icon}
label={item.label}
sub={item.sub}
onSelect={run(item.onSelect)}
/>
))}
</NavMenuGroup>
))}
</NavMenu>
)
const renderButtons = () => {
switch (navbarType) {
case 'admin':
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t("Modifier l'arbre")}
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label={t('Ajouter une personne')}
onClick={onAddPersonClick}
/>
)}
<NavButton
icon={<Share2 size={20} strokeWidth={2} />}
label={t('Contributions')}
onClick={onShareClick}
badge={pendingContributions}
badgeColor={pendingContributions > 0 ? 'orange' : null}
/>
</>
)
case 'contributor_auth':
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Proposer des changements')}
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label={t('Ajouter une personne')}
onClick={onAddPersonClick}
/>
)}
</>
)
case 'contributor_anon':
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Proposer des changements')}
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label={t('Ajouter une personne')}
onClick={onAddPersonClick}
/>
)}
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={t('Créer un compte')}
onClick={handleCreateAccountClick}
/>
</>
)
case 'visitor':
return (
<>
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={t('Créer un compte')}
onClick={handleCreateAccountClick}
/>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Contribuer (mot de passe contribution requis)')}
onClick={onEditClick}
disabled
/>
</>
)
default:
if (userRole.accessMode === 'demo') {
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Modifier la démo')}
onClick={onEditClick}
active={editModeActive}
/>
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={authenticated ? t('Mon compte') : t('Se connecter')}
onClick={() => onProfileClick?.('login')}
/>
</>
)
}
return (
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={t('Se connecter')}
onClick={() => onProfileClick?.('login')}
/>
)
}
}
const avatarInitial = (linkedPersonName || userDisplayName || userEmail || 'P').trim().charAt(0).toUpperCase()
const pendingBadge = isOwner && onShareClick && pendingContributions > 0
return (
<nav
@@ -391,43 +294,18 @@ function ContextualNavbar({
<X size={18} strokeWidth={2.5} />
</button>
{/* Libellé court : la barre doit tenir à droite du bouton de zoom */}
<button type="button" className="nav-family-btn nav-family-btn--primary" onClick={onAddPersonClick} aria-label={t('Ajouter une personne')}>
<UserPlus size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Ajouter')}</span>
<button
type="button"
ref={addBtnRef}
className={`nav-add${openMenu === 'add' ? ' is-open' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
aria-expanded={openMenu === 'add'}
aria-haspopup="menu"
aria-label={t('Ajouter ou corriger')}
>
<Plus size={20} strokeWidth={2.2} aria-hidden="true" />
</button>
<div ref={moreRef} className="nav-more-wrapper">
<button
type="button"
className={`nav-family-btn nav-family-btn--more${moreOpen ? ' is-open' : ''}`}
onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen}
aria-haspopup="menu"
>
<MoreHorizontal size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Plus')}</span>
</button>
{moreOpen && (
<div className="nav-more-menu" role="menu">
{ANNOT_MENU.map((tool) => (
<button
key={tool.id}
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false)
setAnnotMode(true)
handleAnnotToolClick(tool.id)
}}
>
<tool.Icon size={18} strokeWidth={2} aria-hidden="true" />
{tool.label}
</button>
))}
</div>
)}
</div>
{openMenu === 'add' && renderAddMenu()}
{(annotationTools.selectedAnnotationId || annotationTools.isDragging) && renderAnnotTrash()}
</>
@@ -469,58 +347,72 @@ function ContextualNavbar({
</>
) : (
<>
{isFamily && !editModeActive ? renderFamilyButtons() : renderButtons()}
{/* Hors connexion, pas de menu du compte : le choix de la langue vit dans la barre (ou dans « Plus » pour la famille) */}
{!authenticated && !(isFamily && !editModeActive) && <LanguageSwitch className="pz-lang--nav" />}
{authenticated && (
<div ref={avatarMenuRef} className="nav-dropdown-wrapper">
<div
className={`nav-avatar ${userRole.isAdmin ? 'admin' : ''} ${avatarMenuOpen ? 'active' : ''}`}
onClick={() => setAvatarMenuOpen(!avatarMenuOpen)}
role="button"
tabIndex={0}
aria-label={t('Menu compte')}
>
{userAvatarPhoto ? (
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
) : (
<span className="nav-avatar-initial">{avatarInitial}</span>
)}
</div>
{avatarMenuOpen && (
<AvatarMenu
userDisplayName={userDisplayName}
userEmail={userEmail}
linkedPersonName={linkedPersonName}
trees={trees}
activeTreeId={activeTreeId}
activeTreeName={activeTreeName}
onAccountClick={() => {
onAccountClick?.()
setAvatarMenuOpen(false)
}}
onSelectTree={(treeId) => {
onTreeSelect?.(treeId)
setAvatarMenuOpen(false)
}}
onCreateTree={() => {
onCreateTree?.()
setAvatarMenuOpen(false)
}}
onSettings={() => {
onSettingsClick?.()
setAvatarMenuOpen(false)
}}
onLogout={() => {
onLogout?.()
setAvatarMenuOpen(false)
}}
/>
)}
</div>
{hasAddMenu && (
<button
type="button"
ref={addBtnRef}
className={`nav-add${isPhone ? '' : ' nav-add--label'}${openMenu === 'add' ? ' is-open' : ''}${editModeActive ? ' is-editing' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
aria-expanded={openMenu === 'add'}
aria-haspopup="menu"
aria-label={t('Ajouter ou corriger')}
>
<Plus size={20} strokeWidth={2.2} aria-hidden="true" />
{!isPhone && <span className="nav-add-label">{t('Ajouter')}</span>}
</button>
)}
{hasAddMenu && openMenu === 'add' && renderAddMenu()}
{/* Rond du compte : icône de profil hors connexion, initiale ou photo connecté */}
<div className="nav-dropdown-wrapper">
<button
type="button"
ref={avatarBtnRef}
className={`nav-avatar${authenticated ? '' : ' nav-avatar--guest'}${userRole.isAdmin ? ' admin' : ''}${openMenu === 'account' ? ' active' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'account' ? null : 'account'))}
aria-expanded={openMenu === 'account'}
aria-haspopup="menu"
aria-label={t('Menu compte')}
title={t('Menu compte')}
>
{!authenticated ? (
<User size={20} strokeWidth={2} aria-hidden="true" />
) : userAvatarPhoto ? (
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
) : (
<span className="nav-avatar-initial" aria-hidden="true">{avatarInitial}</span>
)}
{pendingBadge && (
<span className="nav-avatar-badge" aria-hidden="true">{pendingContributions > 9 ? '9+' : pendingContributions}</span>
)}
</button>
{openMenu === 'account' && (
<AvatarMenu
phone={isPhone}
triggerRef={avatarBtnRef}
guest={!authenticated}
onClose={closeMenus}
userDisplayName={userDisplayName}
userEmail={userEmail}
linkedPersonName={linkedPersonName}
trees={trees}
activeTreeId={activeTreeId}
activeTreeName={activeTreeName}
isOwner={isOwner}
pendingContributions={pendingContributions}
onContributionsClick={openProposals}
onShareClick={onShareClick}
helpUrl={helpUrl}
onAccountClick={onAccountClick}
onSelectTree={onTreeSelect}
onCreateTree={onCreateTree}
onSettings={onSettingsClick}
onLogin={() => onProfileClick?.('login')}
onRegister={handleCreateAccountClick}
onLogout={onLogout}
/>
)}
</div>
</>
)}
</div>
@@ -529,4 +421,3 @@ function ContextualNavbar({
}
export default ContextualNavbar
+161
View File
@@ -0,0 +1,161 @@
/* Popover d'ordinateur : ancré au-dessus de la pilule, calé à droite, comme sur la maquette C */
.navm-pop {
position: absolute;
right: 0;
bottom: calc(100% + 14px);
width: 300px;
max-height: min(70vh, 560px);
overflow-y: auto;
padding: 8px 8px 10px;
background: var(--pz-surface);
border-radius: 18px;
box-shadow: var(--pz-shadow-lg);
transform-origin: bottom right;
animation: navmPop 200ms var(--pz-ease) both;
scrollbar-width: thin;
scrollbar-color: var(--pz-line) transparent;
}
@keyframes navmPop {
from { opacity: 0; transform: translateY(6px) scale(0.97); }
to { opacity: 1; transform: none; }
}
/* Téléphone : feuille sur toute la largeur, voile sur l'arbre */
.navm-scrim {
position: fixed;
inset: 0;
z-index: 1001;
background: rgba(42, 38, 34, 0.2);
animation: navmFade 200ms ease both;
}
@keyframes navmFade { from { opacity: 0; } to { opacity: 1; } }
.navm-sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 1002;
max-height: 82vh;
overflow-y: auto;
padding: 0 14px calc(16px + env(safe-area-inset-bottom, 0px));
background: var(--pz-surface);
border-radius: 24px 24px 0 0;
box-shadow: 0 -12px 40px rgba(42, 38, 34, 0.16);
/* Pas de fill-mode : une fois l'entrée finie, le glisser (style inline) reprend la main */
animation: navmSheetIn 320ms var(--pz-ease-drawer);
transition: transform 240ms var(--pz-ease-drawer);
overscroll-behavior: contain;
}
.navm-sheet[data-dragging] { transition: none; }
@keyframes navmSheetIn {
from { transform: translateY(100%); }
to { transform: translateY(0); }
}
.navm-handle {
display: block;
width: 100%;
height: 32px;
margin: 0 0 4px;
cursor: grab;
touch-action: none;
position: sticky;
top: 0;
background: var(--pz-surface);
}
.navm-handle span {
display: block;
width: 40px;
height: 4px;
margin: 12px auto 0;
border-radius: 4px;
background: var(--pz-line);
}
/* Titres de groupe : Newsreader, casse normale */
.navm-group {
margin: 12px 10px 4px;
font: 400 14px/1.2 var(--pz-serif);
color: var(--pz-muted);
}
.navm-group:first-child { margin-top: 6px; }
.navm-item {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: 48px;
padding: 8px 10px;
border: 0;
border-radius: 12px;
background: transparent;
color: var(--pz-ink);
font: 500 15px/1.25 var(--pz-sans);
text-align: left;
text-decoration: none;
cursor: pointer;
transition: background-color 140ms ease, transform 140ms var(--pz-ease);
-webkit-tap-highlight-color: transparent;
}
.navm-item:active:not(:disabled) { transform: scale(0.98); }
.navm-item:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: -2px; }
@media (hover: hover) and (pointer: fine) {
.navm-item:hover:not(:disabled) { background: var(--pz-line-soft); }
}
.navm-item:disabled { opacity: 0.45; cursor: not-allowed; }
.navm-text { flex: 1; min-width: 0; display: grid; gap: 2px; }
.navm-text small {
font: 400 12.5px/1.3 var(--pz-sans);
color: var(--pz-muted);
}
.navm-ic {
width: 34px;
height: 34px;
flex: none;
display: grid;
place-items: center;
border-radius: 11px;
background: var(--pz-a50);
color: var(--pz-a700);
}
.navm-ic svg { width: 18px; height: 18px; }
.navm-item--danger { color: var(--pz-danger); }
.navm-item--danger .navm-ic { background: var(--pz-danger-bg); color: var(--pz-danger); }
.navm-badge {
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
background: var(--pz-a500);
color: #fff;
font: 600 12px/22px var(--pz-sans);
text-align: center;
}
.navm-sep {
height: 1px;
margin: 6px 10px;
background: var(--pz-line-soft);
}
/* Ligne « Aide » et langue sur la même ligne */
.navm-row {
display: flex;
align-items: center;
gap: 8px;
min-height: 48px;
padding: 0 10px;
}
.navm-row .navm-item { flex: 1; padding-left: 0; padding-right: 0; width: auto; }
.navm-row .pz-lang { flex: none; }
@media (prefers-reduced-motion: reduce) {
.navm-pop, .navm-sheet, .navm-scrim { animation-duration: 1ms; }
.navm-sheet { transition-duration: 1ms; }
}
@@ -0,0 +1,85 @@
import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'
import { useSheetDrag } from '../../hooks/useSheetDrag'
import './NavMenu.css'
/**
* Menu partagé de la barre flottante.
* Téléphone : feuille qui monte du bas, voile sur l'arbre, poignée qui se glisse vers le bas pour fermer.
* Ordinateur : popover ancré à la pilule, fermé par le clic dehors et par Échap (géré par la barre).
*/
export function NavMenu({ phone = false, label, onClose, triggerRef, children }) {
const ref = useRef(null)
// Le geste part de la poignée seulement (le contenu de la feuille défile et reste intact)
const drag = useSheetDrag({ enabled: phone, zone: '.navm-handle', onClose, closes: ({ offset }) => offset > 70 })
useEffect(() => {
const node = ref.current
node?.querySelector('[role="menuitem"]:not(:disabled)')?.focus({ preventScroll: true })
return () => {
// Le focus revient au déclencheur quand le menu se referme
if (node?.contains(document.activeElement)) triggerRef?.current?.focus({ preventScroll: true })
}
}, [triggerRef])
if (phone) {
return createPortal(
<>
<div className="navm-scrim" onClick={onClose} aria-hidden="true" />
<div
ref={ref}
className="navm-sheet"
role="dialog"
aria-label={label}
style={drag.style}
data-dragging={drag.dragging || undefined}
{...drag.bind}
>
<div className="navm-handle" aria-hidden="true">
<span />
</div>
{children}
</div>
</>,
document.body,
)
}
return (
<div ref={ref} className="navm-pop" role="dialog" aria-label={label}>
{children}
</div>
)
}
export function NavMenuGroup({ label, children }) {
return (
<div role="group" aria-label={label}>
<p className="navm-group" aria-hidden="true">{label}</p>
{children}
</div>
)
}
export function NavMenuItem({ icon, label, sub = null, onSelect, danger = false, disabled = false, right = null, className = '' }) {
return (
<button
type="button"
role="menuitem"
className={`navm-item${danger ? ' navm-item--danger' : ''}${className ? ` ${className}` : ''}`}
onClick={onSelect}
disabled={disabled}
>
{icon && <span className="navm-ic" aria-hidden="true">{icon}</span>}
<span className="navm-text">
{label}
{sub && <small>{sub}</small>}
</span>
{right}
</button>
)
}
export function NavMenuSep() {
return <div className="navm-sep" role="separator" />
}
@@ -5,6 +5,9 @@
right: 20px;
z-index: 1000;
}
/* Ouverte, ses résultats passent devant la bannière d'édition et « Créer mon arbre » (1001), qui descendent
sous le champ sur téléphone et rendaient les résultats intouchables. Les panneaux (1050) restent devant. */
.expandable-search:has(.search-expanded-content) { z-index: 1003; }
.search-icon-button {
width: 46px;
+147
View File
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from 'react'
import {
PHONE_QUERY,
SHEET_WIDTH_QUERY,
canStartSheetDrag,
shouldCloseSheet,
sheetDragIntent,
velocityBetween,
} from '../utils/sheetDrag'
const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
/**
* Vrai en mode feuille : téléphone ou pointeur grossier, et largeur où la fiche se pose en bas (même règle que la barre).
*/
export function useSheetMode() {
const [sheet, setSheet] = useState(() => (
typeof window !== 'undefined' && window.matchMedia(PHONE_QUERY).matches && window.matchMedia(SHEET_WIDTH_QUERY).matches
))
useEffect(() => {
if (typeof window === 'undefined') return undefined
const phone = window.matchMedia(PHONE_QUERY)
const width = window.matchMedia(SHEET_WIDTH_QUERY)
const update = () => setSheet(phone.matches && width.matches)
phone.addEventListener('change', update)
width.addEventListener('change', update)
return () => {
phone.removeEventListener('change', update)
width.removeEventListener('change', update)
}
}, [])
return sheet
}
/**
* Glisser pour fermer une feuille du bas.
* - Le geste ne part que des éléments de `zone` (sélecteur CSS) et jamais d'un champ de saisie ni d'une sélection de texte.
* - La feuille suit le doigt (transform inline) et ne monte jamais au-delà de sa position de repos.
* - Au lâcher : `closes` décide (par défaut, un tiers de la hauteur ou un lâcher rapide). Fermer attend la fin de la sortie,
* puis appelle `onClose`. Si `onClose` renvoie `false` (enregistrement refusé), la feuille revient.
* - Après un vrai glisser, le clic qui suit est avalé (pas de bouton déclenché au lâcher).
*
* Usage : `<div {...drag.bind} style={drag.style} data-dragging={drag.dragging || undefined}>`
*/
export function useSheetDrag({
enabled = true,
zone,
onClose,
closes = shouldCloseSheet,
closeDelayMs = 180,
}) {
const [offset, setOffset] = useState(0)
const [dragging, setDragging] = useState(false)
const gesture = useRef(null)
const suppressClick = useRef(false)
const closingRef = useRef(false)
const settle = () => {
setDragging(false)
setOffset(0)
}
const close = async (height) => {
closingRef.current = true
setDragging(false)
setOffset(height)
await wait(closeDelayMs)
const kept = await onClose?.()
closingRef.current = false
// Enregistrement refusé : la fiche reste, revient à sa place et l'erreur reste affichée par la fiche
if (kept === false) settle()
}
const onPointerDown = (event) => {
suppressClick.current = false
if (!enabled || closingRef.current || event.button > 0) return
const selection = typeof window !== 'undefined' ? window.getSelection?.()?.toString() ?? '' : ''
if (!canStartSheetDrag(event.target, zone, selection)) return
gesture.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
height: event.currentTarget.offsetHeight,
active: false,
prev: null,
last: { y: event.clientY, t: event.timeStamp },
}
}
const onPointerMove = (event) => {
const g = gesture.current
if (!g || event.pointerId !== g.id) return
const dx = event.clientX - g.x
const dy = event.clientY - g.y
if (!g.active) {
const intent = sheetDragIntent(dx, dy)
if (intent === 'pending') return
const selecting = typeof window !== 'undefined' && window.getSelection?.()?.toString()
if (intent === 'other' || selecting) {
gesture.current = null
return
}
g.active = true
suppressClick.current = true
event.currentTarget.setPointerCapture?.(event.pointerId)
setDragging(true)
}
g.prev = g.last
g.last = { y: event.clientY, t: event.timeStamp }
setOffset(Math.max(0, dy))
}
const onPointerUp = (event) => {
const g = gesture.current
if (!g || event.pointerId !== g.id) return
gesture.current = null
if (!g.active) return
const end = { y: event.clientY, t: event.timeStamp }
const offsetNow = Math.max(0, event.clientY - g.y)
const velocity = velocityBetween(g.prev ?? g.last, end)
if (closes({ offset: offsetNow, height: g.height, velocity })) {
close(g.height)
} else {
settle()
}
}
const onPointerCancel = () => {
const g = gesture.current
gesture.current = null
if (g?.active) settle()
}
// Après un vrai glisser, le clic du lâcher ne doit pas atteindre les boutons de la feuille
const onClickCapture = (event) => {
if (!suppressClick.current) return
suppressClick.current = false
event.stopPropagation()
event.preventDefault()
}
return {
dragging,
bind: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture },
style: offset > 0 ? { transform: `translateY(${offset}px)` } : undefined,
}
}
+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',
}
+13
View File
@@ -221,4 +221,17 @@ export default {
"Pas besoin de compte pour regarder l'arbre.": "No account needed to look at the tree.",
"Pour retrouver vos arbres et ajouter des souvenirs.": "To find your trees again and add memories.",
"Ce n'est pas mon compte": "This is not my account",
// Barre d'actions : menu « + » (ajouter, corriger) et menu du compte
"Ajouter ou corriger": "Add or correct",
"Un souvenir": "A memory",
"photo, vidéo, voix, document": "photo, video, voice, document",
"Une personne": "A person",
"Un mot ou un dessin sur l'arbre": "A word or a drawing on the tree",
"Corriger": "Correct",
"dates, noms, liens, le propriétaire relit": "dates, names, links, the owner reviews",
"Terminer la modification": "Finish editing",
"Aide": "Help",
"Se déconnecter": "Sign out",
"Partager l'arbre": "Share the tree",
}
+7
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',
@@ -417,6 +418,12 @@ export default {
'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.',
// Recadrage du portrait (PortraitCropModal, menu du badge, App)
'Recadrer la photo': 'Crop photo',
'Forme du cadre': 'Frame shape',
'Rond': 'Round',
'Carré': 'Square',
'Polaroïd': 'Polaroid',
'Vertical': 'Vertical',
'Horizontal': 'Horizontal',
'Recadrer': 'Crop',
'Options du portrait': 'Portrait options',
'Zone de recadrage': 'Crop area',
-1
View File
@@ -261,6 +261,5 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
.pz-lang-row-label { display: inline-flex; align-items: center; gap: 10px; }
.pz-lang-row-label svg { color: var(--pz-muted); flex: none; }
/* Dans la barre d'actions, séparé des boutons par un filet */
.pz-lang--nav { margin-left: 4px; padding: 0 4px 0 8px; border-left: 1px solid var(--pz-line); align-self: stretch; }
/* Sous les écrans d'accès et de connexion */
.pz-lang--screen { justify-self: center; }
+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')
+28 -7
View File
@@ -7,19 +7,37 @@ export const DEFAULT_CROP = { x: 0.5, y: 0.5, zoom: 1 }
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
// Formes qu'on choisit au recadrage, gardées dans le cadrage (crop.shape). Sans choix, la forme suit la photo
// (très large ou très haute) ou le cadre tiré au hasard pour la personne.
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape']
export const cropShape = (crop) => (FRAME_SHAPES.includes(crop?.shape) ? crop.shape : null)
// Forme du cadre dans le canevas : même règle que resolveFrameType et drawFrame (renderers.js).
// Une photo très large ou très haute change la forme ; sinon le cadre tiré pour la personne (frameType) reste.
export function resolveFrameKind(frameType, imageRatio) {
// La forme choisie l'emporte ; sinon une photo très large ou très haute change la forme, et à défaut
// le cadre tiré pour la personne (frameType) reste.
export function resolveFrameKind(frameType, imageRatio, shape = null) {
if (shape) return shape === 'portrait' || shape === 'landscape' ? 'rect' : shape
if (imageRatio >= 1.28) return 'rect'
if (imageRatio <= 0.78) return 'polaroid'
return frameType || 'round'
}
// Cadre rectangle : couché si on l'a choisi, sinon selon la photo (carrée : debout, le visage reste entier)
export function isLandscapeRect(imageRatio, shape = null) {
return shape ? shape === 'landscape' : imageRatio > 1.05
}
// Nom de la forme affichée, pour le sélecteur
export function shapeOfKind(frameKind, imageRatio) {
if (frameKind !== 'rect') return frameKind
return isLandscapeRect(imageRatio) ? 'landscape' : 'portrait'
}
// Rapport largeur / hauteur de la zone visible, comme drawFrame le dessine.
export function frameAspect(frameKind, imageRatio) {
export function frameAspect(frameKind, imageRatio, shape = null) {
if (frameKind === 'rect') {
// Paysage : 80 x 40 ; portrait : 60 x 68 (renderers.js, drawFrame)
return imageRatio > 1.05 ? 2 : 60 / 68
return isLandscapeRect(imageRatio, shape) ? 2 : 60 / 68
}
// round, polaroid et square : la zone photo est carrée
return 1
@@ -32,7 +50,8 @@ export function normalizeCrop(crop) {
const y = Number(crop.y)
const zoom = Number(crop.zoom)
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom)) return DEFAULT_CROP
return { x: clamp(x, 0, 1), y: clamp(y, 0, 1), zoom: clamp(zoom, 1, CROP_MAX_ZOOM) }
const shape = cropShape(crop)
return { x: clamp(x, 0, 1), y: clamp(y, 0, 1), zoom: clamp(zoom, 1, CROP_MAX_ZOOM), ...(shape ? { shape } : {}) }
}
// Zone de l'image visible dans le cadre, en pixels de l'image. Le centre est borné pour qu'il n'y ait jamais
@@ -77,11 +96,13 @@ export function imagePlacement(imageW, imageH, aspect, crop, windowW = 1, window
}
}
// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut, sinon le cadrage borné.
export function cropForSave(imageW, imageH, aspect, crop) {
// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut sans forme choisie, sinon le cadrage
// borné, avec la forme quand on l'a choisie.
export function cropForSave(imageW, imageH, aspect, crop, shape = null) {
const c = clampCrop(imageW, imageH, aspect, crop)
const round = (value) => Math.round(value * 10000) / 10000
const normalized = { x: round(c.x), y: round(c.y), zoom: round(c.zoom) }
if (shape) return { ...normalized, shape }
if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
return normalized
}
+27
View File
@@ -8,6 +8,7 @@ import {
imagePlacement,
normalizeCrop,
resolveFrameKind,
shapeOfKind,
} from './portraitCrop.js'
// Le cadrage visible ne sort jamais de la photo : aucun vide dans le cadre
@@ -86,3 +87,29 @@ describe('portraitCrop', () => {
expect(frameAspect('rect', 1)).toBeCloseTo(60 / 68)
})
})
describe('forme choisie au recadrage', () => {
it('l emporte sur le format de la photo et sur le tirage de la personne', () => {
// Photo très large : cadre couché par défaut, rond si on le choisit
expect(resolveFrameKind('polaroid', 1.6)).toBe('rect')
expect(resolveFrameKind('polaroid', 1.6, 'round')).toBe('round')
expect(frameAspect(resolveFrameKind('polaroid', 1.6, 'round'), 1.6, 'round')).toBe(1)
// Vertical choisi sur une photo couchée : la zone visible est debout
expect(resolveFrameKind('round', 1.6, 'portrait')).toBe('rect')
expect(frameAspect('rect', 1.6, 'portrait')).toBeCloseTo(60 / 68)
expect(frameAspect('rect', 0.7, 'landscape')).toBe(2)
})
it('le sélecteur montre la forme en cours quand rien n est choisi', () => {
expect(shapeOfKind('rect', 1.6)).toBe('landscape')
expect(shapeOfKind('rect', 1)).toBe('portrait')
expect(shapeOfKind('polaroid', 0.7)).toBe('polaroid')
})
it('est gardée à la lecture et à l enregistrement, même sans recadrage', () => {
expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 2, shape: 'square' })).toEqual({ x: 0.2, y: 0.3, zoom: 2, shape: 'square' })
expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 2, shape: 'hexagone' })).toEqual({ x: 0.2, y: 0.3, zoom: 2 })
expect(cropForSave(400, 400, 1, DEFAULT_CROP)).toBeNull()
expect(cropForSave(400, 400, 1, DEFAULT_CROP, 'round')).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
})
})
+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')
})
})
+51
View File
@@ -0,0 +1,51 @@
// Geste « feuille » : une feuille du bas (fiche de personne, menu de la barre) se ferme en glissant vers le bas.
// Logique pure, sans React ni DOM, testée dans sheetDrag.test.js. Le hook qui l'utilise est hooks/useSheetDrag.js.
// Téléphone ou pointeur grossier : même règle pour la barre et pour la fiche
export const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
// Même seuil que la feuille CSS de la fiche (PersonCard.css) : au-delà, la fiche est un panneau flottant
export const SHEET_WIDTH_QUERY = '(max-width: 768px)'
// Distance avant de trancher entre glisser vertical, horizontal et tap
export const DIRECTION_LOCK_PX = 8
// Lâcher rapide vers le bas : vitesse en px/ms, et course minimale pour qu'il compte
export const FLICK_VELOCITY = 0.6
export const FLICK_MIN_OFFSET = 24
// Deux points plus espacés que ça ne donnent pas une vitesse fiable
const VELOCITY_WINDOW_MS = 120
// Champs de saisie et libellés : jamais le point de départ d'un glisser
export const NO_DRAG_SELECTOR = 'input, textarea, select, label, [contenteditable]:not([contenteditable="false"]), [data-sheet-no-drag]'
/**
* Le glisser peut-il partir de cette cible ?
* `zoneSelector` : les seuls endroits de la feuille qui déclenchent le geste (poignée, en-tête).
* `selectionText` : texte sélectionné au moment de l'appui, auquel cas rien ne bouge.
*/
export function canStartSheetDrag(target, zoneSelector, selectionText = '') {
if (!target || typeof target.closest !== 'function') return false
if (selectionText) return false
if (!target.closest(zoneSelector)) return false
return !target.closest(NO_DRAG_SELECTOR)
}
/** Intention du mouvement depuis l'appui : 'pending' (encore trop petit), 'down' (glisser vers le bas), 'other' (haut ou horizontal) */
export function sheetDragIntent(dx, dy) {
if (Math.abs(dx) < DIRECTION_LOCK_PX && Math.abs(dy) < DIRECTION_LOCK_PX) return 'pending'
if (dy > 0 && dy >= Math.abs(dx)) return 'down'
return 'other'
}
/** Vitesse (px/ms, positive vers le bas) entre deux points {y, t}. Zéro si les points sont trop éloignés dans le temps. */
export function velocityBetween(from, to) {
if (!from || !to) return 0
const dt = to.t - from.t
if (dt <= 0 || dt > VELOCITY_WINDOW_MS) return 0
return (to.y - from.y) / dt
}
/** Au lâcher : fermer si la course dépasse un tiers de la hauteur, ou si le lâcher est rapide vers le bas */
export function shouldCloseSheet({ offset, height, velocity = 0 }) {
if (height > 0 && offset >= height / 3) return true
return velocity >= FLICK_VELOCITY && offset >= FLICK_MIN_OFFSET
}
+97
View File
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest'
import {
NO_DRAG_SELECTOR,
canStartSheetDrag,
shouldCloseSheet,
sheetDragIntent,
velocityBetween,
} from './sheetDrag'
const ZONE = '.pc-grab, .pc-head, .pc-close'
// Cible factice : `closest` ne répond que pour la zone et les champs demandés
function target({ inZone = true, inField = false } = {}) {
return {
closest(selector) {
if (selector === ZONE) return inZone ? {} : null
if (selector === NO_DRAG_SELECTOR) return inField ? {} : null
return null
},
}
}
describe('canStartSheetDrag (zones autorisées)', () => {
it('part de la poignée, de l\'en-tête ou de la croix', () => {
expect(canStartSheetDrag(target(), ZONE)).toBe(true)
})
it('ne part pas du contenu hors zone (onglets, sections)', () => {
expect(canStartSheetDrag(target({ inZone: false }), ZONE)).toBe(false)
})
it('ne part jamais d\'un champ de saisie ou d\'un libellé de l\'en-tête', () => {
expect(canStartSheetDrag(target({ inField: true }), ZONE)).toBe(false)
})
it('ne part pas pendant une sélection de texte', () => {
expect(canStartSheetDrag(target(), ZONE, 'Marie')).toBe(false)
expect(canStartSheetDrag(target(), ZONE, '')).toBe(true)
})
it('refuse une cible sans closest (pas un élément DOM)', () => {
expect(canStartSheetDrag(null, ZONE)).toBe(false)
expect(canStartSheetDrag({}, ZONE)).toBe(false)
})
})
describe('sheetDragIntent (direction)', () => {
it('attend un mouvement suffisant avant de trancher', () => {
expect(sheetDragIntent(0, 0)).toBe('pending')
expect(sheetDragIntent(3, 5)).toBe('pending')
})
it('reconnaît un glisser vers le bas', () => {
expect(sheetDragIntent(0, 20)).toBe('down')
expect(sheetDragIntent(10, 20)).toBe('down')
})
it('rejette le haut et l\'horizontal', () => {
expect(sheetDragIntent(0, -20)).toBe('other')
expect(sheetDragIntent(30, 20)).toBe('other')
})
})
describe('shouldCloseSheet (seuil et vitesse)', () => {
it('ferme au-delà d\'un tiers de la hauteur', () => {
expect(shouldCloseSheet({ offset: 100, height: 300 })).toBe(true)
expect(shouldCloseSheet({ offset: 99, height: 300 })).toBe(false)
})
it('ferme sur un lâcher rapide vers le bas, même petit', () => {
expect(shouldCloseSheet({ offset: 30, height: 600, velocity: 0.7 })).toBe(true)
})
it('ne ferme pas sur un lâcher rapide mais trop court', () => {
expect(shouldCloseSheet({ offset: 10, height: 600, velocity: 2 })).toBe(false)
})
it('ne ferme pas sur une course lente et courte', () => {
expect(shouldCloseSheet({ offset: 60, height: 600, velocity: 0.1 })).toBe(false)
})
it('ne ferme jamais sans déplacement vers le bas', () => {
expect(shouldCloseSheet({ offset: 0, height: 300, velocity: 5 })).toBe(false)
})
})
describe('velocityBetween', () => {
it('donne la vitesse vers le bas en px/ms', () => {
expect(velocityBetween({ y: 100, t: 0 }, { y: 160, t: 60 })).toBe(1)
})
it('est nulle si les points manquent, ne se suivent pas ou sont trop espacés', () => {
expect(velocityBetween(null, { y: 1, t: 1 })).toBe(0)
expect(velocityBetween({ y: 0, t: 10 }, { y: 50, t: 10 })).toBe(0)
expect(velocityBetween({ y: 0, t: 0 }, { y: 50, t: 500 })).toBe(0)
})
})