mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
10 files changed
+650
-508
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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; }
|
||||
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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; }
|
||||
Reference in new issue
Block a user