diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index df28b4a..5043b51 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3095,8 +3095,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} 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/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} -
- )} - - - -
- -
- -
- - - - + )} +
) + + 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 ( + + + {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)}> - - )} - {!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..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( + <> +