diff --git a/frontend/src/components/navbar/AvatarMenu.css b/frontend/src/components/navbar/AvatarMenu.css
index 7ecc013..8f2d1bd 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,6 @@
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; }
diff --git a/frontend/src/components/navbar/AvatarMenu.jsx b/frontend/src/components/navbar/AvatarMenu.jsx
index 255ffed..c72cdfa 100644
--- a/frontend/src/components/navbar/AvatarMenu.jsx
+++ b/frontend/src/components/navbar/AvatarMenu.jsx
@@ -1,95 +1,132 @@
-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) => {
+ const isActive = String(tree.id) === String(activeTreeId)
+ return (
+ {(tree.name || '?').trim().charAt(0).toUpperCase()}}
+ label={tree.name}
+ onSelect={() => { onClose(); onSelectTree?.(tree.id) }}
+ right={isActive ? : null}
+ />
+ )
+ }) : (
+ }
+ label={currentTreeName}
+ disabled
+ className="navm-current"
+ />
+ )}
+ } 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 eb83746..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,98 +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; }
-
-/* Libellé court de la barre de la famille : caché partout, affiché seulement sous 420 px (règle dans le bloc téléphone) */
-.nav-family-label-short { display: none; }
diff --git a/frontend/src/components/navbar/ContextualNavbar.jsx b/frontend/src/components/navbar/ContextualNavbar.jsx
index 94ebbc1..7ab6058 100644
--- a/frontend/src/components/navbar/ContextualNavbar.jsx
+++ b/frontend/src/components/navbar/ContextualNavbar.jsx
@@ -1,11 +1,9 @@
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'
@@ -21,7 +19,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 +27,10 @@ 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
+const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
+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..4212615
--- /dev/null
+++ b/frontend/src/components/navbar/NavMenu.css
@@ -0,0 +1,158 @@
+/* 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);
+ overscroll-behavior: contain;
+}
+@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; }
+}
diff --git a/frontend/src/components/navbar/NavMenu.jsx b/frontend/src/components/navbar/NavMenu.jsx
new file mode 100644
index 0000000..3e7bcfe
--- /dev/null
+++ b/frontend/src/components/navbar/NavMenu.jsx
@@ -0,0 +1,99 @@
+import { useEffect, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
+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)
+ const dragStart = useRef(null)
+ const [dragY, setDragY] = useState(0)
+
+ 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])
+
+ const onHandleDown = (event) => {
+ dragStart.current = event.clientY
+ event.currentTarget.setPointerCapture?.(event.pointerId)
+ }
+ const onHandleMove = (event) => {
+ if (dragStart.current === null) return
+ setDragY(Math.max(0, event.clientY - dragStart.current))
+ }
+ const onHandleUp = (event) => {
+ if (dragStart.current === null) return
+ const distance = event.clientY - dragStart.current
+ dragStart.current = null
+ if (distance > 70) onClose()
+ else setDragY(0)
+ }
+
+ if (phone) {
+ return createPortal(
+ <>
+
+
+
{ dragStart.current = null; setDragY(0) }}
+ aria-hidden="true"
+ >
+
+
+ {children}
+
+ >,
+ 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/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/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; }