Merge pull request #100 from IliesAllali/feat/barre-c

Barre : une pilule à droite, menus Ajouter/Corriger et compte (variante C)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-19 10:43:10 +02:00
commit 0d08b0f148
10 files changed
+650 -508

No files matched your search

+2 -2
View File
@@ -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}
@@ -434,7 +434,7 @@ const ContributionPanel = ({
{/* ── En-tête sessions ── */}
<div className="contrib-list-header">
<div className="contrib-list-heading">
<h2>{t('Propositions à relire')}</h2>
<h2 id="contrib-propositions">{t('Propositions à relire')}</h2>
<p>{t('Acceptez ou refusez chaque modification, puis appliquez vos choix.')}</p>
</div>
<div className="contrib-list-header-actions">
+24 -34
View File
@@ -1,30 +1,25 @@
/* Menu du compte (DA terracotta), s'ouvre au-dessus de l'avatar en bas à droite */
.avatar-menu.account-menu {
position: absolute;
bottom: calc(100% + 10px);
right: 0;
width: min(320px, 88vw);
max-height: min(72vh, 540px);
overflow-y: auto;
transform-origin: bottom right;
scrollbar-width: thin;
scrollbar-color: var(--pz-line) transparent;
}
.am-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
/* Menu du compte : contenu propre, la coque (popover ou feuille) vit dans NavMenu.css */
.navm-head {
min-height: 60px;
padding: 10px;
border: 0;
border-radius: 12px;
background: transparent;
cursor: pointer;
text-align: left;
}
@media (hover: hover) and (pointer: fine) { .am-head:hover { background: var(--pz-line-soft); } }
.am-head-mono {
.navm-head strong {
display: block;
font: 400 18px/1.2 var(--pz-serif);
color: var(--pz-ink);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navm-head small {
font: 400 12.5px var(--pz-sans);
color: var(--pz-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.navm-head-mono {
width: 40px;
height: 40px;
flex: none;
@@ -35,15 +30,11 @@
color: var(--pz-a700);
font: 400 19px/1 var(--pz-serif);
}
.am-head-text { display: grid; gap: 2px; min-width: 0; flex: 1; }
.am-head-text strong { font: 400 18px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.am-head-text span { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.am-head-chevron { color: var(--pz-faint); flex: none; }
.navm-chevron { color: var(--pz-faint); flex: none; }
.am-tree-mono {
.navm-tree-mono {
width: 26px;
height: 26px;
flex: none;
display: grid;
place-items: center;
border-radius: 8px;
@@ -51,7 +42,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; }
+105 -68
View File
@@ -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 (
<div className="avatar-menu account-menu pz-menu" role="menu">
<button type="button" className="am-head" onClick={onAccountClick} role="menuitem">
<span className="am-head-mono" aria-hidden="true">{displayName.trim().charAt(0).toUpperCase()}</span>
<span className="am-head-text">
<strong>{displayName}</strong>
<span>{userEmail || t('Mon compte')}</span>
</span>
<ChevronRight size={16} aria-hidden="true" className="am-head-chevron" />
</button>
<div className="pz-menu-sep" />
<p className="pz-menu-label">{hasSeveralTrees ? t('Vos arbres') : t('Arbre ouvert')}</p>
{hasSeveralTrees ? (
trees.map((tree) => {
const isActive = String(tree.id) === String(activeTreeId)
return (
<button
key={tree.id}
type="button"
role="menuitem"
className={`pz-menu-item ${isActive ? 'is-active' : ''}`}
onClick={() => onSelectTree?.(tree.id)}
>
<span className="am-tree-mono" aria-hidden="true">{(tree.name || '?').trim().charAt(0).toUpperCase()}</span>
<span className="am-tree-name">{tree.name}</span>
{isActive && <Check size={16} aria-hidden="true" className="am-check" />}
</button>
)
})
const helpRow = (
<div className="navm-row">
{helpUrl ? (
<NavMenuItem
icon={<HelpCircle {...ICON} />}
label={t('Aide')}
onSelect={() => { window.open(helpUrl, '_blank', 'noopener'); onClose() }}
/>
) : (
<div className="pz-menu-item am-single" aria-current="true">
<span className="am-tree-mono" aria-hidden="true">{currentTreeName.trim().charAt(0).toUpperCase()}</span>
<span className="am-tree-name">{currentTreeName}</span>
</div>
)}
<button type="button" role="menuitem" className="pz-menu-item" onClick={onCreateTree}>
<Plus size={16} aria-hidden="true" />
<span>{t('Créer un nouvel arbre')}</span>
</button>
<div className="pz-menu-sep" />
<div className="pz-lang-row">
<span className="pz-lang-row-label">
<Globe size={16} aria-hidden="true" />
<span className="navm-item navm-text" aria-hidden="true">
<span className="navm-ic"><Globe {...ICON} /></span>
<span>{t('Langue')}</span>
</span>
<LanguageSwitch />
</div>
<button type="button" role="menuitem" className="pz-menu-item" onClick={onSettings}>
<Settings size={16} aria-hidden="true" />
<span>{t("Paramètres de l'arbre")}</span>
</button>
<button type="button" role="menuitem" className="pz-menu-item pz-menu-item--danger" onClick={onLogout}>
<LogOut size={16} aria-hidden="true" />
<span>{t('Déconnexion')}</span>
</button>
)}
<LanguageSwitch />
</div>
)
const body = guest ? (
<>
<NavMenuItem icon={<LogIn {...ICON} />} label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} />
<NavMenuItem icon={<UserPlus {...ICON} />} label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister?.() }} />
<NavMenuSep />
{helpRow}
</>
) : (
<>
<button type="button" role="menuitem" className="navm-item navm-head" onClick={() => { onClose(); onAccountClick?.() }}>
<span className="navm-head-mono" aria-hidden="true">{displayName.trim().charAt(0).toUpperCase()}</span>
<span className="navm-text">
<strong>{displayName}</strong>
<small>{userEmail || t('Mon compte')}</small>
</span>
<ChevronRight size={16} aria-hidden="true" className="navm-chevron" />
</button>
{isOwner && onContributionsClick && pendingContributions > 0 && (
<NavMenuItem
icon={<Share2 {...ICON} />}
label={t('Propositions à relire')}
onSelect={() => { onClose(); onContributionsClick() }}
right={<span className="navm-badge">{pendingContributions > 99 ? '99+' : pendingContributions}</span>}
/>
)}
<NavMenuSep />
<NavMenuGroup label={t('Mes arbres')}>
{trees.length > 1 ? trees.map((tree) => {
const isActive = String(tree.id) === String(activeTreeId)
return (
<NavMenuItem
key={tree.id}
icon={<span className="navm-tree-mono" aria-hidden="true">{(tree.name || '?').trim().charAt(0).toUpperCase()}</span>}
label={tree.name}
onSelect={() => { onClose(); onSelectTree?.(tree.id) }}
right={isActive ? <Check size={16} aria-hidden="true" className="navm-check" /> : null}
/>
)
}) : (
<NavMenuItem
icon={<TreeDeciduous {...ICON} />}
label={currentTreeName}
disabled
className="navm-current"
/>
)}
<NavMenuItem icon={<Plus {...ICON} />} label={t('Créer un nouvel arbre')} onSelect={() => { onClose(); onCreateTree?.() }} />
</NavMenuGroup>
{isOwner && onShareClick && (
<NavMenuItem icon={<Share2 {...ICON} />} label={t("Partager l'arbre")} onSelect={() => { onClose(); onShareClick() }} />
)}
{isOwner && (
<NavMenuItem icon={<Settings {...ICON} />} label={t("Paramètres de l'arbre")} onSelect={() => { onClose(); onSettings?.() }} />
)}
<NavMenuSep />
{helpRow}
<NavMenuSep />
<NavMenuItem danger icon={<LogOut {...ICON} />} label={t('Se déconnecter')} onSelect={() => { onClose(); onLogout?.() }} />
</>
)
return (
<NavMenu phone={phone} label={t('Menu compte')} onClose={onClose} triggerRef={triggerRef}>
{body}
</NavMenu>
)
}
export default AvatarMenu
@@ -1,4 +1,4 @@
/* Barre d'actions flottante en bas à droite (DA terracotta) */
/* Barre flottante en bas à droite : une seule pilule (+ et compte), le zoom reste à gauche (DA terracotta, maquette C) */
.contextual-navbar {
position: fixed;
bottom: 20px;
@@ -17,39 +17,70 @@
to { opacity: 1; transform: none; }
}
/* La pilule : ancre des menus (ils s'ouvrent au-dessus, calés à droite) */
.navbar-content {
position: relative;
display: flex;
align-items: center;
gap: 2px;
padding: 5px;
gap: 4px;
padding: 6px;
background: var(--pz-surface);
border: 1px solid var(--pz-line);
border-radius: 999px;
box-shadow: var(--pz-shadow-lg);
box-shadow: 0 1px 2px rgba(42, 38, 34, 0.05), 0 10px 30px rgba(42, 38, 34, 0.12);
}
.nav-dropdown-wrapper {
/* Bouton + : noir, rond ; « Ajouter » à côté quand il y a la place */
.nav-add {
position: relative;
margin-left: 4px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 46px;
height: 46px;
padding: 0;
border: 0;
border-radius: 999px;
background: var(--pz-ink);
color: #fff;
font: 500 14px/1 var(--pz-sans);
white-space: nowrap;
cursor: pointer;
transition: background-color 160ms ease, box-shadow 160ms ease, transform 140ms var(--pz-ease);
-webkit-tap-highlight-color: transparent;
}
.nav-add--label { padding: 0 18px 0 14px; }
.nav-add:active { transform: scale(0.96); }
.nav-add:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; }
@media (hover: hover) and (pointer: fine) { .nav-add:hover { background: #3a3530; } }
.nav-add.is-open,
.nav-add.is-editing { box-shadow: 0 0 0 4px var(--pz-a100); }
/* Avatar du compte */
/* Rond du compte */
.nav-dropdown-wrapper { position: static; }
.nav-avatar {
width: 44px;
height: 44px;
position: relative;
width: 46px;
height: 46px;
flex: none;
display: grid;
place-items: center;
overflow: hidden;
overflow: visible;
padding: 0;
border: 0;
border-radius: 50%;
background: var(--pz-a100);
color: var(--pz-a700);
cursor: pointer;
transition: box-shadow 160ms ease, transform 140ms var(--pz-ease);
box-shadow: 0 0 0 2px var(--pz-surface), 0 0 0 3px var(--pz-line);
-webkit-tap-highlight-color: transparent;
}
.nav-avatar:active { transform: scale(.95); }
@media (hover: hover) and (pointer: fine) { .nav-avatar:hover { box-shadow: 0 0 0 2px var(--pz-surface), 0 0 0 3px var(--pz-a300); } }
.nav-avatar.active,
.nav-avatar.admin.active { box-shadow: 0 0 0 2px var(--pz-surface), 0 0 0 4px var(--pz-a500); }
.nav-avatar > img,
.nav-avatar-photo { border-radius: 50%; }
.nav-avatar--guest { background: var(--pz-line-soft); color: var(--pz-muted); }
.nav-avatar:active { transform: scale(0.95); }
@media (hover: hover) and (pointer: fine) { .nav-avatar:hover { box-shadow: 0 0 0 3px var(--pz-a300); } }
.nav-avatar.active { box-shadow: 0 0 0 4px var(--pz-a500); }
.nav-avatar:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; }
.nav-avatar-initial {
@@ -64,11 +95,30 @@
object-fit: cover;
}
@media (max-width: 768px) {
.contextual-navbar { bottom: 12px; right: 12px; }
.navbar-content { padding: 4px; }
/* Pastille : propositions à relire, sur le rond lui-même */
.nav-avatar-badge {
position: absolute;
top: -3px;
right: -3px;
min-width: 18px;
height: 18px;
box-sizing: border-box;
padding: 0 5px;
border: 2px solid #fff;
border-radius: 999px;
background: var(--pz-a500);
color: #fff;
font: 600 10.5px/14px var(--pz-sans);
text-align: center;
}
@media (max-width: 767px) {
.contextual-navbar { bottom: 12px; right: 12px; }
}
@media (prefers-reduced-motion: reduce) {
.contextual-navbar { animation-duration: 1ms; }
}
/* ============================================================
Mobile Annotation Mode — Navbar Morph
The pill transforms to host annotation tools on mobile
@@ -279,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; }
@@ -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({
</button>
)
// Famille arrivée par le lien : les deux gestes utiles écrits en toutes lettres, le reste dans « Plus »
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
const renderFamilyButtons = () => (
<>
{/* Le même crayon que le propriétaire : proposer des changements sans passer par « Plus » */}
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Proposer des changements')}
onClick={onEditClick}
active={editModeActive}
/>
{/* Sous 420 px, un libellé court tient sur une ligne ; le libellé complet reste en aria-label et en title */}
<button type="button" className="nav-family-btn nav-family-btn--primary" onClick={onAddSouvenirClick} aria-label={t('Ajouter un souvenir')} title={t('Ajouter un souvenir')}>
<ImagePlus size={20} strokeWidth={2} aria-hidden="true" />
<span className="nav-family-label-full">{t('Ajouter un souvenir')}</span>
<span className="nav-family-label-short" aria-hidden="true">{t('Souvenir')}</span>
</button>
<button type="button" className="nav-family-btn" onClick={onAddPersonClick} aria-label={t('Ajouter une personne')} title={t('Ajouter une personne')}>
<UserPlus size={20} strokeWidth={2} aria-hidden="true" />
<span className="nav-family-label-full">{t('Ajouter une personne')}</span>
<span className="nav-family-label-short" aria-hidden="true">{t('Personne')}</span>
</button>
<div ref={moreRef} className="nav-more-wrapper">
<button type="button" className={`nav-family-btn nav-family-btn--more${moreOpen ? ' is-open' : ''}`} onClick={() => setMoreOpen((v) => !v)} aria-expanded={moreOpen} aria-haspopup="menu">
<MoreHorizontal size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Plus')}</span>
</button>
{moreOpen && (
<div className="nav-more-menu" role="menu">
<button type="button" role="menuitem" onClick={() => { setMoreOpen(false); onEditClick?.() }}>
<Pencil size={18} strokeWidth={2} aria-hidden="true" />
{t("Dessiner ou écrire sur l'arbre")}
</button>
{helpUrl && (
<a role="menuitem" href={helpUrl} target="_blank" rel="noopener" onClick={() => setMoreOpen(false)}>
<HelpCircle size={18} strokeWidth={2} aria-hidden="true" />
{t("Le mode d'emploi")}
</a>
)}
{!authenticated && (
<button type="button" role="menuitem" onClick={() => { setMoreOpen(false); handleCreateAccountClick() }}>
<User size={18} strokeWidth={2} aria-hidden="true" />
{t('Créer un compte')}
</button>
)}
{!authenticated && (
<div className="pz-lang-row">
<span className="pz-lang-row-label">
<Globe size={18} strokeWidth={2} aria-hidden="true" />
<span>{t('Langue')}</span>
</span>
<LanguageSwitch />
</div>
)}
</div>
)}
</div>
</>
// Le « + » : ce qu'on met sur l'arbre, et ce qu'on corrige
const editLabel = editModeActive
? t('Terminer la modification')
: isFamily
? t('Proposer des changements')
: isDemo
? t('Modifier la démo')
: t("Modifier l'arbre")
let addGroups = []
if (showMobileAnnot) {
addGroups = [{
label: t('Ajouter'),
items: [
{ key: 'person', icon: <UserPlus {...ICON} />, label: t('Une personne'), onSelect: onAddPersonClick },
...ANNOT_MENU.map((tool) => ({
key: tool.id,
icon: <tool.Icon {...ICON} />,
label: tool.label,
onSelect: () => { setAnnotMode(true); handleAnnotToolClick(tool.id) },
})),
],
}]
} else {
const addItems = []
if (onAddSouvenirClick) {
addItems.push({ key: 'souvenir', icon: <ImagePlus {...ICON} />, label: t('Un souvenir'), sub: t('photo, vidéo, voix, document'), onSelect: onAddSouvenirClick })
}
if (isFamily || isOwner || isDemo) {
addItems.push({ key: 'person', icon: <UserPlus {...ICON} />, label: t('Une personne'), onSelect: onAddPersonClick })
addItems.push({ key: 'word', icon: <Brush {...ICON} />, label: t("Un mot ou un dessin sur l'arbre"), onSelect: onEditClick })
}
const fixItems = []
if (isFamily) {
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, sub: editModeActive ? null : t('dates, noms, liens, le propriétaire relit'), onSelect: onEditClick })
} else if (isOwner || isDemo) {
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, onSelect: onEditClick })
}
if (addItems.length) addGroups.push({ label: t('Ajouter'), items: addItems })
if (fixItems.length) addGroups.push({ label: t('Corriger'), items: fixItems })
}
const hasAddMenu = addGroups.length > 0
const renderAddMenu = () => (
<NavMenu phone={isPhone} label={t('Ajouter ou corriger')} onClose={closeMenus} triggerRef={addBtnRef}>
{addGroups.map((group) => (
<NavMenuGroup key={group.label} label={group.label}>
{group.items.map((item) => (
<NavMenuItem
key={item.key}
icon={item.icon}
label={item.label}
sub={item.sub}
onSelect={run(item.onSelect)}
/>
))}
</NavMenuGroup>
))}
</NavMenu>
)
const renderButtons = () => {
switch (navbarType) {
case 'admin':
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t("Modifier l'arbre")}
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label={t('Ajouter une personne')}
onClick={onAddPersonClick}
/>
)}
<NavButton
icon={<Share2 size={20} strokeWidth={2} />}
label={t('Contributions')}
onClick={onShareClick}
badge={pendingContributions}
badgeColor={pendingContributions > 0 ? 'orange' : null}
/>
</>
)
case 'contributor_auth':
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Proposer des changements')}
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label={t('Ajouter une personne')}
onClick={onAddPersonClick}
/>
)}
</>
)
case 'contributor_anon':
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Proposer des changements')}
onClick={onEditClick}
active={editModeActive}
/>
{editModeActive && (
<NavButton
icon={<UserPlus size={20} strokeWidth={2} />}
label={t('Ajouter une personne')}
onClick={onAddPersonClick}
/>
)}
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={t('Créer un compte')}
onClick={handleCreateAccountClick}
/>
</>
)
case 'visitor':
return (
<>
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={t('Créer un compte')}
onClick={handleCreateAccountClick}
/>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Contribuer (mot de passe contribution requis)')}
onClick={onEditClick}
disabled
/>
</>
)
default:
if (userRole.accessMode === 'demo') {
return (
<>
<NavButton
icon={<Edit3 size={20} strokeWidth={2} />}
label={t('Modifier la démo')}
onClick={onEditClick}
active={editModeActive}
/>
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={authenticated ? t('Mon compte') : t('Se connecter')}
onClick={() => onProfileClick?.('login')}
/>
</>
)
}
return (
<NavButton
icon={<User size={20} strokeWidth={2} />}
label={t('Se connecter')}
onClick={() => onProfileClick?.('login')}
/>
)
}
}
const avatarInitial = (linkedPersonName || userDisplayName || userEmail || 'P').trim().charAt(0).toUpperCase()
const pendingBadge = isOwner && onShareClick && pendingContributions > 0
return (
<nav
@@ -391,43 +294,18 @@ function ContextualNavbar({
<X size={18} strokeWidth={2.5} />
</button>
{/* Libellé court : la barre doit tenir à droite du bouton de zoom */}
<button type="button" className="nav-family-btn nav-family-btn--primary" onClick={onAddPersonClick} aria-label={t('Ajouter une personne')}>
<UserPlus size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Ajouter')}</span>
<button
type="button"
ref={addBtnRef}
className={`nav-add${openMenu === 'add' ? ' is-open' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
aria-expanded={openMenu === 'add'}
aria-haspopup="menu"
aria-label={t('Ajouter ou corriger')}
>
<Plus size={20} strokeWidth={2.2} aria-hidden="true" />
</button>
<div ref={moreRef} className="nav-more-wrapper">
<button
type="button"
className={`nav-family-btn nav-family-btn--more${moreOpen ? ' is-open' : ''}`}
onClick={() => setMoreOpen((v) => !v)}
aria-expanded={moreOpen}
aria-haspopup="menu"
>
<MoreHorizontal size={20} strokeWidth={2} aria-hidden="true" />
<span>{t('Plus')}</span>
</button>
{moreOpen && (
<div className="nav-more-menu" role="menu">
{ANNOT_MENU.map((tool) => (
<button
key={tool.id}
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false)
setAnnotMode(true)
handleAnnotToolClick(tool.id)
}}
>
<tool.Icon size={18} strokeWidth={2} aria-hidden="true" />
{tool.label}
</button>
))}
</div>
)}
</div>
{openMenu === 'add' && renderAddMenu()}
{(annotationTools.selectedAnnotationId || annotationTools.isDragging) && renderAnnotTrash()}
</>
@@ -469,58 +347,72 @@ function ContextualNavbar({
</>
) : (
<>
{isFamily && !editModeActive ? renderFamilyButtons() : renderButtons()}
{/* Hors connexion, pas de menu du compte : le choix de la langue vit dans la barre (ou dans « Plus » pour la famille) */}
{!authenticated && !(isFamily && !editModeActive) && <LanguageSwitch className="pz-lang--nav" />}
{authenticated && (
<div ref={avatarMenuRef} className="nav-dropdown-wrapper">
<div
className={`nav-avatar ${userRole.isAdmin ? 'admin' : ''} ${avatarMenuOpen ? 'active' : ''}`}
onClick={() => setAvatarMenuOpen(!avatarMenuOpen)}
role="button"
tabIndex={0}
aria-label={t('Menu compte')}
>
{userAvatarPhoto ? (
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
) : (
<span className="nav-avatar-initial">{avatarInitial}</span>
)}
</div>
{avatarMenuOpen && (
<AvatarMenu
userDisplayName={userDisplayName}
userEmail={userEmail}
linkedPersonName={linkedPersonName}
trees={trees}
activeTreeId={activeTreeId}
activeTreeName={activeTreeName}
onAccountClick={() => {
onAccountClick?.()
setAvatarMenuOpen(false)
}}
onSelectTree={(treeId) => {
onTreeSelect?.(treeId)
setAvatarMenuOpen(false)
}}
onCreateTree={() => {
onCreateTree?.()
setAvatarMenuOpen(false)
}}
onSettings={() => {
onSettingsClick?.()
setAvatarMenuOpen(false)
}}
onLogout={() => {
onLogout?.()
setAvatarMenuOpen(false)
}}
/>
)}
</div>
{hasAddMenu && (
<button
type="button"
ref={addBtnRef}
className={`nav-add${isPhone ? '' : ' nav-add--label'}${openMenu === 'add' ? ' is-open' : ''}${editModeActive ? ' is-editing' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
aria-expanded={openMenu === 'add'}
aria-haspopup="menu"
aria-label={t('Ajouter ou corriger')}
>
<Plus size={20} strokeWidth={2.2} aria-hidden="true" />
{!isPhone && <span className="nav-add-label">{t('Ajouter')}</span>}
</button>
)}
{hasAddMenu && openMenu === 'add' && renderAddMenu()}
{/* Rond du compte : icône de profil hors connexion, initiale ou photo connecté */}
<div className="nav-dropdown-wrapper">
<button
type="button"
ref={avatarBtnRef}
className={`nav-avatar${authenticated ? '' : ' nav-avatar--guest'}${userRole.isAdmin ? ' admin' : ''}${openMenu === 'account' ? ' active' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'account' ? null : 'account'))}
aria-expanded={openMenu === 'account'}
aria-haspopup="menu"
aria-label={t('Menu compte')}
title={t('Menu compte')}
>
{!authenticated ? (
<User size={20} strokeWidth={2} aria-hidden="true" />
) : userAvatarPhoto ? (
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
) : (
<span className="nav-avatar-initial" aria-hidden="true">{avatarInitial}</span>
)}
{pendingBadge && (
<span className="nav-avatar-badge" aria-hidden="true">{pendingContributions > 9 ? '9+' : pendingContributions}</span>
)}
</button>
{openMenu === 'account' && (
<AvatarMenu
phone={isPhone}
triggerRef={avatarBtnRef}
guest={!authenticated}
onClose={closeMenus}
userDisplayName={userDisplayName}
userEmail={userEmail}
linkedPersonName={linkedPersonName}
trees={trees}
activeTreeId={activeTreeId}
activeTreeName={activeTreeName}
isOwner={isOwner}
pendingContributions={pendingContributions}
onContributionsClick={openProposals}
onShareClick={onShareClick}
helpUrl={helpUrl}
onAccountClick={onAccountClick}
onSelectTree={onTreeSelect}
onCreateTree={onCreateTree}
onSettings={onSettingsClick}
onLogin={() => onProfileClick?.('login')}
onRegister={handleCreateAccountClick}
onLogout={onLogout}
/>
)}
</div>
</>
)}
</div>
@@ -529,4 +421,3 @@ function ContextualNavbar({
}
export default ContextualNavbar
+158
View File
@@ -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; }
}
@@ -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(
<>
<div className="navm-scrim" onClick={onClose} aria-hidden="true" />
<div ref={ref} className="navm-sheet" role="dialog" aria-label={label} style={dragY ? { transform: `translateY(${dragY}px)` } : undefined}>
<div
className="navm-handle"
onPointerDown={onHandleDown}
onPointerMove={onHandleMove}
onPointerUp={onHandleUp}
onPointerCancel={() => { dragStart.current = null; setDragY(0) }}
aria-hidden="true"
>
<span />
</div>
{children}
</div>
</>,
document.body,
)
}
return (
<div ref={ref} className="navm-pop" role="dialog" aria-label={label}>
{children}
</div>
)
}
export function NavMenuGroup({ label, children }) {
return (
<div role="group" aria-label={label}>
<p className="navm-group" aria-hidden="true">{label}</p>
{children}
</div>
)
}
export function NavMenuItem({ icon, label, sub = null, onSelect, danger = false, disabled = false, right = null, className = '' }) {
return (
<button
type="button"
role="menuitem"
className={`navm-item${danger ? ' navm-item--danger' : ''}${className ? ` ${className}` : ''}`}
onClick={onSelect}
disabled={disabled}
>
{icon && <span className="navm-ic" aria-hidden="true">{icon}</span>}
<span className="navm-text">
{label}
{sub && <small>{sub}</small>}
</span>
{right}
</button>
)
}
export function NavMenuSep() {
return <div className="navm-sep" role="separator" />
}
+13
View File
@@ -221,4 +221,17 @@ export default {
"Pas besoin de compte pour regarder l'arbre.": "No account needed to look at the tree.",
"Pour retrouver vos arbres et ajouter des souvenirs.": "To find your trees again and add memories.",
"Ce n'est pas mon compte": "This is not my account",
// Barre d'actions : menu « + » (ajouter, corriger) et menu du compte
"Ajouter ou corriger": "Add or correct",
"Un souvenir": "A memory",
"photo, vidéo, voix, document": "photo, video, voice, document",
"Une personne": "A person",
"Un mot ou un dessin sur l'arbre": "A word or a drawing on the tree",
"Corriger": "Correct",
"dates, noms, liens, le propriétaire relit": "dates, names, links, the owner reviews",
"Terminer la modification": "Finish editing",
"Aide": "Help",
"Se déconnecter": "Sign out",
"Partager l'arbre": "Share the tree",
}
-1
View File
@@ -261,6 +261,5 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
.pz-lang-row-label { display: inline-flex; align-items: center; gap: 10px; }
.pz-lang-row-label svg { color: var(--pz-muted); flex: none; }
/* Dans la barre d'actions, séparé des boutons par un filet */
.pz-lang--nav { margin-left: 4px; padding: 0 4px 0 8px; border-left: 1px solid var(--pz-line); align-self: stretch; }
/* Sous les écrans d'accès et de connexion */
.pz-lang--screen { justify-self: center; }