Famille arrivée par le lien : accueil, ajout guidé, mode d'emploi

Replays du 24/09 : personne ne trouvait comment ajouter. Pour qu'une
grand-mère s'en sorte seule depuis un lien :

- arbre ouvert zoomé sur la personne de départ quand il est trop grand
  pour être lisible en entier, sans fiche ouverte par-dessus
- carte d'accueil de deux phrases, une seule fois par arbre
- barre du bas en toutes lettres : Ajouter un souvenir, Ajouter une
  personne, Plus (dessin, mode d'emploi, compte)
- « Pour qui ? » : recherche par prénom avec portraits, puis les souvenirs
  de la personne ; panneau simplifié pour la famille
- une personne ajoutée part tout de suite à l'envoi (prénom, un mot)
- fiche ouverte sur Souvenirs avec le bouton, dates en toutes lettres
- bandeau d'envoi masqué tant qu'il n'y a rien à envoyer
- portraits de l'arbre perdus après un rechargement sans nouveau
  placement : horodatage stable et images rechargées à chaque données
- page parenthese.io/aide/ avec captures de l'arbre de démo, liée depuis
  l'accueil, le menu Plus et le message d'invitation

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-25 18:51:45 +02:00
1 parent 5fc0c08c1c
commit 9e810fd844
24 files changed
+722 -37

No files matched your search

+76 -6
View File
@@ -1,6 +1,8 @@
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import Galaxy from './components/Galaxy'
import PersonCard from './components/PersonCard.jsx'
import { FamilyWelcome, SouvenirPicker, HELP_URL } from './components/FamilyOnboarding.jsx'
import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
import MediaViewer from './components/MediaViewer'
import MediaManagerPanel from './components/MediaManagerPanel'
import AdminPanel from './components/AdminPanel'
@@ -523,6 +525,31 @@ function App() {
authenticated: auth.authenticated,
})
// Famille arrivée par le lien (mot de passe de partage, avec ou sans compte)
const isFamilyVisitor = userRole.navbarType === 'contributor_anon' || userRole.navbarType === 'contributor_auth'
const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false)
const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false)
const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null)
useEffect(() => {
if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) {
setFamilyWelcomeVisible(true)
}
}, [isFamilyVisitor, tree.bootState, tree.treeContext.treeId])
// La personne choisie dans « Pour qui ? » est sélectionnée : ouvrir ses souvenirs
useEffect(() => {
if (pendingMediaPersonId == null) return
if (String(tree.selectedPerson?.id) !== String(pendingMediaPersonId)) return
setPendingMediaPersonId(null)
media.handleOpenMediaManager()
}, [pendingMediaPersonId, tree.selectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
const closeFamilyWelcome = useCallback(() => {
markFamilyWelcomeSeen(tree.treeContext.treeId)
setFamilyWelcomeVisible(false)
}, [tree.treeContext.treeId])
const accountUser = auth.userAuth.user || null
const linkedSelfPerson = useMemo(() => (
persons.find((candidate) => String(candidate.id) === String(linkedSelfPersonId)) || null
@@ -838,8 +865,28 @@ function App() {
const handleOpenAddPerson = useCallback(() => {
setAddPersonError('')
// Le panneau d'ajout vit dans le mode édition : la famille y entre sans passer par le crayon
if (!editMode.isActive) editMode.activate()
setAddPersonPanelVisible(true)
}, [])
}, [editMode.isActive, editMode.activate]) // eslint-disable-line react-hooks/exhaustive-deps
// « Ajouter un souvenir » : choisir la personne, puis ouvrir ses souvenirs
const handleOpenSouvenirPicker = useCallback(() => {
markFamilyWelcomeSeen(tree.treeContext.treeId)
setFamilyWelcomeVisible(false)
setSouvenirPickerVisible(true)
}, [tree.treeContext.treeId])
const handleSouvenirPick = useCallback((person) => {
setSouvenirPickerVisible(false)
tree.setSelectedPerson(person)
setPendingMediaPersonId(person.id)
}, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
const handleSouvenirPickerAddPerson = useCallback(() => {
setSouvenirPickerVisible(false)
handleOpenAddPerson()
}, [handleOpenAddPerson])
const handleCloseAddPerson = useCallback(() => {
setAddPersonPanelVisible(false)
@@ -966,9 +1013,13 @@ function App() {
},
]
editMode.updateDraft({ addedPersons })
// La personne n'apparaît dans l'arbre qu'après relecture : le dire, et fermer le panneau
// La personne n'apparaît dans l'arbre qu'après envoi : on passe tout de suite à l'envoi
// (prénom, un mot), la famille n'a pas à chercher le bouton
setAddPersonPanelVisible(false)
setDraftNotice(`${formData.firstName} est dans vos modifications. Envoyez-les quand vous avez fini.`)
setContribSubmitError('')
setContribChanges(buildContribChangesList(editMode.restoreDraft()))
setShowContribModal(true)
return
}
@@ -1183,7 +1234,8 @@ function App() {
setShowContribModal(false)
setContribSubmitError('')
setContribSuccess(true)
setTimeout(() => setContribSuccess(false), 3500)
setDraftNotice('')
setTimeout(() => setContribSuccess(false), 6000)
} catch (err) {
setContribSubmitError(err.message || "Erreur lors de l'envoi des contributions")
} finally {
@@ -2521,12 +2573,26 @@ function App() {
onCancel={() => setShowContribModal(false)}
/>
{/* Toast succès contribution */}
{draftNotice && !contribSuccess && (
<FamilyWelcome
visible={familyWelcomeVisible && !souvenirPickerVisible}
treeName={tree.treeContext.treeName}
onAddSouvenir={handleOpenSouvenirPicker}
onLook={closeFamilyWelcome}
/>
<SouvenirPicker
visible={souvenirPickerVisible}
persons={persons}
rootPersonId={tree.treeContext.rootPersonId}
onPick={handleSouvenirPick}
onAddPerson={handleSouvenirPickerAddPerson}
onClose={() => setSouvenirPickerVisible(false)}
/>
{draftNotice && !contribSuccess && !showContribModal && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{contribSuccess && (
<div className="contrib-success-toast" role="status">
Contribution envoyée, merci&nbsp;!
Merci, c'est envoyé&nbsp;! Ça apparaîtra dans l'arbre une fois relu.
</div>
)}
{/* Navbar contextuelle */}
@@ -2538,6 +2604,8 @@ function App() {
activeTreeId={tree.treeContext.treeId}
onEditClick={handleEditToggle}
onAddPersonClick={handleOpenAddPerson}
onAddSouvenirClick={isFamilyVisitor ? handleOpenSouvenirPicker : undefined}
helpUrl={isFamilyVisitor ? HELP_URL : ''}
onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined}
onTreeSelect={handleOpenTreeFromAccount}
onCreateTree={handleOpenTreeWizard}
@@ -2573,6 +2641,8 @@ function App() {
annotationHandlers={editMode.isActive ? annot : null}
transformReadRef={galaxyTransformReadRef}
zoomApiRef={galaxyZoomApiRef}
focusPersonId={tree.treeContext.rootPersonId}
assetRevision={tree.assetRevision}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{tree.selectedPerson && (
@@ -2581,7 +2651,7 @@ function App() {
onClose={() => tree.setSelectedPerson(null)}
onMediaSelect={handleMediaSelectFresh}
editMode={editMode.isActive}
canManageMedia={editMode.isActive && tree.canSubmitContribution}
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
onManageMedia={media.handleOpenMediaManager}
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
@@ -4,6 +4,7 @@ import { persons as treePersons } from '../data/mockData'
import { Check, ChevronDown, Copy, RefreshCw, X } from 'lucide-react'
import './SettingsPanels.css'
import PzBusy from './PzBusy.jsx'
import { HELP_URL } from './FamilyOnboarding.jsx'
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -177,7 +178,8 @@ function buildInviteText(url, password) {
`L'arbre est ici ${url}`,
`Le mot de passe pour l'ouvrir est ${password}`,
'',
'Avec le crayon en bas à droite, vous pouvez ajouter une photo, un souvenir ou une personne qui manque.',
"En bas de l'écran, vous pouvez ajouter une photo, un souvenir ou une personne qui manque.",
`Le mode d'emploi, si besoin ${HELP_URL}`,
].join('\n')
}
+6 -3
View File
@@ -9,8 +9,11 @@ function EditSessionBar({ active, hasDraft, role = 'contributor', onDeactivate,
`edit-mode-border ${isAdmin ? 'edit-mode-border--admin' : 'edit-mode-border--contributor'}`
), [isAdmin])
const toastLabel = isAdmin ? "Vous modifiez l'arbre" : 'Vous proposez des modifications'
const submitLabel = isAdmin ? "Mettre à jour l'arbre" : 'Envoyer les modifications'
// Libellés courts : ils doivent tenir sur un téléphone à côté du bouton
const toastLabel = isAdmin ? "Vous modifiez l'arbre" : 'Vos propositions'
const submitLabel = isAdmin ? "Mettre à jour l'arbre" : 'Envoyer mes ajouts'
// La famille ne voit le bouton d'envoi que lorsqu'il y a quelque chose à envoyer
const showSubmit = Boolean(onSubmit) && (isAdmin || hasDraft)
if (!active) return null
@@ -22,7 +25,7 @@ function EditSessionBar({ active, hasDraft, role = 'contributor', onDeactivate,
<span className="edit-mode-toast-dot" aria-hidden="true" />
<span className="edit-mode-toast-text">{toastLabel}</span>
{onSubmit && (
{showSubmit && (
<button
type="button"
className="pz-btn pz-btn--primary pz-btn--sm edit-mode-submit"
@@ -0,0 +1,97 @@
/* Accueil et ajout guidé pour la famille arrivée par le lien : gros textes, grosses cibles */
.fo-welcome { width: min(100%, 480px); gap: 14px; padding: 32px; }
.fo-title { font-size: clamp(28px, 7vw, 34px); line-height: 1.12; }
.fo-lead { margin: 0; font: 400 17px/1.5 var(--pz-sans); color: var(--pz-ink-2); }
.fo-actions { display: grid; gap: 10px; margin-top: 10px; }
.fo-big { min-height: 54px; font-size: 17px; justify-content: center; }
.fo-help {
justify-self: center;
margin-top: 4px;
font: 500 15px/1.4 var(--pz-sans);
color: var(--pz-a700);
text-decoration: underline;
text-underline-offset: 3px;
}
.fo-picker { width: min(100%, 520px); gap: 16px; }
.fo-search {
display: flex;
align-items: center;
gap: 10px;
min-height: 56px;
padding: 0 16px;
background: var(--pz-surface);
border: 1.5px solid var(--pz-a300);
border-radius: 999px;
color: var(--pz-muted);
}
.fo-search:focus-within { border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
.fo-search input {
flex: 1;
min-width: 0;
border: 0;
outline: none;
background: transparent;
font: 400 18px var(--pz-sans);
color: var(--pz-ink);
}
.fo-search input::-webkit-search-cancel-button { display: none; }
.fo-hint { margin: 0 4px -6px; font: 500 14px var(--pz-sans); color: var(--pz-muted); }
.fo-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; }
.fo-person {
width: 100%;
display: flex;
align-items: center;
gap: 14px;
min-height: 64px;
padding: 8px 14px 8px 8px;
background: var(--pz-surface);
border: 1px solid var(--pz-line);
border-radius: var(--pz-r-m);
text-align: left;
cursor: pointer;
transition: border-color 140ms ease, background 140ms ease;
}
@media (hover: hover) and (pointer: fine) { .fo-person:hover { border-color: var(--pz-a300); background: var(--pz-a50); } }
.fo-person:active { background: var(--pz-a50); }
.fo-person:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
.fo-thumb {
flex: none;
width: 48px;
height: 48px;
display: grid;
place-items: center;
overflow: hidden;
border-radius: 12px;
background: var(--pz-a100);
font: 400 20px var(--pz-serif);
color: var(--pz-a700);
}
.fo-thumb img { width: 100%; height: 100%; object-fit: cover; }
.fo-name { display: grid; gap: 2px; min-width: 0; }
.fo-name strong { font: 500 17px/1.3 var(--pz-sans); color: var(--pz-ink); }
.fo-name small { font: 400 14px var(--pz-sans); color: var(--pz-muted); }
.fo-empty { margin: 0 4px; font: 400 16px/1.5 var(--pz-sans); color: var(--pz-ink-2); }
.fo-add-person {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 52px;
padding: 0 16px;
background: transparent;
border: 1.5px dashed var(--pz-a300);
border-radius: var(--pz-r-m);
font: 500 15.5px/1.35 var(--pz-sans);
color: var(--pz-a700);
cursor: pointer;
text-align: center;
}
@media (hover: hover) and (pointer: fine) { .fo-add-person:hover { background: var(--pz-a50); } }
@media (max-width: 560px) {
.fo-welcome { padding: 28px 20px calc(22px + env(safe-area-inset-bottom)); }
}
@@ -0,0 +1,140 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { ImagePlus, Search, UserPlus, X } from 'lucide-react'
import './FamilyOnboarding.css'
export const HELP_URL = 'https://parenthese.io/aide/'
// Carte d'accueil de la famille qui arrive par le lien : ce qu'on peut faire, en deux phrases
export function FamilyWelcome({ visible, treeName, onAddSouvenir, onLook }) {
useEffect(() => {
if (!visible) return undefined
const onKey = (e) => { if (e.key === 'Escape') onLook?.() }
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [visible, onLook])
if (!visible) return null
return (
<div className="pz-overlay fo-overlay">
<div className="pz-modal fo-welcome" role="dialog" aria-modal="true" aria-labelledby="foWelcomeTitle">
<p className="pz-eyebrow">Arbre partagé</p>
<h1 id="foWelcomeTitle" className="pz-title fo-title">
Bienvenue dans <em>{treeName || "l'arbre de la famille"}</em>
</h1>
<p className="fo-lead">Touchez un portrait pour voir ses photos et ses souvenirs.</p>
<p className="fo-lead">Vous en avez à partager&nbsp;? Ajoutez-les ici, toute la famille pourra les voir.</p>
<div className="fo-actions">
<button type="button" className="pz-btn pz-btn--primary fo-big" onClick={onAddSouvenir}>
<ImagePlus size={20} strokeWidth={2} aria-hidden="true" />
Ajouter un souvenir
</button>
<button type="button" className="pz-btn pz-btn--ghost fo-big" onClick={onLook}>
Regarder l'arbre
</button>
</div>
<a className="fo-help" href={HELP_URL} target="_blank" rel="noopener">Besoin d'aide&nbsp;? Le mode d'emploi pas à pas</a>
</div>
</div>
)
}
function normalize(value) {
return String(value || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim()
}
function lifeYears(person) {
if (!person.birthYear) return ''
return person.deathYear ? `${person.birthYear} – ${person.deathYear}` : String(person.birthYear)
}
// « Pour qui ? » : on choisit la personne avant d'ajouter le souvenir
export function SouvenirPicker({ visible, persons = [], rootPersonId = null, onPick, onAddPerson, onClose }) {
const [query, setQuery] = useState('')
const inputRef = useRef(null)
useEffect(() => {
if (!visible) return undefined
setQuery('')
const t = window.setTimeout(() => inputRef.current?.focus(), 120)
const onKey = (e) => { if (e.key === 'Escape') onClose?.() }
document.addEventListener('keydown', onKey)
return () => { window.clearTimeout(t); document.removeEventListener('keydown', onKey) }
}, [visible, onClose])
const results = useMemo(() => {
const q = normalize(query)
if (!q) {
// Sans recherche : la personne de départ puis celles qui ont un portrait
const root = persons.find((p) => String(p.id) === String(rootPersonId))
const withPhoto = persons.filter((p) => p !== root && p.photo)
return [root, ...withPhoto].filter(Boolean).slice(0, 6)
}
const words = q.split(/\s+/)
return persons
.filter((p) => {
const full = normalize(`${p.firstName} ${p.lastName} ${p.birthName || ''}`)
return words.every((w) => full.includes(w))
})
.slice(0, 20)
}, [persons, query, rootPersonId])
if (!visible) return null
return (
<div className="pz-overlay fo-overlay" onClick={() => onClose?.()}>
<div className="pz-modal fo-picker" role="dialog" aria-modal="true" aria-labelledby="foPickerTitle" onClick={(e) => e.stopPropagation()}>
<div className="pz-modal-head">
<p className="pz-eyebrow">Ajouter un souvenir</p>
<h1 id="foPickerTitle" className="pz-title fo-title">Pour <em>qui</em>&nbsp;?</h1>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label="Fermer">
<X size={18} aria-hidden="true" />
</button>
<label className="fo-search">
<Search size={20} strokeWidth={2} aria-hidden="true" />
<input
ref={inputRef}
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Tapez un prénom"
aria-label="Prénom de la personne"
autoComplete="off"
enterKeyHint="search"
/>
</label>
{!query.trim() && results.length > 0 && <p className="fo-hint">Ou choisissez ici</p>}
<ul className="fo-list">
{results.map((person) => (
<li key={person.id}>
<button type="button" className="fo-person" onClick={() => onPick?.(person)}>
<span className="fo-thumb" aria-hidden="true">
{person.photo ? <img src={person.photo} alt="" /> : <span>{person.firstName?.[0] || '?'}</span>}
</span>
<span className="fo-name">
<strong>{person.firstName} {person.lastName}</strong>
{lifeYears(person) && <small>{lifeYears(person)}</small>}
</span>
</button>
</li>
))}
</ul>
{query.trim() && results.length === 0 && (
<p className="fo-empty">Personne ne s'appelle «&nbsp;{query.trim()}&nbsp;» dans l'arbre.</p>
)}
{onAddPerson && (
<button type="button" className="fo-add-person" onClick={onAddPerson}>
<UserPlus size={18} strokeWidth={2} aria-hidden="true" />
La personne n'est pas dans l'arbre&nbsp;? Ajoutez-la
</button>
)}
</div>
</div>
)
}
+23 -5
View File
@@ -25,9 +25,15 @@ function approach(prev, target, k, eps = 1e-3) {
return Math.abs(target - next) < eps ? target : next
}
const MAX_SCALE = 3.2
// Ouverture sur la personne de départ quand l'arbre entier serait illisible
const FOCUS_MIN_READABLE_SCALE = 0.5
const FOCUS_SCALE = 0.95
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0 }) => {
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)
focusPersonIdRef.current = focusPersonId
const layoutDataRef = useRef(null)
const [layoutReady, setLayoutReady] = useState(false)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
@@ -68,7 +74,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const markDirty = useCallback(() => { needsRedrawRef.current = true }, [])
useEffect(() => { needsRedrawRef.current = true })
const imageCache = useImageCache(graphRevision, markDirty)
const imageCache = useImageCache(`${graphRevision}:${assetRevision}`, markDirty)
// Position animée d'un nœud
const getAnimatedPos = useCallback((node, time) => {
@@ -215,9 +221,21 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
(cssH - padding * 2) / Math.max(graphH, 1),
maxInitScale,
)
const scale = Math.max(MIN_SCALE, fitScale)
const offsetX = (cssW - graphW * scale) / 2 - minX * scale
const offsetY = (cssH - graphH * scale) / 2 - minY * scale
let scale = Math.max(MIN_SCALE, fitScale)
let offsetX = (cssW - graphW * scale) / 2 - minX * scale
let offsetY = (cssH - graphH * scale) / 2 - minY * scale
// Grand arbre : vu en entier, les portraits deviennent des pastilles illisibles.
// On ouvre sur la personne de départ, à une taille où l'on reconnaît les visages.
const focusId = focusPersonIdRef.current
const focusNode = focusId != null
? result.children.find((n) => n._type === 'person' && String(n._data?.id) === String(focusId))
: null
if (focusNode && fitScale < FOCUS_MIN_READABLE_SCALE) {
scale = FOCUS_SCALE
offsetX = cssW / 2 - (focusNode.x + focusNode.width / 2) * scale
offsetY = cssH * 0.42 - (focusNode.y + focusNode.height / 2) * scale
}
const init = { x: offsetX, y: offsetY, scale }
initialTransformRef.current = init
+11 -9
View File
@@ -15,6 +15,8 @@ const MEDIA_TYPES = [
{ value: 'geojson', label: 'Carte', Icon: MapPin },
{ value: 'gpx', label: 'Trace GPX', Icon: Route },
]
// La famille garde l'essentiel ; carte et trace GPX restent au propriétaire
const FAMILY_TYPES = ['photo', 'video', 'audio', 'citation', 'document']
const TYPE_NAMES = { photo: 'Photo', video: 'Vidéo', audio: 'Voix', citation: 'Citation', document: 'Document', geojson: 'Carte', gpx: 'Trace GPX' }
const TYPE_SHORT = { video: 'Vidéo', audio: 'Voix', citation: '«', document: 'Doc' }
@@ -133,7 +135,7 @@ const MediaManagerPanel = ({
<div className="pz-overlay media-manager-modal-overlay" onClick={handleOverlayClick}>
<div className="pz-modal pz-modal--wide media-manager-modal-card" role="dialog" aria-modal="true" aria-labelledby="mediaManagerTitle">
<div className="pz-modal-head">
<p className="pz-eyebrow">Souvenirs</p>
<p className="pz-eyebrow">{canManage ? 'Souvenirs' : 'Ajouter un souvenir pour'}</p>
<h1 id="mediaManagerTitle" className="pz-title">{person.firstName} <em>{person.lastName}</em></h1>
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label="Fermer">
@@ -142,9 +144,9 @@ const MediaManagerPanel = ({
<form className="mm-form" onSubmit={handleSubmit}>
<div className="pz-field">
<span className="pz-label" id="mediaUploadTypeLabel">Ajouter</span>
<span className="pz-label" id="mediaUploadTypeLabel">{canManage ? 'Ajouter' : 'Que voulez-vous ajouter ?'}</span>
<div className="mm-types" role="radiogroup" aria-labelledby="mediaUploadTypeLabel">
{MEDIA_TYPES.map((type) => (
{MEDIA_TYPES.filter((type) => canManage || FAMILY_TYPES.includes(type.value)).map((type) => (
<button
key={type.value}
type="button"
@@ -177,7 +179,7 @@ const MediaManagerPanel = ({
<span className="mm-drop-icon" aria-hidden="true"><Upload size={20} strokeWidth={1.8} /></span>
<span className="mm-drop-text">
<strong>{file ? file.name : 'Choisir un fichier'}</strong>
<span>{file ? 'Cliquez pour en choisir un autre' : `${mediaType === 'photo' ? '5 Mo' : '20 Mo'} au maximum`}</span>
<span>{file ? 'Pour en prendre un autre, touchez ici' : `${mediaType === 'photo' ? '5 Mo' : '20 Mo'} au maximum`}</span>
</span>
</label>
<input
@@ -216,9 +218,9 @@ const MediaManagerPanel = ({
</div>
)}
<div className="pz-row2">
<div className={canManage ? 'pz-row2' : undefined}>
<div className="pz-field">
<label htmlFor="mediaUploadCaption">Titre <span className="mm-optional">facultatif</span></label>
<label htmlFor="mediaUploadCaption">{canManage ? 'Titre' : 'Une légende'} <span className="mm-optional">facultatif</span></label>
<input
id="mediaUploadCaption"
type="text"
@@ -227,7 +229,7 @@ const MediaManagerPanel = ({
placeholder="Été à Quiberon, 1978"
/>
</div>
<div className="pz-field">
{canManage && <div className="pz-field">
<label htmlFor="mediaUploadSource">Source <span className="mm-optional">facultatif</span></label>
<input
id="mediaUploadSource"
@@ -236,7 +238,7 @@ const MediaManagerPanel = ({
onChange={(event) => setSource(event.target.value)}
placeholder="Album de famille, ou un lien"
/>
</div>
</div>}
</div>
{!canManage && (
@@ -267,7 +269,7 @@ const MediaManagerPanel = ({
<section className="mm-list-wrap">
<h2 className="pz-eyebrow">{medias.length > 0 ? `Déjà là · ${medias.length}` : 'Déjà là'}</h2>
{medias.length === 0 ? (
<p className="pz-small mm-empty">Pas encore de souvenir. Le premier que vous ajoutez apparaît en tête de sa fiche.</p>
<p className="pz-small mm-empty">{canManage ? 'Pas encore de souvenir. Le premier que vous ajoutez apparaît en tête de sa fiche.' : 'Pas encore de souvenir. Le vôtre sera le premier.'}</p>
) : (
<ul className="mm-list">
{medias.map((media, index) => (
+12 -7
View File
@@ -38,7 +38,8 @@ const PersonCard = ({
relationActionSuccess = '',
relationActionError = '',
}) => {
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 ? 'medias' : 'infos'))
// 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'))
// État local pour les modifications en mode édition
const [edits, setEdits] = useState({})
@@ -242,7 +243,11 @@ const PersonCard = ({
const renderEditableValue = ({ field, value, placeholder, type = 'text' }) => {
if (!editMode) {
const isEmptyValue = value === null || value === undefined || value === ''
return <span className="value">{isEmptyValue ? '-' : value}</span>
// Une date complète se lit « 22 octobre 1928 », pas « 1928-10-22 »
const shown = type === 'date' && /^\d{4}-\d{2}-\d{2}/.test(String(value))
? new Date(`${String(value).slice(0, 10)}T00:00:00Z`).toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC' })
: value
return <span className="value">{isEmptyValue ? '-' : shown}</span>
}
return (
<input
@@ -405,7 +410,7 @@ const PersonCard = ({
{personMedias.length === 0 && (
<div className="pc-empty">
<p className="pz-sub">Pas encore de souvenir pour {person.firstName}.</p>
<p className="pz-small">Une photo, une anecdote, sa voix. Tout le monde peut en ajouter.</p>
<p className="pz-small">{canManageMedia ? 'Une photo, une anecdote, sa voix. Vous pouvez ajouter le premier.' : 'Une photo, une anecdote, sa voix.'}</p>
</div>
)}
@@ -440,7 +445,7 @@ const PersonCard = ({
{media.label && <span className="pc-tile-label">{media.label}</span>}
</button>
))}
{editMode && canManageMedia && (
{canManageMedia && (
<button type="button" className="pc-tile pc-tile--add" onClick={() => onManageMedia?.()} aria-label="Ajouter des souvenirs">
<Plus size={20} strokeWidth={1.8} />
</button>
@@ -462,10 +467,10 @@ const PersonCard = ({
</div>
)}
{editMode && canManageMedia && (
<button type="button" className="pz-btn pz-btn--soft pz-btn--block" onClick={() => onManageMedia?.()}>
{canManageMedia && (
<button type="button" className={`pz-btn pz-btn--block ${isAdmin ? 'pz-btn--soft' : 'pz-btn--primary'}`} onClick={() => onManageMedia?.()}>
<Plus size={16} strokeWidth={2} />
Ajouter ou ranger des souvenirs
{isAdmin ? 'Ajouter ou ranger des souvenirs' : 'Ajouter un souvenir'}
</button>
)}
</div>
@@ -281,3 +281,86 @@
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-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; }
}
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import {
Edit3, Share2, User, UserPlus,
X, MousePointer2, Pencil, Type, Star, Trash2, Image,
X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle,
} from 'lucide-react'
import NavButton from './NavButton'
import AvatarMenu from './AvatarMenu'
@@ -32,6 +32,8 @@ function ContextualNavbar({
activeTreeName = '',
onEditClick,
onAddPersonClick,
onAddSouvenirClick,
helpUrl = '',
onShareClick,
onTreeSelect,
onCreateTree,
@@ -43,6 +45,8 @@ function ContextualNavbar({
}) {
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false)
const [annotPanel, setAnnotPanel] = useState(null)
const [moreOpen, setMoreOpen] = useState(false)
const moreRef = useRef(null)
const [isMobile, setIsMobile] = useState(() =>
typeof window !== 'undefined' && window.matchMedia('(max-width: 768px)').matches
)
@@ -63,6 +67,9 @@ function ContextualNavbar({
if (avatarMenuRef.current && !avatarMenuRef.current.contains(event.target)) {
setAvatarMenuOpen(false)
}
if (moreRef.current && !moreRef.current.contains(event.target)) {
setMoreOpen(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
@@ -83,6 +90,7 @@ function ContextualNavbar({
function handleEscape(event) {
if (event.key === 'Escape') {
setAvatarMenuOpen(false)
setMoreOpen(false)
setAnnotPanel(null)
}
}
@@ -121,6 +129,47 @@ function ContextualNavbar({
}
}
// Famille arrivée par le lien : les deux gestes utiles écrits en toutes lettres, le reste dans « Plus »
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
const renderFamilyButtons = () => (
<>
<button type="button" className="nav-family-btn nav-family-btn--primary" onClick={onAddSouvenirClick}>
<ImagePlus size={20} strokeWidth={2} aria-hidden="true" />
<span>Ajouter un souvenir</span>
</button>
<button type="button" className="nav-family-btn" onClick={onAddPersonClick}>
<UserPlus size={20} strokeWidth={2} aria-hidden="true" />
<span>Ajouter une 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>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" />
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" />
Le mode d'emploi
</a>
)}
{!authenticated && (
<button type="button" role="menuitem" onClick={() => { setMoreOpen(false); handleCreateAccountClick() }}>
<User size={18} strokeWidth={2} aria-hidden="true" />
Créer un compte
</button>
)}
</div>
)}
</div>
</>
)
const renderButtons = () => {
switch (navbarType) {
case 'admin':
@@ -243,7 +292,7 @@ function ContextualNavbar({
return (
<nav
ref={navRef}
className={`contextual-navbar${showMobileAnnot ? ' contextual-navbar--annot-mobile' : ''}${annotationTools?.isDragging ? ' contextual-navbar--dragging' : ''}`}
className={`contextual-navbar${isFamily && !editModeActive ? ' contextual-navbar--family' : ''}${showMobileAnnot ? ' contextual-navbar--annot-mobile' : ''}${annotationTools?.isDragging ? ' contextual-navbar--dragging' : ''}`}
>
{showMobileAnnot && annotPanel && (
<div className="navbar-annot-panel">
@@ -334,7 +383,7 @@ function ContextualNavbar({
</>
) : (
<>
{renderButtons()}
{isFamily && !editModeActive ? renderFamilyButtons() : renderButtons()}
{authenticated && (
<div ref={avatarMenuRef} className="nav-dropdown-wrapper">
+13 -3
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { API_BASE_URL } from '../api/client'
import { fetchTreeGraph, unlockTreeAccess } from '../api/treeApi'
import { loadGraphData, persons as graphPersons, resetToDemoData } from '../data/mockData'
@@ -48,6 +48,11 @@ export function useTreeAccess() {
const [selectedMedia, setSelectedMedia] = useState(null)
const [wizardState, setWizardState] = useState({ visible: false, loading: false, error: '' })
const [graphRevision, setGraphRevision] = useState(0)
// Change à chaque chargement des données, même sans nouveau placement : l'arbre y charge les images manquantes
const [assetRevision, setAssetRevision] = useState(0)
// Horodatage des portraits stable pendant la session : un rechargement ne force pas à tout retélécharger.
// Il ne change que lorsqu'un portrait change (envoi d'une nouvelle photo).
const avatarCacheBustRef = useRef(Date.now())
const canEditCurrentTree = useMemo(() => {
const role = normalizeRole(treeContext.role)
@@ -63,8 +68,10 @@ export function useTreeAccess() {
loadGraphData(graphPayload.graph, {
authToken: token,
apiBaseUrl: API_BASE_URL,
avatarCacheBust: Date.now(),
avatarCacheBust: options.avatarCacheBust ?? avatarCacheBustRef.current,
})
if (options.avatarCacheBust) avatarCacheBustRef.current = options.avatarCacheBust
setAssetRevision((current) => current + 1)
if (options.relayout !== false) {
setGraphRevision((current) => current + 1)
}
@@ -72,7 +79,9 @@ export function useTreeAccess() {
const rootPerson = preferredRootPersonId
? graphPersons.find((person) => String(person.id) === String(preferredRootPersonId)) || null
: null
setSelectedPerson(rootPerson)
// Par le lien de partage, la famille arrive sur l'arbre et sa carte d'accueil, pas sur une fiche ouverte
const openedByShareLink = (options.accessMode ?? '') === 'share'
setSelectedPerson(openedByShareLink ? null : rootPerson)
setSelectedMedia(null)
setTreeContext((current) => ({
@@ -188,6 +197,7 @@ export function useTreeAccess() {
return {
treeContext, setTreeContext,
bootState, setBootState,
assetRevision,
gateLoading, gateError, setGateError,
selectedPerson, setSelectedPerson,
selectedMedia, setSelectedMedia,
+22
View File
@@ -0,0 +1,22 @@
// Carte d'accueil de la famille : montrée une seule fois par arbre et par navigateur
function welcomeSeenKey(treeId) {
return `family_welcome_seen_${treeId}`
}
export function hasSeenFamilyWelcome(treeId) {
if (!treeId) return true
try {
return localStorage.getItem(welcomeSeenKey(treeId)) === '1'
} catch {
return false
}
}
export function markFamilyWelcomeSeen(treeId) {
if (!treeId) return
try {
localStorage.setItem(welcomeSeenKey(treeId), '1')
} catch {
// Ignore localStorage failures
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

+172
View File
@@ -0,0 +1,172 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FBFAF7" />
<title>Vous avez reçu le lien de l'arbre de votre famille, le mode d'emploi</title>
<meta name="description" content="Ouvrir l'arbre avec le mot de passe, regarder les photos, ajouter un souvenir ou une personne qui manque. Le mode d'emploi pas à pas, sur téléphone comme sur ordinateur." />
<link rel="canonical" href="https://parenthese.io/aide/" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Parenthèse" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:url" content="https://parenthese.io/aide/" />
<meta property="og:title" content="Vous avez reçu le lien de l'arbre de votre famille" />
<meta property="og:description" content="Ouvrir l'arbre, regarder, ajouter un souvenir ou une personne. Le mode d'emploi pas à pas." />
<meta property="og:image" content="https://parenthese.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;1,6..72,300&family=DM+Sans:opsz,wght@9..40,400;9..40,500;9..40,600&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/guides.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "HowTo",
"name": "Ouvrir l'arbre de sa famille et y ajouter un souvenir",
"inLanguage": "fr-FR",
"totalTime": "PT5M",
"estimatedCost": { "@type": "MonetaryAmount", "currency": "EUR", "value": "0" },
"step": [
{ "@type": "HowToStep", "position": 1, "name": "Ouvrir l'arbre", "text": "Toucher le lien reçu, taper le mot de passe donné avec le lien, puis toucher « Ouvrir l'arbre »." },
{ "@type": "HowToStep", "position": 2, "name": "Regarder", "text": "Faire glisser l'arbre avec le doigt, écarter deux doigts pour agrandir, toucher un portrait pour ouvrir sa fiche." },
{ "@type": "HowToStep", "position": 3, "name": "Ajouter un souvenir", "text": "Toucher « Ajouter un souvenir », choisir la personne, choisir une photo dans son téléphone, écrire son prénom, puis toucher « Ajouter ce souvenir »." },
{ "@type": "HowToStep", "position": 4, "name": "Ajouter une personne qui manque", "text": "Toucher « Ajouter une personne », écrire son prénom et son nom, dire à qui elle est liée, puis envoyer." },
{ "@type": "HowToStep", "position": 5, "name": "Attendre la relecture", "text": "La personne qui gère l'arbre relit ce qui a été envoyé, puis l'ajoute pour toute la famille." }
]
},
{
"@type": "FAQPage",
"mainEntity": [
{ "@type": "Question", "name": "J'ai perdu le mot de passe de l'arbre, que faire ?", "acceptedAnswer": { "@type": "Answer", "text": "Demandez-le à la personne qui vous a envoyé le lien. Elle le retrouve dans l'arbre, dans le panneau de partage." } },
{ "@type": "Question", "name": "Faut-il créer un compte ?", "acceptedAnswer": { "@type": "Answer", "text": "Non. Le lien et le mot de passe suffisent pour regarder l'arbre et y ajouter des souvenirs. Un compte sert seulement à retrouver l'arbre sans retaper le mot de passe." } },
{ "@type": "Question", "name": "Qui peut voir l'arbre ?", "acceptedAnswer": { "@type": "Answer", "text": "Seules les personnes qui ont le lien et le mot de passe. L'arbre n'apparaît pas dans les moteurs de recherche." } }
]
}
]
}
</script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/" aria-label="Parenthèse, accueil">
<img src="/logo.svg" alt="" width="166" height="22" />
</a>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=aide">Créer mon arbre</a>
</div>
</header>
<main>
<article class="wrap">
<span class="eyebrow">Mode d'emploi</span>
<h1>Vous avez reçu le lien de l'arbre de votre famille</h1>
<p class="lead">Quelqu'un de votre famille a fait un arbre sur Parenthèse et vous invite à le regarder et à le compléter. Il n'y a rien à installer et pas de compte à créer. Voici comment faire, pas à pas, sur téléphone comme sur ordinateur.</p>
<h2>1. Ouvrir l'arbre</h2>
<p>Touchez le lien que vous avez reçu par mail, par SMS ou sur WhatsApp. Une page s'ouvre avec le nom de l'arbre.</p>
<p>Tapez le mot de passe donné avec le lien, en respectant les majuscules, puis touchez «&nbsp;Ouvrir l'arbre&nbsp;». Gardez le message qui contient le lien et le mot de passe, il vous servira pour revenir.</p>
<div class="shots">
<figure><img src="/aide/ouvrir.webp" alt="La page qui demande le mot de passe, avec le bouton Ouvrir l'arbre" width="600" height="1022" loading="lazy" /></figure>
</div>
<h2>2. Regarder</h2>
<p>La première fois, un petit mot d'accueil s'affiche. Touchez «&nbsp;Regarder l'arbre&nbsp;» pour le fermer.</p>
<p>Chaque portrait est une personne de la famille. Pour vous déplacer, faites glisser l'arbre avec le doigt. Pour agrandir, écartez deux doigts sur l'écran, et rapprochez-les pour voir plus large. La loupe, en haut à droite, retrouve quelqu'un par son prénom.</p>
<p>Touchez un portrait pour ouvrir sa fiche, avec ses photos, ses souvenirs et ses dates. La croix, en haut de la fiche, la referme.</p>
<div class="shots">
<figure><img src="/aide/arbre.webp" alt="L'arbre avec les portraits de la famille, et en bas les boutons Ajouter un souvenir, Ajouter une personne et Plus" width="600" height="1022" loading="lazy" /></figure>
<figure><img src="/aide/fiche.webp" alt="La fiche d'une personne, ouverte en touchant son portrait" width="600" height="1022" loading="lazy" /></figure>
</div>
<h2>3. Ajouter un souvenir</h2>
<p>Un souvenir, c'est une photo, une vidéo, un enregistrement de voix, une phrase qu'elle ou il disait souvent, ou un document comme une lettre ou un faire-part.</p>
<ol>
<li>Touchez «&nbsp;Ajouter un souvenir&nbsp;», en bas de l'écran.</li>
<li>Tapez les premières lettres du prénom de la personne, puis touchez son portrait.</li>
<li>Choisissez ce que vous ajoutez, par exemple Photo, puis touchez «&nbsp;Choisir un fichier&nbsp;». Votre téléphone vous montre vos photos, touchez celle que vous voulez.</li>
<li>Si vous le souhaitez, écrivez une légende (l'année, le lieu, qui est sur la photo). Écrivez aussi votre prénom, pour que la famille sache qui l'a partagée.</li>
<li>Touchez «&nbsp;Ajouter ce souvenir&nbsp;». Un message vous confirme que c'est envoyé.</li>
</ol>
<div class="shots">
<figure><img src="/aide/pour-qui.webp" alt="Choisir la personne en tapant les premières lettres de son prénom" width="600" height="1022" loading="lazy" /></figure>
<figure><img src="/aide/souvenir.webp" alt="Choisir une photo, écrire une légende et son prénom, puis Ajouter ce souvenir" width="600" height="1022" loading="lazy" /></figure>
</div>
<div class="note">
<p>Vous pouvez aussi partir de la fiche. Touchez le portrait de la personne, puis «&nbsp;Ajouter un souvenir&nbsp;», sous ses photos.</p>
</div>
<h2>4. Ajouter une personne qui manque</h2>
<ol>
<li>Touchez «&nbsp;Ajouter une personne&nbsp;», en bas de l'écran.</li>
<li>Écrivez son prénom et son nom, et ses dates si vous les connaissez.</li>
<li>Dites à qui elle est liée. Choisissez Parent, Enfant, Conjoint·e ou Frère·Sœur, puis tapez le prénom de la personne déjà dans l'arbre et touchez-la dans la liste.</li>
<li>Touchez «&nbsp;Ajouter cette personne&nbsp;». Une fenêtre s'ouvre avec votre prénom et une case pour un mot, si vous voulez expliquer. Touchez «&nbsp;Envoyer mes contributions&nbsp;».</li>
</ol>
<div class="shots">
<figure><img src="/aide/personne.webp" alt="Dire à qui la nouvelle personne est liée, ici enfant de Philippe" width="600" height="1022" loading="lazy" /></figure>
<figure><img src="/aide/envoi.webp" alt="La fenêtre d'envoi, avec son prénom et un mot pour accompagner" width="600" height="1022" loading="lazy" /></figure>
</div>
<h2>5. Et ensuite</h2>
<p>La personne qui gère l'arbre reçoit ce que vous avez envoyé, le relit, puis l'ajoute. Vos photos, vos souvenirs et les personnes que vous avez ajoutées apparaissent alors pour toute la famille. Vous n'avez rien d'autre à faire.</p>
<p>Si vous voyez une date fausse ou un prénom mal écrit, le plus simple est de le dire à la personne qui vous a envoyé le lien.</p>
<div class="shots">
<figure><img src="/aide/merci.webp" alt="Le message de confirmation après l'envoi" width="600" height="1022" loading="lazy" /></figure>
</div>
<h2>Questions fréquentes</h2>
<div class="faq">
<details>
<summary>J'ai perdu le mot de passe</summary>
<p>Demandez-le à la personne qui vous a envoyé le lien. Elle le retrouve dans l'arbre, dans le panneau de partage.</p>
</details>
<details>
<summary>Le mot de passe est refusé</summary>
<p>Vérifiez les majuscules et qu'il n'y a pas d'espace en trop avant ou après. S'il est toujours refusé, il a peut-être été changé. Demandez le nouveau à la personne qui gère l'arbre.</p>
</details>
<details>
<summary>Faut-il créer un compte&nbsp;?</summary>
<p>Non. Le lien et le mot de passe suffisent pour regarder l'arbre et y ajouter des souvenirs. Un compte sert seulement à retrouver l'arbre sans retaper le mot de passe.</p>
</details>
<details>
<summary>Est-ce que ça marche sur ordinateur&nbsp;?</summary>
<p>Oui. Les mêmes boutons se trouvent en bas à droite de l'écran. Pour agrandir l'arbre, tournez la molette de la souris, et cliquez sur un portrait pour ouvrir sa fiche.</p>
</details>
<details>
<summary>Ma photo ne passe pas</summary>
<p>Une photo peut peser jusqu'à 5 Mo et une vidéo jusqu'à 20 Mo. Si elle est refusée, envoyez-la en taille réduite, ou demandez à quelqu'un de la famille de l'ajouter pour vous.</p>
</details>
<details>
<summary>Qui peut voir l'arbre&nbsp;?</summary>
<p>Seules les personnes qui ont le lien et le mot de passe. L'arbre n'apparaît pas dans les moteurs de recherche. Le détail est sur la page <a href="/donnees-et-vie-privee/">données et vie privée</a>.</p>
</details>
</div>
<section class="cta">
<h2>Faire l'arbre de votre famille</h2>
<p>C'est gratuit, et la famille le complète par un lien, comme vous venez de le faire.</p>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=aide">Créer mon arbre</a>
</section>
</article>
</main>
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/">parenthese.io</a></span>
<nav aria-label="Guides">
<a href="/comment-faire-un-arbre-genealogique/">Comment faire un arbre généalogique</a>
<a href="/arbre-genealogique-a-remplir/">Arbre généalogique à remplir</a>
<a href="/application-arbre-genealogique/">Quelle application choisir</a>
<a href="/arbre-genealogique-avec-photos/">Arbre généalogique avec photos</a>
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
</nav>
</div>
</footer>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+6
View File
@@ -50,6 +50,11 @@ strong { font-weight: 600; }
.note { padding: 18px 22px; border-left: 3px solid var(--a500); background: var(--a50); border-radius: 0 14px 14px 0; margin: 24px 0; }
.note p:last-child { margin-bottom: 0; }
/* Captures d'écran de téléphone (mode d'emploi) */
.shots { display: flex; flex-wrap: wrap; gap: 20px; margin: 20px 0 28px; }
.shots figure { margin: 0; flex: 0 1 250px; }
.shots img { display: block; width: 100%; height: auto; border-radius: 26px; border: 1px solid var(--trait); box-shadow: 0 10px 30px rgba(42, 38, 34, .08); }
table { width: 100%; border-collapse: collapse; margin: 16px 0 28px; font-size: 15px; line-height: 1.5; }
th, td { text-align: left; vertical-align: top; padding: 12px 10px; border-bottom: 1px solid var(--trait); }
th { font: 500 12px/1.3 var(--sans); letter-spacing: 0.08em; text-transform: uppercase; color: var(--doux); border-bottom-color: var(--a300); }
@@ -76,6 +81,7 @@ footer a { color: inherit; text-decoration: none; }
footer a:hover { color: var(--encre); }
@media (max-width: 600px) {
.shots figure { flex-basis: calc(50% - 10px); }
body { font-size: 16px; }
main { padding-top: 40px; }
.lead { font-size: 18px; }
+6
View File
@@ -48,4 +48,10 @@
<changefreq>yearly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://parenthese.io/aide/</loc>
<lastmod>2026-09-25</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>