mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-14 16:30:50 +00:00
Merge remote-tracking branch 'origin/main' into fix/generation-parente
This commit is contained in:
commit
ae8b473a25
31 files changed
+1377
-584
No files matched your search
@@ -1,9 +1,10 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Cadrage d'un portrait : centre (proportions de l'image) et zoom. Zoom 1 = l'image entière recouvre le cadre.
|
||||
// Les bornes qui empêchent le vide sont appliquées à l'affichage (frontend, portraitCrop.js), le serveur
|
||||
// vérifie seulement la forme et les plages.
|
||||
export type AvatarCrop = { x: number; y: number; zoom: number }
|
||||
// Cadrage d'un portrait : centre (proportions de l'image), zoom, et forme du cadre si on l'a choisie.
|
||||
// Zoom 1 = l'image entière recouvre le cadre. Les bornes qui empêchent le vide sont appliquées à
|
||||
// l'affichage (frontend, portraitCrop.js), le serveur vérifie seulement la forme et les plages.
|
||||
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] as const
|
||||
export type AvatarCrop = { x: number; y: number; zoom: number; shape?: (typeof FRAME_SHAPES)[number] }
|
||||
|
||||
export const AVATAR_CROP_MAX_ZOOM = 4
|
||||
|
||||
@@ -13,16 +14,18 @@ export const avatarCropInputSchema = z.object({
|
||||
x: z.number().min(0).max(1),
|
||||
y: z.number().min(0).max(1),
|
||||
zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM),
|
||||
shape: z.enum(FRAME_SHAPES).optional(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
const round4 = (value: number) => Math.round(value * 10000) / 10000
|
||||
|
||||
// Forme à écrire en base. Le cadrage par défaut se stocke comme absence de cadrage (null).
|
||||
// Forme à écrire en base. Le cadrage par défaut, sans forme choisie, se stocke comme absence de cadrage (null).
|
||||
export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null {
|
||||
if (!crop) return null
|
||||
const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) }
|
||||
if (crop.shape) return { ...normalized, shape: crop.shape }
|
||||
if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
@@ -166,6 +166,19 @@ describe('PUT /trees/:id/persons/:personId/avatar/crop', () => {
|
||||
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
|
||||
})
|
||||
|
||||
it('garde la forme choisie, même avec un cadrage centré, et refuse une forme inconnue', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const put = (crop: unknown) => app.inject({ method: 'PUT', url, headers: { authorization: `Bearer ${token}` }, payload: { crop } })
|
||||
|
||||
const saved = await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
|
||||
expect(saved.statusCode).toBe(200)
|
||||
expect(saved.json()).toEqual({ ok: true, avatarCrop: { x: 0.5, y: 0.5, zoom: 1, shape: 'round' } })
|
||||
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
|
||||
|
||||
expect((await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'hexagone' })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
+113
-21
@@ -4,6 +4,7 @@ import Galaxy from './components/Galaxy'
|
||||
import PersonCard from './components/PersonCard.jsx'
|
||||
import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx'
|
||||
import { HELP_URL } from './utils/siteLinks.js'
|
||||
import { firstNameLabel } from './utils/personName.js'
|
||||
import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
|
||||
import MediaViewer from './components/MediaViewer'
|
||||
import MediaManagerPanel from './components/MediaManagerPanel'
|
||||
@@ -50,7 +51,7 @@ import {
|
||||
reorderSiblings,
|
||||
uploadPersonMedia,
|
||||
} from './api/treeApi'
|
||||
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
|
||||
import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
|
||||
import { useMediaManager } from './hooks/useMediaManager'
|
||||
@@ -563,6 +564,10 @@ function App() {
|
||||
const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false)
|
||||
const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false)
|
||||
const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null)
|
||||
// Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries
|
||||
const [draftNotice, setDraftNotice] = useState('')
|
||||
// Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef)
|
||||
const personSaveOnLeaveRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) {
|
||||
@@ -811,29 +816,55 @@ function App() {
|
||||
updateDraft: editMode.updateDraft,
|
||||
})
|
||||
|
||||
const handlePersonSelect = useCallback((personLike) => {
|
||||
// Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un
|
||||
// souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte
|
||||
// avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas.
|
||||
const leaveSelectedPerson = useCallback(async () => {
|
||||
const current = tree.selectedPerson
|
||||
const saveOnLeave = personSaveOnLeaveRef.current
|
||||
if (!current || !saveOnLeave || isDemoMode) return true
|
||||
const outcome = await saveOnLeave()
|
||||
if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) }))
|
||||
return outcome !== 'blocked'
|
||||
}, [tree.selectedPerson, isDemoMode])
|
||||
|
||||
const chooseSelectedPerson = useCallback(async (next) => {
|
||||
if (!(await leaveSelectedPerson())) return false
|
||||
tree.setSelectedPerson(next)
|
||||
return true
|
||||
}, [leaveSelectedPerson, tree.setSelectedPerson])
|
||||
|
||||
const deactivateEditMode = useCallback(async () => {
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
editMode.deactivate()
|
||||
}, [leaveSelectedPerson, editMode.deactivate])
|
||||
|
||||
const handlePersonSelect = useCallback(async (personLike) => {
|
||||
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
|
||||
if (personId === null || personId === undefined) {
|
||||
tree.setSelectedPerson(null)
|
||||
await chooseSelectedPerson(null)
|
||||
return
|
||||
}
|
||||
|
||||
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
|
||||
// Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ;
|
||||
// sa saisie reste en mémoire et reprend quand on rouvre « Ajouter »
|
||||
setAddPersonPanelVisible(false)
|
||||
|
||||
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
|
||||
tree.setSelectedPerson(freshPerson)
|
||||
trackTreeInteraction('text')
|
||||
trackDemoInteraction('person')
|
||||
}, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleMediaSelectFresh = useCallback((media, personLike) => {
|
||||
const handleMediaSelectFresh = useCallback(async (media, personLike) => {
|
||||
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
|
||||
const freshPerson = personId !== null && personId !== undefined
|
||||
? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
|
||||
: personLike || null
|
||||
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
tree.handleMediaSelect(media, freshPerson)
|
||||
trackTreeInteraction(toInteractionMediaType(media?.type))
|
||||
trackDemoInteraction('media')
|
||||
@@ -895,22 +926,26 @@ function App() {
|
||||
const handleEditToggle = useCallback(() => {
|
||||
if (editMode.isActive && editMode.hasDraft) {
|
||||
setShowConfirmModal(true)
|
||||
} else if (editMode.isActive) {
|
||||
deactivateEditMode()
|
||||
} else {
|
||||
if (!editMode.isActive) trackDemoInteraction('edit')
|
||||
trackDemoInteraction('edit')
|
||||
editMode.toggle()
|
||||
}
|
||||
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Actions de la modal de confirmation
|
||||
const handleConfirmContinue = useCallback(() => {
|
||||
setShowConfirmModal(false)
|
||||
}, [])
|
||||
|
||||
const handleConfirmSaveAndQuit = useCallback(() => {
|
||||
const handleConfirmSaveAndQuit = useCallback(async () => {
|
||||
const left = await leaveSelectedPerson()
|
||||
setShowConfirmModal(false)
|
||||
if (!left) return
|
||||
editMode.saveDraft()
|
||||
editMode.deactivate()
|
||||
setShowConfirmModal(false)
|
||||
}, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
editMode.clearDraft()
|
||||
@@ -966,11 +1001,11 @@ function App() {
|
||||
setSouvenirPickerVisible(true)
|
||||
}, [tree.treeContext.treeId])
|
||||
|
||||
const handleSouvenirPick = useCallback((person) => {
|
||||
const handleSouvenirPick = useCallback(async (person) => {
|
||||
if (!(await chooseSelectedPerson(person))) return
|
||||
setSouvenirPickerVisible(false)
|
||||
tree.setSelectedPerson(person)
|
||||
setPendingMediaPersonId(person.id)
|
||||
}, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSouvenirPickerAddPerson = useCallback(() => {
|
||||
setSouvenirPickerVisible(false)
|
||||
@@ -984,6 +1019,8 @@ function App() {
|
||||
|
||||
const handleAddPersonSubmit = useCallback(async (formData) => {
|
||||
if (!tree.treeContext.treeId && !isDemoMode) return
|
||||
// La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
|
||||
setAddPersonLoading(true)
|
||||
setAddPersonError('')
|
||||
@@ -1193,7 +1230,6 @@ function App() {
|
||||
const [contribSubmitLoading, setContribSubmitLoading] = useState(false)
|
||||
const [contribSubmitError, setContribSubmitError] = useState('')
|
||||
const [contribSuccess, setContribSuccess] = useState(false)
|
||||
const [draftNotice, setDraftNotice] = useState('')
|
||||
|
||||
// Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite.
|
||||
// L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit.
|
||||
@@ -1233,6 +1269,8 @@ function App() {
|
||||
|
||||
// Soumission des modifications
|
||||
const handleEditSubmit = useCallback(async () => {
|
||||
// Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
if (isDemoMode) {
|
||||
// Démo: édition locale uniquement, jamais de persistance serveur.
|
||||
editMode.saveDraft()
|
||||
@@ -1648,6 +1686,51 @@ function App() {
|
||||
}
|
||||
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
|
||||
|
||||
// « + photo » dans l'arbre : le sélecteur part dans le clic (Safari iOS refuse un input file ouvert hors geste),
|
||||
// puis la fenêtre de recadrage s'ouvre dans la fiche de la personne
|
||||
const portraitInputRef = useRef(null)
|
||||
const portraitPickerPersonIdRef = useRef(null)
|
||||
const [pendingPortraitCrop, setPendingPortraitCrop] = useState(null)
|
||||
const [photoSourceRequest, setPhotoSourceRequest] = useState(null)
|
||||
const [immichSourceReady, setImmichSourceReady] = useState(false)
|
||||
|
||||
// Administrateur : Immich proposé dans le menu des sources seulement s'il est configuré
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const treeId = tree.treeContext.treeId
|
||||
const token = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!userRole.isAdmin || !tree.canEditCurrentTree || !treeId || !token) {
|
||||
setImmichSourceReady(false)
|
||||
return undefined
|
||||
}
|
||||
fetchImmichConfig(treeId, token)
|
||||
.then(({ config }) => { if (!cancelled) setImmichSourceReady(Boolean(config?.configured)) })
|
||||
.catch(() => { if (!cancelled) setImmichSourceReady(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [userRole.isAdmin, tree.canEditCurrentTree, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
|
||||
|
||||
const handlePhotoPlaceholderClick = useCallback((personId) => {
|
||||
// En modification avec Immich : le menu des sources s'ouvre dans la fiche (le badge en est l'ancre)
|
||||
if (editMode.isActive && immichSourceReady) {
|
||||
setPhotoSourceRequest({ personId })
|
||||
} else {
|
||||
// Sans menu : le sélecteur de fichiers part tout de suite, dans le clic
|
||||
portraitPickerPersonIdRef.current = personId
|
||||
portraitInputRef.current?.click()
|
||||
}
|
||||
handlePersonSelect(personId)
|
||||
}, [editMode.isActive, immichSourceReady, handlePersonSelect])
|
||||
|
||||
const handlePortraitPicked = useCallback(async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
const personId = portraitPickerPersonIdRef.current
|
||||
portraitPickerPersonIdRef.current = null
|
||||
if (!file || personId === null) return
|
||||
const result = await handlePersonAvatarUpload(personId, file)
|
||||
if (result?.previewUrl) setPendingPortraitCrop({ personId, src: result.previewUrl })
|
||||
}, [handlePersonAvatarUpload])
|
||||
|
||||
// Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant)
|
||||
const handleImmichChange = useCallback(async (personId) => {
|
||||
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
|
||||
@@ -2967,11 +3050,11 @@ function App() {
|
||||
hasResults={search.hasResults}
|
||||
onSearch={search.handleSearch}
|
||||
onClear={search.clearSearch}
|
||||
onResultClick={(person) => {
|
||||
onResultClick={async (person) => {
|
||||
const fullPerson = persons.find(p => p.id === person.id)
|
||||
if (!fullPerson) return
|
||||
trackDemoInteraction('search')
|
||||
tree.setSelectedPerson(fullPerson)
|
||||
if (!(await chooseSelectedPerson(fullPerson))) return
|
||||
galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id)
|
||||
}}
|
||||
/>
|
||||
@@ -2988,7 +3071,7 @@ function App() {
|
||||
hasDraft={editMode.hasDraft}
|
||||
draftTimestamp={editMode.draftTimestamp}
|
||||
role={userRole.isAdmin ? 'admin' : 'contributor'}
|
||||
onDeactivate={editMode.deactivate}
|
||||
onDeactivate={deactivateEditMode}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
{/* Confirmation Modal (quitter mode édition avec draft) */}
|
||||
@@ -3057,8 +3140,8 @@ function App() {
|
||||
activeTreeId={tree.treeContext.treeId}
|
||||
onEditClick={handleEditToggle}
|
||||
onAddPersonClick={handleOpenAddPerson}
|
||||
onAddSouvenirClick={isFamilyVisitor ? handleOpenSouvenirPicker : undefined}
|
||||
helpUrl={isFamilyVisitor ? HELP_URL : ''}
|
||||
onAddSouvenirClick={isFamilyVisitor || userRole.isAdmin ? handleOpenSouvenirPicker : undefined}
|
||||
helpUrl={HELP_URL}
|
||||
onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined}
|
||||
onTreeSelect={handleOpenTreeFromAccount}
|
||||
onCreateTree={handleOpenTreeWizard}
|
||||
@@ -3097,21 +3180,30 @@ function App() {
|
||||
focusPersonId={tree.treeContext.rootPersonId}
|
||||
assetRevision={tree.assetRevision}
|
||||
photoHint={tree.canEditCurrentTree}
|
||||
onPhotoPlaceholderClick={tree.canEditCurrentTree ? handlePhotoPlaceholderClick : null}
|
||||
reorderEnabled={canReorderSiblings}
|
||||
onReorderSiblings={handleReorderSiblings}
|
||||
/>
|
||||
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
|
||||
{tree.canEditCurrentTree && (
|
||||
<input ref={portraitInputRef} type="file" accept="image/*" className="pc-hidden-input" onChange={handlePortraitPicked} />
|
||||
)}
|
||||
{tree.selectedPerson && (
|
||||
<PersonCard
|
||||
person={tree.selectedPerson}
|
||||
onClose={() => tree.setSelectedPerson(null)}
|
||||
onClose={() => chooseSelectedPerson(null)}
|
||||
onMediaSelect={handleMediaSelectFresh}
|
||||
saveOnLeaveRef={personSaveOnLeaveRef}
|
||||
editMode={editMode.isActive}
|
||||
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
|
||||
onManageMedia={media.handleOpenMediaManager}
|
||||
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
|
||||
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
|
||||
onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined}
|
||||
pendingPortraitCrop={pendingPortraitCrop}
|
||||
onPendingPortraitCropDone={() => setPendingPortraitCrop(null)}
|
||||
photoSourceRequest={photoSourceRequest}
|
||||
onPhotoSourceRequestDone={() => setPhotoSourceRequest(null)}
|
||||
treeId={tree.treeContext.treeId || null}
|
||||
immichToken={tree.treeContext.accessToken || auth.userAuth.token}
|
||||
onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -40,7 +40,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
|
||||
const centerX = (node) => node.x + node.width / 2
|
||||
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, reorderEnabled = false, onReorderSiblings = null }) => {
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null }) => {
|
||||
const canvasRef = useRef(null)
|
||||
// Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours
|
||||
const focusPersonIdRef = useRef(focusPersonId)
|
||||
@@ -139,7 +139,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
canvasRef, transformRef, targetTransformRef,
|
||||
layoutDataRef, nodeRandomDataRef, entranceRef,
|
||||
hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos,
|
||||
getAnimatedPos, onPersonSelect, onMediaSelect,
|
||||
getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick,
|
||||
annotationHandlers,
|
||||
minScale: MIN_SCALE,
|
||||
maxScale: MAX_SCALE,
|
||||
|
||||
@@ -252,8 +252,15 @@
|
||||
left: 0; right: 0; bottom: 0; width: auto;
|
||||
max-height: 78dvh; padding: 10px 18px calc(18px + env(safe-area-inset-bottom));
|
||||
border-radius: 26px 26px 0 0; border-bottom: 0;
|
||||
animation: pc-sheet 400ms var(--pz-ease-drawer) both;
|
||||
/* Pas de fill-mode « both » : une fois l'entrée finie, le glisser (style inline) reprend la main sur transform */
|
||||
animation: pc-sheet 400ms var(--pz-ease-drawer) backwards;
|
||||
transition: transform 240ms var(--pz-ease-drawer);
|
||||
}
|
||||
/* Pendant le glisser, la feuille suit le doigt sans transition */
|
||||
.person-card[data-dragging] { transition: none; }
|
||||
/* La poignée et l'en-tête portent le geste : le navigateur ne doit pas le prendre pour un défilement (les champs gardent le leur) */
|
||||
.person-card .pc-grab, .person-card .pc-close, .person-card .pc-head { touch-action: none; }
|
||||
.person-card .pc-head :is(input, textarea, select, label, [contenteditable]) { touch-action: auto; }
|
||||
.pc-grab { display: block; width: 38px; height: 4px; margin: 0 auto 2px; border-radius: 4px; background: var(--pz-line); flex: none; }
|
||||
.pc-close { top: 10px; right: 10px; }
|
||||
.pc-photo { width: 68px; height: 68px; }
|
||||
@@ -263,6 +270,7 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.person-card { animation: pz-fade 200ms ease both; }
|
||||
.person-card { transition-duration: 1ms; }
|
||||
}
|
||||
|
||||
/* Prénom inconnu (issue Anaïs, 18/10) : libellé en italique, case à cocher sous les champs de nom */
|
||||
@@ -270,6 +278,7 @@
|
||||
.pc-unknown-first { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin: -4px 0 0; font: 400 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; white-space: nowrap; }
|
||||
.pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
|
||||
.pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); }
|
||||
.pc-save-error { margin: 0; font: 500 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); }
|
||||
|
||||
/* Modification de l'onglet Infos : pastilles des champs vides (DA terracotta, sans nouvelle couleur) */
|
||||
.pc-fill-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
|
||||
@@ -10,6 +10,7 @@ import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||||
import ImmichFacePanel from './ImmichFacePanel.jsx'
|
||||
import './PersonCard.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
|
||||
import PortraitCropModal from './PortraitCropModal.jsx'
|
||||
import { imagePlacement } from '../utils/portraitCrop.js'
|
||||
import { t, dateLocale } from '../i18n/index.js'
|
||||
@@ -17,6 +18,7 @@ import { formatPlace } from '../utils/place.js'
|
||||
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js'
|
||||
import { isYearOnly } from '../utils/dateInput.js'
|
||||
import DateField from './DateField.jsx'
|
||||
import { runOnce } from '../utils/saveOnLeave.js'
|
||||
|
||||
// Aucune modification en cours (référence stable)
|
||||
const NO_EDITS = Object.freeze({})
|
||||
@@ -51,6 +53,10 @@ const PersonCard = ({
|
||||
onAvatarUpload,
|
||||
onAvatarDelete,
|
||||
onAvatarCrop,
|
||||
pendingPortraitCrop = null,
|
||||
onPendingPortraitCropDone = null,
|
||||
photoSourceRequest = null,
|
||||
onPhotoSourceRequestDone = null,
|
||||
treeId = null,
|
||||
immichToken = null,
|
||||
onImmichChange,
|
||||
@@ -70,6 +76,7 @@ const PersonCard = ({
|
||||
onMoveSibling,
|
||||
onUploadMomentFile,
|
||||
onMomentSaved,
|
||||
saveOnLeaveRef = null,
|
||||
}) => {
|
||||
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
|
||||
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
|
||||
@@ -114,6 +121,9 @@ const PersonCard = ({
|
||||
const [lastNameMissing, setLastNameMissing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
// Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse
|
||||
const saveInFlight = useRef(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showImmichPanel, setShowImmichPanel] = useState(false)
|
||||
// null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose
|
||||
@@ -138,6 +148,9 @@ const PersonCard = ({
|
||||
const [openedFields, setOpenedFields] = useState([])
|
||||
const [focusField, setFocusField] = useState(null)
|
||||
const bodyRef = useRef(null)
|
||||
// Feuille du bas (téléphone) : on la ferme en la glissant par la poignée ou l'en-tête, comme la croix
|
||||
const sheetMode = useSheetMode()
|
||||
const drag = useSheetDrag({ enabled: sheetMode, zone: '.pc-grab, .pc-head, .pc-close', onClose })
|
||||
|
||||
// Reset edits quand la personne change
|
||||
useEffect(() => {
|
||||
@@ -149,6 +162,7 @@ const PersonCard = ({
|
||||
setActiveRelationSearch(null)
|
||||
setRelationSearchQuery('')
|
||||
setSaveSuccess(false)
|
||||
setSaveError('')
|
||||
setShowSourceMenu(false)
|
||||
setMomentSheet(null)
|
||||
setLifeNotice('')
|
||||
@@ -170,16 +184,19 @@ const PersonCard = ({
|
||||
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
|
||||
// traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source.
|
||||
// Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur.
|
||||
// Dépend de la présence de onImmichChange, pas de sa valeur : App le recrée à chaque rendu, ce qui
|
||||
// relançait la lecture de la config et refermait le menu des sources juste après son ouverture
|
||||
const canChangeImmich = Boolean(onImmichChange)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setImmichConfigured(null)
|
||||
setShowSourceMenu(false)
|
||||
if (!editMode || !isAdmin || !treeId || !onImmichChange || !immichToken) return undefined
|
||||
if (!editMode || !isAdmin || !treeId || !canChangeImmich || !immichToken) return undefined
|
||||
fetchImmichConfig(treeId, immichToken)
|
||||
.then(({ config }) => { if (!cancelled) setImmichConfigured(Boolean(config?.configured)) })
|
||||
.catch(() => { if (!cancelled) setImmichConfigured(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [editMode, isAdmin, treeId, immichToken, onImmichChange])
|
||||
}, [editMode, isAdmin, treeId, immichToken, canChangeImmich])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSourceMenu) return undefined
|
||||
@@ -240,6 +257,21 @@ const PersonCard = ({
|
||||
const chooseFileSource = () => { setShowSourceMenu(false); avatarInputRef.current?.click() }
|
||||
const chooseImmichSource = () => { setShowSourceMenu(false); setShowImmichPanel(true) }
|
||||
|
||||
// Clic sur « + photo » dans l'arbre : la photo vient d'être envoyée, le recadrage s'ouvre tout de suite
|
||||
useEffect(() => {
|
||||
if (!pendingPortraitCrop || String(pendingPortraitCrop.personId) !== String(person.id)) return
|
||||
setCropState({ src: pendingPortraitCrop.src, mode: 'new', ownsUrl: true })
|
||||
onPendingPortraitCropDone?.()
|
||||
}, [pendingPortraitCrop, person.id, onPendingPortraitCropDone])
|
||||
|
||||
// Clic sur « + photo » dans l'arbre, en modification avec Immich : même menu que le badge, sans clic de plus
|
||||
useEffect(() => {
|
||||
if (!photoSourceRequest || String(photoSourceRequest.personId) !== String(person.id)) return
|
||||
if (immichConfigured === null) return
|
||||
onPhotoSourceRequestDone?.()
|
||||
if (immichUsable) openAvatarPicker()
|
||||
}, [photoSourceRequest, person.id, immichConfigured, immichUsable, onPhotoSourceRequestDone]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Focus search input when relation search opens
|
||||
useEffect(() => {
|
||||
if (activeRelationSearch) {
|
||||
@@ -313,29 +345,53 @@ const PersonCard = ({
|
||||
]
|
||||
const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : []
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || !onSave) return
|
||||
// Enregistre les modifications de cette fiche (bouton « Enregistrer » et départ de la fiche).
|
||||
// Résultat : 'saved', 'unchanged' (rien à enregistrer) ou 'blocked' (validation, ou échec réseau :
|
||||
// le message reste sur la fiche, la saisie n'est pas jetée).
|
||||
const persistEdits = () => runOnce(saveInFlight, async () => {
|
||||
if (!hasChanges || !onSave) return 'unchanged'
|
||||
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
|
||||
setFirstNameMissing(true)
|
||||
return
|
||||
return 'blocked'
|
||||
}
|
||||
// Prénom inconnu : il faut alors un nom de famille
|
||||
if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) {
|
||||
setLastNameMissing(true)
|
||||
return
|
||||
return 'blocked'
|
||||
}
|
||||
const savedFor = person.id
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
const savedFor = person.id
|
||||
await onSave(savedFor, edits)
|
||||
setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev))
|
||||
setOpenedFields([])
|
||||
setSaveSuccess(true)
|
||||
setTimeout(() => setSaveSuccess(false), 2000)
|
||||
return 'saved'
|
||||
} catch (error) {
|
||||
// Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique
|
||||
setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez."))
|
||||
return 'blocked'
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// App enregistre la fiche avant de la quitter (changement de personne, fermeture, sortie du mode
|
||||
// modification) : la référence suit toujours la dernière version de persistEdits
|
||||
useEffect(() => {
|
||||
if (!saveOnLeaveRef) return undefined
|
||||
saveOnLeaveRef.current = persistEdits
|
||||
return () => { if (saveOnLeaveRef.current === persistEdits) saveOnLeaveRef.current = null }
|
||||
})
|
||||
|
||||
// Sortie du mode modification : les modifications restantes (abandonnées) ne reviennent pas à la prochaine entrée
|
||||
useEffect(() => {
|
||||
if (!editMode) setEditState({ personId: person.id, values: {}, changed: false })
|
||||
}, [editMode]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSave = () => { persistEdits() }
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!onDelete) return
|
||||
@@ -436,6 +492,8 @@ const PersonCard = ({
|
||||
unions: personUnions.map((union) => ({
|
||||
...union,
|
||||
partner: persons.find((p) => p.id === (union.partner1Id === person.id ? union.partner2Id : union.partner1Id)) || null,
|
||||
// Enfants du couple : placent dans la frise une union dont la date manque
|
||||
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -602,7 +660,14 @@ const PersonCard = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`} role="dialog" aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}>
|
||||
<div
|
||||
className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`}
|
||||
role="dialog"
|
||||
aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}
|
||||
style={drag.style}
|
||||
data-dragging={drag.dragging || undefined}
|
||||
{...drag.bind}
|
||||
>
|
||||
<span className="pc-grab" aria-hidden="true" />
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer la fiche')}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
@@ -998,6 +1063,7 @@ const PersonCard = ({
|
||||
</div>
|
||||
|
||||
{/* ── Actions d'édition ── */}
|
||||
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
|
||||
{editMode && (
|
||||
<footer className="pc-foot">
|
||||
{showDeleteConfirm ? (
|
||||
|
||||
@@ -55,3 +55,16 @@
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pcrop-img { transition: none; }
|
||||
}
|
||||
|
||||
/* Forme du cadre : chaque choix montre sa forme, son nom est dans aria-label et title */
|
||||
.pcrop-shapes { width: min(100%, 300px); margin: 0 auto; }
|
||||
.pcrop-shapes .pz-tab { display: grid; place-items: center; min-height: 40px; padding: 0; color: var(--pz-muted); }
|
||||
.pcrop-shapes .pz-tab.is-active { color: var(--pz-a700, #93402A); }
|
||||
.pcrop-shapes .pz-tab:disabled { cursor: default; opacity: .5; }
|
||||
.pcrop-ico { display: block; border: 1.75px solid currentColor; }
|
||||
.pcrop-ico--round { width: 20px; height: 20px; border-radius: 50%; }
|
||||
.pcrop-ico--square { width: 20px; height: 20px; border-radius: 3px; }
|
||||
/* Polaroïd : la marge du bas plus large que les côtés */
|
||||
.pcrop-ico--polaroid { width: 20px; height: 22px; border-radius: 2px; border-bottom-width: 5px; }
|
||||
.pcrop-ico--portrait { width: 16px; height: 22px; border-radius: 3px; }
|
||||
.pcrop-ico--landscape { width: 26px; height: 16px; border-radius: 3px; }
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
DEFAULT_CROP,
|
||||
clampCrop,
|
||||
cropForSave,
|
||||
cropShape,
|
||||
cropViewport,
|
||||
frameAspect,
|
||||
imagePlacement,
|
||||
normalizeCrop,
|
||||
resolveFrameKind,
|
||||
shapeOfKind,
|
||||
} from '../utils/portraitCrop.js'
|
||||
import './PortraitCropModal.css'
|
||||
|
||||
@@ -23,8 +25,11 @@ const KEY_STEP = 0.04
|
||||
const KEY_STEP_LARGE = 0.16
|
||||
const KEY_ZOOM_STEP = 0.2
|
||||
|
||||
const SHAPE_LABELS = { round: t('Rond'), square: t('Carré'), polaroid: t('Polaroïd'), portrait: t('Vertical'), landscape: t('Horizontal') }
|
||||
|
||||
// Recadrage d'un portrait. Le masque garde la forme du cadre de la personne dans l'arbre ; la photo
|
||||
// glisse dessous. `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo).
|
||||
// glisse dessous. La forme se choisit aussi ici : sans choix, elle suit la photo ou le tirage de la personne.
|
||||
// `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo).
|
||||
const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop = null, saving = false, error = '', onSave, onClose }) => {
|
||||
const stageRef = useRef(null)
|
||||
const dragRef = useRef(null)
|
||||
@@ -33,11 +38,13 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop
|
||||
const [size, setSize] = useState(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [crop, setCrop] = useState(() => normalizeCrop(initialCrop))
|
||||
const [shape, setShape] = useState(() => cropShape(initialCrop))
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const imageRatio = size ? size.w / size.h : 1
|
||||
const frameKind = resolveFrameKind(frameType, imageRatio)
|
||||
const aspect = frameAspect(frameKind, imageRatio)
|
||||
const frameKind = resolveFrameKind(frameType, imageRatio, shape)
|
||||
const aspect = frameAspect(frameKind, imageRatio, shape)
|
||||
const shownShape = shape ?? shapeOfKind(frameKind, imageRatio)
|
||||
const winW = aspect >= 1 ? WINDOW_SIZE : WINDOW_SIZE * aspect
|
||||
const winH = aspect >= 1 ? WINDOW_SIZE / aspect : WINDOW_SIZE
|
||||
|
||||
@@ -159,7 +166,7 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop
|
||||
const setZoom = (zoom) => apply({ ...current, zoom: Number(zoom) })
|
||||
const save = () => {
|
||||
if (!size || saving) return
|
||||
onSave?.(cropForSave(size.w, size.h, aspect, current))
|
||||
onSave?.(cropForSave(size.w, size.h, aspect, current, shape))
|
||||
}
|
||||
|
||||
const isNew = mode === 'new'
|
||||
@@ -208,6 +215,24 @@ const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop
|
||||
{!size && !loadFailed && <div className="pcrop-loading"><PzBusy busy busyLabel={t('Chargement de la photo')} /></div>}
|
||||
</div>
|
||||
|
||||
<div className="pz-tabs pcrop-shapes" role="radiogroup" aria-label={t('Forme du cadre')}>
|
||||
{Object.entries(SHAPE_LABELS).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={shownShape === value}
|
||||
className={`pz-tab ${shownShape === value ? 'is-active' : ''}`}
|
||||
onClick={() => setShape(value)}
|
||||
disabled={!size || saving}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<span className={`pcrop-ico pcrop-ico--${value}`} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p id="pcropHint" className="pz-small pcrop-hint">
|
||||
{t('Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.')}
|
||||
</p>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { t } from '../../i18n/index.js'
|
||||
import { getPersonById, getPersonMedias } from '../../data/mockData'
|
||||
import { cropViewport } from '../../utils/portraitCrop.js'
|
||||
import { cropShape, cropViewport, isLandscapeRect, resolveFrameKind } from '../../utils/portraitCrop.js'
|
||||
// Tracés Lucide dessinés sur le canvas : données publiques de @lucide/icons (Map renommé, il masquerait
|
||||
// le Map global utilisé plus bas)
|
||||
import { Map as mapIcon, MapPin as mapPinIcon, Route as routeIcon } from '@lucide/icons'
|
||||
@@ -201,6 +201,8 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD
|
||||
// useGalaxyInteractions.
|
||||
// ============================================================
|
||||
export const renvoiHitAreas = []
|
||||
// Zones « + photo » : portraits vides, chez le propriétaire (cercle du nœud, rempli au dessin)
|
||||
export const photoPlaceholderHitAreas = []
|
||||
const renvoiPills = []
|
||||
const RENVOI_FONT = '500 11px "DM Sans", system-ui, sans-serif'
|
||||
// Pastilles collées aux personnes : juste sous le couple, juste au-dessus du portrait de l'enfant
|
||||
@@ -450,6 +452,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
const { cx, cy } = pos
|
||||
const person = getPersonById(node._data.id) || node._data
|
||||
const rd = nodeRandomData.get(node.id)
|
||||
// Zone « + photo » : seulement là où le cadre invite à poser une photo (même condition que le dessin)
|
||||
if (photoHint && !person.photo && nodeProgress >= 0.5) {
|
||||
photoPlaceholderHitAreas.push({ personId: node._data.id, cx, cy, r: nodeRadius(node) })
|
||||
}
|
||||
const img = person.photo ? imageCache.get(person.photo) : null
|
||||
const frameType = resolveFrameType(person, img)
|
||||
const frameRot = rd?.frameRotation || 0
|
||||
@@ -891,26 +897,12 @@ function drawMediaContent(ctx, media, ms, imageCache) {
|
||||
// ============================================================
|
||||
// Dessiner le cadre photo (polaroid, rect, round)
|
||||
// ============================================================
|
||||
// Forme choisie au recadrage d'abord ; sinon une photo très large ou très haute impose la sienne, et une photo
|
||||
// carrée ou presque garde la forme tirée au hasard pour la personne (pickFrameType), pour que l'arbre ne soit
|
||||
// pas uniforme. Même règle que la fenêtre de recadrage (portraitCrop.js).
|
||||
function resolveFrameType(person, img) {
|
||||
const fallback = person.frameType || 'round'
|
||||
|
||||
if (!img || !img.naturalWidth || !img.naturalHeight) {
|
||||
return fallback
|
||||
}
|
||||
|
||||
const ratio = img.naturalWidth / img.naturalHeight
|
||||
|
||||
if (ratio >= 1.28) {
|
||||
return 'rect'
|
||||
}
|
||||
|
||||
if (ratio <= 0.78) {
|
||||
return 'polaroid'
|
||||
}
|
||||
|
||||
// Photo carrée ou presque : chaque personne garde sa forme tirée au hasard
|
||||
// (person.frameType, voir pickFrameType), pour que l'arbre ne soit pas uniforme
|
||||
return fallback
|
||||
const ratio = img?.naturalWidth && img?.naturalHeight ? img.naturalWidth / img.naturalHeight : 1
|
||||
return resolveFrameKind(person.frameType || 'round', ratio, cropShape(person.avatarCrop))
|
||||
}
|
||||
|
||||
function drawFrame(ctx, frameType, person, img, hint = 0) {
|
||||
@@ -952,8 +944,8 @@ function drawFrame(ctx, frameType, person, img, hint = 0) {
|
||||
const ratio = img?.naturalWidth && img?.naturalHeight
|
||||
? img.naturalWidth / img.naturalHeight
|
||||
: 1
|
||||
// Photo carrée : cadre vertical, qui garde le visage entier
|
||||
const isLandscape = ratio > 1.05
|
||||
// Couché ou debout selon la forme choisie, sinon selon la photo (carrée : debout, le visage reste entier)
|
||||
const isLandscape = isLandscapeRect(ratio, cropShape(person.avatarCrop))
|
||||
const rw = isLandscape ? PERSON_R + 12 : PERSON_R + 2
|
||||
const rh = isLandscape ? PERSON_R - 8 : PERSON_R + 6
|
||||
const pad = 3
|
||||
@@ -1043,6 +1035,7 @@ function drawImageCover(ctx, img, x, y, w, h, crop = null) {
|
||||
// ============================================================
|
||||
export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, elapsed, renderState) {
|
||||
const view = renderState.view || null
|
||||
photoPlaceholderHitAreas.length = 0
|
||||
layoutData.children.forEach(node => {
|
||||
const pos = posMap.get(node.id)
|
||||
if (!pos) return
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRef, useCallback } from 'react'
|
||||
import { getPersonMedias } from '../../data/mockData'
|
||||
import { MAX_ORBIT_MEDIAS, ORBIT_MEDIA_SIZE } from './constants'
|
||||
import { nodeRadius, renvoiHitAreas } from './renderers'
|
||||
import { nodeRadius, renvoiHitAreas, photoPlaceholderHitAreas } from './renderers'
|
||||
|
||||
const hitRenvoi = (x, y) => renvoiHitAreas.find(a => x >= a.x0 && x <= a.x1 && y >= a.y0 && y <= a.y1)
|
||||
const hitPhotoPlaceholder = (x, y) => photoPlaceholderHitAreas.find(a => Math.hypot(x - a.cx, y - a.cy) < a.r)
|
||||
import { getEntranceProgress } from './utils'
|
||||
|
||||
const CURSOR_GRAB = 'default'
|
||||
@@ -21,7 +22,7 @@ export function useGalaxyInteractions({
|
||||
canvasRef, transformRef, targetTransformRef,
|
||||
layoutDataRef, nodeRandomDataRef, entranceRef,
|
||||
hoveredNodeIdRef, hoveredMediaKeyRef, mouseWorldPosRef,
|
||||
getAnimatedPos, onPersonSelect, onMediaSelect,
|
||||
getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick = null,
|
||||
annotationHandlers,
|
||||
minScale = 0.2,
|
||||
maxScale = 4,
|
||||
@@ -138,6 +139,7 @@ export function useGalaxyInteractions({
|
||||
}
|
||||
}
|
||||
if (!isOverClickable && hitRenvoi(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true
|
||||
if (!isOverClickable && hitPhotoPlaceholder(mouseWorldPosRef.current.x, mouseWorldPosRef.current.y)) isOverClickable = true
|
||||
hoveredNodeIdRef.current = newHoveredNode
|
||||
hoveredMediaKeyRef.current = newHoveredMedia
|
||||
|
||||
@@ -262,6 +264,13 @@ export function useGalaxyInteractions({
|
||||
}
|
||||
}
|
||||
|
||||
// « + photo » : le clic lance directement la photo (la fiche s'ouvre aussi, côté App)
|
||||
const photoZone = hitPhotoPlaceholder(clickX, clickY)
|
||||
if (photoZone && onPhotoPlaceholderClick) {
|
||||
onPhotoPlaceholderClick(photoZone.personId)
|
||||
return
|
||||
}
|
||||
|
||||
for (const node of layoutData.children) {
|
||||
if (node._type !== 'person') continue
|
||||
if (ent && !ent.finished) {
|
||||
@@ -286,7 +295,7 @@ export function useGalaxyInteractions({
|
||||
y: rect2.height / 2 - renvoi.targetY * scale,
|
||||
}
|
||||
}
|
||||
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, annotationHandlers])
|
||||
}, [canvasRef, transformRef, targetTransformRef, layoutDataRef, nodeRandomDataRef, entranceRef, getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, annotationHandlers])
|
||||
|
||||
return {
|
||||
isDragging, isZooming, zoomTimeoutRef, hasDragged,
|
||||
|
||||
@@ -1,30 +1,25 @@
|
||||
/* Menu du compte (DA terracotta), s'ouvre au-dessus de l'avatar en bas à droite */
|
||||
.avatar-menu.account-menu {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 10px);
|
||||
right: 0;
|
||||
width: min(320px, 88vw);
|
||||
max-height: min(72vh, 540px);
|
||||
overflow-y: auto;
|
||||
transform-origin: bottom right;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--pz-line) transparent;
|
||||
}
|
||||
|
||||
.am-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
/* Menu du compte : contenu propre, la coque (popover ou feuille) vit dans NavMenu.css */
|
||||
.navm-head {
|
||||
min-height: 60px;
|
||||
padding: 10px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) { .am-head:hover { background: var(--pz-line-soft); } }
|
||||
.am-head-mono {
|
||||
.navm-head strong {
|
||||
display: block;
|
||||
font: 400 18px/1.2 var(--pz-serif);
|
||||
color: var(--pz-ink);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.navm-head small {
|
||||
font: 400 12.5px var(--pz-sans);
|
||||
color: var(--pz-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.navm-head-mono {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex: none;
|
||||
@@ -35,15 +30,11 @@
|
||||
color: var(--pz-a700);
|
||||
font: 400 19px/1 var(--pz-serif);
|
||||
}
|
||||
.am-head-text { display: grid; gap: 2px; min-width: 0; flex: 1; }
|
||||
.am-head-text strong { font: 400 18px/1.2 var(--pz-serif); color: var(--pz-ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.am-head-text span { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.am-head-chevron { color: var(--pz-faint); flex: none; }
|
||||
.navm-chevron { color: var(--pz-faint); flex: none; }
|
||||
|
||||
.am-tree-mono {
|
||||
.navm-tree-mono {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
@@ -51,7 +42,8 @@
|
||||
color: var(--pz-a700);
|
||||
font: 400 14px/1 var(--pz-serif);
|
||||
}
|
||||
.am-tree-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.am-check { color: var(--pz-a700) !important; }
|
||||
.am-single { cursor: default; }
|
||||
@media (hover: hover) and (pointer: fine) { .am-single:hover { background: transparent; } }
|
||||
.navm-check { color: var(--pz-a700); }
|
||||
.navm-current { cursor: default; }
|
||||
.navm-current:hover { background: transparent !important; }
|
||||
/* Arbre en cours : désactivé pour le clavier et le clic, mais texte à sa couleur normale (pas la mise en retrait de .navm-item:disabled) */
|
||||
.navm-item.navm-current:disabled { opacity: 1; cursor: default; color: var(--pz-ink); }
|
||||
@@ -1,95 +1,136 @@
|
||||
import { Check, ChevronRight, Globe, LogOut, Plus, Settings } from 'lucide-react'
|
||||
import { Check, ChevronRight, Globe, HelpCircle, LogIn, LogOut, Plus, Settings, Share2, TreeDeciduous, UserPlus } from 'lucide-react'
|
||||
import { t } from '../../i18n/index.js'
|
||||
import LanguageSwitch from './LanguageSwitch'
|
||||
import { NavMenu, NavMenuGroup, NavMenuItem, NavMenuSep } from './NavMenu'
|
||||
import './AvatarMenu.css'
|
||||
|
||||
const ICON = { size: 18, strokeWidth: 2, 'aria-hidden': true }
|
||||
|
||||
/**
|
||||
* Menu du rond de compte. Connecté : identité, propositions à relire, arbres, paramètres, aide, langue, déconnexion.
|
||||
* Sans compte : se connecter, créer son arbre, aide, langue.
|
||||
*/
|
||||
function AvatarMenu({
|
||||
phone = false,
|
||||
triggerRef,
|
||||
guest = false,
|
||||
onClose,
|
||||
userDisplayName,
|
||||
userEmail,
|
||||
linkedPersonName = '',
|
||||
trees = [],
|
||||
activeTreeId,
|
||||
activeTreeName = '',
|
||||
isOwner = false,
|
||||
pendingContributions = 0,
|
||||
onContributionsClick,
|
||||
onShareClick,
|
||||
helpUrl = '',
|
||||
onAccountClick,
|
||||
onSelectTree,
|
||||
onCreateTree,
|
||||
onSettings,
|
||||
onLogin,
|
||||
onRegister,
|
||||
onLogout,
|
||||
}) {
|
||||
const hasSeveralTrees = trees.length > 1
|
||||
const currentTreeName = (
|
||||
trees.find((entry) => String(entry.id) === String(activeTreeId))?.name
|
||||
|| activeTreeName
|
||||
|| trees[0]?.name
|
||||
|| t('Aucun arbre')
|
||||
)
|
||||
|
||||
const displayName = linkedPersonName || userDisplayName || t('Mon compte')
|
||||
const currentTreeName = trees.find((entry) => String(entry.id) === String(activeTreeId))?.name
|
||||
|| activeTreeName || trees[0]?.name || t('Aucun arbre')
|
||||
|
||||
return (
|
||||
<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) => {
|
||||
// L'arbre en cours : affiché comme courant (coche terracotta), non cliquable, pas grisé
|
||||
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={isActive ? undefined : () => { onClose(); onSelectTree?.(tree.id) }}
|
||||
disabled={isActive}
|
||||
className={isActive ? 'navm-current' : undefined}
|
||||
right={isActive ? <Check size={16} aria-hidden="true" className="navm-check" /> : null}
|
||||
/>
|
||||
)
|
||||
}) : (
|
||||
<NavMenuItem
|
||||
icon={<TreeDeciduous {...ICON} />}
|
||||
label={currentTreeName}
|
||||
disabled
|
||||
className="navm-current"
|
||||
right={<Check size={16} aria-hidden="true" className="navm-check" />}
|
||||
/>
|
||||
)}
|
||||
<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,95 +329,3 @@
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Famille arrivée par le lien : gestes écrits en toutes lettres
|
||||
============================================================ */
|
||||
.nav-family-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 18px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font: 500 15.5px/1 var(--pz-sans);
|
||||
color: var(--pz-ink);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 140ms ease, transform 140ms var(--pz-ease);
|
||||
}
|
||||
.nav-family-btn:active { transform: scale(.97); }
|
||||
.nav-family-btn:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
@media (hover: hover) and (pointer: fine) { .nav-family-btn:hover { background: var(--pz-a50); } }
|
||||
.nav-family-btn--primary { background: var(--pz-ink); color: #fff; }
|
||||
@media (hover: hover) and (pointer: fine) { .nav-family-btn--primary:hover { background: #3a3530; } }
|
||||
.nav-family-btn--more.is-open { background: var(--pz-a100); }
|
||||
|
||||
.nav-more-wrapper { position: relative; }
|
||||
.nav-more-menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 12px);
|
||||
min-width: 260px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 6px;
|
||||
background: var(--pz-surface);
|
||||
border: 1px solid var(--pz-line);
|
||||
border-radius: var(--pz-r-m);
|
||||
box-shadow: var(--pz-shadow-lg);
|
||||
animation: pz-rise 180ms var(--pz-ease) both;
|
||||
}
|
||||
.nav-more-menu button,
|
||||
.nav-more-menu a {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 48px;
|
||||
padding: 0 14px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
font: 500 15.5px/1.2 var(--pz-sans);
|
||||
color: var(--pz-ink);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) { .nav-more-menu button:hover, .nav-more-menu a:hover { background: var(--pz-a50); } }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Sur téléphone, la barre prend toute la largeur : trois cibles larges, icône et mot court */
|
||||
.contextual-navbar--family { left: 12px; right: 12px; }
|
||||
.contextual-navbar--family .navbar-content { justify-content: space-between; gap: 4px; }
|
||||
.contextual-navbar--family .nav-family-btn {
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-height: 64px;
|
||||
padding: 8px 6px;
|
||||
border-radius: 22px;
|
||||
font-size: 14px;
|
||||
line-height: 1.15;
|
||||
white-space: normal;
|
||||
text-align: center;
|
||||
}
|
||||
.contextual-navbar--family .nav-family-label-short { display: none; }
|
||||
/* Sous 420 px, trois libellés complets passent sur deux lignes : on prend les courts, sur une ligne */
|
||||
@media (max-width: 419px) {
|
||||
.contextual-navbar--family .nav-family-label-full { display: none; }
|
||||
.contextual-navbar--family .nav-family-label-short { display: inline; white-space: nowrap; }
|
||||
}
|
||||
.contextual-navbar--family .nav-more-wrapper { flex: 1; display: flex; }
|
||||
.contextual-navbar--family .nav-dropdown-wrapper { margin-left: 0; align-self: center; }
|
||||
/* Le zoom passe au-dessus de la barre pleine largeur */
|
||||
body:has(.contextual-navbar--family) .zoom-control { bottom: 104px; }
|
||||
/* Les messages (merci, ajout) passent au-dessus de la barre, en gros */
|
||||
body:has(.contextual-navbar--family) .contrib-success-toast { bottom: 108px; font-size: 15px; }
|
||||
}
|
||||
|
||||
/* Choix de la langue dans le menu « Plus » de la famille : même gabarit que les autres lignes */
|
||||
.nav-more-menu .pz-lang-row { min-height: 48px; padding-left: 14px; font-size: 15.5px; }
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Edit3, Share2, User, UserPlus,
|
||||
X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle, Globe, ArrowLeft,
|
||||
Brush, ImagePlus, Plus, UserPlus, User, X, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft,
|
||||
} from 'lucide-react'
|
||||
import NavButton from './NavButton'
|
||||
import { NavMenu, NavMenuGroup, NavMenuItem } from './NavMenu'
|
||||
import AvatarMenu from './AvatarMenu'
|
||||
import LanguageSwitch from './LanguageSwitch'
|
||||
import DrawingOptions from '../annotations/DrawingOptions'
|
||||
import TextOptions from '../annotations/TextOptions'
|
||||
import StickerPicker from '../annotations/StickerPicker'
|
||||
import { toJourneyRole, toOpaqueTreeId, trackAppEvent } from '../../utils/analytics'
|
||||
import { t } from '../../i18n/index.js'
|
||||
import { PHONE_QUERY } from '../../utils/sheetDrag'
|
||||
import './ContextualNavbar.css'
|
||||
|
||||
const ANNOT_TOOLS = [
|
||||
@@ -21,7 +20,7 @@ const ANNOT_TOOLS = [
|
||||
{ id: 'photo', label: t('Photo'), Icon: Image },
|
||||
]
|
||||
|
||||
// Mobile : les outils de dessin attendent dans « Plus », la barre ne montre que l'essentiel
|
||||
// Téléphone, en modification : les outils de dessin passent dans le menu « + »
|
||||
const ANNOT_MENU = [
|
||||
{ id: 'drawing', label: t('Dessiner'), Icon: Pencil },
|
||||
{ id: 'text', label: t('Écrire un texte'), Icon: Type },
|
||||
@@ -29,6 +28,9 @@ const ANNOT_MENU = [
|
||||
{ id: 'photo', label: t('Poser une photo'), Icon: Image },
|
||||
]
|
||||
|
||||
// Téléphone (ou pointeur grossier) : le « + » et le compte s'ouvrent en feuille, sinon en popover (PHONE_QUERY, partagé avec la fiche)
|
||||
const ICON = { size: 18, strokeWidth: 2, 'aria-hidden': true }
|
||||
|
||||
function ContextualNavbar({
|
||||
userRole,
|
||||
pendingContributions = 0,
|
||||
@@ -53,39 +55,35 @@ function ContextualNavbar({
|
||||
onLogout,
|
||||
annotationTools = null,
|
||||
}) {
|
||||
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false)
|
||||
// Un seul menu ouvert à la fois : 'add' (le +), 'account' (le rond), ou null
|
||||
const [openMenu, setOpenMenu] = useState(null)
|
||||
const [annotPanel, setAnnotPanel] = useState(null)
|
||||
const [annotMode, setAnnotMode] = useState(false)
|
||||
const [moreOpen, setMoreOpen] = useState(false)
|
||||
const moreRef = useRef(null)
|
||||
const [isMobile, setIsMobile] = useState(() =>
|
||||
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
|
||||
const [isPhone, setIsPhone] = useState(() =>
|
||||
typeof window !== 'undefined' && window.matchMedia(PHONE_QUERY).matches
|
||||
)
|
||||
|
||||
const avatarMenuRef = useRef(null)
|
||||
const navRef = useRef(null)
|
||||
const addBtnRef = useRef(null)
|
||||
const avatarBtnRef = useRef(null)
|
||||
const photoInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia('(max-width: 768px)')
|
||||
const handler = (event) => setIsMobile(event.matches)
|
||||
const mql = window.matchMedia(PHONE_QUERY)
|
||||
const handler = (event) => setIsPhone(event.matches)
|
||||
mql.addEventListener('change', handler)
|
||||
return () => mql.removeEventListener('change', handler)
|
||||
}, [])
|
||||
|
||||
// Clic dehors ferme le menu sur ordinateur (sur téléphone, c'est le voile)
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event) {
|
||||
if (avatarMenuRef.current && !avatarMenuRef.current.contains(event.target)) {
|
||||
setAvatarMenuOpen(false)
|
||||
}
|
||||
if (moreRef.current && !moreRef.current.contains(event.target)) {
|
||||
setMoreOpen(false)
|
||||
}
|
||||
if (isPhone) return
|
||||
if (navRef.current && !navRef.current.contains(event.target)) setOpenMenu(null)
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
}, [isPhone])
|
||||
|
||||
useEffect(() => {
|
||||
if (!annotPanel) return
|
||||
@@ -97,15 +95,14 @@ function ContextualNavbar({
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [annotPanel])
|
||||
|
||||
// Échap ferme le menu ouvert et le panneau d'options
|
||||
useEffect(() => {
|
||||
function handleEscape(event) {
|
||||
if (event.key === 'Escape') {
|
||||
setAvatarMenuOpen(false)
|
||||
setMoreOpen(false)
|
||||
setOpenMenu(null)
|
||||
setAnnotPanel(null)
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [])
|
||||
@@ -118,13 +115,34 @@ function ContextualNavbar({
|
||||
}, [editModeActive])
|
||||
|
||||
const { navbarType, authenticated } = userRole
|
||||
const showMobileAnnot = isMobile && editModeActive && annotationTools
|
||||
const isOwner = navbarType === 'admin'
|
||||
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
|
||||
const isDemo = userRole.accessMode === 'demo' && !['admin', 'contributor_auth', 'contributor_anon', 'visitor'].includes(navbarType)
|
||||
const showMobileAnnot = isPhone && editModeActive && annotationTools
|
||||
const accountRole = toJourneyRole(userRole.role) || 'visitor'
|
||||
const accountClickProps = {
|
||||
role: accountRole,
|
||||
tree_id: toOpaqueTreeId(activeTreeId),
|
||||
}
|
||||
|
||||
const closeMenus = () => setOpenMenu(null)
|
||||
|
||||
// « Propositions à relire » ouvre la fenêtre de partage, positionnée sur la section des propositions
|
||||
const openProposals = () => {
|
||||
onShareClick?.()
|
||||
const scrollWhenReady = (retries) => {
|
||||
const section = document.getElementById('contrib-propositions')
|
||||
if (section) section.scrollIntoView({ block: 'start' })
|
||||
else if (retries > 0) setTimeout(() => scrollWhenReady(retries - 1), 100)
|
||||
}
|
||||
scrollWhenReady(20)
|
||||
}
|
||||
// Chaque action ferme le menu avant de se lancer
|
||||
const run = (action) => () => {
|
||||
closeMenus()
|
||||
action?.()
|
||||
}
|
||||
|
||||
// Création de compte dans l'app
|
||||
const handleCreateAccountClick = () => {
|
||||
trackAppEvent('create_account_clicked', accountClickProps)
|
||||
@@ -156,184 +174,69 @@ function ContextualNavbar({
|
||||
</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,161 @@
|
||||
/* Popover d'ordinateur : ancré au-dessus de la pilule, calé à droite, comme sur la maquette C */
|
||||
.navm-pop {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: calc(100% + 14px);
|
||||
width: 300px;
|
||||
max-height: min(70vh, 560px);
|
||||
overflow-y: auto;
|
||||
padding: 8px 8px 10px;
|
||||
background: var(--pz-surface);
|
||||
border-radius: 18px;
|
||||
box-shadow: var(--pz-shadow-lg);
|
||||
transform-origin: bottom right;
|
||||
animation: navmPop 200ms var(--pz-ease) both;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--pz-line) transparent;
|
||||
}
|
||||
@keyframes navmPop {
|
||||
from { opacity: 0; transform: translateY(6px) scale(0.97); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Téléphone : feuille sur toute la largeur, voile sur l'arbre */
|
||||
.navm-scrim {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1001;
|
||||
background: rgba(42, 38, 34, 0.2);
|
||||
animation: navmFade 200ms ease both;
|
||||
}
|
||||
@keyframes navmFade { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.navm-sheet {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 1002;
|
||||
max-height: 82vh;
|
||||
overflow-y: auto;
|
||||
padding: 0 14px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--pz-surface);
|
||||
border-radius: 24px 24px 0 0;
|
||||
box-shadow: 0 -12px 40px rgba(42, 38, 34, 0.16);
|
||||
/* Pas de fill-mode : une fois l'entrée finie, le glisser (style inline) reprend la main */
|
||||
animation: navmSheetIn 320ms var(--pz-ease-drawer);
|
||||
transition: transform 240ms var(--pz-ease-drawer);
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.navm-sheet[data-dragging] { transition: none; }
|
||||
@keyframes navmSheetIn {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.navm-handle {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin: 0 0 4px;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--pz-surface);
|
||||
}
|
||||
.navm-handle span {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 4px;
|
||||
margin: 12px auto 0;
|
||||
border-radius: 4px;
|
||||
background: var(--pz-line);
|
||||
}
|
||||
|
||||
/* Titres de groupe : Newsreader, casse normale */
|
||||
.navm-group {
|
||||
margin: 12px 10px 4px;
|
||||
font: 400 14px/1.2 var(--pz-serif);
|
||||
color: var(--pz-muted);
|
||||
}
|
||||
.navm-group:first-child { margin-top: 6px; }
|
||||
|
||||
.navm-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
min-height: 48px;
|
||||
padding: 8px 10px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--pz-ink);
|
||||
font: 500 15px/1.25 var(--pz-sans);
|
||||
text-align: left;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, transform 140ms var(--pz-ease);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.navm-item:active:not(:disabled) { transform: scale(0.98); }
|
||||
.navm-item:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: -2px; }
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.navm-item:hover:not(:disabled) { background: var(--pz-line-soft); }
|
||||
}
|
||||
.navm-item:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||
|
||||
.navm-text { flex: 1; min-width: 0; display: grid; gap: 2px; }
|
||||
.navm-text small {
|
||||
font: 400 12.5px/1.3 var(--pz-sans);
|
||||
color: var(--pz-muted);
|
||||
}
|
||||
|
||||
.navm-ic {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 11px;
|
||||
background: var(--pz-a50);
|
||||
color: var(--pz-a700);
|
||||
}
|
||||
.navm-ic svg { width: 18px; height: 18px; }
|
||||
|
||||
.navm-item--danger { color: var(--pz-danger); }
|
||||
.navm-item--danger .navm-ic { background: var(--pz-danger-bg); color: var(--pz-danger); }
|
||||
|
||||
.navm-badge {
|
||||
min-width: 22px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--pz-a500);
|
||||
color: #fff;
|
||||
font: 600 12px/22px var(--pz-sans);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.navm-sep {
|
||||
height: 1px;
|
||||
margin: 6px 10px;
|
||||
background: var(--pz-line-soft);
|
||||
}
|
||||
|
||||
/* Ligne « Aide » et langue sur la même ligne */
|
||||
.navm-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 48px;
|
||||
padding: 0 10px;
|
||||
}
|
||||
.navm-row .navm-item { flex: 1; padding-left: 0; padding-right: 0; width: auto; }
|
||||
.navm-row .pz-lang { flex: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.navm-pop, .navm-sheet, .navm-scrim { animation-duration: 1ms; }
|
||||
.navm-sheet { transition-duration: 1ms; }
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { useSheetDrag } from '../../hooks/useSheetDrag'
|
||||
import './NavMenu.css'
|
||||
|
||||
/**
|
||||
* Menu partagé de la barre flottante.
|
||||
* Téléphone : feuille qui monte du bas, voile sur l'arbre, poignée qui se glisse vers le bas pour fermer.
|
||||
* Ordinateur : popover ancré à la pilule, fermé par le clic dehors et par Échap (géré par la barre).
|
||||
*/
|
||||
export function NavMenu({ phone = false, label, onClose, triggerRef, children }) {
|
||||
const ref = useRef(null)
|
||||
// Le geste part de la poignée seulement (le contenu de la feuille défile et reste intact)
|
||||
const drag = useSheetDrag({ enabled: phone, zone: '.navm-handle', onClose, closes: ({ offset }) => offset > 70 })
|
||||
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
node?.querySelector('[role="menuitem"]:not(:disabled)')?.focus({ preventScroll: true })
|
||||
return () => {
|
||||
// Le focus revient au déclencheur quand le menu se referme
|
||||
if (node?.contains(document.activeElement)) triggerRef?.current?.focus({ preventScroll: true })
|
||||
}
|
||||
}, [triggerRef])
|
||||
|
||||
if (phone) {
|
||||
return createPortal(
|
||||
<>
|
||||
<div className="navm-scrim" onClick={onClose} aria-hidden="true" />
|
||||
<div
|
||||
ref={ref}
|
||||
className="navm-sheet"
|
||||
role="dialog"
|
||||
aria-label={label}
|
||||
style={drag.style}
|
||||
data-dragging={drag.dragging || undefined}
|
||||
{...drag.bind}
|
||||
>
|
||||
<div className="navm-handle" 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" />
|
||||
}
|
||||
@@ -5,6 +5,9 @@
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
}
|
||||
/* Ouverte, ses résultats passent devant la bannière d'édition et « Créer mon arbre » (1001), qui descendent
|
||||
sous le champ sur téléphone et rendaient les résultats intouchables. Les panneaux (1050) restent devant. */
|
||||
.expandable-search:has(.search-expanded-content) { z-index: 1003; }
|
||||
|
||||
.search-icon-button {
|
||||
width: 46px;
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
PHONE_QUERY,
|
||||
SHEET_WIDTH_QUERY,
|
||||
canStartSheetDrag,
|
||||
shouldCloseSheet,
|
||||
sheetDragIntent,
|
||||
velocityBetween,
|
||||
} from '../utils/sheetDrag'
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => { setTimeout(resolve, ms) })
|
||||
|
||||
/**
|
||||
* Vrai en mode feuille : téléphone ou pointeur grossier, et largeur où la fiche se pose en bas (même règle que la barre).
|
||||
*/
|
||||
export function useSheetMode() {
|
||||
const [sheet, setSheet] = useState(() => (
|
||||
typeof window !== 'undefined' && window.matchMedia(PHONE_QUERY).matches && window.matchMedia(SHEET_WIDTH_QUERY).matches
|
||||
))
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const phone = window.matchMedia(PHONE_QUERY)
|
||||
const width = window.matchMedia(SHEET_WIDTH_QUERY)
|
||||
const update = () => setSheet(phone.matches && width.matches)
|
||||
phone.addEventListener('change', update)
|
||||
width.addEventListener('change', update)
|
||||
return () => {
|
||||
phone.removeEventListener('change', update)
|
||||
width.removeEventListener('change', update)
|
||||
}
|
||||
}, [])
|
||||
return sheet
|
||||
}
|
||||
|
||||
/**
|
||||
* Glisser pour fermer une feuille du bas.
|
||||
* - Le geste ne part que des éléments de `zone` (sélecteur CSS) et jamais d'un champ de saisie ni d'une sélection de texte.
|
||||
* - La feuille suit le doigt (transform inline) et ne monte jamais au-delà de sa position de repos.
|
||||
* - Au lâcher : `closes` décide (par défaut, un tiers de la hauteur ou un lâcher rapide). Fermer attend la fin de la sortie,
|
||||
* puis appelle `onClose`. Si `onClose` renvoie `false` (enregistrement refusé), la feuille revient.
|
||||
* - Après un vrai glisser, le clic qui suit est avalé (pas de bouton déclenché au lâcher).
|
||||
*
|
||||
* Usage : `<div {...drag.bind} style={drag.style} data-dragging={drag.dragging || undefined}>`
|
||||
*/
|
||||
export function useSheetDrag({
|
||||
enabled = true,
|
||||
zone,
|
||||
onClose,
|
||||
closes = shouldCloseSheet,
|
||||
closeDelayMs = 180,
|
||||
}) {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const gesture = useRef(null)
|
||||
const suppressClick = useRef(false)
|
||||
const closingRef = useRef(false)
|
||||
|
||||
const settle = () => {
|
||||
setDragging(false)
|
||||
setOffset(0)
|
||||
}
|
||||
|
||||
const close = async (height) => {
|
||||
closingRef.current = true
|
||||
setDragging(false)
|
||||
setOffset(height)
|
||||
await wait(closeDelayMs)
|
||||
const kept = await onClose?.()
|
||||
closingRef.current = false
|
||||
// Enregistrement refusé : la fiche reste, revient à sa place et l'erreur reste affichée par la fiche
|
||||
if (kept === false) settle()
|
||||
}
|
||||
|
||||
const onPointerDown = (event) => {
|
||||
suppressClick.current = false
|
||||
if (!enabled || closingRef.current || event.button > 0) return
|
||||
const selection = typeof window !== 'undefined' ? window.getSelection?.()?.toString() ?? '' : ''
|
||||
if (!canStartSheetDrag(event.target, zone, selection)) return
|
||||
gesture.current = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
height: event.currentTarget.offsetHeight,
|
||||
active: false,
|
||||
prev: null,
|
||||
last: { y: event.clientY, t: event.timeStamp },
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
const g = gesture.current
|
||||
if (!g || event.pointerId !== g.id) return
|
||||
const dx = event.clientX - g.x
|
||||
const dy = event.clientY - g.y
|
||||
if (!g.active) {
|
||||
const intent = sheetDragIntent(dx, dy)
|
||||
if (intent === 'pending') return
|
||||
const selecting = typeof window !== 'undefined' && window.getSelection?.()?.toString()
|
||||
if (intent === 'other' || selecting) {
|
||||
gesture.current = null
|
||||
return
|
||||
}
|
||||
g.active = true
|
||||
suppressClick.current = true
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
setDragging(true)
|
||||
}
|
||||
g.prev = g.last
|
||||
g.last = { y: event.clientY, t: event.timeStamp }
|
||||
setOffset(Math.max(0, dy))
|
||||
}
|
||||
|
||||
const onPointerUp = (event) => {
|
||||
const g = gesture.current
|
||||
if (!g || event.pointerId !== g.id) return
|
||||
gesture.current = null
|
||||
if (!g.active) return
|
||||
const end = { y: event.clientY, t: event.timeStamp }
|
||||
const offsetNow = Math.max(0, event.clientY - g.y)
|
||||
const velocity = velocityBetween(g.prev ?? g.last, end)
|
||||
if (closes({ offset: offsetNow, height: g.height, velocity })) {
|
||||
close(g.height)
|
||||
} else {
|
||||
settle()
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerCancel = () => {
|
||||
const g = gesture.current
|
||||
gesture.current = null
|
||||
if (g?.active) settle()
|
||||
}
|
||||
|
||||
// Après un vrai glisser, le clic du lâcher ne doit pas atteindre les boutons de la feuille
|
||||
const onClickCapture = (event) => {
|
||||
if (!suppressClick.current) return
|
||||
suppressClick.current = false
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
}
|
||||
|
||||
return {
|
||||
dragging,
|
||||
bind: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture },
|
||||
style: offset > 0 ? { transform: `translateY(${offset}px)` } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -101,4 +101,6 @@ export default {
|
||||
'Conflit détecté (email ou slug déjà utilisé).': 'Conflict detected (email or slug already in use).',
|
||||
"Erreur backend. Vérifiez que l'API et la base sont lancées.": 'Server error. Check that the API and database are running.',
|
||||
'Le serveur ne répond pas. Vérifiez la connexion et réessayez.': 'The server is not responding. Check your connection and try again.',
|
||||
// App.jsx : enregistrement automatique en quittant une fiche
|
||||
'Modifications de {name} enregistrées': 'Changes to {name} saved',
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
@@ -176,6 +176,7 @@ export default {
|
||||
'Marie': 'Mary',
|
||||
'Dupont': 'Smith',
|
||||
'Il faut au moins un prénom.': 'A first name is required.',
|
||||
"L'enregistrement a échoué. Vos modifications sont gardées, réessayez.": 'Saving failed. Your changes are kept, try again.',
|
||||
'si différent': 'if different',
|
||||
'Martin': 'Johnson',
|
||||
'Place dans la famille': 'Place in the family',
|
||||
@@ -417,6 +418,12 @@ export default {
|
||||
'Il faut au moins un nom si le prénom est inconnu.': 'A last name is required when the first name is unknown.',
|
||||
// Recadrage du portrait (PortraitCropModal, menu du badge, App)
|
||||
'Recadrer la photo': 'Crop photo',
|
||||
'Forme du cadre': 'Frame shape',
|
||||
'Rond': 'Round',
|
||||
'Carré': 'Square',
|
||||
'Polaroïd': 'Polaroid',
|
||||
'Vertical': 'Vertical',
|
||||
'Horizontal': 'Horizontal',
|
||||
'Recadrer': 'Crop',
|
||||
'Options du portrait': 'Portrait options',
|
||||
'Zone de recadrage': 'Crop area',
|
||||
|
||||
@@ -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; }
|
||||
@@ -107,7 +107,7 @@ const siblingWord = (p) => {
|
||||
|
||||
/**
|
||||
* @param person personne de la fiche (birthDate, deathDate, birthPlace, birthYear, deathYear)
|
||||
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType }] }
|
||||
* @param family { parents, children, siblings, unions: [{ id, partner, startYear, endYear, unionType, childIds }] }
|
||||
* @param life réponse de GET /trees/:id/persons/:personId/life, ou null tant qu'elle n'est pas là
|
||||
* @returns entrées triées { key, year, kind: 'mile' | 'minor' | 'fam', title, meta, note, sources, mediaIds,
|
||||
* event (moment modifiable), milestone ({ type, unionId } d'une naissance, d'un mariage, d'un décès) }
|
||||
@@ -144,10 +144,18 @@ export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
const partnerName = union.partner ? fullName(union.partner) : t('Conjoint inconnu')
|
||||
const place = formatPlace(detail.startPlace || '')
|
||||
const extra = sourcesOf('marriage', union.id)
|
||||
// Union sans date : à sa place logique, juste avant le premier enfant du couple, sinon avant la fin de
|
||||
// l'union ou le décès du conjoint. Aucune année n'est inventée, la colonne reste vide.
|
||||
const childIds = new Set((union.childIds || []).map(String))
|
||||
const childYears = (family.children || []).filter((c) => childIds.has(String(c.id))).map((c) => yearOf(c.birthDate) ?? c.birthYear).filter(within)
|
||||
const partnerDeath = union.partner ? yearOf(union.partner.deathDate) ?? union.partner.deathYear : null
|
||||
const anchor = year ? null : (childYears.length ? Math.min(...childYears) : null) ?? union.endYear ?? (within(partnerDeath) ? partnerDeath : null)
|
||||
out.push({
|
||||
key: `union-${union.id}`, year, order: 2, kind: 'mile',
|
||||
key: `union-${union.id}`, year, sortYear: anchor, order: year ? 2 : 1, kind: 'mile',
|
||||
title: union.unionType === 'union' ? t('Union avec {name}', { name: partnerName }) : t('Épouse {name}', { name: partnerName }),
|
||||
meta: joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year)),
|
||||
meta: year
|
||||
? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year))
|
||||
: joinMeta(place, t('date inconnue')),
|
||||
note: extra?.notes || '', sources: extra?.sources || '',
|
||||
...milestoneFields(extra, { type: 'marriage', unionId: union.id }),
|
||||
})
|
||||
@@ -171,7 +179,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
}
|
||||
out.push({
|
||||
key: `event-${event.id}`, year, order: 1, kind: 'minor', title,
|
||||
meta: joinMeta(formatPlace(event.place || ''), describeDate(event.date, event.dateEnd, event.datePrecision), ageAt(birthYear, year)),
|
||||
meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthYear, year)),
|
||||
note: event.notes || '', sources: event.sources || '',
|
||||
mediaIds: event.mediaIds || [],
|
||||
// Seuls les moments propres à la personne se modifient depuis l'onglet (pas ceux d'une union)
|
||||
@@ -206,9 +214,9 @@ export function buildLifeTimeline(person, family = {}, life = null) {
|
||||
})
|
||||
}
|
||||
|
||||
// Les entrées sans date restent en bas, dans l'ordre où elles sont arrivées
|
||||
// Une union sans date se range à sa place logique (sortYear) ; le reste sans date va en bas, dans l'ordre d'arrivée
|
||||
return out
|
||||
.map((entry, index) => ({ ...entry, index }))
|
||||
.sort((a, b) => (a.year ?? Infinity) - (b.year ?? Infinity) || a.order - b.order || a.index - b.index)
|
||||
.map(({ order, index, ...entry }) => entry) // eslint-disable-line no-unused-vars
|
||||
.sort((a, b) => (a.year ?? a.sortYear ?? Infinity) - (b.year ?? b.sortYear ?? Infinity) || a.order - b.order || a.index - b.index)
|
||||
.map(({ order, index, sortYear, ...entry }) => entry) // eslint-disable-line no-unused-vars
|
||||
}
|
||||
@@ -66,6 +66,30 @@ describe('frise de vie', () => {
|
||||
expect(entries.map((e) => [e.title, e.year, e.meta])).toEqual([['Naissance', 1900, ''], ['Décès', null, 'date inconnue']])
|
||||
})
|
||||
|
||||
it('union sans date : avant le premier enfant du couple, sinon avant le décès du conjoint, sinon en bas', () => {
|
||||
const lucie = { id: 1, firstName: 'Lucie', birthYear: 1940 }
|
||||
const entries = buildLifeTimeline(lucie, {
|
||||
children: [{ id: 7, firstName: 'Hugo', birthYear: 1966 }, { id: 8, firstName: 'Léa', birthYear: 1981 }],
|
||||
unions: [
|
||||
{ id: 'u1', partner: { id: 2, firstName: 'Marc', lastName: 'Roy', deathYear: 2013 }, unionType: 'mariage', childIds: [7] },
|
||||
{ id: 'u2', partner: { id: 3, firstName: 'Paul', lastName: 'Vidal', deathYear: 2016 }, unionType: 'mariage', childIds: [] },
|
||||
{ id: 'u3', partner: { id: 4, firstName: 'Yves', lastName: 'Mallet' }, unionType: 'mariage' },
|
||||
],
|
||||
}, null)
|
||||
expect(entries.map((e) => e.title)).toEqual([
|
||||
'Naissance', 'Épouse Marc Roy', 'Naissance de Hugo', 'Naissance de Léa', 'Décès de Marc', 'Épouse Paul Vidal', 'Décès de Paul', 'Épouse Yves Mallet',
|
||||
])
|
||||
// Aucune année inventée : colonne vide, « date inconnue » dit pourquoi
|
||||
for (const key of ['union-u1', 'union-u2', 'union-u3']) {
|
||||
expect(entries.find((e) => e.key === key)).toMatchObject({ year: undefined, meta: 'date inconnue' })
|
||||
}
|
||||
expect(entries.find((e) => e.key === 'union-u1')).not.toHaveProperty('sortYear')
|
||||
})
|
||||
|
||||
it('un moment sans date dit « date inconnue »', () => {
|
||||
expect(entries.find((e) => e.title === 'Résidence').meta).toBe('Rue des Écoles · date inconnue')
|
||||
})
|
||||
|
||||
it('dit les dates comme on les dit', () => {
|
||||
expect(describeDate('1879-01-01', null, 'about')).toBe('vers 1879')
|
||||
expect(describeDate('1879-01-01', null, 'before')).toBe('avant 1879')
|
||||
|
||||
@@ -7,19 +7,37 @@ export const DEFAULT_CROP = { x: 0.5, y: 0.5, zoom: 1 }
|
||||
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
|
||||
|
||||
// Formes qu'on choisit au recadrage, gardées dans le cadrage (crop.shape). Sans choix, la forme suit la photo
|
||||
// (très large ou très haute) ou le cadre tiré au hasard pour la personne.
|
||||
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape']
|
||||
export const cropShape = (crop) => (FRAME_SHAPES.includes(crop?.shape) ? crop.shape : null)
|
||||
|
||||
// Forme du cadre dans le canevas : même règle que resolveFrameType et drawFrame (renderers.js).
|
||||
// Une photo très large ou très haute change la forme ; sinon le cadre tiré pour la personne (frameType) reste.
|
||||
export function resolveFrameKind(frameType, imageRatio) {
|
||||
// La forme choisie l'emporte ; sinon une photo très large ou très haute change la forme, et à défaut
|
||||
// le cadre tiré pour la personne (frameType) reste.
|
||||
export function resolveFrameKind(frameType, imageRatio, shape = null) {
|
||||
if (shape) return shape === 'portrait' || shape === 'landscape' ? 'rect' : shape
|
||||
if (imageRatio >= 1.28) return 'rect'
|
||||
if (imageRatio <= 0.78) return 'polaroid'
|
||||
return frameType || 'round'
|
||||
}
|
||||
|
||||
// Cadre rectangle : couché si on l'a choisi, sinon selon la photo (carrée : debout, le visage reste entier)
|
||||
export function isLandscapeRect(imageRatio, shape = null) {
|
||||
return shape ? shape === 'landscape' : imageRatio > 1.05
|
||||
}
|
||||
|
||||
// Nom de la forme affichée, pour le sélecteur
|
||||
export function shapeOfKind(frameKind, imageRatio) {
|
||||
if (frameKind !== 'rect') return frameKind
|
||||
return isLandscapeRect(imageRatio) ? 'landscape' : 'portrait'
|
||||
}
|
||||
|
||||
// Rapport largeur / hauteur de la zone visible, comme drawFrame le dessine.
|
||||
export function frameAspect(frameKind, imageRatio) {
|
||||
export function frameAspect(frameKind, imageRatio, shape = null) {
|
||||
if (frameKind === 'rect') {
|
||||
// Paysage : 80 x 40 ; portrait : 60 x 68 (renderers.js, drawFrame)
|
||||
return imageRatio > 1.05 ? 2 : 60 / 68
|
||||
return isLandscapeRect(imageRatio, shape) ? 2 : 60 / 68
|
||||
}
|
||||
// round, polaroid et square : la zone photo est carrée
|
||||
return 1
|
||||
@@ -32,7 +50,8 @@ export function normalizeCrop(crop) {
|
||||
const y = Number(crop.y)
|
||||
const zoom = Number(crop.zoom)
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(zoom)) return DEFAULT_CROP
|
||||
return { x: clamp(x, 0, 1), y: clamp(y, 0, 1), zoom: clamp(zoom, 1, CROP_MAX_ZOOM) }
|
||||
const shape = cropShape(crop)
|
||||
return { x: clamp(x, 0, 1), y: clamp(y, 0, 1), zoom: clamp(zoom, 1, CROP_MAX_ZOOM), ...(shape ? { shape } : {}) }
|
||||
}
|
||||
|
||||
// Zone de l'image visible dans le cadre, en pixels de l'image. Le centre est borné pour qu'il n'y ait jamais
|
||||
@@ -77,11 +96,13 @@ export function imagePlacement(imageW, imageH, aspect, crop, windowW = 1, window
|
||||
}
|
||||
}
|
||||
|
||||
// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut, sinon le cadrage borné.
|
||||
export function cropForSave(imageW, imageH, aspect, crop) {
|
||||
// Ce qu'il faut à l'enregistrement : null pour le cadrage par défaut sans forme choisie, sinon le cadrage
|
||||
// borné, avec la forme quand on l'a choisie.
|
||||
export function cropForSave(imageW, imageH, aspect, crop, shape = null) {
|
||||
const c = clampCrop(imageW, imageH, aspect, crop)
|
||||
const round = (value) => Math.round(value * 10000) / 10000
|
||||
const normalized = { x: round(c.x), y: round(c.y), zoom: round(c.zoom) }
|
||||
if (shape) return { ...normalized, shape }
|
||||
if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
|
||||
return normalized
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
imagePlacement,
|
||||
normalizeCrop,
|
||||
resolveFrameKind,
|
||||
shapeOfKind,
|
||||
} from './portraitCrop.js'
|
||||
|
||||
// Le cadrage visible ne sort jamais de la photo : aucun vide dans le cadre
|
||||
@@ -86,3 +87,29 @@ describe('portraitCrop', () => {
|
||||
expect(frameAspect('rect', 1)).toBeCloseTo(60 / 68)
|
||||
})
|
||||
})
|
||||
|
||||
describe('forme choisie au recadrage', () => {
|
||||
it('l emporte sur le format de la photo et sur le tirage de la personne', () => {
|
||||
// Photo très large : cadre couché par défaut, rond si on le choisit
|
||||
expect(resolveFrameKind('polaroid', 1.6)).toBe('rect')
|
||||
expect(resolveFrameKind('polaroid', 1.6, 'round')).toBe('round')
|
||||
expect(frameAspect(resolveFrameKind('polaroid', 1.6, 'round'), 1.6, 'round')).toBe(1)
|
||||
// Vertical choisi sur une photo couchée : la zone visible est debout
|
||||
expect(resolveFrameKind('round', 1.6, 'portrait')).toBe('rect')
|
||||
expect(frameAspect('rect', 1.6, 'portrait')).toBeCloseTo(60 / 68)
|
||||
expect(frameAspect('rect', 0.7, 'landscape')).toBe(2)
|
||||
})
|
||||
|
||||
it('le sélecteur montre la forme en cours quand rien n est choisi', () => {
|
||||
expect(shapeOfKind('rect', 1.6)).toBe('landscape')
|
||||
expect(shapeOfKind('rect', 1)).toBe('portrait')
|
||||
expect(shapeOfKind('polaroid', 0.7)).toBe('polaroid')
|
||||
})
|
||||
|
||||
it('est gardée à la lecture et à l enregistrement, même sans recadrage', () => {
|
||||
expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 2, shape: 'square' })).toEqual({ x: 0.2, y: 0.3, zoom: 2, shape: 'square' })
|
||||
expect(normalizeCrop({ x: 0.2, y: 0.3, zoom: 2, shape: 'hexagone' })).toEqual({ x: 0.2, y: 0.3, zoom: 2 })
|
||||
expect(cropForSave(400, 400, 1, DEFAULT_CROP)).toBeNull()
|
||||
expect(cropForSave(400, 400, 1, DEFAULT_CROP, 'round')).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// Enregistrement d'une fiche en modification, partagé par le bouton « Enregistrer » et le départ de la fiche.
|
||||
// Un appel pendant un enregistrement reçoit la même promesse : pas de double envoi.
|
||||
export function runOnce(inFlightRef, run) {
|
||||
if (inFlightRef.current) return inFlightRef.current
|
||||
const promise = Promise.resolve().then(run).finally(() => { inFlightRef.current = null })
|
||||
inFlightRef.current = promise
|
||||
return promise
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { runOnce } from './saveOnLeave.js'
|
||||
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
describe('runOnce (enregistrement de fiche)', () => {
|
||||
it('un second appel pendant l’envoi reçoit le même résultat, sans second envoi', async () => {
|
||||
const ref = { current: null }
|
||||
const send = vi.fn(async () => { await tick(); return 'saved' })
|
||||
const first = runOnce(ref, send)
|
||||
const second = runOnce(ref, send)
|
||||
expect(second).toBe(first)
|
||||
expect(await first).toBe('saved')
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('une fois l’envoi terminé, un nouvel appel enregistre à nouveau (nouvelle modification)', async () => {
|
||||
const ref = { current: null }
|
||||
const send = vi.fn(async () => 'saved')
|
||||
await runOnce(ref, send)
|
||||
expect(ref.current).toBeNull()
|
||||
await runOnce(ref, send)
|
||||
expect(send).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('un échec se propage au résultat et libère le verrou pour une nouvelle tentative', async () => {
|
||||
const ref = { current: null }
|
||||
const send = vi.fn()
|
||||
.mockImplementationOnce(async () => { throw new Error('réseau') })
|
||||
.mockImplementationOnce(async () => 'saved')
|
||||
await expect(runOnce(ref, send)).rejects.toThrow('réseau')
|
||||
expect(ref.current).toBeNull()
|
||||
expect(await runOnce(ref, send)).toBe('saved')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// Geste « feuille » : une feuille du bas (fiche de personne, menu de la barre) se ferme en glissant vers le bas.
|
||||
// Logique pure, sans React ni DOM, testée dans sheetDrag.test.js. Le hook qui l'utilise est hooks/useSheetDrag.js.
|
||||
|
||||
// Téléphone ou pointeur grossier : même règle pour la barre et pour la fiche
|
||||
export const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
|
||||
// Même seuil que la feuille CSS de la fiche (PersonCard.css) : au-delà, la fiche est un panneau flottant
|
||||
export const SHEET_WIDTH_QUERY = '(max-width: 768px)'
|
||||
|
||||
// Distance avant de trancher entre glisser vertical, horizontal et tap
|
||||
export const DIRECTION_LOCK_PX = 8
|
||||
// Lâcher rapide vers le bas : vitesse en px/ms, et course minimale pour qu'il compte
|
||||
export const FLICK_VELOCITY = 0.6
|
||||
export const FLICK_MIN_OFFSET = 24
|
||||
// Deux points plus espacés que ça ne donnent pas une vitesse fiable
|
||||
const VELOCITY_WINDOW_MS = 120
|
||||
|
||||
// Champs de saisie et libellés : jamais le point de départ d'un glisser
|
||||
export const NO_DRAG_SELECTOR = 'input, textarea, select, label, [contenteditable]:not([contenteditable="false"]), [data-sheet-no-drag]'
|
||||
|
||||
/**
|
||||
* Le glisser peut-il partir de cette cible ?
|
||||
* `zoneSelector` : les seuls endroits de la feuille qui déclenchent le geste (poignée, en-tête).
|
||||
* `selectionText` : texte sélectionné au moment de l'appui, auquel cas rien ne bouge.
|
||||
*/
|
||||
export function canStartSheetDrag(target, zoneSelector, selectionText = '') {
|
||||
if (!target || typeof target.closest !== 'function') return false
|
||||
if (selectionText) return false
|
||||
if (!target.closest(zoneSelector)) return false
|
||||
return !target.closest(NO_DRAG_SELECTOR)
|
||||
}
|
||||
|
||||
/** Intention du mouvement depuis l'appui : 'pending' (encore trop petit), 'down' (glisser vers le bas), 'other' (haut ou horizontal) */
|
||||
export function sheetDragIntent(dx, dy) {
|
||||
if (Math.abs(dx) < DIRECTION_LOCK_PX && Math.abs(dy) < DIRECTION_LOCK_PX) return 'pending'
|
||||
if (dy > 0 && dy >= Math.abs(dx)) return 'down'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
/** Vitesse (px/ms, positive vers le bas) entre deux points {y, t}. Zéro si les points sont trop éloignés dans le temps. */
|
||||
export function velocityBetween(from, to) {
|
||||
if (!from || !to) return 0
|
||||
const dt = to.t - from.t
|
||||
if (dt <= 0 || dt > VELOCITY_WINDOW_MS) return 0
|
||||
return (to.y - from.y) / dt
|
||||
}
|
||||
|
||||
/** Au lâcher : fermer si la course dépasse un tiers de la hauteur, ou si le lâcher est rapide vers le bas */
|
||||
export function shouldCloseSheet({ offset, height, velocity = 0 }) {
|
||||
if (height > 0 && offset >= height / 3) return true
|
||||
return velocity >= FLICK_VELOCITY && offset >= FLICK_MIN_OFFSET
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
NO_DRAG_SELECTOR,
|
||||
canStartSheetDrag,
|
||||
shouldCloseSheet,
|
||||
sheetDragIntent,
|
||||
velocityBetween,
|
||||
} from './sheetDrag'
|
||||
|
||||
const ZONE = '.pc-grab, .pc-head, .pc-close'
|
||||
|
||||
// Cible factice : `closest` ne répond que pour la zone et les champs demandés
|
||||
function target({ inZone = true, inField = false } = {}) {
|
||||
return {
|
||||
closest(selector) {
|
||||
if (selector === ZONE) return inZone ? {} : null
|
||||
if (selector === NO_DRAG_SELECTOR) return inField ? {} : null
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('canStartSheetDrag (zones autorisées)', () => {
|
||||
it('part de la poignée, de l\'en-tête ou de la croix', () => {
|
||||
expect(canStartSheetDrag(target(), ZONE)).toBe(true)
|
||||
})
|
||||
|
||||
it('ne part pas du contenu hors zone (onglets, sections)', () => {
|
||||
expect(canStartSheetDrag(target({ inZone: false }), ZONE)).toBe(false)
|
||||
})
|
||||
|
||||
it('ne part jamais d\'un champ de saisie ou d\'un libellé de l\'en-tête', () => {
|
||||
expect(canStartSheetDrag(target({ inField: true }), ZONE)).toBe(false)
|
||||
})
|
||||
|
||||
it('ne part pas pendant une sélection de texte', () => {
|
||||
expect(canStartSheetDrag(target(), ZONE, 'Marie')).toBe(false)
|
||||
expect(canStartSheetDrag(target(), ZONE, '')).toBe(true)
|
||||
})
|
||||
|
||||
it('refuse une cible sans closest (pas un élément DOM)', () => {
|
||||
expect(canStartSheetDrag(null, ZONE)).toBe(false)
|
||||
expect(canStartSheetDrag({}, ZONE)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sheetDragIntent (direction)', () => {
|
||||
it('attend un mouvement suffisant avant de trancher', () => {
|
||||
expect(sheetDragIntent(0, 0)).toBe('pending')
|
||||
expect(sheetDragIntent(3, 5)).toBe('pending')
|
||||
})
|
||||
|
||||
it('reconnaît un glisser vers le bas', () => {
|
||||
expect(sheetDragIntent(0, 20)).toBe('down')
|
||||
expect(sheetDragIntent(10, 20)).toBe('down')
|
||||
})
|
||||
|
||||
it('rejette le haut et l\'horizontal', () => {
|
||||
expect(sheetDragIntent(0, -20)).toBe('other')
|
||||
expect(sheetDragIntent(30, 20)).toBe('other')
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldCloseSheet (seuil et vitesse)', () => {
|
||||
it('ferme au-delà d\'un tiers de la hauteur', () => {
|
||||
expect(shouldCloseSheet({ offset: 100, height: 300 })).toBe(true)
|
||||
expect(shouldCloseSheet({ offset: 99, height: 300 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ferme sur un lâcher rapide vers le bas, même petit', () => {
|
||||
expect(shouldCloseSheet({ offset: 30, height: 600, velocity: 0.7 })).toBe(true)
|
||||
})
|
||||
|
||||
it('ne ferme pas sur un lâcher rapide mais trop court', () => {
|
||||
expect(shouldCloseSheet({ offset: 10, height: 600, velocity: 2 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ne ferme pas sur une course lente et courte', () => {
|
||||
expect(shouldCloseSheet({ offset: 60, height: 600, velocity: 0.1 })).toBe(false)
|
||||
})
|
||||
|
||||
it('ne ferme jamais sans déplacement vers le bas', () => {
|
||||
expect(shouldCloseSheet({ offset: 0, height: 300, velocity: 5 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('velocityBetween', () => {
|
||||
it('donne la vitesse vers le bas en px/ms', () => {
|
||||
expect(velocityBetween({ y: 100, t: 0 }, { y: 160, t: 60 })).toBe(1)
|
||||
})
|
||||
|
||||
it('est nulle si les points manquent, ne se suivent pas ou sont trop espacés', () => {
|
||||
expect(velocityBetween(null, { y: 1, t: 1 })).toBe(0)
|
||||
expect(velocityBetween({ y: 0, t: 10 }, { y: 50, t: 10 })).toBe(0)
|
||||
expect(velocityBetween({ y: 0, t: 0 }, { y: 50, t: 500 })).toBe(0)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user