Lien d'un onglet de famille : ?famille=<charnière> ouvre l'arbre sur cette famille, et le lien de « La famille participe » suit l'onglet ouvert (#167)

- Galaxy : l'onglet du lien s'ouvre dès que l'arbre et ses onglets sont connus, et de nouveau si la personne de départ arrive après (visiteur), tant qu'on n'a pas changé d'onglet soi-même ; onglets coupés dans ce navigateur, l'arbre entier s'ouvre sur la charnière
- App : l'adresse de la page porte l'onglet ouvert, le lien à envoyer aussi, avec le nom de la famille dans le titre
- Même mot de passe, même accès : le lien choisit l'onglet d'arrivée, il ne restreint rien (Patch, 26/10)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 authored and GitHub committed 2026-10-26 16:21:36 +01:00
1 parent b9f301619b
commit 42e4ed63bb
4 files changed
+55 -8

No files matched your search

+15 -1
View File
@@ -544,6 +544,17 @@ function App() {
})
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
// Onglet de famille ouvert : l'adresse de la page le porte (?famille=<charnière>), et le lien à envoyer à la
// famille aussi, pour qu'il ouvre l'arbre sur cette famille
const [initialFamilyKey] = useState(() => new URLSearchParams(window.location.search).get('famille'))
const [openFamily, setOpenFamily] = useState(null)
const handleFamilyChange = useCallback((family) => {
setOpenFamily(family)
const url = new URL(window.location.href)
if (family) url.searchParams.set('famille', family.key)
else url.searchParams.delete('famille')
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
}, [])
// Grandes belles-familles dans des onglets (réglage de ce navigateur, voir Paramètres de l'arbre)
const [familyTabs, setFamilyTabsState] = useState(() => { try { return localStorage.getItem('parenthese.familyTabs') !== 'off' } catch { return true } })
const setFamilyTabs = useCallback((on) => { setFamilyTabsState(on); try { localStorage.setItem('parenthese.familyTabs', on ? 'on' : 'off') } catch { /* navigateur sans stockage */ } }, [])
@@ -3404,6 +3415,8 @@ function App() {
onAnnotationsAnchored={persistAnnotationAnchors}
selfPersonId={linkedSelfPersonId || null}
ghosts={ghosts}
initialFamilyKey={initialFamilyKey}
onFamilyChange={handleFamilyChange}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{selectedGhost && !tree.selectedPerson && (
@@ -3510,7 +3523,8 @@ function App() {
canModerate={tree.canEditCurrentTree}
loading={contrib.contribState.loading}
errorMessage={contrib.contribState.error}
inviteShareUrl={contribInviteState.shareUrl}
inviteShareUrl={contribInviteState.shareUrl && openFamily ? `${contribInviteState.shareUrl}?famille=${encodeURIComponent(openFamily.key)}` : contribInviteState.shareUrl}
inviteFamilyName={openFamily?.label || ''}
inviteKnownPasswords={contribInviteState.knownPasswords}
inviteSaving={contribInviteState.loading}
inviteError={contribInviteState.error}
@@ -240,6 +240,8 @@ const ContributionPanel = ({
loading,
errorMessage,
inviteShareUrl,
// Onglet de famille ouvert : le lien ouvre l'arbre sur cette famille
inviteFamilyName = '',
inviteKnownPasswords,
inviteSaving,
inviteError,
@@ -403,7 +405,7 @@ const ContributionPanel = ({
{/* ── Section invitation ── */}
<div className="contrib-invite">
<div className="contrib-invite-top">
<strong className="contrib-invite-title">{t('Le lien à envoyer à la famille')}</strong>
<strong className="contrib-invite-title">{inviteFamilyName ? t('Le lien à envoyer à la famille {name}', { name: inviteFamilyName }) : t('Le lien à envoyer à la famille')}</strong>
{copyFeedback && <span className="contrib-copy-feedback">{copyFeedback}</span>}
</div>
<div className="contrib-invite-link">
+36 -6
View File
@@ -44,7 +44,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
const centerX = (node) => node.x + node.width / 2
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null }) => {
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
const canvasRef = useRef(null)
const layoutDataRef = useRef(null)
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
@@ -54,6 +54,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const [activeKey, setActiveKey] = useState(focusPersonId != null ? String(focusPersonId) : null)
const tabsCacheRef = useRef({ rev: -1, start: undefined, tabs: [] })
const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement
// Lien d'un onglet (?famille=<charnière>) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si
// la personne de départ arrive après (visiteur), tant qu'on n'a pas soi-même changé d'onglet
const linkFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
const pendingFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
// Famille du lien sans onglet dans ce navigateur (onglets coupés) : l'arbre entier s'ouvre sur sa charnière
const frameOnRef = useRef(null)
const onFamilyChangeRef = useRef(onFamilyChange)
useEffect(() => { onFamilyChangeRef.current = onFamilyChange }, [onFamilyChange])
const activeTabRef = useRef(null)
const familyTabsRef = useRef(familyTabs)
useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs])
@@ -63,7 +71,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored)
useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored])
const startRefRef = useRef(focusPersonId)
useEffect(() => { startRefRef.current = focusPersonId; setActiveKey(focusPersonId != null ? String(focusPersonId) : null) }, [focusPersonId])
useEffect(() => {
startRefRef.current = focusPersonId
if (linkFamilyRef.current != null) pendingFamilyRef.current = linkFamilyRef.current
setActiveKey(focusPersonId != null ? String(focusPersonId) : null)
}, [focusPersonId])
// Onglet choisi par la personne (barre, badge, recherche) : le lien d'arrivée ne s'impose plus
const chooseTab = useCallback((key) => { linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
// Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché
const focusPersonIdRef = useRef(focusPersonId)
// La barre défile quand les onglets débordent : l'onglet ouvert se met en vue (sans animation) et les bords
@@ -83,7 +97,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
return () => window.removeEventListener('resize', updateTabsEdges)
}, [activeKey, tabs, updateTabsEdges])
// Clic sur un badge : l'onglet de cette famille (ou, depuis un onglet, celui d'où l'on vient)
const openFamily = useCallback((tabKey) => { if (tabKey != null) setActiveKey(String(tabKey)) }, [])
const openFamily = useCallback((tabKey) => { if (tabKey != null) chooseTab(String(tabKey)) }, [chooseTab])
// Propriétaire : un portrait vide survolé montre « + photo »
const [layoutReady, setLayoutReady] = useState(false)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
@@ -418,7 +432,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const id = String(personId)
const all = tabsCacheRef.current.tabs
const home = all.find((tb) => tb.personIds?.has(id) && !(tb.hinges || []).map(String).includes(id)) || all.find((tb) => tb.personIds?.has(id)) || all[0]
if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; setActiveKey(home.key) }
if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; chooseTab(home.key) }
return
}
if (!canvas || !node) return
@@ -490,6 +504,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
setTabs(tabsCacheRef.current.tabs)
}
if (!familyTabs && tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
// La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur sa charnière
if (pendingFamilyRef.current != null && graphPersons.length) {
const key = pendingFamilyRef.current
pendingFamilyRef.current = null
if (familyTabs && tabsCacheRef.current.tabs.some((tb) => tb.key === key)) {
frameOnRef.current = null
if (key !== activeKey) { setActiveKey(key); return }
} else frameOnRef.current = key
}
const tab = familyTabs ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null
activeTabRef.current = tab
// La personne de départ (ou la charnière de l'onglet) sert de référence aux rangées
@@ -554,7 +577,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// 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 focusId = frameOnRef.current ?? focusPersonIdRef.current
const personNodes = result.children.filter((n) => n._type === 'person')
let focusNode = focusId != null
? personNodes.find((n) => String(n._data?.id) === String(focusId))
@@ -663,6 +686,13 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts])
// Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ
useEffect(() => {
if (pendingFamilyRef.current != null) return
const open = familyTabs && tabs.length > 1 ? tabs.find((tb) => tb.key === activeKey) : null
onFamilyChangeRef.current?.(open && open !== tabs[0] ? { key: open.key, label: open.label } : null)
}, [activeKey, tabs, familyTabs])
// Sync selectedPersonId
useEffect(() => {
selectedPersonIdRef.current = selectedPersonId
@@ -1114,7 +1144,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
type="button"
className={`family-tab${active ? ' is-active' : ''}`}
aria-current={active ? 'page' : undefined}
onClick={() => setActiveKey(tb.key)}
onClick={() => chooseTab(tb.key)}
>
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
<span className="family-tab-count">{tb.count}</span>
+1
View File
@@ -56,6 +56,7 @@ export default {
'participe': 'joins in',
'Fermer': 'Close',
'Le lien à envoyer à la famille': 'The link to send to your family',
'Le lien à envoyer à la famille {name}': 'The link to send to the {name} family',
'Lien indisponible': 'Link unavailable',
'Lien copié': 'Link copied',
'Copier': 'Copy',