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>
No files matched your search
@@ -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 !
|
||||
Merci, c'est envoyé ! Ç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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ? 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 ? 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> ?</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 « {query.trim()} » 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 ? Ajoutez-la
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -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&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 « Ouvrir l'arbre ». 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 « Regarder l'arbre » 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 « Ajouter un souvenir », 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 « Choisir un fichier ». 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 « Ajouter ce souvenir ». 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 « Ajouter un souvenir », sous ses photos.</p>
|
||||
</div>
|
||||
|
||||
<h2>4. Ajouter une personne qui manque</h2>
|
||||
<ol>
|
||||
<li>Touchez « Ajouter une personne », 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 « Ajouter cette personne ». Une fenêtre s'ouvre avec votre prénom et une case pour un mot, si vous voulez expliquer. Touchez « Envoyer mes contributions ».</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 ?</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 ?</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 ?</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&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>
|
||||
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||