mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82ae5e289d | ||
|
|
642b6020c3 | ||
|
|
09b2acf4fe | ||
|
|
51924229da | ||
|
|
64979ef66a | ||
|
|
ef620c9cb5 | ||
|
|
332b5711ff | ||
|
|
9bf55a16f3 | ||
|
|
2c7e5361cc | ||
|
|
98de13aaa0 |
No files matched your search
Generated
+8
-8
@@ -8,9 +8,9 @@
|
||||
"name": "backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cookie": "^11.1.5",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.5",
|
||||
"@fastify/jwt": "^10.2.6",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@node-rs/bcrypt": "^2.0.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
@@ -536,9 +536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.3.tgz",
|
||||
"integrity": "sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==",
|
||||
"version": "11.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.5.tgz",
|
||||
"integrity": "sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -640,9 +640,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/jwt": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.5.tgz",
|
||||
"integrity": "sha512-pObTiMwxYNYdTZ2E/aoU3REIQAMCFXtXMBcUTP1r60nbPqxQEDbN9zh/SQUEefL7gn+KBOaXcoU/Qmza4NxANA==",
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.6.tgz",
|
||||
"integrity": "sha512-tnhl0U6foEHz9v/eGh8zh+tMiJlX867h+yv0sCpWAvWcXYCdjt2Ji/szfD1gDWLy/EQKwYt4OZCKCKIjvenwXw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -17,9 +17,9 @@
|
||||
"prisma:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cookie": "^11.1.5",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.5",
|
||||
"@fastify/jwt": "^10.2.6",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@node-rs/bcrypt": "^2.0.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
Generated
+50
-60
@@ -8,10 +8,10 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.61.0",
|
||||
"@lucide/icons": "^1.66.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"lucide-react": "^1.61.0",
|
||||
"posthog-js": "^1.438.21",
|
||||
"lucide-react": "^1.66.0",
|
||||
"posthog-js": "^1.444.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
@@ -21,10 +21,10 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint": "^10.14.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"globals": "^17.14.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
@@ -483,6 +483,13 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/nonrecursive-glob-match": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
|
||||
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -554,9 +561,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lucide/icons": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.61.0.tgz",
|
||||
"integrity": "sha512-YNm6W6kz5/zlr63oNCJJY3BjSYU01L6HbGvOkKH3NNmu2vKGkCxSHLPx55FdSU9aY7jwwdvCfgU2d5bq4mNLig==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.66.0.tgz",
|
||||
"integrity": "sha512-Qrf8DOz1zKZlsxRY8eDSVEoBwGXc84VnvOzAoosKUhu5VpcxhM/ivhkDemwHV9U6/LkS6+hApB0YWLvdzw3+uQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
@@ -570,29 +577,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.9.11",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.11.tgz",
|
||||
"integrity": "sha512-ZCwJIgiDBRoEAlrlYxaIIIAUfITmBDpWDQJp+IZmAcTyvJka5/ebEKu8uV+jSSA/RhpvDKKte1WmhLUbkIWQjA==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.10.2.tgz",
|
||||
"integrity": "sha512-ieHSMmY4xG+HfkMTrT9yZoG0NMBWyA4aXMFPCuOOV+xRe706+zoPuwqmNHXv7LGXfi6CvOv4qksEoPNWfP359Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.60.1",
|
||||
"@posthog/types": "^1.416.0",
|
||||
"@posthog/core": "^1.61.2",
|
||||
"@posthog/types": "^1.418.0",
|
||||
"preact": "^10.29.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.60.1.tgz",
|
||||
"integrity": "sha512-UpswaG2957O6Ywfx1aCeI8BbXcfoKduKwq1ytBml80AQuAmgK672OmhDcldA7wg4vGP49LpDTgxcNbLvZwQOqw==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.62.0.tgz",
|
||||
"integrity": "sha512-KjNwGC7s0141wnWWHubNMdg1lOhZjqKRw2OTNstuzdVvNDfvv2RbFJQSSvvABmbCmTXwlMpdnbb/M/aAigDnNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.416.0"
|
||||
"@posthog/types": "^1.418.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.416.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
|
||||
"integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
|
||||
"version": "1.419.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.419.0.tgz",
|
||||
"integrity": "sha512-XX7zRYt/vK8AzaD6V+oDquAzacZf5exJv971dja9En8Qf6g84Qvc3rpT0w6hDYYLWrjaSSkdjFfvvoXI0ouJDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
@@ -705,9 +712,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -725,9 +729,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -745,9 +746,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -765,9 +763,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -785,9 +780,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -805,9 +797,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1922,9 +1911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.13.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
|
||||
"integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
|
||||
"version": "10.14.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
|
||||
"integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -1937,7 +1926,7 @@
|
||||
"@eslint/config-helpers": "^0.7.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.3",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanfs/node": "^0.16.8",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
@@ -1958,7 +1947,7 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.5",
|
||||
"minimatch": "^10.2.6",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -2247,9 +2236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"version": "17.14.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
|
||||
"integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2712,9 +2701,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz",
|
||||
"integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.66.0.tgz",
|
||||
"integrity": "sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2737,16 +2726,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
|
||||
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
"@isaacs/nonrecursive-glob-match": "^1.0.3",
|
||||
"brace-expansion": "^5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -2921,14 +2911,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.438.21",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.21.tgz",
|
||||
"integrity": "sha512-KFrb9SOhyWg65OYOJ6dIxog26AqyD3ETj5pin/dWBcGll3pqa3FnqoJxq5IkHC6dtms/YjRCj6cT06u9j+EkoA==",
|
||||
"version": "1.444.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.444.0.tgz",
|
||||
"integrity": "sha512-4m+QsAFi1h5Zt8eFq6ciJGD8bcLMt1P/O1cLlo26RrX5F4oMOIKZsNIlf1SIMZWlRNNCFemSpzn6HhwtkZMrnA==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/browser-common": "^0.9.11",
|
||||
"@posthog/core": "^1.60.1",
|
||||
"@posthog/types": "^1.416.0",
|
||||
"@posthog/browser-common": "^0.10.2",
|
||||
"@posthog/core": "^1.62.0",
|
||||
"@posthog/types": "^1.419.0",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -12,10 +12,10 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lucide/icons": "^1.61.0",
|
||||
"@lucide/icons": "^1.66.0",
|
||||
"blurhash": "^2.0.5",
|
||||
"lucide-react": "^1.61.0",
|
||||
"posthog-js": "^1.438.21",
|
||||
"lucide-react": "^1.66.0",
|
||||
"posthog-js": "^1.444.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
@@ -25,12 +25,15 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint": "^10.14.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"globals": "^17.14.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^11.0.4"
|
||||
}
|
||||
}
|
||||
@@ -585,6 +585,9 @@ function App() {
|
||||
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 */ } }, [])
|
||||
// Nom de famille sous le prénom dans l'arbre, celui de naissance s'il est connu (#168). Réglage de ce navigateur.
|
||||
// Proches des autres familles dessinés, estompés, autour d'un onglet : 0 (ma ligne seulement) à 3 liens
|
||||
const [tabKinDepth, setTabKinDepthState] = useState(() => { try { const v = localStorage.getItem('parenthese.tabKinDepth'); return ['0', '1', '2', '3'].includes(v) ? Number(v) : 1 } catch { return 1 } })
|
||||
const setTabKinDepth = useCallback((d) => { setTabKinDepthState(d); try { localStorage.setItem('parenthese.tabKinDepth', String(d)) } catch { /* navigateur sans stockage */ } }, [])
|
||||
const [fullNames, setFullNamesState] = useState(() => { try { return localStorage.getItem('parenthese.fullNames') === 'on' } catch { return false } })
|
||||
const setFullNames = useCallback((on) => { setFullNamesState(on); try { localStorage.setItem('parenthese.fullNames', on ? 'on' : 'off') } catch { /* navigateur sans stockage */ } }, [])
|
||||
// Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre
|
||||
@@ -1684,7 +1687,6 @@ function App() {
|
||||
setShowContribModal(false)
|
||||
setContribSubmitError('')
|
||||
setContribSuccess(true)
|
||||
trackAppEvent('proposal_sent', { changes: changes.length, tree_id: opaqueTreeId })
|
||||
setDraftNotice('')
|
||||
setTimeout(() => setContribSuccess(false), 6000)
|
||||
} catch (err) {
|
||||
@@ -3572,6 +3574,7 @@ function App() {
|
||||
reorderEnabled={canReorderSiblings}
|
||||
onReorderSiblings={handleReorderSiblings}
|
||||
familyTabs={familyTabs}
|
||||
tabKinDepth={tabKinDepth}
|
||||
fullNames={fullNames}
|
||||
annotationViewRef={annotationViewRef}
|
||||
onAnnotationsAnchored={persistAnnotationAnchors}
|
||||
@@ -3753,6 +3756,8 @@ function App() {
|
||||
onClose={() => setAdminPanelVisible(false)}
|
||||
familyTabs={familyTabs}
|
||||
onFamilyTabsChange={setFamilyTabs}
|
||||
tabKinDepth={tabKinDepth}
|
||||
onTabKinDepthChange={setTabKinDepth}
|
||||
fullNames={fullNames}
|
||||
onFullNamesChange={setFullNames}
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,8 @@ const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '
|
||||
const AdminPanel = ({
|
||||
familyTabs = true,
|
||||
onFamilyTabsChange = null,
|
||||
tabKinDepth = 1,
|
||||
onTabKinDepthChange = null,
|
||||
fullNames = false,
|
||||
onFullNamesChange = null,
|
||||
visible,
|
||||
@@ -535,6 +537,7 @@ const AdminPanel = ({
|
||||
|
||||
<fieldset className="ts-policy">
|
||||
<legend className="pz-eyebrow">{t('Affichage')}</legend>
|
||||
<p className="pz-small">{t('Réglages de ce navigateur.')}</p>
|
||||
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -543,10 +546,21 @@ const AdminPanel = ({
|
||||
onChange={(event) => onFamilyTabsChange?.(event.target.checked)}
|
||||
/>
|
||||
<span className="ts-choice-text">
|
||||
<strong>{t('Les grandes familles par alliance dans des onglets')}</strong>
|
||||
<small>{t("Une belle-famille plus large qu'un écran s'ouvre dans son propre onglet au lieu d'être dessinée dans l'arbre. Réglage de ce navigateur.")}</small>
|
||||
<strong>{t('Séparer les belles-familles en onglets')}</strong>
|
||||
<small>{t("Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.")}</small>
|
||||
</span>
|
||||
</label>
|
||||
<div className="pz-field">
|
||||
<span className="pz-label" id="settingsTabKinLabel">{t("Dans un onglet, montrer aussi les proches de l'autre côté")}</span>
|
||||
<div className="ts-icons" role="radiogroup" aria-labelledby="settingsTabKinLabel">
|
||||
{[[0, t('Ma ligne seulement')], [1, t('1 lien')], [2, t('2 liens')], [3, t('3 liens')]].map(([d, text]) => (
|
||||
<button key={d} type="button" role="radio" aria-checked={tabKinDepth === d} disabled={!familyTabs} className="ts-icon ts-icon--letter" onClick={() => onTabKinDepthChange?.(d)}>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="pz-hint">{t('Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.')}</p>
|
||||
</div>
|
||||
<label className={`ts-choice ${fullNames ? 'is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -555,8 +569,8 @@ const AdminPanel = ({
|
||||
onChange={(event) => onFullNamesChange?.(event.target.checked)}
|
||||
/>
|
||||
<span className="ts-choice-text">
|
||||
<strong>{t("Le nom de famille sous le prénom")}</strong>
|
||||
<small>{t("Le nom de naissance quand il est connu, sinon le nom d'usage. Réglage de ce navigateur.")}</small>
|
||||
<strong>{t('Afficher le nom de famille')}</strong>
|
||||
<small>{t("Sous chaque prénom. Le nom de naissance s'il est connu.")}</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
@@ -7,7 +7,8 @@ import './SettingsPanels.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { HELP_URL } from '../utils/siteLinks.js'
|
||||
import { t, dateLocale } from '../i18n/index.js'
|
||||
import { buildInviteText } from '../utils/inviteText.js'
|
||||
import { buildInviteText, isChristmasSeason } from '../utils/inviteText.js'
|
||||
import { toOpaqueTreeId, trackAppEvent } from '../utils/analytics.js'
|
||||
import { sexLabel } from '../utils/personSex.js'
|
||||
import Skeleton from './Skeleton.jsx'
|
||||
|
||||
@@ -346,10 +347,19 @@ const ContributionPanel = ({
|
||||
const [inviteText, setInviteText] = useState('')
|
||||
useEffect(() => { setInviteText(defaultInviteText) }, [defaultInviteText])
|
||||
|
||||
const handleCopy = async (value, label) => {
|
||||
// what : link, password ou message. Ce qui a été copié, jamais le texte lui-même
|
||||
const handleCopy = async (value, label, what) => {
|
||||
const text = String(value || '').trim()
|
||||
if (!text) return
|
||||
try { await navigator.clipboard.writeText(text); setCopyFeedback(label) }
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopyFeedback(label)
|
||||
trackAppEvent('share_copied', {
|
||||
what,
|
||||
...(what === 'message' ? { seasonal: Boolean(seasonalInvite && isChristmasSeason()) } : {}),
|
||||
tree_id: toOpaqueTreeId(treeId),
|
||||
})
|
||||
}
|
||||
catch { setCopyFeedback(t('Copie indisponible')) }
|
||||
}
|
||||
|
||||
@@ -398,7 +408,7 @@ const ContributionPanel = ({
|
||||
</div>
|
||||
<div className="contrib-invite-link">
|
||||
<code className="contrib-link-url">{inviteShareUrl || t('Lien indisponible')}</code>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!inviteShareUrl} onClick={() => handleCopy(inviteShareUrl, t('Lien copié'))}><Copy size={14} strokeWidth={2} />{t('Copier')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!inviteShareUrl} onClick={() => handleCopy(inviteShareUrl, t('Lien copié'), 'link')}><Copy size={14} strokeWidth={2} />{t('Copier')}</button>
|
||||
</div>
|
||||
<div className="contrib-invite-passwords">
|
||||
<div className="contrib-role-row">
|
||||
@@ -412,7 +422,7 @@ const ContributionPanel = ({
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className={`contrib-pass-reveal${knownShare && !showPassword ? ' contrib-pass-reveal--blurred' : ''}`} onClick={() => setShowPassword((v) => !v)} disabled={!knownShare} title={showPassword ? t('Cliquer pour masquer') : t('Cliquer pour révéler')}>{knownShare || t('Pas encore enregistré')}</button>
|
||||
{knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(knownShare, t('Mot de passe copié'))}>{t('Copier')}</button>}
|
||||
{knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(knownShare, t('Mot de passe copié'), 'password')}>{t('Copier')}</button>}
|
||||
{!knownShare && <button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setPasswordMode('remember')}>{t("Saisir l'actuel")}</button>}
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" disabled={inviteSaving} onClick={() => setPasswordMode('change')}>{t('Changer')}</button>
|
||||
</>
|
||||
@@ -427,7 +437,7 @@ const ContributionPanel = ({
|
||||
{defaultInviteText && (
|
||||
<div className="contrib-invite-message">
|
||||
<div className="contrib-invite-message-row">
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(inviteText, t('Message copié'))}><Copy size={14} strokeWidth={2} />{t("Copier le message d'invitation")}</button>
|
||||
<button type="button" className="contrib-pill-btn contrib-pill-btn--edit" onClick={() => handleCopy(inviteText, t('Message copié'), 'message')}><Copy size={14} strokeWidth={2} />{t("Copier le message d'invitation")}</button>
|
||||
<button type="button" className="contrib-link-btn" aria-expanded={showInviteMessage} aria-controls="contrib-invite-text" onClick={() => setShowInviteMessage((v) => !v)}>
|
||||
{showInviteMessage ? t('Masquer le message') : t('Voir ou modifier le message')}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as g
|
||||
import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_NODE_DURATION, ENTRANCE_CAMERA_DURATION, ENTRANCE_CAMERA_FROM_SCALE, ENTRANCE_REDUCED_DURATION, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants'
|
||||
import { easeOutCubic } from './galaxy/utils'
|
||||
import { computeLayout, generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
|
||||
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
|
||||
import { computeBlockLayout, familyTabsOf, kinAround, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
|
||||
import { hasGhosts, withGhosts } from '../utils/ghosts'
|
||||
|
||||
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
|
||||
@@ -77,7 +77,7 @@ function barTabs(tabs, activeKey) {
|
||||
})
|
||||
}
|
||||
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = 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, tabKinDepth = 1, fullNames = false, 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
|
||||
@@ -675,6 +675,12 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
// Dans l'onglet d'une autre famille, la personne commune (« C'est moi », sinon la personne de départ) reste
|
||||
// là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps »)
|
||||
const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set()
|
||||
// Autour, estompés aussi : l'autre parent des porteurs du nom venus d'ailleurs, et les proches de l'autre côté
|
||||
// jusqu'à la profondeur choisie dans les réglages (Patch, 06/11)
|
||||
if (tab?.personIds) {
|
||||
for (const id of tab.faded || []) line.add(id)
|
||||
for (const id of kinAround(tab.personIds, tabKinDepth)) line.add(id)
|
||||
}
|
||||
const tabGraph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
|
||||
const graph = hasGhosts(ghosts)
|
||||
? withGhosts(tabGraph || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghosts, !tabGraph)
|
||||
@@ -875,7 +881,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
pendingFrameRef.current = null
|
||||
requestAnimationFrame(() => zoomApiRef?.current?.focusPersons?.(ids))
|
||||
}
|
||||
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
|
||||
}, [graphRevision, activeKey, familyTabs, tabKinDepth, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
|
||||
|
||||
// Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ
|
||||
useEffect(() => {
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
.ts-icon--letter { width: auto; padding: 0 12px; border-radius: 999px; font: 500 13px var(--pz-sans); color: var(--pz-ink-2); }
|
||||
.ts-icon[aria-checked="true"] { border-color: var(--pz-a500); background: var(--pz-a50); box-shadow: 0 0 0 3px var(--pz-a100); }
|
||||
.ts-icon:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
.ts-icon:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Cinq onglets : ils rétrécissent (texte sur deux lignes) au lieu de déborder du panneau sur un petit téléphone */
|
||||
.ts-tabs .pz-tab { min-width: 0; }
|
||||
|
||||
@@ -4,6 +4,7 @@ import PzBusy from './PzBusy.jsx'
|
||||
import { dateLocale, t } from '../i18n/index.js'
|
||||
import { initialOf } from '../utils/initial.js'
|
||||
import Skeleton from './Skeleton.jsx'
|
||||
import { toOpaqueTreeId, trackAppEvent } from '../utils/analytics.js'
|
||||
|
||||
// Onglet Gestion des paramètres de l'arbre (issue #145). Le propriétaire invite des co-gestionnaires par leur
|
||||
// adresse : Parenthèse n'envoie pas de mail, il donne un lien à transmettre. Les co-gestionnaires voient la liste.
|
||||
@@ -42,6 +43,8 @@ const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName
|
||||
const url = invitationUrl(token)
|
||||
let copied
|
||||
try { await navigator.clipboard.writeText(url); copied = true } catch { copied = false }
|
||||
// Le lien créé compte comme un partage même si le presse-papiers est refusé (il est alors montré en clair)
|
||||
trackAppEvent('share_copied', { what: 'manager_invite', copied, tree_id: toOpaqueTreeId(treeId) })
|
||||
setLink({ url, email: forEmail, copied })
|
||||
}
|
||||
|
||||
|
||||
@@ -854,34 +854,35 @@ export function subgraphOf(personIds, graphData = null) {
|
||||
return { persons, unions, filiations }
|
||||
}
|
||||
|
||||
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
|
||||
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
|
||||
// sans fratries autour. Chaque pas d'un enfant à un parent garde ses deux parents (le fil part de leur union,
|
||||
// et sans l'autre parent le placement dessine un conjoint « Inconnu »).
|
||||
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
|
||||
export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const self = selfId != null ? String(selfId) : null
|
||||
if (self == null || members.has(self)) return new Set()
|
||||
const persons = graphData?.persons || globalPersons
|
||||
if (!persons.some(p => String(p.id) === self)) return new Set()
|
||||
// La parenté d'un arbre : pour chaque personne, ses voisins (conjoints, parents, enfants), chacun avec les personnes
|
||||
// que ce pas fait dessiner en plus. Chaque pas entre un enfant et un parent garde l'autre parent (le fil part de
|
||||
// leur union, et sans l'autre parent le placement dessine un conjoint « Inconnu »).
|
||||
function kinshipOf(graphData = null) {
|
||||
const unions = graphData?.unions || globalUnions
|
||||
const filiations = graphData?.filiations || globalFiliations
|
||||
const unionById = new Map(unions.map(u => [String(u.id), u]))
|
||||
// Voisins : [voisin, personnes que ce pas fait dessiner en plus]
|
||||
const next = new Map()
|
||||
const link = (a, b, extra = []) => { if (!next.has(a)) next.set(a, []); next.get(a).push([b, extra]) }
|
||||
const partnersOf = new Map()
|
||||
for (const u of unions) {
|
||||
const a = String(u.partner1Id), b = String(u.partner2Id)
|
||||
link(a, b); link(b, a)
|
||||
if (!partnersOf.has(a)) partnersOf.set(a, new Set())
|
||||
if (!partnersOf.has(b)) partnersOf.set(b, new Set())
|
||||
partnersOf.get(a).add(b); partnersOf.get(b).add(a)
|
||||
}
|
||||
const parentsOf = new Map()
|
||||
const childrenOf = new Map()
|
||||
for (const f of filiations) {
|
||||
const child = String(f.childId)
|
||||
const u = f.unionId ? unionById.get(String(f.unionId)) : null
|
||||
const parents = u ? [String(u.partner1Id), String(u.partner2Id)] : f.parentId != null ? [String(f.parentId)] : []
|
||||
if (!parentsOf.has(child)) parentsOf.set(child, new Set())
|
||||
for (const p of parents) parentsOf.get(child).add(p)
|
||||
for (const p of parents) {
|
||||
parentsOf.get(child).add(p)
|
||||
if (!childrenOf.has(p)) childrenOf.set(p, new Set())
|
||||
childrenOf.get(p).add(child)
|
||||
}
|
||||
}
|
||||
for (const [child, parents] of parentsOf) {
|
||||
for (const p of parents) {
|
||||
@@ -889,6 +890,20 @@ export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
link(child, p, couple); link(p, child, couple)
|
||||
}
|
||||
}
|
||||
return { next, parentsOf, childrenOf, partnersOf }
|
||||
}
|
||||
|
||||
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
|
||||
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
|
||||
// sans fratries autour, chaque pas d'un enfant à un parent gardant ses deux parents.
|
||||
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
|
||||
export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const self = selfId != null ? String(selfId) : null
|
||||
if (self == null || members.has(self)) return new Set()
|
||||
const persons = graphData?.persons || globalPersons
|
||||
if (!persons.some(p => String(p.id) === self)) return new Set()
|
||||
const { next } = kinshipOf(graphData)
|
||||
const from = new Map([[self, null]])
|
||||
const queue = [self]
|
||||
let hit = null
|
||||
@@ -909,6 +924,60 @@ export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
return line
|
||||
}
|
||||
|
||||
// Les porteurs du nom d'une famille (Patch, 06/11 : « si un gonz a un nom de famille il fait partie de la famille
|
||||
// au même nom ») : un enfant d'un membre qui porte le nom de l'onglet en devient membre, puis ses enfants au même
|
||||
// nom, de génération en génération. Nom de naissance s'il est connu, sinon nom d'usage (une femme mariée notée au
|
||||
// nom de son mari reste chez elle). Seuls les enfants d'un membre comptent : un homonyme sans lien reste dehors.
|
||||
// Le couple d'un porteur est dessiné entier (`added` prend ses conjoints) ; l'autre parent d'un porteur ajouté,
|
||||
// sans le nom, est rendu à part (`coParents`) pour être estompé, comme un proche de l'autre côté.
|
||||
const nomCle = (s) => (s || '').trim().toLocaleLowerCase('fr')
|
||||
export function bearersOfName(memberIds, name, graphData = null) {
|
||||
const added = new Set()
|
||||
const coParents = new Set()
|
||||
const target = nomCle(name)
|
||||
if (!target) return { added, coParents }
|
||||
const persons = graphData?.persons || globalPersons
|
||||
const nameOf = new Map(persons.map(p => [String(p.id), nomCle(p.birthName || p.lastName)]))
|
||||
const { parentsOf, childrenOf, partnersOf } = kinshipOf(graphData)
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const bearers = []
|
||||
const queue = [...members]
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const c of childrenOf.get(queue[i]) || []) {
|
||||
if (members.has(c) || added.has(c) || nameOf.get(c) !== target) continue
|
||||
added.add(c); bearers.push(c); queue.push(c)
|
||||
}
|
||||
}
|
||||
const inTab = (id) => members.has(id) || added.has(id)
|
||||
for (const b of bearers) for (const p of partnersOf.get(b) || []) if (!inTab(p)) added.add(p)
|
||||
for (const b of bearers) for (const p of parentsOf.get(b) || []) if (!inTab(p)) coParents.add(p)
|
||||
return { added, coParents }
|
||||
}
|
||||
|
||||
// Les proches de l'autre côté d'un onglet (réglage « Proches des autres familles ») : les personnes à `depth` pas
|
||||
// de parenté au plus d'un membre, chaque pas d'un enfant à un parent gardant l'autre parent. Estompées à l'écran.
|
||||
export function kinAround(memberIds, depth, graphData = null) {
|
||||
const around = new Set()
|
||||
if (!(depth > 0)) return around
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const { next } = kinshipOf(graphData)
|
||||
const dist = new Map([...members].map(id => [id, 0]))
|
||||
const queue = [...members]
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const d = dist.get(queue[i])
|
||||
if (d >= depth) continue
|
||||
for (const [nb, extra] of next.get(queue[i]) || []) {
|
||||
for (const e of extra) if (!members.has(e)) around.add(e)
|
||||
if (dist.has(nb)) continue
|
||||
dist.set(nb, d + 1)
|
||||
around.add(nb)
|
||||
queue.push(nb)
|
||||
}
|
||||
}
|
||||
for (const id of members) around.delete(id)
|
||||
return around
|
||||
}
|
||||
|
||||
// Toutes les familles d'un arbre, de proche en proche : l'arbre de la personne de départ, puis chaque grande
|
||||
// belle-famille qu'il cache (un onglet = ce bloc caché plus le conjoint charnière, posé avec lui comme
|
||||
// référence), puis celles que ces onglets cachent à leur tour. Chaque personne est dans un seul onglet, sauf
|
||||
@@ -933,7 +1002,9 @@ export function familyTabsOf(graphData = null, referenceId = null) {
|
||||
const key = String(f.anchorId).replace(/^p-/, '')
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
tabs.push({ key, referenceId: key, parentKey: tab.key, personIds: new Set([...f.members, ...f.hinges]), hinges: f.hinges, label: f.label, count: f.count })
|
||||
const base = new Set([...f.members, ...f.hinges])
|
||||
const { added, coParents } = bearersOfName(base, f.label, graphData)
|
||||
tabs.push({ key, referenceId: key, parentKey: tab.key, personIds: new Set([...base, ...added]), faded: coParents, hinges: f.hinges, label: f.label, count: f.count + added.size })
|
||||
}
|
||||
}
|
||||
// Deux onglets au même nom (homonymes, ou une branche sortie de sa propre lignée) : sauf le plus grand, chacun
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bearersOfName, kinAround } from './blockLayout'
|
||||
|
||||
// Henri Garnier (membre de l'onglet Garnier) et Odette, née Morel, notée au nom de son mari.
|
||||
// Leurs enfants : Paul Garnier (marié à Julie Petit, une fille Lou Garnier) et Anne, au nom de sa mère.
|
||||
// Un Garnier sans lien, ailleurs dans l'arbre.
|
||||
const p = (id, firstName, lastName, birthName = null) => ({ id, firstName, lastName, birthName })
|
||||
const graph = {
|
||||
persons: [
|
||||
p(1, 'Henri', 'Garnier'), p(2, 'Odette', 'Garnier', 'Morel'), p(3, 'Paul', 'Garnier'), p(4, 'Anne', 'Morel'),
|
||||
p(5, 'Julie', 'Petit'), p(6, 'Lou', 'Garnier'), p(7, 'Jean', 'Garnier'), p(8, 'Marc', 'Morel'),
|
||||
],
|
||||
unions: [{ id: 'u1', partner1Id: 1, partner2Id: 2 }, { id: 'u2', partner1Id: 3, partner2Id: 5 }],
|
||||
filiations: [
|
||||
{ childId: 3, unionId: 'u1' }, { childId: 4, unionId: 'u1' }, { childId: 6, unionId: 'u2' }, { childId: 2, parentId: 8 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('bearersOfName', () => {
|
||||
const { added, coParents } = bearersOfName(['1'], 'Garnier', graph)
|
||||
it('prend les enfants au nom, de génération en génération, avec leurs conjoints', () => {
|
||||
expect([...added].sort()).toEqual(['3', '5', '6'])
|
||||
})
|
||||
it("laisse dehors l'enfant au nom de sa mère, l'homonyme sans lien, et la mère notée au nom du mari", () => {
|
||||
expect(added.has('4')).toBe(false)
|
||||
expect(added.has('7')).toBe(false)
|
||||
expect(added.has('2')).toBe(false)
|
||||
})
|
||||
it("rend l'autre parent à estomper", () => {
|
||||
expect([...coParents]).toEqual(['2'])
|
||||
})
|
||||
it('ignore la casse du nom', () => {
|
||||
expect(bearersOfName(['1'], 'GARNIER', graph).added.has('3')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kinAround', () => {
|
||||
it('ma ligne seulement : personne autour', () => {
|
||||
expect(kinAround(['1'], 0, graph).size).toBe(0)
|
||||
})
|
||||
it('1 lien : conjoint et enfants, et l\'autre parent', () => {
|
||||
expect([...kinAround(['1'], 1, graph)].sort()).toEqual(['2', '3', '4'])
|
||||
})
|
||||
it('2 liens : petits-enfants, conjoints des enfants, parents du conjoint', () => {
|
||||
expect([...kinAround(['1'], 2, graph)].sort()).toEqual(['2', '3', '4', '5', '6', '8'])
|
||||
})
|
||||
})
|
||||
@@ -148,6 +148,13 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
|
||||
|
||||
try {
|
||||
const result = await submitContributionSession(treeContext.treeId, payload, treeContext.accessToken)
|
||||
// Ici et pas dans l'appelant : toutes les propositions passent par cette fonction (fenêtre d'envoi
|
||||
// comme raccourcis de la fiche). « approved » : un co-gestionnaire, appliqué sans relecture.
|
||||
trackAppEvent('proposal_sent', {
|
||||
changes: Array.isArray(payload.changes) ? payload.changes.length : 0,
|
||||
status: result?.session?.status || 'pending',
|
||||
tree_id: toOpaqueTreeId(treeContext.treeId),
|
||||
})
|
||||
|
||||
if (result?.session?.status === 'approved') {
|
||||
await refreshCurrentTreeGraph()
|
||||
|
||||
@@ -5,10 +5,17 @@ export default {
|
||||
'Famille': 'Family',
|
||||
'Familles': 'Families',
|
||||
'Affichage': 'Display',
|
||||
'Les grandes familles par alliance dans des onglets': 'Large in-law families in tabs',
|
||||
"Une belle-famille plus large qu'un écran s'ouvre dans son propre onglet au lieu d'être dessinée dans l'arbre. Réglage de ce navigateur.": 'An in-law family wider than a screen opens in its own tab instead of being drawn in the tree. Saved in this browser.',
|
||||
'Le nom de famille sous le prénom': 'Last name under the first name',
|
||||
"Le nom de naissance quand il est connu, sinon le nom d'usage. Réglage de ce navigateur.": 'The maiden name when it is known, otherwise the current last name. Saved in this browser.',
|
||||
'Réglages de ce navigateur.': 'Saved in this browser.',
|
||||
'Séparer les belles-familles en onglets': 'Put in-law families in tabs',
|
||||
"Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.": "When a spouse's family is too wide for the screen, it opens in a tab at the top of the tree.",
|
||||
"Dans un onglet, montrer aussi les proches de l'autre côté": 'In a tab, also show relatives from the other side',
|
||||
'Ma ligne seulement': 'Only my line',
|
||||
'1 lien': '1 link',
|
||||
'2 liens': '2 links',
|
||||
'3 liens': '3 links',
|
||||
'Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.': 'A link is a parent, a child or a spouse. They appear lighter; your own line always stays visible.',
|
||||
'Afficher le nom de famille': 'Show the last name',
|
||||
"Sous chaque prénom. Le nom de naissance s'il est connu.": 'Under each first name. The maiden name when it is known.',
|
||||
// Arbre sur canvas (components/galaxy/renderers.js)
|
||||
'Voir les parents': 'See parents',
|
||||
'Voir {firstName}': 'See {firstName}',
|
||||
|
||||
@@ -41,8 +41,8 @@ export default {
|
||||
|
||||
// ─── Message d'invitation ───
|
||||
'Bonjour à tous,': 'Hi everyone,',
|
||||
"Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon, et apportez vos vieilles photos, on le complétera ensemble à table.":
|
||||
"For Christmas, I put our family tree online. Have a look before Christmas Eve, and bring your old photos, we'll fill it in together at the table.",
|
||||
"Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon et apportez vos vieilles photos, on le complétera ensemble à table. Et si quelqu'un raconte une histoire de famille, enregistrez-la sur votre téléphone, sa voix aura sa place dans l'arbre.":
|
||||
"For Christmas, I put our family tree online. Have a look before Christmas Eve and bring your old photos, we'll fill it in together at the table. And if someone tells a family story, record it on your phone, their voice belongs in the tree.",
|
||||
"Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter.":
|
||||
"I'm putting our family's history together in an online tree, and I'd love your help filling it in.",
|
||||
"L'arbre est ici {url}": 'The tree is here {url}',
|
||||
|
||||
@@ -12,7 +12,7 @@ export function buildInviteText(url, password, { seasonal = false, now = new Dat
|
||||
t('Bonjour à tous,'),
|
||||
'',
|
||||
seasonal && isChristmasSeason(now)
|
||||
? t("Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon, et apportez vos vieilles photos, on le complétera ensemble à table.")
|
||||
? t("Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon et apportez vos vieilles photos, on le complétera ensemble à table. Et si quelqu'un raconte une histoire de famille, enregistrez-la sur votre téléphone, sa voix aura sa place dans l'arbre.")
|
||||
: t("Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter."),
|
||||
'',
|
||||
t("L'arbre est ici {url}", { url }),
|
||||
|
||||
Generated
+19
-19
@@ -8,7 +8,7 @@
|
||||
"name": "landing",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"posthog-js": "^1.438.21",
|
||||
"posthog-js": "^1.444.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
@@ -80,29 +80,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.9.11",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.11.tgz",
|
||||
"integrity": "sha512-ZCwJIgiDBRoEAlrlYxaIIIAUfITmBDpWDQJp+IZmAcTyvJka5/ebEKu8uV+jSSA/RhpvDKKte1WmhLUbkIWQjA==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.10.2.tgz",
|
||||
"integrity": "sha512-ieHSMmY4xG+HfkMTrT9yZoG0NMBWyA4aXMFPCuOOV+xRe706+zoPuwqmNHXv7LGXfi6CvOv4qksEoPNWfP359Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.60.1",
|
||||
"@posthog/types": "^1.416.0",
|
||||
"@posthog/core": "^1.61.2",
|
||||
"@posthog/types": "^1.418.0",
|
||||
"preact": "^10.29.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.60.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.60.1.tgz",
|
||||
"integrity": "sha512-UpswaG2957O6Ywfx1aCeI8BbXcfoKduKwq1ytBml80AQuAmgK672OmhDcldA7wg4vGP49LpDTgxcNbLvZwQOqw==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.62.0.tgz",
|
||||
"integrity": "sha512-KjNwGC7s0141wnWWHubNMdg1lOhZjqKRw2OTNstuzdVvNDfvv2RbFJQSSvvABmbCmTXwlMpdnbb/M/aAigDnNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.416.0"
|
||||
"@posthog/types": "^1.418.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.416.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
|
||||
"integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
|
||||
"version": "1.419.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.419.0.tgz",
|
||||
"integrity": "sha512-XX7zRYt/vK8AzaD6V+oDquAzacZf5exJv971dja9En8Qf6g84Qvc3rpT0w6hDYYLWrjaSSkdjFfvvoXI0ouJDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
@@ -1433,14 +1433,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.438.21",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.21.tgz",
|
||||
"integrity": "sha512-KFrb9SOhyWg65OYOJ6dIxog26AqyD3ETj5pin/dWBcGll3pqa3FnqoJxq5IkHC6dtms/YjRCj6cT06u9j+EkoA==",
|
||||
"version": "1.444.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.444.0.tgz",
|
||||
"integrity": "sha512-4m+QsAFi1h5Zt8eFq6ciJGD8bcLMt1P/O1cLlo26RrX5F4oMOIKZsNIlf1SIMZWlRNNCFemSpzn6HhwtkZMrnA==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/browser-common": "^0.9.11",
|
||||
"@posthog/core": "^1.60.1",
|
||||
"@posthog/types": "^1.416.0",
|
||||
"@posthog/browser-common": "^0.10.2",
|
||||
"@posthog/core": "^1.62.0",
|
||||
"@posthog/types": "^1.419.0",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"fflate": "^0.4.8",
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs",
|
||||
"build": "vite build && vite build --ssr src/entry-server.jsx --outDir dist-ssr && node scripts/prerender.mjs && node scripts/inject-pages.mjs",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test"
|
||||
},
|
||||
"dependencies": {
|
||||
"posthog-js": "^1.438.21",
|
||||
"posthog-js": "^1.444.0",
|
||||
"react": "^19.3.1",
|
||||
"react-dom": "^19.3.1"
|
||||
},
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<h2>Mesure d'audience</h2>
|
||||
<p>Parenthèse utilise PostHog pour compter les visites et voir ce qui est utilisé. PostHog est hébergé aux États-Unis, les données d'usage décrites ici y sont envoyées.</p>
|
||||
<p>Ce qui est mesuré, et rien de plus. Sur le site, la visite d'une page, le défilement, les clics sur les boutons « Créer mon arbre », avec la provenance (page précédente, mobile ou ordinateur). Dans l'application, l'ouverture de l'écran de compte, la création d'un compte ou d'un arbre, et pour les arbres partagés, l'ouverture, le temps passé et le type de contenu consulté (texte, photo, vidéo), avec le type d'accès. L'arbre y est désigné par un code brouillé, jamais par son nom.</p>
|
||||
<p>Ce qui est mesuré, et rien de plus. Sur le site, la visite d'une page, le défilement, la lecture de la vidéo de présentation, les clics sur les boutons « Créer mon arbre », avec la provenance (lien d'arrivée, page précédente, mobile ou ordinateur). Dans l'application, l'ouverture de l'écran de compte, la création d'un compte ou d'un arbre, l'ouverture de l'arbre d'exemple et les gestes qu'on y fait, les modifications d'un arbre comptées sans leur contenu (ajout d'une personne, d'un portrait ou de souvenirs, import d'un fichier GEDCOM, enregistrement), l'envoi et la relecture des propositions de la famille, la copie du lien, du mot de passe ou du message de partage (jamais ce qui est copié), et pour les arbres partagés, l'ouverture, le temps passé et le type de contenu consulté (texte, photo, vidéo), avec le type d'accès. L'arbre y est désigné par un code brouillé, jamais par son nom.</p>
|
||||
<p>Il n'y a pas de capture automatique des clics ni des pages, et pas d'enregistrement des sessions. Aucun contenu d'arbre (nom, personne, photo) n'est envoyé, ni l'adresse de l'arbre, retirée des liens avant tout envoi. Quand vous êtes connecté à votre compte, un identifiant technique du compte (jamais votre adresse email) relie vos visites entre elles. Un cookie est posé sur parenthese.io et ses sous-domaines pour reconnaître la même visite entre le site et l'application.</p>
|
||||
|
||||
<h2>Vos droits</h2>
|
||||
|
||||
@@ -96,7 +96,7 @@
|
||||
|
||||
<h2>Audience measurement</h2>
|
||||
<p>Parenthèse uses PostHog to count visits and see what gets used. PostHog is hosted in the United States, and the usage data described here is sent there.</p>
|
||||
<p>Here is what is measured, and nothing more. On the website, page views, scrolling, clicks on the “Start your tree” buttons, with where they came from (previous page, mobile or computer). In the app, opening the account screen, creating an account or a tree, and for shared trees, opening them, the time spent and the type of content viewed (text, photo, video), with the type of access. The tree is identified there by a scrambled code, never by its name.</p>
|
||||
<p>Here is what is measured, and nothing more. On the website, page views, scrolling, playing the introduction video, clicks on the “Start your tree” buttons, with where they came from (link used to arrive, previous page, mobile or computer). In the app, opening the account screen, creating an account or a tree, opening the example tree and what is done in it, edits to a tree counted without their content (adding a person, a portrait or memories, importing a GEDCOM file, saving), sending and reviewing the family's suggestions, copying the share link, password or message (never what is copied), and for shared trees, opening them, the time spent and the type of content viewed (text, photo, video), with the type of access. The tree is identified there by a scrambled code, never by its name.</p>
|
||||
<p>There is no automatic capture of clicks or pages, and no session recording. No tree content (name, person, photo) is sent, and neither is the address of the tree, which is removed from links before anything is sent. When you are logged in to your account, a technical account identifier (never your email address) links your visits together. A cookie is set on parenthese.io and its subdomains to recognize the same visit across the site and the app.</p>
|
||||
|
||||
<h2>Your rights</h2>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Pose la mesure d'audience (src/pages.js, nom à empreinte lu dans le manifeste de Vite) dans chaque page
|
||||
// statique de dist/ : guides, aide, vidéo, vie privée. Ces pages viennent de public/ et ne passent pas par
|
||||
// Vite. La page d'accueil (fr et en, <div id="root">) a déjà sa propre mesure (App.jsx), elle est sautée.
|
||||
// Lancé par `npm run build` après scripts/prerender.mjs.
|
||||
import { readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const dist = fileURLToPath(new URL('../dist', import.meta.url))
|
||||
const manifestPath = join(dist, '.vite', 'manifest.json')
|
||||
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'))
|
||||
const entry = manifest['src/pages.js']
|
||||
if (!entry?.file) throw new Error('src/pages.js absent du manifeste de Vite')
|
||||
const tag = `<script type="module" src="/${entry.file}"></script>`
|
||||
|
||||
async function* htmlFiles(dir) {
|
||||
for (const item of await readdir(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, item.name)
|
||||
if (item.isDirectory()) yield* htmlFiles(path)
|
||||
else if (item.name === 'index.html') yield path
|
||||
}
|
||||
}
|
||||
|
||||
let injected = 0
|
||||
for await (const path of htmlFiles(dist)) {
|
||||
const html = await readFile(path, 'utf8')
|
||||
if (html.includes('<div id="root">')) continue
|
||||
if (html.includes(tag)) continue
|
||||
if (!html.includes('</body>')) throw new Error(`</body> introuvable dans ${path}`)
|
||||
await writeFile(path, html.replace('</body>', ` ${tag}\n</body>`))
|
||||
injected++
|
||||
}
|
||||
if (injected === 0) throw new Error('Aucune page statique trouvée pour la mesure d\'audience')
|
||||
|
||||
// Le manifeste ne sert qu'au build, il n'a rien à faire en ligne
|
||||
await rm(join(dist, '.vite'), { recursive: true, force: true })
|
||||
console.log(`Mesure d'audience posée dans ${injected} pages statiques (${entry.file})`)
|
||||
@@ -0,0 +1,85 @@
|
||||
// Mesure d'audience des pages statiques (guides, aide, vidéo, vie privée), injectée au build
|
||||
// par scripts/inject-pages.mjs. Mêmes événements et mêmes propriétés que la page d'accueil (App.jsx),
|
||||
// et rien de plus que ce que décrit la page « Données et vie privée ».
|
||||
import { analytics } from './analytics.js'
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
// Provenance de l'arrivée (?source=newsletter…), sinon « direct », comme sur la page d'accueil
|
||||
const arrival = params.get('source') || 'direct'
|
||||
const lang = document.documentElement.lang?.startsWith('en') ? 'en' : 'fr'
|
||||
|
||||
const eventProps = (extra = {}) => ({
|
||||
source: arrival,
|
||||
referrer: document.referrer || 'none',
|
||||
viewport: window.innerWidth < 768 ? 'mobile' : 'desktop',
|
||||
lang,
|
||||
...extra,
|
||||
})
|
||||
|
||||
// Les liens vers l'app gardent la provenance de l'arrivée : un lecteur venu de ?source=newsletter
|
||||
// ouvre la démo en newsletter, pas en « video ». Sans provenance, le lien garde celle de la page.
|
||||
function forwardSource() {
|
||||
if (arrival === 'direct') return
|
||||
for (const link of document.querySelectorAll('a[href^="https://app.parenthese.io"]')) {
|
||||
const url = new URL(link.href)
|
||||
url.searchParams.set('source', arrival)
|
||||
link.href = url.toString()
|
||||
}
|
||||
}
|
||||
|
||||
// Un clic vers la création de compte : le même événement que la page d'accueil (funnel PostHog)
|
||||
function trackSignupClicks() {
|
||||
document.addEventListener('click', (event) => {
|
||||
const link = event.target.closest?.('a[href^="https://app.parenthese.io"]')
|
||||
if (!link || !link.href.includes('account=register')) return
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
|
||||
event.preventDefault()
|
||||
analytics.captureThenNavigate('signup_cta_clicked', eventProps({ placement: 'page' }), link.href)
|
||||
})
|
||||
}
|
||||
|
||||
function trackScroll() {
|
||||
const reached = { half: false, full: false }
|
||||
const onScroll = () => {
|
||||
const doc = document.documentElement
|
||||
const max = Math.max(0, doc.scrollHeight - window.innerHeight)
|
||||
const progress = max > 0 ? (window.scrollY || 0) / max : 1
|
||||
if (!reached.half && progress >= 0.5) {
|
||||
reached.half = true
|
||||
analytics.capture('scroll_depth_50', eventProps())
|
||||
}
|
||||
if (!reached.full && progress >= 0.99) {
|
||||
reached.full = true
|
||||
analytics.capture('scroll_depth_100', eventProps())
|
||||
window.removeEventListener('scroll', onScroll)
|
||||
}
|
||||
}
|
||||
window.addEventListener('scroll', onScroll, { passive: true })
|
||||
}
|
||||
|
||||
// La vidéo de présentation (/video/) : une lecture par visite, avec le format regardé
|
||||
function trackVideo() {
|
||||
let played = false
|
||||
for (const video of document.querySelectorAll('video')) {
|
||||
video.addEventListener('play', () => {
|
||||
if (played) return
|
||||
played = true
|
||||
analytics.capture('video_played', eventProps({ format: video.videoWidth > video.videoHeight ? '16x9' : '9x16' }))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
forwardSource()
|
||||
trackSignupClicks()
|
||||
// PostHog après le premier rendu, comme main.jsx : les événements d'ici là partent à l'init
|
||||
if ('requestIdleCallback' in window) window.requestIdleCallback(() => analytics.init(), { timeout: 3000 })
|
||||
else window.setTimeout(() => analytics.init(), 1500)
|
||||
analytics.capture('page_viewed', eventProps())
|
||||
trackScroll()
|
||||
trackVideo()
|
||||
}
|
||||
|
||||
// Une page préchargée (speculation rules) n'est comptée que si on l'ouvre vraiment
|
||||
if (document.prerendering) document.addEventListener('prerenderingchange', start, { once: true })
|
||||
else start()
|
||||
@@ -9,5 +9,11 @@ export default defineConfig({
|
||||
target: ['chrome107', 'edge107', 'firefox104', 'safari16'],
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
// Le manifeste donne le nom à empreinte de src/pages.js, que scripts/inject-pages.mjs pose
|
||||
// dans les pages statiques de public/ (guides, aide, vidéo), copiées sans passer par Vite
|
||||
manifest: true,
|
||||
rollupOptions: {
|
||||
input: { main: 'index.html', pages: 'src/pages.js' },
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user