diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx
index 85f5574..805e834 100644
--- a/frontend/src/components/Galaxy.jsx
+++ b/frontend/src/components/Galaxy.jsx
@@ -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,
diff --git a/frontend/src/components/PersonCard.css b/frontend/src/components/PersonCard.css
index d1754b6..8930ab2 100644
--- a/frontend/src/components/PersonCard.css
+++ b/frontend/src/components/PersonCard.css
@@ -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; }
diff --git a/frontend/src/components/PersonCard.jsx b/frontend/src/components/PersonCard.jsx
index b4cd7c0..d50b17a 100644
--- a/frontend/src/components/PersonCard.jsx
+++ b/frontend/src/components/PersonCard.jsx
@@ -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 (
-
+
{/* ── Actions d'édition ── */}
+ {editMode && saveError &&
{saveError}
}
{editMode && (
+
+ {Object.entries(SHAPE_LABELS).map(([value, label]) => (
+
+ ))}
+
+
{t('Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.')}
diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js
index 3c8dd8f..1063d40 100644
--- a/frontend/src/components/galaxy/renderers.js
+++ b/frontend/src/components/galaxy/renderers.js
@@ -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
diff --git a/frontend/src/components/galaxy/useGalaxyInteractions.js b/frontend/src/components/galaxy/useGalaxyInteractions.js
index 2273403..f149fa9 100644
--- a/frontend/src/components/galaxy/useGalaxyInteractions.js
+++ b/frontend/src/components/galaxy/useGalaxyInteractions.js
@@ -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,
diff --git a/frontend/src/components/navbar/AvatarMenu.css b/frontend/src/components/navbar/AvatarMenu.css
index 7ecc013..e480cc3 100644
--- a/frontend/src/components/navbar/AvatarMenu.css
+++ b/frontend/src/components/navbar/AvatarMenu.css
@@ -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); }
diff --git a/frontend/src/components/navbar/AvatarMenu.jsx b/frontend/src/components/navbar/AvatarMenu.jsx
index 255ffed..20e1e13 100644
--- a/frontend/src/components/navbar/AvatarMenu.jsx
+++ b/frontend/src/components/navbar/AvatarMenu.jsx
@@ -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 (
-
-
-
-
-
{hasSeveralTrees ? t('Vos arbres') : t('Arbre ouvert')}
-
- {hasSeveralTrees ? (
- trees.map((tree) => {
- const isActive = String(tree.id) === String(activeTreeId)
- return (
-
- )
- })
+ const helpRow = (
+
+ {helpUrl ? (
+
}
+ label={t('Aide')}
+ onSelect={() => { window.open(helpUrl, '_blank', 'noopener'); onClose() }}
+ />
) : (
-
- {currentTreeName.trim().charAt(0).toUpperCase()}
- {currentTreeName}
-
- )}
-
-
-
-
-
-
-
-
+
+
{t('Langue')}
-
-
-
-
-
-
+ )}
+
)
+
+ const body = guest ? (
+ <>
+
} label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} />
+
} label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister?.() }} />
+
+ {helpRow}
+ >
+ ) : (
+ <>
+
+
+ {isOwner && onContributionsClick && pendingContributions > 0 && (
+
}
+ label={t('Propositions à relire')}
+ onSelect={() => { onClose(); onContributionsClick() }}
+ right={
{pendingContributions > 99 ? '99+' : pendingContributions}}
+ />
+ )}
+
+
+
+ {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 (
+ {(tree.name || '?').trim().charAt(0).toUpperCase()}}
+ label={tree.name}
+ onSelect={isActive ? undefined : () => { onClose(); onSelectTree?.(tree.id) }}
+ disabled={isActive}
+ className={isActive ? 'navm-current' : undefined}
+ right={isActive ? : null}
+ />
+ )
+ }) : (
+ }
+ label={currentTreeName}
+ disabled
+ className="navm-current"
+ right={}
+ />
+ )}
+ } label={t('Créer un nouvel arbre')} onSelect={() => { onClose(); onCreateTree?.() }} />
+
+
+ {isOwner && onShareClick && (
+
} label={t("Partager l'arbre")} onSelect={() => { onClose(); onShareClick() }} />
+ )}
+
+ {isOwner && (
+
} label={t("Paramètres de l'arbre")} onSelect={() => { onClose(); onSettings?.() }} />
+ )}
+
+
+ {helpRow}
+
+
} label={t('Se déconnecter')} onSelect={() => { onClose(); onLogout?.() }} />
+ >
+ )
+
+ return (
+
+ {body}
+
+ )
}
export default AvatarMenu
diff --git a/frontend/src/components/navbar/ContextualNavbar.css b/frontend/src/components/navbar/ContextualNavbar.css
index afa8203..74ab50d 100644
--- a/frontend/src/components/navbar/ContextualNavbar.css
+++ b/frontend/src/components/navbar/ContextualNavbar.css
@@ -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; }
diff --git a/frontend/src/components/navbar/ContextualNavbar.jsx b/frontend/src/components/navbar/ContextualNavbar.jsx
index 94ebbc1..06c9c3c 100644
--- a/frontend/src/components/navbar/ContextualNavbar.jsx
+++ b/frontend/src/components/navbar/ContextualNavbar.jsx
@@ -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({
)
- // 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 » */}
-
}
- 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 */}
-
-
-
-
- {moreOpen && (
-
-
- {helpUrl && (
-
setMoreOpen(false)}>
-
- {t("Le mode d'emploi")}
-
- )}
- {!authenticated && (
-
- )}
- {!authenticated && (
-
-
-
- {t('Langue')}
-
-
-
- )}
-
- )}
-
- >
+ // 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:
, label: t('Une personne'), onSelect: onAddPersonClick },
+ ...ANNOT_MENU.map((tool) => ({
+ key: tool.id,
+ icon:
,
+ label: tool.label,
+ onSelect: () => { setAnnotMode(true); handleAnnotToolClick(tool.id) },
+ })),
+ ],
+ }]
+ } else {
+ const addItems = []
+ if (onAddSouvenirClick) {
+ addItems.push({ key: 'souvenir', icon:
, label: t('Un souvenir'), sub: t('photo, vidéo, voix, document'), onSelect: onAddSouvenirClick })
+ }
+ if (isFamily || isOwner || isDemo) {
+ addItems.push({ key: 'person', icon:
, label: t('Une personne'), onSelect: onAddPersonClick })
+ addItems.push({ key: 'word', icon:
, label: t("Un mot ou un dessin sur l'arbre"), onSelect: onEditClick })
+ }
+ const fixItems = []
+ if (isFamily) {
+ fixItems.push({ key: 'edit', 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:
, 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 = () => (
+
+ {addGroups.map((group) => (
+
+ {group.items.map((item) => (
+
+ ))}
+
+ ))}
+
)
- const renderButtons = () => {
- switch (navbarType) {
- case 'admin':
- return (
- <>
-
}
- label={t("Modifier l'arbre")}
- onClick={onEditClick}
- active={editModeActive}
- />
- {editModeActive && (
-
}
- label={t('Ajouter une personne')}
- onClick={onAddPersonClick}
- />
- )}
-
}
- label={t('Contributions')}
- onClick={onShareClick}
- badge={pendingContributions}
- badgeColor={pendingContributions > 0 ? 'orange' : null}
- />
- >
- )
-
- case 'contributor_auth':
- return (
- <>
-
}
- label={t('Proposer des changements')}
- onClick={onEditClick}
- active={editModeActive}
- />
- {editModeActive && (
-
}
- label={t('Ajouter une personne')}
- onClick={onAddPersonClick}
- />
- )}
- >
- )
-
- case 'contributor_anon':
- return (
- <>
-
}
- label={t('Proposer des changements')}
- onClick={onEditClick}
- active={editModeActive}
- />
- {editModeActive && (
-
}
- label={t('Ajouter une personne')}
- onClick={onAddPersonClick}
- />
- )}
-
}
- label={t('Créer un compte')}
- onClick={handleCreateAccountClick}
- />
- >
- )
-
- case 'visitor':
- return (
- <>
-
}
- label={t('Créer un compte')}
- onClick={handleCreateAccountClick}
- />
-
}
- label={t('Contribuer (mot de passe contribution requis)')}
- onClick={onEditClick}
- disabled
- />
- >
- )
-
- default:
- if (userRole.accessMode === 'demo') {
- return (
- <>
-
}
- label={t('Modifier la démo')}
- onClick={onEditClick}
- active={editModeActive}
- />
-
}
- label={authenticated ? t('Mon compte') : t('Se connecter')}
- onClick={() => onProfileClick?.('login')}
- />
- >
- )
- }
-
- return (
-
}
- label={t('Se connecter')}
- onClick={() => onProfileClick?.('login')}
- />
- )
- }
- }
-
const avatarInitial = (linkedPersonName || userDisplayName || userEmail || 'P').trim().charAt(0).toUpperCase()
+ const pendingBadge = isOwner && onShareClick && pendingContributions > 0
return (
- {/* Libellé court : la barre doit tenir à droite du bouton de zoom */}
-
@@ -529,4 +421,3 @@ function ContextualNavbar({
}
export default ContextualNavbar
-
diff --git a/frontend/src/components/navbar/NavMenu.css b/frontend/src/components/navbar/NavMenu.css
new file mode 100644
index 0000000..e29f469
--- /dev/null
+++ b/frontend/src/components/navbar/NavMenu.css
@@ -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; }
+}
diff --git a/frontend/src/components/navbar/NavMenu.jsx b/frontend/src/components/navbar/NavMenu.jsx
new file mode 100644
index 0000000..46e7747
--- /dev/null
+++ b/frontend/src/components/navbar/NavMenu.jsx
@@ -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(
+ <>
+
+
+ >,
+ document.body,
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function NavMenuGroup({ label, children }) {
+ return (
+
+ )
+}
+
+export function NavMenuItem({ icon, label, sub = null, onSelect, danger = false, disabled = false, right = null, className = '' }) {
+ return (
+
+ {icon && {icon}}
+
+ {label}
+ {sub && {sub}}
+
+ {right}
+
+ )
+}
+
+export function NavMenuSep() {
+ return
+}
diff --git a/frontend/src/components/search/ExpandableSearch.css b/frontend/src/components/search/ExpandableSearch.css
index 4c98a0c..6f9dd02 100644
--- a/frontend/src/components/search/ExpandableSearch.css
+++ b/frontend/src/components/search/ExpandableSearch.css
@@ -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;
diff --git a/frontend/src/hooks/useSheetDrag.js b/frontend/src/hooks/useSheetDrag.js
new file mode 100644
index 0000000..acb5683
--- /dev/null
+++ b/frontend/src/hooks/useSheetDrag.js
@@ -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 : `
`
+ */
+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,
+ }
+}
diff --git a/frontend/src/i18n/en/app.js b/frontend/src/i18n/en/app.js
index 885661f..52725a0 100644
--- a/frontend/src/i18n/en/app.js
+++ b/frontend/src/i18n/en/app.js
@@ -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',
}
diff --git a/frontend/src/i18n/en/compte.js b/frontend/src/i18n/en/compte.js
index fc27741..356c50f 100644
--- a/frontend/src/i18n/en/compte.js
+++ b/frontend/src/i18n/en/compte.js
@@ -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",
}
diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js
index dd768de..75018d8 100644
--- a/frontend/src/i18n/en/fiche.js
+++ b/frontend/src/i18n/en/fiche.js
@@ -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',
diff --git a/frontend/src/styles/pz.css b/frontend/src/styles/pz.css
index d62a4b2..ef32ef0 100644
--- a/frontend/src/styles/pz.css
+++ b/frontend/src/styles/pz.css
@@ -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; }
diff --git a/frontend/src/utils/lifeTimeline.js b/frontend/src/utils/lifeTimeline.js
index ed684f9..5bb3078 100644
--- a/frontend/src/utils/lifeTimeline.js
+++ b/frontend/src/utils/lifeTimeline.js
@@ -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
}
diff --git a/frontend/src/utils/lifeTimeline.test.js b/frontend/src/utils/lifeTimeline.test.js
index a4b0105..d99a320 100644
--- a/frontend/src/utils/lifeTimeline.test.js
+++ b/frontend/src/utils/lifeTimeline.test.js
@@ -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')
diff --git a/frontend/src/utils/portraitCrop.js b/frontend/src/utils/portraitCrop.js
index b1b7b8a..e50fbd7 100644
--- a/frontend/src/utils/portraitCrop.js
+++ b/frontend/src/utils/portraitCrop.js
@@ -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
}
diff --git a/frontend/src/utils/portraitCrop.test.js b/frontend/src/utils/portraitCrop.test.js
index 6b4eff8..c11bff2 100644
--- a/frontend/src/utils/portraitCrop.test.js
+++ b/frontend/src/utils/portraitCrop.test.js
@@ -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' })
+ })
+})
diff --git a/frontend/src/utils/saveOnLeave.js b/frontend/src/utils/saveOnLeave.js
new file mode 100644
index 0000000..89665f5
--- /dev/null
+++ b/frontend/src/utils/saveOnLeave.js
@@ -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
+}
diff --git a/frontend/src/utils/saveOnLeave.test.js b/frontend/src/utils/saveOnLeave.test.js
new file mode 100644
index 0000000..c8277cb
--- /dev/null
+++ b/frontend/src/utils/saveOnLeave.test.js
@@ -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')
+ })
+})
diff --git a/frontend/src/utils/sheetDrag.js b/frontend/src/utils/sheetDrag.js
new file mode 100644
index 0000000..b54063d
--- /dev/null
+++ b/frontend/src/utils/sheetDrag.js
@@ -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
+}
diff --git a/frontend/src/utils/sheetDrag.test.js b/frontend/src/utils/sheetDrag.test.js
new file mode 100644
index 0000000..9ee6dea
--- /dev/null
+++ b/frontend/src/utils/sheetDrag.test.js
@@ -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)
+ })
+})