diff --git a/backend/src/lib/avatar-crop.ts b/backend/src/lib/avatar-crop.ts index c93aee0..d386377 100644 --- a/backend/src/lib/avatar-crop.ts +++ b/backend/src/lib/avatar-crop.ts @@ -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 } diff --git a/backend/test/avatar-crop.test.ts b/backend/test/avatar-crop.test.ts index 0dfb78a..5a1a2d9 100644 --- a/backend/test/avatar-crop.test.ts +++ b/backend/test/avatar-crop.test.ts @@ -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) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index e6ba106..8a3b67e 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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} /> + {tree.canEditCurrentTree && ( + + )} {tree.selectedPerson && ( 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} diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 409762b..89eb67d 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -434,7 +434,7 @@ const ContributionPanel = ({ {/* ── En-tête sessions ── */}
-

{t('Propositions à relire')}

+

{t('Propositions à relire')}

{t('Acceptez ou refusez chaque modification, puis appliquez vos choix.')}

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 && (
{showDeleteConfirm ? ( diff --git a/frontend/src/components/PortraitCropModal.css b/frontend/src/components/PortraitCropModal.css index 7391117..8909225 100644 --- a/frontend/src/components/PortraitCropModal.css +++ b/frontend/src/components/PortraitCropModal.css @@ -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; } diff --git a/frontend/src/components/PortraitCropModal.jsx b/frontend/src/components/PortraitCropModal.jsx index 445da28..9c49f05 100644 --- a/frontend/src/components/PortraitCropModal.jsx +++ b/frontend/src/components/PortraitCropModal.jsx @@ -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 &&
}
+
+ {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} -
- )} - - - -
- -
- -
- - - - + )} +
) + + 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 ( + + + {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)}> - - )} - {!authenticated && ( - - )} - {!authenticated && ( -
- - - -
- )} -
- )} -
- + // 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 (
@@ -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( + <> +