From 42e4ed63bb3edbb3ebbfb8d801516ae11af16654 Mon Sep 17 00:00:00 2001 From: IliesAllali <97601122+IliesAllali@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:21:36 +0100 Subject: [PATCH] =?UTF-8?q?Lien=20d'un=20onglet=20de=20famille=20:=20=3Ffa?= =?UTF-8?q?mille=3D=20ouvre=20l'arbre=20sur=20cette=20fami?= =?UTF-8?q?lle,=20et=20le=20lien=20de=20=C2=AB=20La=20famille=20participe?= =?UTF-8?q?=20=C2=BB=20suit=20l'onglet=20ouvert=20(#167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- frontend/src/App.jsx | 16 ++++++- frontend/src/components/ContributionPanel.jsx | 4 +- frontend/src/components/Galaxy.jsx | 42 ++++++++++++++++--- frontend/src/i18n/en/contribution.js | 1 + 4 files changed, 55 insertions(+), 8 deletions(-) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9383df0..a0a36c6 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -544,6 +544,17 @@ function App() { }) const [adminPanelVisible, setAdminPanelVisible] = useState(false) + // Onglet de famille ouvert : l'adresse de la page le porte (?famille=), 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} /> {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} diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index fa62099..3719f04 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -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 ── */}
- {t('Le lien à envoyer à la famille')} + {inviteFamilyName ? t('Le lien à envoyer à la famille {name}', { name: inviteFamilyName }) : t('Le lien à envoyer à la famille')} {copyFeedback && {copyFeedback}}
diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index f0314c8..0bd7c7d 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -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=) : 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)} > {tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')} {tb.count} diff --git a/frontend/src/i18n/en/contribution.js b/frontend/src/i18n/en/contribution.js index 5d20a9b..6bdc980 100644 --- a/frontend/src/i18n/en/contribution.js +++ b/frontend/src/i18n/en/contribution.js @@ -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',