10 Commits
Author SHA1 Message Date
IliesAllali 82ae5e289d Merge pull request #217 from IliesAllali/feat/onglets-nom-et-proches
Onglets de familles : les porteurs du nom dans leur famille, proches de l'autre côté réglables
2026-11-06 18:25:59 +01:00
IliesAllaliandClaude Opus 5.5 642b6020c3 onglets : les porteurs du nom dans leur famille, proches de l'autre côté réglables
Un enfant d'un membre qui porte le nom d'un onglet entre dans cet onglet (nom de naissance d'abord,
homonymes sans lien laissés dehors), avec son couple ; l'autre parent est estompé. Nouveau réglage
d'affichage : proches de l'autre côté à 0, 1, 2 ou 3 liens, 1 par défaut. Libellés des réglages
d'affichage réécrits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 18:12:21 +01:00
dependabot[bot] 09b2acf4fe build(deps): bump the frontend-minor-patch group (#216)
Bumps the frontend-minor-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [@lucide/icons](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide) | `1.61.0` | `1.66.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.61.0` | `1.66.0` |
| [posthog-js](https://github.com/PostHog/posthog-js) | `1.438.21` | `1.444.0` |
| [eslint](https://github.com/eslint/eslint) | `10.13.0` | `10.14.0` |
| [globals](https://github.com/sindresorhus/globals) | `17.13.0` | `17.14.0` |


Updates `@lucide/icons` from 1.61.0 to 1.66.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.66.0/packages/lucide)

Updates `lucide-react` from 1.61.0 to 1.66.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.66.0/packages/lucide-react)

Updates `posthog-js` from 1.438.21 to 1.444.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.21...posthog-js@1.444.0)

Updates `eslint` from 10.13.0 to 10.14.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Commits](https://github.com/eslint/eslint/compare/v10.13.0...v10.14.0)

Updates `globals` from 17.13.0 to 17.14.0
- [Release notes](https://github.com/sindresorhus/globals/releases)
- [Commits](https://github.com/sindresorhus/globals/compare/v17.13.0...v17.14.0)

---
updated-dependencies:
- dependency-name: "@lucide/icons"
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.66.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: posthog-js
  dependency-version: 1.444.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: eslint
  dependency-version: 10.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: globals
  dependency-version: 17.14.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 12:20:06 +01:00
dependabot[bot] 51924229da build(deps): bump the backend-minor-patch group (#215)
Bumps the backend-minor-patch group in /backend with 2 updates: [@fastify/cookie](https://github.com/fastify/fastify-cookie) and [@fastify/jwt](https://github.com/fastify/fastify-jwt).


Updates `@fastify/cookie` from 11.1.3 to 11.1.5
- [Release notes](https://github.com/fastify/fastify-cookie/releases)
- [Commits](https://github.com/fastify/fastify-cookie/compare/v11.1.3...v11.1.5)

Updates `@fastify/jwt` from 10.2.5 to 10.2.6
- [Release notes](https://github.com/fastify/fastify-jwt/releases)
- [Commits](https://github.com/fastify/fastify-jwt/compare/v10.2.5...v10.2.6)

---
updated-dependencies:
- dependency-name: "@fastify/cookie"
  dependency-version: 11.1.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
- dependency-name: "@fastify/jwt"
  dependency-version: 10.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: backend-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 12:19:58 +01:00
dependabot[bot] 64979ef66a build(deps): bump posthog-js (#214)
Bumps the landing-minor-patch group in /landing with 1 update: [posthog-js](https://github.com/PostHog/posthog-js).


Updates `posthog-js` from 1.438.21 to 1.444.0
- [Release notes](https://github.com/PostHog/posthog-js/releases)
- [Changelog](https://github.com/PostHog/posthog-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PostHog/posthog-js/compare/posthog-js@1.438.21...posthog-js@1.444.0)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.444.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: landing-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-06 12:19:32 +01:00
IliesAllaliandClaude Opus 5.5 ef620c9cb5 mesure : copie du lien de partage, page vie privée à jour
share_copied quand le propriétaire copie le lien, le mot de passe ou le message d'invitation
(what, seasonal pour le message de Noël), ou crée le lien d'un co-gestionnaire (manager_invite).
Rien de ce qui est copié ne part. La page vie privée (fr, en) décrit maintenant tout ce que l'app
mesure : démo, modifications comptées sans leur contenu, propositions, partage.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:32:10 +01:00
IliesAllaliandClaude Opus 5.5 332b5711ff mesure : proposal_sent pour toutes les propositions
L'événement ne partait que de la fenêtre d'envoi (App.jsx) : les raccourcis de la fiche passent par
handleSubmitContributionSession sans lui, d'où zéro proposal_sent en 60 jours pour deux propositions
relues. L'événement part maintenant de la fonction commune, après réponse du serveur, avec le statut
(pending, ou approved pour un co-gestionnaire).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:24:19 +01:00
IliesAllaliandClaude Opus 5.5 9bf55a16f3 landing : mesure d'audience des pages statiques, provenance gardée jusqu'à l'app
Les guides, l'aide, la vidéo et les pages vie privée (public/, copiés sans Vite) n'avaient aucune
mesure. src/pages.js, construit par Vite avec une empreinte et posé au build dans chaque page par
scripts/inject-pages.mjs, envoie les mêmes événements que la page d'accueil (page_viewed, scroll_depth_50
et 100, signup_cta_clicked) et video_played sur /video/. Les liens vers l'app gardent le ?source=
d'arrivée au lieu de la provenance fixe de la page. Page vie privée (fr, en) complétée.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:01:07 +01:00
IliesAllaliandClaude Opus 5.5 2c7e5361cc texte : l'invitation de Noël parle aussi de la voix
Le message d'invitation de décembre (SIGNUP_PROMPTS) propose d'enregistrer
une histoire de famille racontée à table, en plus des vieilles photos.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:09:16 +01:00
IliesAllaliandClaude Opus 5.5 98de13aaa0 deps(front): minimatch 11 pour eslint (alerte Dependabot #24)
minimatch < 11.0.1 (déni de service par amplification des globparts). Seul eslint
en dépend, outillage de dev ; Dependabot ne peut pas monter la version lui-même
(eslint demande ^10), d'où l'override. Lint, 359 tests, build, npm audit à 0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:29:36 +01:00
23 changed files with 425 additions and 133 deletions

No files matched your search

+8 -8
View File
@@ -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",
+2 -2
View File
@@ -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",
+50 -60
View File
@@ -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",
+8 -5
View File
@@ -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"
}
}
+6 -1
View File
@@ -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}
/>
+18 -4
View File
@@ -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>
+16 -6
View File
@@ -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>
+9 -3
View File
@@ -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 })
}
+85 -14
View File
@@ -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'])
})
})
+7
View File
@@ -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()
+11 -4
View File
@@ -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}',
+2 -2
View File
@@ -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}',
+1 -1
View File
@@ -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 }),
+19 -19
View File
@@ -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",
+2 -2
View File
@@ -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>
+1 -1
View File
@@ -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>
+37
View File
@@ -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})`)
+85
View 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()
+6
View File
@@ -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' },
},
},
})