46 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
IliesAllaliandClaude Opus 5.5 1558b0dc6b 1.6.0 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:23:49 +01:00
IliesAllali ce82aa5f3b Merge pull request #213 from IliesAllali/fix/photos-couple
Onglet Vie : les photos d'un mariage chez les deux conjoints
2026-11-04 17:23:21 +01:00
IliesAllaliandClaude Opus 5.5 43dda9c962 fix(vie): les photos d'un mariage se voient chez les deux conjoints (#209)
Le moment du couple arrivait bien chez les deux, mais ses souvenirs n'étaient cherchés
que parmi ceux de la fiche ouverte. L'onglet Vie et la fenêtre d'un moment du couple
lisent aussi les souvenirs du conjoint ; une photo du conjoint s'ouvre à son nom.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:41:16 +01:00
IliesAllaliandClaude Opus 5.5 69b08116cf i18n(en): Partner à la place de Spouse sur la fiche et l'arbre
Même mot que les liens de parenté depuis #204 : « spouse » suppose un mariage,
« conjoint » non.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:39:13 +01:00
IliesAllali 419fa4cda0 Merge pull request #204 from Arc676/cousins
Fix some English relations
2026-11-04 16:38:05 +01:00
IliesAllaliandClaude Opus 5.5 68ab7924f2 ci(images): la vraie version dans le label OCI (#207)
org.opencontainers.image.version vaut X.Y.Z sur un tag et la sortie de git describe
sur main (1.5.9-11-gd5f9da6), au lieu de « latest ».

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 16:33:25 +01:00
IliesAllali d5f9da6777 Merge pull request #212 from IliesAllali/feat/demo-creer-mon-arbre
feat(demo): « Créer mon arbre » en bouton noir de la barre
2026-11-03 16:54:03 +01:00
IliesAllaliandClaude Opus 5.5 0ef5d08fec feat(demo): « Créer mon arbre » en bouton noir de la barre, « Modifier » en clair
Dans la démo, l'inscription n'était que dans le menu du rond de profil et dans un
lien après une modification. Elle devient le bouton noir de la barre du bas ;
« Modifier » reste à côté en clair (crayon seul sur téléphone) et quitte le noir.
« Créer mon arbre » quitte le menu du rond pour ne pas apparaître deux fois.

Seulement sur l'instance qui invite à s'inscrire (SIGNUP_PROMPTS, inscription
ouverte), sans compte, et pas dans la démo intégrée à la landing (?embed).
Clic suivi : create_account_clicked, via=demo-barre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:48:17 +01:00
IliesAllali 7b7a589062 Merge pull request #211 from IliesAllali/fix/lien-source-demo
fix(entree): un lien de diffusion (?source=) sans session ouvre la démo
2026-11-03 16:34:02 +01:00
IliesAllaliandClaude Opus 5.5 0cf851475f fix(entree): un lien de diffusion (?source=) sans session ouvre la démo
Depuis l'accueil à deux choix (18/10), un visiteur sans session arrivé par
app.parenthese.io/?source=… tombait sur « Que voulez-vous faire ? » au lieu
de l'arbre d'exemple. Tous les liens de diffusion (presse, créateurs, groupes)
sont écrits sans ?demo, dont plusieurs déjà envoyés.

Sans session et avec ?source : la démo, comme avant. Sans paramètre : l'accueil
à deux choix reste. ?account=register et les liens de famille ne changent pas.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:26:02 +01:00
IliesAllaliandClaude Opus 5.5 db09620a58 Page vidéo : Parenthèse en 30 secondes (#210)
/video/ sur parenthese.io : la vidéo validée par Patch le 03/11, en 16:9 sur
ordinateur et 9:16 sur téléphone (la seconde ne se charge qu'au besoin), aperçu,
balises og:video et JSON-LD VideoObject, bouton vers l'arbre d'exemple
(source=video). Lien à donner dans les mails de presse et aux créateurs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 11:33:09 +01:00
IliesAllaliandClaude Opus 5.5 374fa35fbd Guide anglais « A family tree as a Christmas gift » (#208)
/en/family-tree-gift/, pendant du guide de Noël français (hreflang dans les deux sens,
lien English et Français dans le pied de page), lien dans le pied de page des huit pages
anglaises, plan du site. « family tree gift » : 590 recherches par mois aux États-Unis,
1 900 en décembre (DataForSEO, 02/11).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:17:19 +01:00
IliesAllaliandClaude Opus 5.5 bb6ad7d5d2 Guide « Arbre généalogique en cadeau de Noël » (#206)
Nouvelle page /arbre-genealogique-cadeau-noel/ sur le gabarit des guides (HowTo et FAQPage
en JSON-LD, français seul), lien dans le pied de page des huit pages françaises, plan du site.
Bouton avec source=guide-noel. En ligne avant mi-novembre pour être classée en décembre
(« arbre généalogique cadeau » x3,4 en décembre).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 01:38:42 +01:00
IliesAllaliandClaude Opus 5.5 7fee10478d kinship en : 4th cousin, great-uncle, sans virgule avant « removed »
- ordinal() : il manquait le $, un cousin au 4e degré s'affichait « {n}th cousin »
- Great-uncle et great-great-uncle, la forme courante, au lieu de granduncle
- « First cousin once removed », sans virgule
- Style du dépôt (guillemets simples, sans points-virgules)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:41:25 +01:00
IliesAllaliandClaude Opus 5.5 f990e1c5de Miniatures de portrait : le recadrage s'applique partout (#205)
Seuls l'arbre et la fiche appliquaient le cadrage enregistré du portrait. Les miniatures
(auteur et « Sur la photo » dans la visionneuse, recherche, liens de la fiche, ajout d'un
proche, compte, menu du compte, choix d'un souvenir, recherche ailleurs) affichaient la
photo brute. Un composant commun, PortraitThumb, place l'image avec imagePlacement,
comme la fiche. Signalé par Phyll sur Discord (29/10).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:51:53 +01:00
IliesAllaliandClaude Opus 5.5 1565552141 Plusieurs prénoms dans le champ Prénom : « Paul, Marie, Jean » crée trois enfants (#203)
Pour un enfant ou un frère, une virgule sépare les prénoms : une personne par prénom, mêmes liens,
nom et dates pour chacune (vides et doublons ignorés, 20 au plus). Parent et conjoint gardent un seul
prénom. Vaut pour le propriétaire, la famille (brouillons) et la démo ; la bulle dit « Paul, Marie et
Jean … sont sur l'arbre ». Sur téléphone, aucune fiche ne s'ouvre et la caméra cadre toute la fratrie
(focusPersons dans Galaxy, qui attend la remise en place de l'arbre). person_added porte batch.

Le corps de l'ajout passe dans addOne : relire avec git diff -w.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:20:59 +01:00
Arc676/Alessandro Vinciguerra 568db606da kinship: implement g-rule
Allow arbitrary depth

Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 12:06:21 +01:00
Arc676/Alessandro Vinciguerra 24e7ea1994 kinship: use nibling
Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 11:27:46 +01:00
Arc676/Alessandro Vinciguerra e0f7eb4913 kinship: compute cousin type by degree and removal
Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 10:40:54 +01:00
Arc676/Alessandro Vinciguerra e2468f6d39 kinship: use partner in english
Signed-off-by: Arc676/Alessandro Vinciguerra <alesvinciguerra@gmail.com>
2026-11-01 10:40:10 +01:00
IliesAllaliandClaude Opus 5.5 e596887a44 Invitations à créer son arbre, coupées en auto-hébergement (SIGNUP_PROMPTS) (#202)
- La famille sans compte trouve « Faire le mien » en bas du menu Ajouter ; la ligne quitte le menu du
  compte. Après l'inscription, l'arbre de la famille est rattaché et l'assistant de création s'ouvre.
- Démo : les messages des liens et la bulle après « Enregistrer et quitter » proposent l'inscription
  (lien cliquable, message gardé 6 s). La démo lit aussi les options de l'instance.
- Message d'invitation de Noël du 1er décembre au 6 janvier.
- SIGNUP_PROMPTS (API, /auth/options.signupPrompts) : éteint par défaut, allumé pour l'instance hébergée
  dans ecosystem.config.cjs, toujours coupé quand l'inscription est fermée. README, .env.example, OpenAPI.
- Mesures : via sur create_account_clicked et account_screen_viewed, source arbre-partage ou demo
  quand l'adresse n'en porte pas.
- « Quitter l'édition ? » dans la démo ne dit plus que les modifications restent sur l'appareil :
  la démo n'enregistre rien et tout disparaît au rechargement.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:17:59 +01:00
IliesAllaliandClaude Opus 5.5 473414744d Aperçu des liens partagés : balises Open Graph fixes pour l'app (#201)
Un lien d'arbre ou de démo collé dans WhatsApp ou Messenger s'affichait sans
image, avec pour seul texte la description destinée aux moteurs. L'app sert
désormais les mêmes balises pour toutes ses adresses : un titre, une phrase et
l'image de la démo (copie de celle de la landing). Rien ne vient de l'arbre
partagé, aucun nom de famille ne part chez les robots d'aperçu.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:06:41 +01:00
IliesAllaliandClaude Opus 5.5 0269b97efd 1.5.9 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:12:39 +01:00
IliesAllaliandClaude Opus 5.5 cd6dfc6955 Enfant ajouté à une personne qui n'a qu'un conjoint : enfant du couple (#200)
Avant, « + Enfant » sur la fiche d'Isabelle (un seul conjoint, Robert) rattachait l'enfant à Isabelle seule :
il pendait sous elle et un parent « Inconnu », loin de ses frères et sœurs. Il passe maintenant par les liens
du couple (getCoupleParentLinks), comme un frère ou une sœur, pour la démo, la famille qui propose et le
propriétaire. Sans conjoint, avec plusieurs conjoints, ou en adoption : l'enfant reste au seul parent choisi.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:44:23 +01:00
IliesAllaliandClaude Opus 5.5 f595af581a Téléphone : plus de réglette de zoom, la loupe seule reste (on zoome à deux doigts) (#199)
La notification n'a plus à se décaler pour l'éviter.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 14:35:38 +01:00
IliesAllaliandClaude Opus 5.5 b803f6291d Fiche : « – 1995 » ne se coupe plus après le tiret ; bouton loupe nommé « Zoomer »
Une espace insécable seule ne suffit pas : Chrome coupe après un tiret demi-cadratin. Le gluon U+2060
devant l'espace insécable garde « – 1995 » d'un bloc (vu sur la fiche de Robert, démo, 1440 px).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:43:45 +01:00
IliesAllaliandClaude Opus 5.5 1b13b093ba 1.5.8 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:08:57 +01:00
IliesAllaliandClaude Opus 5.5 c1734501ae Onglets de familles : noms, repère et liens entre onglets (#192)
- Nom d'un onglet : le nom le plus porté par ses membres (naissance, sinon usage), et non plus celui
  de la seule personne de référence. Deux onglets
  au même nom : le plus petit prend le nom suivant le plus porté chez lui, les prénoms de la charnière
  en dernier recours.
- Changer d'onglet garde la personne charnière à sa place et à son zoom : seules les familles autour changent.
- Survol d'un onglet : la charnière s'allume dans l'arbre affiché (halo de la recherche), sans texte.
- Barre rangée comme l'arbre (chaque famille après celle par laquelle on y entre), groupée par branche
  (10 px dans une branche, 16 px entre branches), trait d'union entre deux onglets dont le second part
  du premier, terracotta sur le chemin de l'onglet ouvert.
- Pastille de retour : même place que celle de l'aller, flèche retournée (« ‹ Famille X · n »).
- Nombre d'un onglet : « n personnes » au survol.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:08:25 +01:00
b444c1ad39 build(deps): bump @node-rs/bcrypt from 1.10.7 to 2.0.0 in /backend (#197)
* build(deps): bump @node-rs/bcrypt from 1.10.7 to 2.0.0 in /backend

Bumps [@node-rs/bcrypt](https://github.com/napi-rs/node-rs) from 1.10.7 to 2.0.0.
- [Release notes](https://github.com/napi-rs/node-rs/releases)
- [Commits](https://github.com/napi-rs/node-rs/compare/@node-rs/bcrypt@1.10.7...@node-rs/bcrypt@2.0.0)

---
updated-dependencies:
- dependency-name: "@node-rs/bcrypt"
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>

* bcrypt 2 : le coût passe en options (hash(value, { cost }))

Les empreintes déjà enregistrées restent valables : une empreinte faite en 1.10.7 se vérifie en 2.0.
Les nouvelles sortent en $2b$ au lieu de $2y$, même coût.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: IliesAllali <pro.allali.ilies@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:28:22 +01:00
dependabot[bot] 04dc7f0e88 build(deps): bump the landing-minor-patch group (#196)
Bumps the landing-minor-patch group in /landing with 2 updates: [posthog-js](https://github.com/PostHog/posthog-js) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


Updates `posthog-js` from 1.438.12 to 1.438.21
- [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.12...posthog-js@1.438.21)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: posthog-js
  dependency-version: 1.438.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: landing-minor-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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-10-29 12:24:13 +01:00
dependabot[bot] 1c78c043bd build(deps): bump the frontend-minor-patch group (#195)
Bumps the frontend-minor-patch group in /frontend with 4 updates: [@lucide/icons](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide), [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react), [posthog-js](https://github.com/PostHog/posthog-js) and [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite).


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

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

Updates `posthog-js` from 1.438.12 to 1.438.21
- [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.12...posthog-js@1.438.21)

Updates `vite` from 8.3.4 to 8.3.5
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.5/packages/vite)

---
updated-dependencies:
- dependency-name: "@lucide/icons"
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: lucide-react
  dependency-version: 1.61.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-minor-patch
- dependency-name: posthog-js
  dependency-version: 1.438.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-minor-patch
- dependency-name: vite
  dependency-version: 8.3.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  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-10-29 12:24:08 +01:00
dependabot[bot] cd93a470b8 build(deps): bump dotenv in /backend in the backend-minor-patch group (#194)
Bumps the backend-minor-patch group in /backend with 1 update: [dotenv](https://github.com/motdotla/dotenv).


Updates `dotenv` from 18.0.8 to 18.0.9
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v18.0.8...v18.0.9)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 18.0.9
  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-10-29 12:24:04 +01:00
dependabot[bot] b39d1f68e8 build(deps): bump docker/login-action (#193)
Bumps the actions-minor-patch group with 1 update: [docker/login-action](https://github.com/docker/login-action).


Updates `docker/login-action` from 4.6.0 to 4.7.0
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/dbcb813823bdd20940b903addbd779551569679f...81c6f93d4bc991e510b342a00b419b3bb4efc5c6)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-version: 4.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: actions-minor-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-29 12:23:54 +01:00
IliesAllaliandClaude Opus 5.5 d013c8b8fc Âge : l'anniversaire compte une fois passé (#198)
L'âge d'une personne vivante était l'année en cours moins l'année de naissance : né en décembre,
on prenait un an dès janvier. Même défaut pour l'âge au décès et les âges de l'onglet Vie.
Quand les deux dates sont complètes, l'âge se compte au jour près (utils/age.js) ; une année seule
garde la différence des années.

Fixes #198

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:23:45 +01:00
IliesAllaliandClaude Opus 5.5 3dfc8625e9 README : OIDC, seule la mise en place réelle avec Pocket ID est citée (#153)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:40:39 +01:00
IliesAllaliandClaude Opus 5.5 1a65f28b21 README : OIDC n'est plus expérimental, le conteneur api doit joindre le fournisseur (#153)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 10:22:23 +01:00
91 changed files with 2300 additions and 698 deletions

No files matched your search

+4
View File
@@ -40,6 +40,10 @@ CORS_ORIGIN=http://localhost
# de passe. Une instance sans aucun compte accepte quand même le premier (le vôtre) :
# créez-le juste après le premier démarrage, avant de donner l'adresse.
# ALLOW_REGISTRATION=true
# À true, l'app invite à créer son propre arbre : « Faire le mien » dans le menu Ajouter de la
# famille, l'inscription proposée dans les messages de la démo, un message d'invitation pour Noël
# (du 1er décembre au 6 janvier). Éteint par défaut, allumé sur app.parenthese.io.
# SIGNUP_PROMPTS=false
# ── Chercher quelqu'un dans des sources publiques ────────────────────────────
# Sources que le serveur interroge pour le panneau « Quelqu'un ailleurs » (menu +) :
+10 -4
View File
@@ -63,16 +63,21 @@ jobs:
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
persist-credentials: false
# Tout l'historique et les tags, pour lire la version (git describe)
fetch-depth: 0
- name: Short commit
# Version lisible dans le label OCI (#207) : 1.5.9 sur un tag, 1.5.9-11-gd5f9da6 sur main après la 1.5.9
- name: Short commit and version
id: commit
run: echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
run: |
echo "sha=$(git rev-parse --short=7 HEAD)" >> "$GITHUB_OUTPUT"
echo "version=$(git describe --tags --match 'v*' | sed 's/^v//')" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0
- uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -89,6 +94,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'workflow_run' }}
type=raw,value=sha-${{ steps.commit.outputs.sha }}
labels: |
org.opencontainers.image.version=${{ steps.commit.outputs.version }}
org.opencontainers.image.revision=${{ github.event.workflow_run.head_sha || github.sha }}
org.opencontainers.image.licenses=PolyForm-Noncommercial-1.0.0
@@ -120,7 +126,7 @@ jobs:
persist-credentials: false
# Tant que les paquets ne sont pas publics, le pull demande une connexion.
- uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
- uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
+63
View File
@@ -2,6 +2,69 @@
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
## 1.6.0 (2026-11-04)
### Adding people
- Several first names in the First name field, separated by commas, add several children or siblings at once. "Paul, Marie, Jean" creates three people with the same links, last name and dates. Empty names and duplicates are skipped, up to 20 names. For a parent or a partner the field still takes one name. On a phone no profile opens after a group add, and the view frames the whole family (#203).
### Life tab
- Photos of a marriage show for both partners. The marriage was already in both Life tabs, but its photos only showed on the profile they were added from. A partner's photo opens in their name (#209).
### Words
- English relationship names go as far as the tree does. Great-great-grandfather, great-great-uncle, grandnephew, second cousin, first cousin twice removed, 4th cousin. "Partner" replaces "Spouse" everywhere, on the profile and the tree too, and "Nibling" is the neutral form of nephew and niece. Thanks to Arc676 (#204).
### Photos
- Portrait thumbnails follow the saved crop everywhere, not only on the tree and the profile. That covers the viewer, "In this photo", search, the profile links, adding a relative, the account and memory picking (#205).
### Shared links
- A link to the app pasted in WhatsApp, Messenger or iMessage shows a title, a sentence and an image. They are the same for every address, so no name or photo from a tree reaches the preview bots (#201).
- A link with `?source=` and no session opens the demo again, not the welcome screen (#211).
### Hosted instance
- New `SIGNUP_PROMPTS` setting for the API, off by default. When it's on and registration is open, the app invites people to make their own tree in places that already exist: the Add menu of a shared tree, the demo messages, a "Create my tree" button in the demo bar, and a Christmas wording in the family invitation from December 1 to January 6. A self-hosted instance sees none of this unless it turns it on (#202, #212).
- In the demo, the dialog that closes editing no longer says changes stay on this device. The demo saves nothing and the sentence now says so (#202).
### Docker
- `org.opencontainers.image.version` carries the real version: `1.6.0` on a release tag, and for example `1.5.9-11-gd5f9da6` on `latest` between releases. Container watchers like What's Up Docker can show it instead of a digest (#207).
## 1.5.9 (2026-10-29)
### Tree
- A child added to someone who has a single partner becomes the couple's child, next to their brothers and sisters, instead of hanging under one parent and an "Unknown" one. With no partner, several partners, or an adoption, the child stays with the parent chosen (#200).
### Phone
- No more zoom slider: it opened by itself and covered a corner of the tree. Pinch to zoom, the magnifier zooms in one step. On a computer the slider still shows on hover (#199).
### Profile
- "– 1995" no longer breaks after the dash at the end of a line. The magnifier button is labelled "Zoom in" for screen readers.
## 1.5.8 (2026-10-29)
### Family tabs
- A tab is named after the surname most of its members carry (birth name, otherwise current name), not after the person it starts from. When two tabs would share a name, the smaller one takes its next most common surname (#192).
- Switching tabs keeps the person who joins the two families at the same place and zoom, only the families around change. Hovering a tab lights that person up in the tree (#192).
- The tab bar follows the shape of the tree, grouped by branch, with a line between a tab and the one it comes from. The way-back badge sits where the way-in badge was (#192).
### Profile
- The age counts the birthday: with a full birth date, a person born in December is not a year older from January. Same for the age at death and the ages in the Life tab. With only a year, the age stays the difference between the years (#198).
### Dependencies
- `@node-rs/bcrypt` 2.0: the cost is passed as an option. Stored passwords still verify, new hashes are `$2b$` with the same cost (#197).
- Minor updates for the frontend, the landing, the backend and the CI (#193, #194, #195, #196).
## 1.5.7 (2026-10-28)
### Phone
+2
View File
@@ -59,6 +59,8 @@ Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HT
Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse.
Votre instance ne pousse personne à y créer un compte. Sur app.parenthese.io, l'instance hébergée, l'application invite à créer son propre arbre : la famille qui ouvre un arbre partagé trouve « Faire le mien » dans le menu Ajouter, les messages de la démo proposent l'inscription, et du 1er décembre au 6 janvier le message d'invitation parle de Noël. Ces invitations sont éteintes par défaut. Pour les allumer, mettez `SIGNUP_PROMPTS=true` dans `.env` (elles restent coupées si l'inscription est fermée).
#### Chercher quelqu'un dans des sources publiques
Le menu « + » propose « Quelqu'un ailleurs » : un panneau à droite de l'arbre où l'on tape un nom pour voir ce que les sources publiques disent de cette personne. Les liens vers des sites sans recherche interrogeable d'ici (presse ancienne sur Gallica, registres matricules sur FranceArchives, Geneanet, FamilySearch et quelques autres, la France d'abord) marchent toujours : votre navigateur ouvre le site avec le nom déjà tapé, et rien ne passe par le serveur.
+5 -1
View File
@@ -65,6 +65,8 @@ To open the instance to your family over the Internet, put an HTTPS reverse prox
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
Your instance doesn't push anyone to create an account. On app.parenthese.io, the hosted instance, the app suggests starting your own tree: relatives who open a shared tree find « Faire le mien » ("Make my own") in the Add menu, the demo's messages offer to sign up, and from December 1 to January 6 the invitation message mentions Christmas. These prompts are off by default. To turn them on, set `SIGNUP_PROMPTS=true` in `.env` (they stay off while registration is closed).
#### Search for someone in public records
The "+" menu offers "Someone elsewhere": a panel on the right of the tree where you type a name and see what public records hold about that person. Links to sites without a search we can query from here (old newspapers on Gallica, military registers on FranceArchives, Geneanet, FamilySearch and a few others, France first) always work: your browser opens the site with the name already typed in, and nothing goes through the server.
@@ -83,7 +85,7 @@ Only the first name, last name and birth year leave the server, and the panel sa
#### Sign in with your identity provider (OIDC)
*Experimental in 1.2.0: tested against a certified OpenID provider, feedback from real setups is welcome in the issues.*
*Used with Pocket ID on a real setup (#153).*
Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`:
@@ -95,6 +97,8 @@ OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback
OIDC_PROVIDER_NAME=Authentik # shown on the button
```
The `api` container talks to the provider itself, to read its configuration and exchange the sign-in code. If your Docker network is marked `internal`, give `api` a network that can reach the provider, not just `web`.
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. Accounts that already have a password are not linked to the provider by email: before switching, each owner opens their account and clicks « Se connecter aussi avec … » ("Also sign in with …"), then signs in with the provider once. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
+3
View File
@@ -14,6 +14,9 @@ IMMICH_ALLOWED_API_URLS=""
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
# compte accepte quand même le premier, celui du propriétaire.
ALLOW_REGISTRATION=true
# Invitations à créer son propre arbre (famille, démo, message de Noël). Éteintes par défaut, allumées sur
# l'instance hébergée.
SIGNUP_PROMPTS=false
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
# les mots de passe Parenthèse.
OIDC_ISSUER=""
+4 -1
View File
@@ -563,7 +563,10 @@ paths:
properties:
name:
type: string
required: [registration, passwordLogin, oidc]
signupPrompts:
type: boolean
description: true when SIGNUP_PROMPTS=true and accounts can be created. The app then suggests starting one's own tree to relatives and demo visitors
required: [registration, passwordLogin, oidc, signupPrompts]
/auth/oidc/start:
get:
+80 -128
View File
@@ -8,15 +8,15 @@
"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": "^1.10.7",
"@node-rs/bcrypt": "^2.0.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"blurhash": "^2.0.5",
"dotenv": "^18.0.8",
"dotenv": "^18.0.9",
"fastify": "^5.12.7",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
@@ -62,17 +62,6 @@
"@electric-sql/pglite": "0.4.3"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
@@ -83,16 +72,6 @@
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
@@ -557,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",
@@ -661,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",
@@ -1281,22 +1260,10 @@
"node": ">=8"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.4.3",
"@emnapi/runtime": "^1.4.3",
"@tybys/wasm-util": "^0.10.0"
}
},
"node_modules/@node-rs/bcrypt": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.10.7.tgz",
"integrity": "sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-2.0.0.tgz",
"integrity": "sha512-perB+8PsvBsJeUJ0pE2GgOFAZZ3IXSfKhQ3Pkr+y/L5yZm6W727659g+ZtHmlhIi9obs3y2euUMjVqRB/DVlvA==",
"license": "MIT",
"engines": {
"node": ">= 10"
@@ -1306,26 +1273,25 @@
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@node-rs/bcrypt-android-arm-eabi": "1.10.7",
"@node-rs/bcrypt-android-arm64": "1.10.7",
"@node-rs/bcrypt-darwin-arm64": "1.10.7",
"@node-rs/bcrypt-darwin-x64": "1.10.7",
"@node-rs/bcrypt-freebsd-x64": "1.10.7",
"@node-rs/bcrypt-linux-arm-gnueabihf": "1.10.7",
"@node-rs/bcrypt-linux-arm64-gnu": "1.10.7",
"@node-rs/bcrypt-linux-arm64-musl": "1.10.7",
"@node-rs/bcrypt-linux-x64-gnu": "1.10.7",
"@node-rs/bcrypt-linux-x64-musl": "1.10.7",
"@node-rs/bcrypt-wasm32-wasi": "1.10.7",
"@node-rs/bcrypt-win32-arm64-msvc": "1.10.7",
"@node-rs/bcrypt-win32-ia32-msvc": "1.10.7",
"@node-rs/bcrypt-win32-x64-msvc": "1.10.7"
"@node-rs/bcrypt-android-arm-eabi": "2.0.0",
"@node-rs/bcrypt-android-arm64": "2.0.0",
"@node-rs/bcrypt-darwin-arm64": "2.0.0",
"@node-rs/bcrypt-darwin-x64": "2.0.0",
"@node-rs/bcrypt-freebsd-x64": "2.0.0",
"@node-rs/bcrypt-linux-arm-gnueabihf": "2.0.0",
"@node-rs/bcrypt-linux-arm64-gnu": "2.0.0",
"@node-rs/bcrypt-linux-arm64-musl": "2.0.0",
"@node-rs/bcrypt-linux-x64-gnu": "2.0.0",
"@node-rs/bcrypt-linux-x64-musl": "2.0.0",
"@node-rs/bcrypt-win32-arm64-msvc": "2.0.0",
"@node-rs/bcrypt-win32-ia32-msvc": "2.0.0",
"@node-rs/bcrypt-win32-x64-msvc": "2.0.0"
}
},
"node_modules/@node-rs/bcrypt-android-arm-eabi": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.10.7.tgz",
"integrity": "sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-2.0.0.tgz",
"integrity": "sha512-BGxkS6uXxDphPoMWevOVCXd3A07x45o1p4uYoXTjcvG7K+6iZXaXzOL5ZrGMUP71BAJ6b89/QStjuhGlMmuxOQ==",
"cpu": [
"arm"
],
@@ -1339,9 +1305,9 @@
}
},
"node_modules/@node-rs/bcrypt-android-arm64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.10.7.tgz",
"integrity": "sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-2.0.0.tgz",
"integrity": "sha512-aKS02ZDZ26mLM5resokCCXHFhmlixytN0G7GbR1Xm3kq76mL9munu7vS0xk/5OszEls69o7oHH+NDe8xWyolnQ==",
"cpu": [
"arm64"
],
@@ -1355,9 +1321,9 @@
}
},
"node_modules/@node-rs/bcrypt-darwin-arm64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.10.7.tgz",
"integrity": "sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-2.0.0.tgz",
"integrity": "sha512-gHKuT+80v2jAeJujfmJPbbDt5zzE/ne5x/eoG52Twyfx/1bCWAN+cZoDEnzuD0Yduw484bDFtgq5PKXBPV2PYw==",
"cpu": [
"arm64"
],
@@ -1371,9 +1337,9 @@
}
},
"node_modules/@node-rs/bcrypt-darwin-x64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.10.7.tgz",
"integrity": "sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-2.0.0.tgz",
"integrity": "sha512-v8xBU+AZshfQ5fDNUvUDQsmc1mYGASnHXD2yVxjuDvKu/gaEHEz5gHtTFqzkNR9NPdWx3YxJ1UiW8fwSuN4XCQ==",
"cpu": [
"x64"
],
@@ -1387,9 +1353,9 @@
}
},
"node_modules/@node-rs/bcrypt-freebsd-x64": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.10.7.tgz",
"integrity": "sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-2.0.0.tgz",
"integrity": "sha512-XSFyizSOH0CWw+60XL4xaeOIRn5F9bgd919EvaIXQcjV7mWuhWbtGc2Z1eOcI8o7Yzh6LSkcIbZJ4fT094S7ww==",
"cpu": [
"x64"
],
@@ -1403,9 +1369,9 @@
}
},
"node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.10.7.tgz",
"integrity": "sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-2.0.0.tgz",
"integrity": "sha512-YYXkdl4rQuTayFB7MeQva8/H5AVeaaIkzuTNtD0RDEjBSQCKYMalM23pP673ScCvBAWLuiYHtviMITdubiER6Q==",
"cpu": [
"arm"
],
@@ -1419,12 +1385,15 @@
}
},
"node_modules/@node-rs/bcrypt-linux-arm64-gnu": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.10.7.tgz",
"integrity": "sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-2.0.0.tgz",
"integrity": "sha512-jLbuJ/MVa4xvFV5lmd71xx6DOfkQvpj4zR0X0AuOopofP0z6SmyA/zKQOrVrfDUCbBwAHoi22VD9umfU8HsrGA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1435,12 +1404,15 @@
}
},
"node_modules/@node-rs/bcrypt-linux-arm64-musl": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.10.7.tgz",
"integrity": "sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-2.0.0.tgz",
"integrity": "sha512-31ORIUbGuBPGyqvZMppQc99l7hdKZrhDxtfsqFcWyeQ5ie4iR7QHv7FGXj+uTLQtJDFVBQQFpVIXTL4jNW7OPQ==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1451,12 +1423,15 @@
}
},
"node_modules/@node-rs/bcrypt-linux-x64-gnu": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.10.7.tgz",
"integrity": "sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-2.0.0.tgz",
"integrity": "sha512-Kyo2iGkvtGDcfPcqjRQXS1DYDucpAMJbIw0aPmdiKFLAt8tdOeafK2+zfwuWq8/BDs/n9jKV2ajlda53AqFHMA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1467,12 +1442,15 @@
}
},
"node_modules/@node-rs/bcrypt-linux-x64-musl": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.10.7.tgz",
"integrity": "sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-2.0.0.tgz",
"integrity": "sha512-NkQN7yL4yRQOPpK80SAgHDlYE352QJmnSP3oKp1SXHS/G92b1IwjGlTB2+9ZKIGEDdqxnyI3dRzP0P/Sabv9zg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1482,26 +1460,10 @@
"node": ">= 10"
}
},
"node_modules/@node-rs/bcrypt-wasm32-wasi": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.10.7.tgz",
"integrity": "sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==",
"cpu": [
"wasm32"
],
"license": "MIT",
"optional": true,
"dependencies": {
"@napi-rs/wasm-runtime": "^0.2.5"
},
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@node-rs/bcrypt-win32-arm64-msvc": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.10.7.tgz",
"integrity": "sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-2.0.0.tgz",
"integrity": "sha512-7BzjgPzfE9GktBafMaOTQR5klfcQ4SnzLS5dPKKdXEJ/rHilKbqm8EBszhGimlBMkutIBd53OE7MAqFVhtgIZw==",
"cpu": [
"arm64"
],
@@ -1515,9 +1477,9 @@
}
},
"node_modules/@node-rs/bcrypt-win32-ia32-msvc": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.10.7.tgz",
"integrity": "sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-2.0.0.tgz",
"integrity": "sha512-nbbwy8LOd4NxSJ2HrLTrXq5zNy0B6+LtX9wberLp9+DOQZVBfZF7PHlH4KlXi9aHrOpZHoXZNGIhWANtOpJlNA==",
"cpu": [
"ia32"
],
@@ -1531,9 +1493,9 @@
}
},
"node_modules/@node-rs/bcrypt-win32-x64-msvc": {
"version": "1.10.7",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.10.7.tgz",
"integrity": "sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==",
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-2.0.0.tgz",
"integrity": "sha512-9KdovFbXWVUhhdsUjtMR8WLw7+U4dCFMyYFRogmdpe+a4SMy8IcN8rv3azxmGYgtoH5wW63BQ0COKZq6C7O96g==",
"cpu": [
"x64"
],
@@ -2234,16 +2196,6 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.4",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -3364,9 +3316,9 @@
}
},
"node_modules/dotenv": {
"version": "18.0.8",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
"version": "18.0.9",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz",
"integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==",
"license": "BSD-2-Clause",
"bin": {
"dotenv": "dist/index.cjs"
+4 -4
View File
@@ -17,15 +17,15 @@
"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": "^1.10.7",
"@node-rs/bcrypt": "^2.0.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"blurhash": "^2.0.5",
"dotenv": "^18.0.8",
"dotenv": "^18.0.9",
"fastify": "^5.12.7",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
+5
View File
@@ -68,6 +68,11 @@ const envSchema = z.object({
// Internet ne laisse pas un inconnu qui trouve l'adresse créer ses arbres. Une instance sans aucun compte
// accepte quand même le premier, celui du propriétaire (voir lib/registration.ts).
ALLOW_REGISTRATION: z.string().default('true').transform(parseBooleanFlag('ALLOW_REGISTRATION')),
// Invitations à créer son propre arbre, ajoutées pour l'instance hébergée app.parenthese.io : « Faire le mien »
// dans le menu Ajouter de la famille, l'inscription proposée dans les messages de la démo, message d'invitation
// de Noël. Éteintes par défaut : une instance auto-hébergée n'a pas à pousser vers la création de compte.
// L'instance hébergée les allume dans ecosystem.config.cjs.
SIGNUP_PROMPTS: z.string().default('false').transform(parseBooleanFlag('SIGNUP_PROMPTS')),
// Connexion par un fournisseur d'identité OIDC (#115 ; Authentik, Authelia, Keycloak, Pocket ID…). Éteinte sans
// OIDC_ISSUER. Le fournisseur renvoie vers OIDC_REDIRECT_URI, l'adresse publique de /auth/oidc/callback.
OIDC_ISSUER: z.string().trim().default(''),
+1 -1
View File
@@ -8,7 +8,7 @@ import { env } from '../config/env.js'
const HASH_ROUNDS = 12
export async function hashPassword(value: string): Promise<string> {
return hash(value, HASH_ROUNDS)
return hash(value, { cost: HASH_ROUNDS })
}
export async function verifyPassword(value: string, storedHash: string): Promise<boolean> {
+10 -5
View File
@@ -109,11 +109,16 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
app.get(
'/options',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (_request, reply) => reply.send({
registration: await isRegistrationOpen(app.prisma),
passwordLogin: env.PASSWORD_LOGIN,
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
}),
async (_request, reply) => {
const registration = await isRegistrationOpen(app.prisma)
return reply.send({
registration,
passwordLogin: env.PASSWORD_LOGIN,
oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null,
// Rien à proposer quand l'instance ne crée plus de compte
signupPrompts: env.SIGNUP_PROMPTS && registration && env.PASSWORD_LOGIN,
})
},
)
app.post(
+1 -1
View File
@@ -114,7 +114,7 @@ function fragmentOf(location: string | undefined) {
describe('départ et retour', () => {
it("annonce le fournisseur à l'écran de connexion", async () => {
const res = await app.inject({ method: 'GET', url: '/auth/options' })
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' } })
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' }, signupPrompts: false })
})
it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => {
+22 -2
View File
@@ -27,7 +27,7 @@ vi.mock('../src/lib/prisma.js', () => ({
}))
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean }
let env: { ALLOW_REGISTRATION: boolean, SIGNUP_PROMPTS: boolean }
const newAccount = { firstName: 'Camille', email: 'camille@example.com', password: 'securepass123' }
@@ -50,6 +50,7 @@ afterAll(async () => {
beforeEach(() => {
vi.clearAllMocks()
env.ALLOW_REGISTRATION = false
env.SIGNUP_PROMPTS = false
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.count.mockResolvedValue(3)
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: newAccount.email, firstName: 'Camille' })
@@ -102,7 +103,7 @@ describe('inscription fermée', () => {
const res = await app.inject({ method: 'GET', url: '/auth/options' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null })
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null, signupPrompts: false })
})
it('instance neuve : le premier compte se crée, sous verrou', async () => {
@@ -145,3 +146,22 @@ describe('inscription ouverte (par défaut)', () => {
expect(prismaMock.user.count).not.toHaveBeenCalled()
})
})
// Invitations à créer son arbre (SIGNUP_PROMPTS) : éteintes par défaut, jamais annoncées quand l'instance ne crée plus de compte
describe('invitations à créer son arbre', () => {
it('éteintes par défaut', async () => {
env.ALLOW_REGISTRATION = true
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false)
})
it('allumées avec SIGNUP_PROMPTS et une inscription ouverte', async () => {
env.ALLOW_REGISTRATION = true
env.SIGNUP_PROMPTS = true
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(true)
})
it("coupées quand l'inscription est fermée, même allumées", async () => {
env.SIGNUP_PROMPTS = true
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false)
})
})
+3
View File
@@ -12,6 +12,9 @@ module.exports = {
PORT: 4000,
// nginx relaie vers 127.0.0.1:4000 (deploy/nginx.conf) : l'API n'écoute pas sur les interfaces publiques
HOST: '127.0.0.1',
// Instance hébergée app.parenthese.io : invite la famille et la démo à créer leur arbre (SIGNUP_PROMPTS,
// éteint par défaut en auto-hébergement)
SIGNUP_PROMPTS: 'true',
},
max_memory_restart: '512M',
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
+12
View File
@@ -8,6 +8,18 @@
<meta name="description" content="Parenthèse, l'application. Les arbres partagés sont privés : la vitrine publique est sur parenthese.io." />
<!-- Arbres familiaux privés (noms, dates) : l'app ne doit pas être indexée, la vitrine est parenthese.io -->
<meta name="robots" content="noindex, nofollow" />
<!-- Aperçu d'un lien collé dans WhatsApp, Messenger, iMessage… : le même pour toutes les adresses de l'app.
Rien ne vient de l'arbre partagé (ni nom de famille, ni photo) : les robots d'aperçu ne reçoivent que ce texte et l'image de la démo. -->
<meta property="og:type" content="website" />
<meta property="og:site_name" content="Parenthèse" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:title" content="Un arbre de famille à parcourir" />
<meta property="og:description" content="Photos, voix et souvenirs de la famille, réunis sur un arbre. S'ouvre depuis ce lien, sans compte." />
<meta property="og:image" content="https://app.parenthese.io/og-image.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:image:alt" content="Un arbre de famille avec des portraits et des souvenirs autour de chaque personne" />
<meta name="twitter:card" content="summary_large_image" />
</head>
<body>
<div id="root"></div>
+191 -116
View File
@@ -8,10 +8,10 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"@lucide/icons": "^1.59.0",
"@lucide/icons": "^1.66.0",
"blurhash": "^2.0.5",
"lucide-react": "^1.59.0",
"posthog-js": "^1.438.12",
"lucide-react": "^1.66.0",
"posthog-js": "^1.444.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
@@ -21,12 +21,12 @@
"@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.4",
"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,15 +561,15 @@
"license": "MIT"
},
"node_modules/@lucide/icons": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.59.0.tgz",
"integrity": "sha512-olGAqAKEtttGTAyjz+pmKEppYLu7X0jUtpLwt6JBvl9TMZaHnG7q6ECWVXh7rvt1ZLAsRASsuvtE4JtWU0Q2Ow==",
"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": {
"version": "0.152.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -570,34 +577,35 @@
}
},
"node_modules/@posthog/browser-common": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.5.tgz",
"integrity": "sha512-oXnBgZvM8UQ84IcBLfGP0WSGd9rA0PCGR37H8Pw0NszJRwxwbJnFXIUih0pvI/ze2eGmzkIEC9i3SwC+bMAgjQ==",
"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.59.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.59.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz",
"integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==",
"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": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [
"arm"
],
@@ -612,9 +620,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [
"arm64"
],
@@ -629,9 +637,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
@@ -646,9 +654,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [
"x64"
],
@@ -663,9 +671,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [
"x64"
],
@@ -680,9 +688,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [
"arm"
],
@@ -697,9 +705,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [
"arm64"
],
@@ -714,9 +722,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [
"arm64"
],
@@ -731,9 +739,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [
"ppc64"
],
@@ -748,9 +756,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [
"s390x"
],
@@ -765,9 +773,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [
"x64"
],
@@ -782,9 +790,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [
"x64"
],
@@ -799,9 +807,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [
"arm64"
],
@@ -816,9 +824,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [
"arm64"
],
@@ -833,9 +841,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [
"x64"
],
@@ -1350,6 +1358,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -1837,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": [
@@ -1852,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",
@@ -1873,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"
},
@@ -2162,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": {
@@ -2627,9 +2701,9 @@
}
},
"node_modules/lucide-react": {
"version": "1.59.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz",
"integrity": "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==",
"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": "*",
@@ -2652,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"
@@ -2836,14 +2911,14 @@
}
},
"node_modules/posthog-js": {
"version": "1.438.12",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.12.tgz",
"integrity": "sha512-hjhvrtqbk6pFlbtNEGk0FrWKKFdeXe/WWA/LppN0f+uDoHKR9/SZJOxUHSVCGYbGTJvNRoDjjQvZuTcq4oKSlg==",
"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.5",
"@posthog/core": "^1.59.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",
@@ -2950,13 +3025,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.12",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.152.0",
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -2966,21 +3041,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.12",
"@rolldown/binding-android-arm64": "1.2.12",
"@rolldown/binding-darwin-arm64": "1.2.12",
"@rolldown/binding-darwin-x64": "1.2.12",
"@rolldown/binding-freebsd-x64": "1.2.12",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
"@rolldown/binding-linux-arm64-musl": "1.2.12",
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
"@rolldown/binding-linux-x64-gnu": "1.2.12",
"@rolldown/binding-linux-x64-musl": "1.2.12",
"@rolldown/binding-openharmony-arm64": "1.2.12",
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
"@rolldown/binding-win32-x64-msvc": "1.2.12"
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
}
},
"node_modules/scheduler": {
@@ -3149,16 +3224,16 @@
}
},
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -3175,7 +3250,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
+9 -6
View File
@@ -12,10 +12,10 @@
"test:watch": "vitest"
},
"dependencies": {
"@lucide/icons": "^1.59.0",
"@lucide/icons": "^1.66.0",
"blurhash": "^2.0.5",
"lucide-react": "^1.59.0",
"posthog-js": "^1.438.12",
"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.4",
"vite": "^8.3.5",
"vitest": "^5.0.5"
},
"overrides": {
"minimatch": "^11.0.4"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+264 -176
View File
@@ -67,16 +67,18 @@ import { useEditMode } from './hooks/useEditMode'
import { useAnnotations } from './hooks/useAnnotations'
import AnnotationToolbar from './components/annotations/AnnotationToolbar'
import TextInputOverlay from './components/annotations/TextInputOverlay'
import { joinFirstNames, splitFirstNames } from './utils/firstNames.js'
import { filiations, getChildren, getParents, getPersonUnions, medias, persons, replaceGraph, unions } from './data/mockData'
import { readGedcomFile } from './utils/gedcomText'
import { readGedcomBundle, zipErrorMessage } from './utils/gedcomBundle.js'
import { materializeFile } from './utils/zipReader.js'
import { loadMediaRefs, loadPhotoRefs, pickFirstFaces, prepareImageForUpload, saveMediaRefs, savePhotoRefs, sizeBucket } from './utils/photoImport'
import { fileToBase64, getAccountErrorMessage } from './utils/errorMessages'
import { getSiblingParentLinks } from './utils/familyLinks'
import { getCoupleParentLinks, getSiblingParentLinks } from './utils/familyLinks'
import { getUploadMimeType, inferMediaTypeFromMimeType, validateMediaFile } from './utils/mediaUpload'
import {
getSource,
rememberSource,
identifyUser,
resetUser,
toInteractionMediaType,
@@ -583,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
@@ -607,8 +612,22 @@ function App() {
const [accountEntryMode, setAccountEntryMode] = useState('login')
// Options de l'instance. Inscription fermée (ALLOW_REGISTRATION=false, #113) ou mot de passe désactivé
// (PASSWORD_LOGIN=false, #115) : plus de « Créer un compte » nulle part. oidc : le bouton du fournisseur.
const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null })
const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null, signupPrompts: false })
const registrationOpen = authOptions.registration !== false && authOptions.passwordLogin !== false
// Invitations à créer son arbre (SIGNUP_PROMPTS, allumé sur l'instance hébergée) : « Faire le mien » pour la
// famille, l'inscription au bout des messages de la démo, le message d'invitation de Noël
const signupPrompts = authOptions.signupPrompts === true && registrationOpen
// Message de la démo suivi de l'invitation ; le lien ouvre l'inscription sans recharger la page
const demoSignupMessage = (lead) => (
<span>
{lead} {t('Pour votre famille,')}{' '}
<a
href="/?account=register&source=demo"
className="pz-inline-link"
onClick={(event) => { event.preventDefault(); handleUseAccount('register', 'demo') }}
>{t('créez votre arbre')}</a>.
</span>
)
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
@@ -1107,7 +1126,8 @@ function App() {
if (!left) return
editMode.saveDraft()
editMode.deactivate()
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
if (isDemoMode && signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.')))
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate, isDemoMode, signupPrompts]) // eslint-disable-line react-hooks/exhaustive-deps
const handleConfirmDiscard = useCallback(() => {
editMode.clearDraft()
@@ -1129,9 +1149,10 @@ function App() {
useEffect(() => {
if (!relationFeedback.success) return undefined
// Le message de la démo qui porte l'invitation (lien vers l'inscription) reste le temps de le lire et de cliquer
const timeout = setTimeout(() => {
setRelationFeedback((current) => ({ ...current, success: '' }))
}, 2500)
}, typeof relationFeedback.success === 'string' ? 2500 : 6000)
return () => clearTimeout(timeout)
}, [relationFeedback.success])
@@ -1225,192 +1246,234 @@ function App() {
siblingParentLinks.push(...links)
}
}
// « Paul, Marie, Jean » pour un enfant ou un frère : une personne par prénom, mêmes liens pour chacune
const firstNames = splitFirstNames(formData.firstName, formData.relations || [])
const batch = firstNames.length > 1 ? { batch: firstNames.length } : {}
// Un enfant ajouté à une personne qui n'a qu'un conjoint est l'enfant du couple : il passe par les liens
// du couple, comme un frère ou une sœur. Une adoption reste au seul parent choisi.
const relationCount = (formData.relations || []).length
formData = {
...formData,
relations: (formData.relations || []).filter((rel) => {
if (rel.type !== 'child' || rel.adopted) return true
const coupleLinks = getCoupleParentLinks(rel.personId, unions)
if (!coupleLinks.length) return true
siblingParentLinks.push(...coupleLinks)
return false
}),
}
if (!tree.canEditCurrentTree) {
if (isDemoMode) {
const personId = makeLocalId('local-person')
const birthYear = extractYearFromIsoDate(formData.birthDate)
const deathYear = extractYearFromIsoDate(formData.deathDate)
const newPerson = {
id: personId,
firstName: formData.firstName,
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
deathYear,
isAlive: !deathYear,
photo: null,
frameType: 'round',
}
const addOne = async (formData) => {
if (!tree.canEditCurrentTree) {
if (isDemoMode) {
const personId = makeLocalId('local-person')
const birthYear = extractYearFromIsoDate(formData.birthDate)
const deathYear = extractYearFromIsoDate(formData.deathDate)
const newPerson = {
id: personId,
firstName: formData.firstName,
lastName: formData.lastName || '',
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
birthYear,
deathYear,
isAlive: !deathYear,
photo: null,
frameType: 'round',
}
replaceGraph({ persons: [...persons, newPerson] })
replaceGraph({ persons: [...persons, newPerson] })
for (const rel of formData.relations) {
if (rel.type === 'spouse') {
const existsUnion = unions.some((union) => (
(String(union.partner1Id) === String(rel.personId) && String(union.partner2Id) === String(personId))
|| (String(union.partner1Id) === String(personId) && String(union.partner2Id) === String(rel.personId))
))
if (!existsUnion) {
unions.push({
id: makeLocalId('local-union'),
partner1Id: rel.personId,
partner2Id: personId,
unionType: 'mariage',
startYear: null,
endYear: null,
displayOrder: unions.length + 1,
for (const rel of formData.relations) {
if (rel.type === 'spouse') {
const existsUnion = unions.some((union) => (
(String(union.partner1Id) === String(rel.personId) && String(union.partner2Id) === String(personId))
|| (String(union.partner1Id) === String(personId) && String(union.partner2Id) === String(rel.personId))
))
if (!existsUnion) {
unions.push({
id: makeLocalId('local-union'),
partner1Id: rel.personId,
partner2Id: personId,
unionType: 'mariage',
startYear: null,
endYear: null,
displayOrder: unions.length + 1,
})
}
} else if (rel.type === 'parent') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: rel.personId,
parentId: personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
} else if (rel.type === 'child') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: rel.personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
}
} else if (rel.type === 'parent') {
filiations.push({
id: makeLocalId('local-filiation'),
childId: rel.personId,
parentId: personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
displayOrder: filiations.length + 1,
})
} else if (rel.type === 'child') {
}
for (const link of siblingParentLinks) {
const alreadyLinked = filiations.some((filiation) => (
String(filiation.childId) === String(personId)
&& (link.unionId
? String(filiation.unionId) === link.unionId
: String(filiation.parentId) === link.parentId)
))
if (alreadyLinked) continue
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: rel.personId,
unionId: null,
parentageType: rel.adopted ? 'adoption' : 'biologique',
parentId: link.unionId ? null : link.parentId,
unionId: link.unionId,
parentageType: 'biologique',
displayOrder: filiations.length + 1,
})
}
tree.bumpGraphRevision()
tree.setSelectedPerson({ ...newPerson })
}
for (const link of siblingParentLinks) {
const alreadyLinked = filiations.some((filiation) => (
String(filiation.childId) === String(personId)
&& (link.unionId
? String(filiation.unionId) === link.unionId
: String(filiation.parentId) === link.parentId)
))
if (alreadyLinked) continue
filiations.push({
id: makeLocalId('local-filiation'),
childId: personId,
parentId: link.unionId ? null : link.parentId,
unionId: link.unionId,
parentageType: 'biologique',
displayOrder: filiations.length + 1,
})
}
tree.bumpGraphRevision()
tree.setSelectedPerson({ ...newPerson })
const draft = editMode.restoreDraft()
const ref = `tmp:${makeLocalId('personne')}`
const addedPersons = [
...(draft.addedPersons || []),
{
ref,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
birthPlace: formData.birthPlace || null,
deathPlace: formData.deathPlace || null,
toVerify: Boolean(formData.toVerify),
notes: formData.notes || null,
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
siblingParentLinks,
},
]
editMode.updateDraft({ addedPersons })
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'family', relations: relationCount, tree_id: opaqueTreeId, ...batch })
// La personne apparaît en fantôme « Brouillon » : la famille peut en ajouter d'autres, puis envoyer une
// fois avec le bouton « Envoyer » de la barre (Patch, 25/10). Avant, la fenêtre d'envoi s'ouvrait d'office,
// seule façon de montrer qu'un ajout invisible comptait.
setAddPersonPanelVisible(false)
setDraftNotice(t("{name} est sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
return { proposed: true, id: isDemoMode ? null : `ghost:brouillon:${ref}`, demoPerson: isDemoMode ? persons[persons.length - 1] : null }
}
const draft = editMode.restoreDraft()
const addedPersons = [
...(draft.addedPersons || []),
{
ref: `tmp:${makeLocalId('personne')}`,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName || null,
nickname: formData.nickname || null,
sex: formData.sex || null,
birthDate: formData.birthDate || null,
deathDate: formData.deathDate || null,
isDeceased: Boolean(formData.isDeceased),
birthPlace: formData.birthPlace || null,
deathPlace: formData.deathPlace || null,
toVerify: Boolean(formData.toVerify),
notes: formData.notes || null,
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
siblingParentLinks,
},
]
editMode.updateDraft({ addedPersons })
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'family', relations: (formData.relations || []).length, tree_id: opaqueTreeId })
// La personne apparaît en fantôme « Brouillon » : la famille peut en ajouter d'autres, puis envoyer une
// fois avec le bouton « Envoyer » de la barre (Patch, 25/10). Avant, la fenêtre d'envoi s'ouvrait d'office,
// seule façon de montrer qu'un ajout invisible comptait.
setAddPersonPanelVisible(false)
setDraftNotice(t("{name} est sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
return { proposed: true }
}
if (!auth.userAuth.token) return
if (!auth.userAuth.token) return
const newPerson = await createPerson(
tree.treeContext.treeId,
{
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
sex: formData.sex || null,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
// Personne venue d'une source publique (panneau « Chercher quelqu'un ») : lieux, « à vérifier » et source
...(formData.birthPlace ? { birthPlace: formData.birthPlace } : {}),
...(formData.deathPlace ? { deathPlace: formData.deathPlace } : {}),
...(formData.toVerify ? { toVerify: true } : {}),
...(formData.notes ? { notes: formData.notes } : {}),
},
auth.userAuth.token
)
// Créer les liens de relation
const linkedParentIds = new Set()
for (const rel of formData.relations) {
if (rel.type === 'child') {
linkedParentIds.add(String(rel.personId))
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'parent') {
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'spouse') {
await createUnion(
tree.treeContext.treeId,
{ partner1PersonId: rel.personId, partner2PersonId: newPerson.id, unionType: 'mariage', displayOrder: 1 },
auth.userAuth.token
)
}
}
for (const link of siblingParentLinks) {
if (linkedParentIds.has(link.parentId)) continue
linkedParentIds.add(link.parentId)
await createParentChildLink(
const newPerson = await createPerson(
tree.treeContext.treeId,
{
parentPersonId: link.parentId,
childPersonId: newPerson.id,
viaUnionId: link.unionId,
parentageType: 'biologique',
displayOrder: 1,
firstName: formData.firstName,
lastName: formData.lastName,
birthName: formData.birthName,
nickname: formData.nickname,
sex: formData.sex || null,
birthDate: formData.birthDate,
deathDate: formData.deathDate,
isDeceased: Boolean(formData.isDeceased),
// Personne venue d'une source publique (panneau « Chercher quelqu'un ») : lieux, « à vérifier » et source
...(formData.birthPlace ? { birthPlace: formData.birthPlace } : {}),
...(formData.deathPlace ? { deathPlace: formData.deathPlace } : {}),
...(formData.toVerify ? { toVerify: true } : {}),
...(formData.notes ? { notes: formData.notes } : {}),
},
auth.userAuth.token
)
// Créer les liens de relation
const linkedParentIds = new Set()
for (const rel of formData.relations) {
if (rel.type === 'child') {
linkedParentIds.add(String(rel.personId))
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'parent') {
await createParentChildLink(
tree.treeContext.treeId,
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
auth.userAuth.token
)
} else if (rel.type === 'spouse') {
await createUnion(
tree.treeContext.treeId,
{ partner1PersonId: rel.personId, partner2PersonId: newPerson.id, unionType: 'mariage', displayOrder: 1 },
auth.userAuth.token
)
}
}
for (const link of siblingParentLinks) {
if (linkedParentIds.has(link.parentId)) continue
linkedParentIds.add(link.parentId)
await createParentChildLink(
tree.treeContext.treeId,
{
parentPersonId: link.parentId,
childPersonId: newPerson.id,
viaUnionId: link.unionId,
parentageType: 'biologique',
displayOrder: 1,
},
auth.userAuth.token
)
}
// Mettre à jour le draft avec la nouvelle personne
editMode.updateDraft({
addedPersons: [...(editMode.restoreDraft().addedPersons || []), newPerson.id],
})
await tree.refreshCurrentTreeGraph()
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'manager', relations: relationCount, tree_id: opaqueTreeId, ...batch })
return newPerson
}
// Mettre à jour le draft avec la nouvelle personne
editMode.updateDraft({
addedPersons: [...(editMode.restoreDraft().addedPersons || []), newPerson.id],
})
if (firstNames.length < 2) return await addOne(formData)
await tree.refreshCurrentTreeGraph()
trackAppEvent('person_added', { via: addPersonViaRef.current, role: 'manager', relations: (formData.relations || []).length, tree_id: opaqueTreeId })
return newPerson
const added = []
for (const firstName of firstNames) {
const result = await addOne({ ...formData, firstName })
if (!result) break
added.push(result)
}
if (!added.length) return null
const addedIds = added.map((result) => result.demoPerson?.id ?? result.id).filter((id) => id != null)
if (added[0].proposed) {
const names = joinFirstNames(firstNames.slice(0, added.length), t('et'))
setDraftNotice(t("{names} sont sur l'arbre, en brouillon. Envoyez quand vous avez fini.", { names: [names, formData.lastName].filter(Boolean).join(' ') }))
}
// Sur téléphone, une fiche ouverte couvrirait la fratrie : pas de fiche, la caméra les cadre tous
if (window.matchMedia?.('(max-width: 768px)').matches) {
tree.setSelectedPerson(null)
if (addedIds.length > 1) requestAnimationFrame(() => galaxyZoomApiRef.current?.focusPersons?.(addedIds))
} else if (added[0].demoPerson) {
tree.setSelectedPerson({ ...added[0].demoPerson })
}
return added[0]
} catch (err) {
setAddPersonError(err.message || t("Erreur lors de l'ajout de la personne"))
} finally {
@@ -1472,6 +1535,7 @@ function App() {
editMode.deactivate()
setContribSubmitError('')
setShowContribModal(false)
if (signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.')))
return
}
@@ -1623,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) {
@@ -2109,7 +2172,7 @@ function App() {
const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ addedRelations })
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien familial ajouté (démo locale)'), error: '' })
setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien ajouté à la démo.')) : t('Lien familial ajouté (démo locale)'), error: '' })
return
}
@@ -2174,7 +2237,7 @@ function App() {
if (isDemoMode) {
for (const link of childLinks) link.parentageType = parentageType
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' })
setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien modifié dans la démo.')) : t('Lien modifié (démo locale)'), error: '' })
return
}
@@ -2251,7 +2314,7 @@ function App() {
const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }]
editMode.updateDraft({ removedRelations })
tree.bumpGraphRevision()
setRelationFeedback({ loading: false, success: t('Lien familial supprimé (démo locale)'), error: '' })
setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien retiré de la démo.')) : t('Lien familial supprimé (démo locale)'), error: '' })
return
}
@@ -2403,6 +2466,8 @@ function App() {
// Explicit demo modes (landing iframe / forced demo)
if (query.has('embed') || query.has('demo')) {
tree.handleUseDemo()
// Sans attendre : la démo s'ouvre tout de suite, les options (inscription, invitations) arrivent ensuite
fetchAuthOptions().then((options) => { if (active && options) setAuthOptions(options) }).catch(() => {})
return
}
@@ -2439,10 +2504,16 @@ function App() {
const resolvedTreeOwnerEmail = ''
// Root app:
// - not connected, arrivé par un lien de diffusion (?source=…) => la démo, comme avant le 18/10 : ces liens
// promettent l'arbre d'exemple et ont été envoyés sans ?demo (presse, créateurs, groupes)
// - not connected => accueil à deux choix (variante B, 18/10)
// - connected => continue bootstrap to open the user's tree
if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) {
const userToken = auth.restoreUserToken()
if (!userToken && query.has('source')) {
tree.handleUseDemo()
return
}
if (!userToken) {
await authOptionsReady
if (!active) return
@@ -2592,7 +2663,7 @@ function App() {
// ?account=register (CTA de la landing) : écran compte directement en création
if (query.get('account') === 'register' && registrationAllowed) {
setAccountEntryMode('register')
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: getSource() })
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, via: 'lien', source: getSource() })
}
tree.setBootState('account')
}
@@ -2635,8 +2706,14 @@ function App() {
}
}, [tree.bootState, auth.authenticated, auth.accountLoading, auth.accountTrees.length, accountFromTree]) // eslint-disable-line react-hooks/exhaustive-deps
const handleUseAccount = (mode = 'login') => {
const ownTreeAfterSignupRef = useRef(false)
// via : la porte qui a mené à l'écran de compte (ajouter, compte, demo), pour les mesures
const handleUseAccount = (mode = 'login', via = 'autre') => {
const ctx = tree.treeContext
// « Faire le mien » : après l'inscription, l'assistant de création s'ouvre au-dessus de l'arbre de la famille
ownTreeAfterSignupRef.current = mode === 'register' && via === 'ajouter'
// Inscription depuis un arbre reçu ou depuis la démo, sans source dans l'adresse : on le note
if (mode === 'register' && ctx.treeId && ctx.accessMode !== 'member') rememberSource(ctx.accessMode === 'demo' ? 'demo' : 'arbre-partage')
pendingSharedTreeRef.current = ctx.treeId && ctx.accessMode !== 'demo' && ctx.accessMode !== 'member'
? {
treeId: ctx.treeId,
@@ -2653,7 +2730,7 @@ function App() {
setAccountPanelVisible(false)
const entryMode = mode === 'register' && registrationOpen ? 'register' : 'login'
setAccountEntryMode(entryMode)
trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), via, source: getSource() })
media.resetMediaManager()
setAdminPanelVisible(false)
contrib.resetContribState()
@@ -2701,7 +2778,7 @@ function App() {
const entry = trees.find((candidate) => String(candidate.id) === String(pending.treeId))
if (entry) {
await openAccountTree(entry, token, { promptWizard: false })
return true
return 'opened'
}
} catch {
// Jeton expiré ou refusé : on repasse par le mot de passe
@@ -2711,7 +2788,7 @@ function App() {
tree.setTreeContext(buildEmptyTreeContext(pending))
tree.setGateError('')
tree.setBootState('need-access')
return true
return 'gate'
} finally {
pendingSharedTreeRef.current = null
setPendingSharedTreeName('')
@@ -2763,7 +2840,11 @@ function App() {
trackAppEvent('account_registered', { from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), source: getSource() })
const token = auth.restoreUserToken()
if (token && await resumePendingSharedTree(token)) {
const resumed = token ? await resumePendingSharedTree(token) : false
if (resumed) {
// L'arbre de la famille est rattaché au compte et rouvert ; « Faire le mien » ouvre aussi l'assistant
if (resumed === 'opened' && ownTreeAfterSignupRef.current) setTreeWizardVisible(true)
ownTreeAfterSignupRef.current = false
return
}
@@ -3374,6 +3455,7 @@ function App() {
onContinue={handleConfirmContinue}
onSaveAndQuit={handleConfirmSaveAndQuit}
onDiscard={handleConfirmDiscard}
demo={isDemoMode}
/>
{/* Slide-in Ajouter Personne */}
<AddPersonPanel
@@ -3454,12 +3536,14 @@ function App() {
onCreateTree={handleOpenTreeWizard}
onProfileClick={handleUseAccount}
canRegister={registrationOpen}
signupPrompts={signupPrompts}
onAccountClick={() => setAccountPanelVisible(true)}
onSettingsClick={() => setAdminPanelVisible(true)}
onLogout={handleLogout}
userDisplayName={accountDisplayName}
userEmail={accountEmail}
userAvatarPhoto={accountAvatarPhoto}
userAvatarCrop={linkedSelfPerson?.avatarCrop ?? null}
linkedPersonName={linkedSelfPerson ? `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() : ''}
activeTreeName={activeAccountTreeName}
annotationTools={editMode.isActive ? {
@@ -3490,6 +3574,7 @@ function App() {
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
familyTabs={familyTabs}
tabKinDepth={tabKinDepth}
fullNames={fullNames}
annotationViewRef={annotationViewRef}
onAnnotationsAnchored={persistAnnotationAnchors}
@@ -3622,6 +3707,7 @@ function App() {
onRotatePasswords={handleContributionPasswordRotate}
onRememberPassword={handleContributionPasswordRemember}
onReviewSession={contrib.handleReviewContributionSession}
seasonalInvite={signupPrompts}
/>
<SearchSourcesPanel
visible={searchPanelVisible}
@@ -3670,6 +3756,8 @@ function App() {
onClose={() => setAdminPanelVisible(false)}
familyTabs={familyTabs}
onFamilyTabsChange={setFamilyTabs}
tabKinDepth={tabKinDepth}
onTabKinDepthChange={setTabKinDepth}
fullNames={fullNames}
onFullNamesChange={setFullNames}
/>
+3 -2
View File
@@ -3,6 +3,7 @@ import { X } from 'lucide-react'
import './AccountScreens.css'
import { t } from '../i18n/index.js'
import { initialOf } from '../utils/initial.js'
import PortraitThumb from './PortraitThumb.jsx'
const AccountPanel = ({
visible,
@@ -51,7 +52,7 @@ const AccountPanel = ({
<div className="ap-linked">
<span className="ap-avatar" aria-hidden="true">
{linkedPerson?.photo ? <img src={linkedPerson.photo} alt="" /> : initialOf(linkedPerson?.firstName || userDisplayName, 'C')}
{linkedPerson?.photo ? <PortraitThumb src={linkedPerson.photo} crop={linkedPerson.avatarCrop} /> : initialOf(linkedPerson?.firstName || userDisplayName, 'C')}
</span>
<p className="pz-small">
{linkedPerson
@@ -87,7 +88,7 @@ const AccountPanel = ({
onClick={() => onSelectPerson?.(person.id)}
>
<span className="ap-item-avatar" aria-hidden="true">
{person.photo ? <img src={person.photo} alt="" /> : initialOf(person.firstName)}
{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : initialOf(person.firstName)}
</span>
<span className="ap-item-name">{person.firstName} <em>{person.lastName || ''}</em></span>
{isSelected && <span className="pz-tag pz-tag--accent">{t("C'est moi")}</span>}
+2 -1
View File
@@ -8,6 +8,7 @@ import { firstNameLabel } from '../utils/personName.js'
import { SEX_CHOICES } from '../utils/personSex.js'
import { initialOf } from '../utils/initial.js'
import { findLikelyDuplicates } from '../utils/duplicates.js'
import PortraitThumb from './PortraitThumb.jsx'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
@@ -392,7 +393,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
<li key={p.id}>
<button type="button" className="apn-dropdown-item" onClick={() => handleAddRelation(p)}>
<span className="apn-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : initialOf(p.firstName)}
{p.photo ? <PortraitThumb src={p.photo} crop={p.avatarCrop} /> : initialOf(p.firstName)}
</span>
<span className="apn-dropdown-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{(p.birthYear || p.birthDate) && (
+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>
+18 -21
View File
@@ -7,6 +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, isChristmasSeason } from '../utils/inviteText.js'
import { toOpaqueTreeId, trackAppEvent } from '../utils/analytics.js'
import { sexLabel } from '../utils/personSex.js'
import Skeleton from './Skeleton.jsx'
@@ -217,21 +219,6 @@ function formatSessionDate(value) {
})
}
// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp
function buildInviteText(url, password) {
return [
t('Bonjour à tous,'),
'',
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 }),
t("Le mot de passe pour l'ouvrir est {password}", { password }),
'',
t("En bas de l'écran, vous pouvez ajouter une photo, un souvenir ou une personne qui manque."),
t("Le mode d'emploi, si besoin {url}", { url: HELP_URL }),
].join('\n')
}
// ─── component ──────────────────────────────────────────────────────────────
const ContributionPanel = ({
@@ -254,6 +241,7 @@ const ContributionPanel = ({
onRotatePasswords,
onRememberPassword,
onReviewSession,
seasonalInvite = false,
}) => {
const [decisionMap, setDecisionMap] = useState({})
const [collapsedSessions, setCollapsedSessions] = useState(new Set())
@@ -355,14 +343,23 @@ const ContributionPanel = ({
}, [inviteKnownPasswords?.share, visible])
const knownShare = inviteKnownPasswords?.share || ''
const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare) : ''
const defaultInviteText = knownShare && inviteShareUrl ? buildInviteText(inviteShareUrl, knownShare, { seasonal: seasonalInvite }) : ''
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')) }
}
@@ -411,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">
@@ -425,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>
</>
@@ -440,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>
+5 -2
View File
@@ -12,7 +12,8 @@ import { t } from '../i18n/index.js'
* - Enregistrer brouillon et quitter
* - Abandonner les modifications
*/
function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard }) {
// demo : la démo ne garde rien sur l'appareil, ses modifications vivent le temps de la page
function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard, demo = false }) {
const continueRef = useRef(null)
// Focus sur "Continuer" à l'ouverture + trap ESC
@@ -47,7 +48,9 @@ function EditConfirmModal({ visible, onContinue, onSaveAndQuit, onDiscard }) {
<div className="pz-modal-head">
<p className="pz-eyebrow">{t('Modifications en cours')}</p>
<h2 id="edit-confirm-title" className="pz-title">{titleBefore}<em>{t("l'édition")}</em>{titleAfter}</h2>
<p className="pz-sub">{t('Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.')}</p>
<p className="pz-sub">{demo
? t('Vos dernières modifications restent dans la démo tant que cette page est ouverte, puis elles disparaissent.')
: t('Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.')}</p>
</div>
<div className="pz-modal-actions pz-modal-actions--split">
+2 -1
View File
@@ -3,6 +3,7 @@ import { ImagePlus, Search, UserPlus, X } from 'lucide-react'
import './FamilyOnboarding.css'
import { t } from '../i18n/index.js'
import { HELP_URL } from '../utils/siteLinks.js'
import PortraitThumb from './PortraitThumb.jsx'
// Carte d'accueil de la famille qui arrive par le lien : ce qu'on peut faire, en deux phrases
export function FamilyWelcome({ visible, treeName, onAddSouvenir, onLook }) {
@@ -117,7 +118,7 @@ export function SouvenirPicker({ visible, persons = [], rootPersonId = null, onP
<li key={person.id}>
<button type="button" className="fo-person" onClick={() => onPick?.(person)}>
<span className="fo-thumb" aria-hidden="true">
{person.photo ? <img src={person.photo} alt="" /> : <span>{person.firstName?.[0] || '?'}</span>}
{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : <span>{person.firstName?.[0] || '?'}</span>}
</span>
<span className="fo-name">
<strong>{person.firstName} {person.lastName}</strong>
+7
View File
@@ -119,3 +119,10 @@
body:has(.expandable-search.expanded) .family-tabs { top: 80px; max-width: calc(100vw - 20px); }
.family-tab { font-size: 14px; padding: 6px 12px 7px; }
}
/* Trait d'union entre deux onglets voisins quand le second part du premier (planche A, variante 2) */
.family-tabs { gap: 0; }
.family-tab + .family-tab:not([style]) { margin-left: 6px; }
.family-tab--suite { position: relative; }
.family-tab--suite::before { content: ""; position: absolute; right: 100%; top: 50%; width: 10px; height: 1.3px; transform: translateY(-50%); background: #E3A590; pointer-events: none; }
.family-tab--trait-on::before { background: #D2694A; }
+108 -8
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.
@@ -50,7 +50,34 @@ const centerX = (node) => node.x + node.width / 2
const easeOutQuint = (t) => 1 - (1 - t) ** 5
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
// Barre des familles rangée comme l'arbre (Patch, 28/10, planche A variante 2 « le trait d'union ») : chaque
// famille juste après celle par laquelle on y entre, la famille de départ en tête. Les onglets d'une même branche
// (sous-arbre d'un enfant direct de la famille de départ) se serrent à 10 px, 16 px entre les branches ; un trait
// relie deux voisins quand le second part du premier (jamais vers la famille de départ, sa place en tête suffit).
// Le trait qui mène à l'onglet ouvert est terracotta, les autres couleur fil.
function barTabs(tabs, activeKey) {
if (!tabs.length) return []
const root = tabs[0]
const kids = new Map()
for (const tb of tabs.slice(1)) { const pk = tabs.some((x) => x.key === tb.parentKey) ? tb.parentKey : root.key; if (!kids.has(pk)) kids.set(pk, []); kids.get(pk).push(tb) }
const order = []
const visit = (tb) => { order.push(tb); for (const c of kids.get(tb.key) || []) visit(c) }
visit(root)
for (const tb of tabs) if (!order.includes(tb)) order.push(tb)
const byKey = new Map(tabs.map((tb) => [tb.key, tb]))
const pathTo = (tb) => { const out = []; for (let t = tb; t; t = byKey.get(t.parentKey)) out.unshift(t.key); return out }
const branchOf = (tb) => { const p = pathTo(tb); return p[1] || p[0] }
const active = byKey.get(activeKey) || root
const lit = new Set(pathTo(active))
return order.map((tb, i) => {
const prev = order[i - 1]
const gap = i === 0 ? 0 : (prev !== root && branchOf(tb) === branchOf(prev) ? 10 : 16)
const trait = i > 0 && prev !== root && tb.parentKey === prev.key ? (lit.has(tb.key) ? 'on' : 'off') : null
return { tb, gap, trait }
})
}
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
@@ -69,6 +96,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [])
useEffect(() => cancelTabsRequest, [cancelTabsRequest])
const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement
const pendingFrameRef = useRef(null) // personnes à cadrer une fois l'arbre reposé (ajout de plusieurs prénoms)
// Lien d'un onglet (?famille=<charnière>) : ouvert dès que l'arbre et ses onglets sont connus, et de nouveau si
// la personne de départ arrive après (visiteur), tant qu'on n'a pas soi-même changé d'onglet
const linkFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
@@ -94,7 +122,32 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
setActiveKey(focusPersonId != null ? String(focusPersonId) : null)
}, [focusPersonId])
// Onglet choisi par la personne (barre, badge, recherche) : le lien d'arrivée ne s'impose plus
const chooseTab = useCallback((key) => { linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
// Repère du changement d'onglet (Patch, 28/10 : « on perd un peu rapidement le fil ») : la personne qui relie
// les deux familles garde sa place et son zoom à l'écran, seules les familles autour changent
const anchorRef = useRef(null)
const linkPeople = (key) => {
const list = tabsCacheRef.current.tabs
const target = list.find((tb) => tb.key === String(key)), cur = activeTabRef.current || list[0]
return [target?.referenceId, ...(target?.hinges || []), cur?.referenceId, ...(cur?.hinges || [])].filter((x) => x != null).map(String)
}
const rememberAnchor = (key) => {
const layout = layoutDataRef.current, t = transformRef.current
if (!layout || !t) return
const ids = linkPeople(key)
const node = layout.children.find((n) => n._type === 'person' && ids.includes(String(n._data?.id)))
anchorRef.current = node ? { id: String(node._data.id), sx: (node.x + node.width / 2) * t.scale + t.x, sy: (node.y + node.height / 2) * t.scale + t.y, scale: t.scale } : null
}
const chooseTab = useCallback((key) => { rememberAnchor(key); linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
// Survol d'un onglet : la personne par qui l'on y passe s'allume dans l'arbre affiché, sans texte (Patch : « que ça les suggère »)
const hoverTab = (key) => {
const layout = layoutDataRef.current
if (!layout) return
const present = new Set(layout.children.filter((n) => n._type === 'person').map((n) => String(n._data?.id)))
const ids = linkPeople(key).filter((id) => present.has(id))
searchHighlightSetRef.current = new Set([...searchHighlightIdsRef.current.map((id) => `p-${id}`), ...ids.map((id) => `p-${id}`)])
needsRedrawRef.current = true
}
const leaveTab = () => { searchHighlightSetRef.current = new Set(searchHighlightIdsRef.current.map((id) => `p-${id}`)); needsRedrawRef.current = true }
// Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché
const focusPersonIdRef = useRef(focusPersonId)
// La barre défile quand les onglets débordent : l'onglet ouvert se met en vue (sans animation) et les bords
@@ -464,6 +517,26 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const screenY = phone ? rect.height * 0.22 : rect.height / 2
targetTransformRef.current = { scale, x: screenX - pos.cx * scale, y: screenY - pos.cy * scale }
},
// Plusieurs personnes venant d'être ajoutées : toutes dans le champ, au-dessus de la barre du bas. Si l'arbre
// n'est pas encore reposé avec elles, le cadrage attend la prochaine mise en place.
focusPersons: (personIds) => {
const canvas = canvasRef.current
const wanted = new Set(personIds.map((id) => `p-${id}`))
const nodes = (layoutDataRef.current?.children || []).filter((n) => wanted.has(n.id))
if (!canvas || nodes.length < personIds.length) { pendingFrameRef.current = personIds; return }
pendingFrameRef.current = null
const rect = canvas.getBoundingClientRect()
const now = performance.now()
const centers = nodes.map((n) => getAnimatedPos(n, now))
const xs = centers.map((p) => p.cx)
const ys = centers.map((p) => p.cy)
const margin = 140 // un portrait et son prénom
const fit = Math.min((rect.width - 32) / (Math.max(...xs) - Math.min(...xs) + margin), (rect.height * 0.6) / (Math.max(...ys) - Math.min(...ys) + margin))
const scale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.min(Math.max(targetTransformRef.current.scale, 1), fit)))
const cx = (Math.min(...xs) + Math.max(...xs)) / 2
const cy = (Math.min(...ys) + Math.max(...ys)) / 2
targetTransformRef.current = { scale, x: rect.width / 2 - cx * scale, y: rect.height * 0.42 - cy * scale }
},
getScale: () => transformRef.current.scale,
moveSibling,
getSiblingPlace,
@@ -602,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)
@@ -630,7 +709,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const parent = tabsCacheRef.current.tabs.find((tb) => tb.key === tab.parentKey)
for (const h of (tab.hinges || [tab.referenceId])) {
const node = result.children.find((n) => n.id === `p-${h}`)
if (parent && node) node._familyTab = { anchorId: node.id, label: parent.label, count: parent.count, tabKey: parent.key, place: 'above' }
if (parent && node) node._familyTab = { anchorId: node.id, label: parent.label, count: parent.count, tabKey: parent.key, place: 'above', back: true }
}
}
@@ -684,6 +763,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
offsetY = cssH * (cssW < 768 ? 0.4 : 0.42) - (focusNode.y + focusNode.height / 2) * scale
}
const anc = anchorRef.current
anchorRef.current = null
const ancNode = anc && personNodes.find((n) => String(n._data?.id) === anc.id)
if (ancNode) {
scale = anc.scale
offsetX = anc.sx - (ancNode.x + ancNode.width / 2) * scale
offsetY = anc.sy - (ancNode.y + ancNode.height / 2) * scale
}
const init = { x: offsetX, y: offsetY, scale }
initialTransformRef.current = init
transformRef.current = init
@@ -788,7 +876,12 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFocusRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
}
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
if (pendingFrameRef.current) {
const ids = pendingFrameRef.current
pendingFrameRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPersons?.(ids))
}
}, [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(() => {
@@ -803,7 +896,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [selectedPersonId])
// Sync searchHighlightIds
const searchHighlightIdsRef = useRef(searchHighlightIds)
useEffect(() => {
searchHighlightIdsRef.current = searchHighlightIds
searchHighlightSetRef.current = new Set(searchHighlightIds.map(id => `p-${id}`))
}, [searchHighlightIds])
@@ -1259,18 +1354,23 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
)}
{familyTabs && tabs.length > 1 && (
<nav ref={tabsBarRef} onScroll={updateTabsEdges} className={`family-tabs${annotationHandlers ? ' family-tabs--lowered' : ''}${tabsEdges}`} aria-label={tr('Familles')}>
{tabs.map((tb) => {
{barTabs(tabs, activeKey).map(({ tb, gap, trait }) => {
const active = tb.key === (tabs.some((x) => x.key === activeKey) ? activeKey : tabs[0].key)
return (
<button
key={tb.key}
type="button"
className={`family-tab${active ? ' is-active' : ''}`}
style={gap ? { marginLeft: gap } : undefined}
className={`family-tab${active ? ' is-active' : ''}${trait ? ` family-tab--suite family-tab--trait-${trait}` : ''}`}
aria-current={active ? 'page' : undefined}
onClick={() => chooseTab(tb.key)}
onMouseEnter={() => { if (!active) hoverTab(tb.key) }}
onMouseLeave={leaveTab}
onFocus={() => { if (!active) hoverTab(tb.key) }}
onBlur={leaveTab}
>
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
<span className="family-tab-count">{tb.count}</span>
<span className="family-tab-count" title={tr('{n} personnes', { n: tb.count })}>{tb.count}</span>
</button>
)
})}
+2 -1
View File
@@ -3,6 +3,7 @@ import { Check, Search, UserPlus, X } from 'lucide-react'
import { t } from '../i18n/index.js'
import { firstNameLabel, fullNameLabel } from '../utils/personName.js'
import './MediaPeople.css'
import PortraitThumb from './PortraitThumb.jsx'
const MAX_RESULTS = 6
@@ -10,7 +11,7 @@ const normalize = (value) => String(value || '').normalize('NFD').replace(/[̀-
function PersonAvatar({ person }) {
return person?.photo
? <img className="mp-avatar" src={person.photo} alt="" />
? <PortraitThumb className="mp-avatar" src={person.photo} crop={person.avatarCrop} />
: <span className="mp-avatar mp-avatar--initial" aria-hidden="true">{person?.firstName?.[0] || '?'}</span>
}
+2 -1
View File
@@ -15,6 +15,7 @@ import { eventTitle } from '../utils/lifeTimeline.js'
import { getAccountErrorMessage } from '../utils/errorMessages.js'
import { growFromTransform, takeOrigin } from '../utils/openOrigin.js'
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag.js'
import PortraitThumb from './PortraitThumb.jsx'
const HTTP_URL_RE = /^https?:\/\/\S+$/i
const MAX_TITLE_LENGTH = 280
@@ -648,7 +649,7 @@ const MediaViewer = ({
<div className="mv-meta">
<span className="mv-person">
{person?.photo ? (
<img className="mv-person-avatar" src={person.photo} alt="" />
<PortraitThumb className="mv-person-avatar" src={person.photo} crop={person.avatarCrop} />
) : (
<span className="mv-person-avatar mv-person-initial">{person?.firstName?.[0] || '?'}</span>
)}
+15 -8
View File
@@ -18,12 +18,14 @@ import { t, dateLocale } from '../i18n/index.js'
import { formatPlace } from '../utils/place.js'
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js'
import { isYearOnly } from '../utils/dateInput.js'
import { ageInYears } from '../utils/age.js'
import DateField from './DateField.jsx'
import { runOnce } from '../utils/saveOnLeave.js'
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
import { ProposalBanner } from './ProposalCard.jsx'
import { initialsOf } from '../utils/initial.js'
import { blurPlaceholder } from '../utils/blurhash.js'
import PortraitThumb from './PortraitThumb.jsx'
// Aucune modification en cours (référence stable)
const NO_EDITS = Object.freeze({})
@@ -448,9 +450,9 @@ const PersonCard = ({
const hasDeathYear = Number.isFinite(deathYearNumber)
const age = hasBirthYear
? person.isAlive
? new Date().getFullYear() - birthYearNumber
? ageInYears(person.birthDate ?? birthYearNumber)
: hasDeathYear
? deathYearNumber - birthYearNumber
? ageInYears(person.birthDate ?? birthYearNumber, person.deathDate ?? deathYearNumber)
: null
: null
@@ -514,6 +516,9 @@ const PersonCard = ({
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
})),
}
// Un moment du couple (mariage, divorce…) se montre chez les deux conjoints, avec ses souvenirs,
// même quand la photo a été ajoutée depuis la fiche de l'autre (#209)
const lifeMedias = [...personMedias, ...lifeFamily.unions.flatMap((union) => (union.partner ? getPersonMedias(union.partner.id) : []))]
// Filter persons for relation search (exclude current person + already linked)
const getFilteredPersonsForRelation = (relationType) => {
@@ -630,7 +635,9 @@ const PersonCard = ({
// <bdi> : un lieu en arabe ou en hébreu ne retourne pas l'ordre de la ligne (« 1980, الدار البيضاء · 46 ans »)
if (born) parts.push(person.birthPlace ? <>{born}, <bdi>{formatPlace(person.birthPlace)}</bdi></> : born)
if (!person.isAlive) parts.push(died || '?')
return parts.length ? parts.reduce((line, part) => <>{line} – {part}</>) : null
// « – 1995 » passe entier à la ligne : Chrome coupe après un tiret même suivi d'une espace insécable,
// le gluon U+2060 l'en empêche
return parts.length ? parts.reduce((line, part) => <>{line} –{'\u2060\u00a0'}{part}</>) : null
})()
const ageLabel = age === null ? null : person.isAlive ? t('{age} ans', { age }) : t('{age} ans au décès', { age })
// Une seule ligne sous le nom : surnom, repères de vie, âge, séparés par un point médian
@@ -644,7 +651,7 @@ const PersonCard = ({
const renderRelationRow = (target, relationType, meta, removable = true) => (
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
<span className="pc-rel-avatar" aria-hidden="true">
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
{target?.photo ? <PortraitThumb src={target.photo} crop={target.avatarCrop} /> : <span>{target?.firstName?.[0] || '?'}</span>}
</span>
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
{meta && <span className="pc-rel-meta">{meta}</span>}
@@ -946,13 +953,13 @@ const PersonCard = ({
life={lifeState.personId === person.id ? lifeState.data : null}
loading={lifeState.personId !== person.id || lifeState.loading}
failed={lifeState.personId === person.id && lifeState.failed}
medias={personMedias}
medias={lifeMedias}
canAdd={canAddMoment}
canEdit={canEditMoments}
notice={lifeNotice}
onAdd={() => { setLifeNotice(''); setMomentSheet({ entry: null }) }}
onEdit={(entry) => { setLifeNotice(''); setMomentSheet({ entry }) }}
onOpenMedia={(media) => onMediaSelect?.(media, person)}
onOpenMedia={(media) => onMediaSelect?.(media, media.personId === person.id ? person : (getPersonById(media.personId) || person))}
onOpenPerson={onOpenPerson}
/>
)}
@@ -1272,7 +1279,7 @@ const PersonCard = ({
entry={momentSheet.entry}
treeId={treeId}
token={immichToken}
medias={personMedias}
medias={momentSheet.entry?.milestone?.unionId ? lifeMedias : personMedias}
isAdmin={canEditMoments}
onUploadFile={onUploadMomentFile}
onClose={() => setMomentSheet(null)}
@@ -1350,7 +1357,7 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
<li key={p.id}>
<button type="button" className="pc-search-item" onClick={() => onSelect(p)}>
<span className="pc-rel-avatar" aria-hidden="true">
{p.photo ? <img src={p.photo} alt="" /> : <span>{p.firstName[0]}</span>}
{p.photo ? <PortraitThumb src={p.photo} crop={p.avatarCrop} /> : <span>{p.firstName[0]}</span>}
</span>
<span className="pc-rel-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
{p.birthYear && <span className="pc-rel-meta">{p.birthYear}</span>}
@@ -0,0 +1,5 @@
/* Cadre de la miniature : remplit son conteneur, sauf si une classe passée (mp-avatar, nav-avatar-photo…) en décide
autrement. :where ne pèse rien, la classe passée l'emporte toujours. */
:where(.portrait-thumb) { position: relative; display: block; width: 100%; height: 100%; border-radius: inherit; }
.portrait-thumb { overflow: hidden; }
.portrait-thumb > img { display: block; width: 100%; height: 100%; object-fit: cover; }
+25
View File
@@ -0,0 +1,25 @@
import { useEffect, useState } from 'react'
import { imagePlacement } from '../utils/portraitCrop.js'
import './PortraitThumb.css'
// Portrait en miniature (listes, puces, visionneuse, menu du compte) avec le recadrage enregistré :
// le même cadrage que la fiche et l'arbre (portraitCrop.js). Le cadre est carré ; l'image se place
// une fois sa taille connue, et reste en « cover » centré jusque-là ou sans cadrage.
export default function PortraitThumb({ src, crop = null, alt = '', className = '' }) {
const [size, setSize] = useState(null)
useEffect(() => { setSize(null) }, [src])
const placement = size && crop ? imagePlacement(size.w, size.h, 1, crop) : null
const style = placement
? { position: 'absolute', maxWidth: 'none', left: `${placement.left}%`, top: `${placement.top}%`, width: `${placement.width}%`, height: `${placement.height}%` }
: undefined
return (
<span className={`portrait-thumb${className ? ` ${className}` : ''}`}>
<img
src={src}
alt={alt}
onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
style={style}
/>
</span>
)
}
@@ -9,6 +9,7 @@ import { SEARCH_LINKS, SEARCH_LINK_REGIONS } from '../data/searchLinks.js'
import { changesFromResult, personFromResult } from '../utils/searchResultChanges.js'
import { fullNameLabel } from '../utils/personName.js'
import Skeleton from './Skeleton.jsx'
import PortraitThumb from './PortraitThumb.jsx'
// Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche
const SOURCE_INFO = {
@@ -387,7 +388,7 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
{candidates.map(({ person, yearOff }) => (
<li key={person.id}>
<button type="button" className={`apn-dropdown-item${picker.relative?.id === person.id ? ' is-selected' : ''}`} aria-pressed={picker.mode === 'new' ? picker.relative?.id === person.id : undefined} onClick={() => handlePick(person)} disabled={picker.saving}>
<span className="apn-avatar" aria-hidden="true">{person.photo ? <img src={person.photo} alt="" /> : (person.firstName || '?').charAt(0)}</span>
<span className="apn-avatar" aria-hidden="true">{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : (person.firstName || '?').charAt(0)}</span>
<span className="apn-dropdown-name">{person.firstName} <em>{person.lastName}</em></span>
<span className={`apn-dropdown-date${yearOff && picker.mode === 'existing' ? ' sp-year-off' : ''}`} title={yearOff && picker.mode === 'existing' ? t("Pas la même année de naissance que ce résultat") : undefined}>{personYears(person)}</span>
</button>
+3 -5
View File
@@ -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; }
@@ -292,12 +293,9 @@
.undo-toast-btn::before { content: ''; position: absolute; inset: -8px; }
.undo-toast-btn:hover { background: color-mix(in srgb, var(--pz-bg) 26%, transparent); }
.undo-toast-btn:focus-visible { outline: 2px solid var(--pz-bg); outline-offset: 2px; }
/* Le lien d'une invitation (démo) reste cliquable, le reste de la bulle laisse passer les clics */
.contrib-success-toast a { pointer-events: auto; }
@media (max-width: 768px) { .contrib-success-toast { bottom: 78px; padding: 11px 16px; border-radius: 16px; font-size: 13px; } }
/* Sous 768 px, la réglette de zoom reste ouverte en bas à gauche (ZoomControl.css, de 8 à 60 px du bord) :
tant qu'elle se voit (ni panneau ni fiche par-dessus), la notification commence après elle au lieu de passer dessus */
@media (max-width: 768px) {
body:not(:has(.pz-overlay, .person-card)) .contrib-success-toast { left: 72px; right: 12px; width: auto; max-width: none; transform: none; }
}
@media (max-width: 560px) {
.contrib-invite-link { flex-wrap: wrap; border-radius: var(--pz-r-m); padding: 10px; }
@@ -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 })
}
-12
View File
@@ -185,16 +185,4 @@
width: 44px;
height: 44px;
}
/* Slider toujours visible sur mobile */
.zoom-slider-panel.visible {
max-height: 240px;
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
pointer-events: auto;
}
.zoom-slider-track {
cursor: pointer;
}
}
+4 -3
View File
@@ -104,7 +104,8 @@ export default function ZoomControl({ zoomApiRef }) {
document.addEventListener('pointerup', onUp)
}, [zoomApiRef])
const panelVisible = isHovered || isMobile
// Sur téléphone, on zoome à deux doigts : la réglette n'apparaît jamais, la loupe seule reste
const panelVisible = isHovered && !isMobile
return (
<div
@@ -112,7 +113,7 @@ export default function ZoomControl({ zoomApiRef }) {
onMouseEnter={showSlider}
onMouseLeave={hideSlider}
>
{/* Panel slider — hover sur desktop, toujours visible sur mobile */}
{/* Panel slider — au survol sur ordinateur, jamais sur téléphone */}
<div
className={`zoom-slider-panel ${panelVisible ? 'visible' : ''}`}
onMouseEnter={showSlider}
@@ -139,7 +140,7 @@ export default function ZoomControl({ zoomApiRef }) {
<button
className="zoom-button"
onClick={stepZoom}
aria-label={t('Contrôle du zoom')}
aria-label={t('Zoomer')}
>
<ZoomIn size={20} strokeWidth={1.5} className="zoom-btn-icon" />
</button>
+117 -20
View File
@@ -127,6 +127,20 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
const result = computeLayout(graphData, referenceId)
const nodes = result.children
const byId = new Map(nodes.map(n => [n.id, n]))
// Nom d'une famille : celui que portent le plus ses membres (nom de naissance, sinon nom), pas celui de la seule
// personne de référence. Ex. : 175 personnes dont 5 portent le nom de la référence. Égalité : le nom
// de secours (celui de la référence) s'il est parmi les premiers, sinon le premier rencontré.
const nomMajoritaire = (ids, secours = '') => {
const n = new Map()
for (const id of ids) {
const d = byId.get(id)?._type === 'person' ? byId.get(id)._data : null
const nom = (d?.birthName || d?.lastName || '').trim()
if (nom) n.set(nom, (n.get(nom) || 0) + 1)
}
let best = '', max = 0
for (const [nom, c] of n) if (c > max) { best = nom; max = c }
return secours && n.get(secours) === max ? secours : (best || secours)
}
// Chaînes de couples (composantes d'une rangée) telles que computeLayout les a ordonnées
const chainOf = new Map()
@@ -416,8 +430,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
unplace(block.members)
forgetExternal(snap.externalBefore, snap.externalAfter)
const A = byId.get(id)
const label = A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || ''
const members = new Set([...extra, ...nested].filter(m => !keep.has(m)))
const label = nomMajoritaire(members, A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || '')
registerTab({ anchorId: pcPersons[0], pu: parentUnionOf.get(pcPersons[0]) || pu, label, count: members.size, members, hinges: pcPersons.slice(1), hingeEdges: [] })
for (const x of pcPersons) byId.get(x)._familyTab = { anchorId: pcPersons[0], label, count: members.size, tabKey: pcPersons[0].replace(/^p-/, ''), place: 'above' }
const saved = anchor
@@ -461,7 +475,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
anchor = saved
if (block.width > FAMILY_TAB_PX && !familyTabs) {
const A = byId.get(id)
bigFamilies.push({ anchorId: id, label: A._data?.birthName || A._data?.lastName || '', count: [...block.members].filter(m => byId.get(m)?._type === 'person').length })
bigFamilies.push({ anchorId: id, label: nomMajoritaire(block.members, A._data?.birthName || A._data?.lastName || ''), count: [...block.members].filter(m => byId.get(m)?._type === 'person').length })
}
if (block.width > FAMILY_TAB_PX && familyTabs) {
// Trop large pour cet arbre : la famille devient un onglet et n'est pas dessinée ici. Sa construction ne
@@ -474,8 +488,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
forgetExternal(externalBefore, new Set(externalEdges))
const A = byId.get(id)
const partners = [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key].map(k => byId.get(k)?._data).filter(Boolean)
const label = A._data?.birthName || partners.find(d => d.lastName)?.lastName || A._data?.lastName || ''
const members = new Set([...[...block.members].filter(m => byId.get(m)?._type === 'person'), ...nested])
const label = nomMajoritaire(members, A._data?.birthName || partners.find(d => d.lastName)?.lastName || A._data?.lastName || '')
registerTab({ anchorId: id, pu, label, count: members.size, members, hinges: [], hingeEdges: [[pu, id]] })
A._familyTab = { anchorId: id, label, count: members.size, tabKey: id.replace(/^p-/, ''), place: 'above' }
continue
@@ -818,7 +832,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
// la barre d'onglets ; un onglet est le même arbre recalculé avec le conjoint comme référence.
const drawn = nodes.filter(n => n._type === 'person')
result.families = [
{ id: 'main', label: referenceNode?._data?.lastName || drawn[0]?._data?.lastName || '', count: drawn.length, referenceId: result.referenceId ?? null },
{ id: 'main', label: nomMajoritaire(drawn.map(n => n.id), referenceNode?._data?.lastName || drawn[0]?._data?.lastName || ''), count: drawn.length, referenceId: result.referenceId ?? null },
...tabs.map(tb => ({ id: tb.anchorId, anchorId: tb.anchorId, label: tb.label, count: tb.count, members: [...tb.members].map(m => m.replace(/^p-/, '')), hinges: [tb.anchorId, ...tb.hinges].map(m => m.replace(/^p-/, '')) })),
...bigFamilies.map(f => ({ id: f.anchorId, anchorId: f.anchorId, label: f.label, count: f.count, members: [], drawn: true })),
]
@@ -840,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) {
@@ -875,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
@@ -895,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
@@ -919,20 +1002,34 @@ 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
// prend les prénoms de sa charnière, ceux que porte la pastille qui y mène (Patch, 20/10 : « plusieurs fois
// les mêmes familles »)
const firstName = new Map((graphData?.persons || globalPersons).map(p => [String(p.id), p.firstName]))
// 28/10 (Patch : « ptet le Joseph et Marie ») : l'onglet le plus petit prend d'abord le nom suivant le plus
// porté chez lui et pas encore pris par un autre onglet ; les prénoms de la charnière ne servent qu'en dernier recours.
const persons = graphData?.persons || globalPersons
const firstName = new Map(persons.map(p => [String(p.id), p.firstName]))
const familyName = new Map(persons.map(p => [String(p.id), (p.birthName || p.lastName || '').trim()]))
const namesByCount = (tb) => {
const n = new Map()
for (const id of tb.personIds || []) { const nom = familyName.get(String(id).replace(/^p-/, '')); if (nom) n.set(nom, (n.get(nom) || 0) + 1) }
return [...n].sort((x, y) => y[1] - x[1]).map(([nom]) => nom)
}
const byLabel = new Map()
for (const tb of tabs) { if (!byLabel.has(tb.label)) byLabel.set(tb.label, []); byLabel.get(tb.label).push(tb) }
const taken = new Set(tabs.map(tb => tb.label))
for (const group of byLabel.values()) {
if (group.length < 2) continue
const biggest = group.reduce((a, b) => (b.count > a.count ? b : a))
for (const tb of group) {
for (const tb of [...group].sort((a, b) => b.count - a.count)) {
if (tb === biggest) continue
const next = namesByCount(tb).find(nom => !taken.has(nom))
if (next) { tb.label = next; taken.add(next); continue }
const names = [...new Set((tb.hinges || [tb.referenceId]).map(id => firstName.get(String(id))).filter(Boolean))].slice(0, 2)
if (names.length) tb.label = `${tb.label} · ${names.join(' et ')}`
}
@@ -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'])
})
})
+4 -1
View File
@@ -697,7 +697,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
// la charnière peut avoir des enfants dessous, jamais rien au-dessus que le fil de ses parents.
if (withDetail && node._familyTab) {
const tab = node._familyTab
const text = `${tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')} · ${tab.count} ›`
// Retour vers la famille d'où l'on vient : même place que la pastille de l'aller (la charnière ne bouge pas),
// flèche retournée (Patch, 28/10, planche D)
const name = tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')
const text = tab.back ? `‹ ${name} · ${tab.count}` : `${name} · ${tab.count} ›`
const py = cy - PERSON_R * personScale - RENVOI_CHILD_GAP
familyPills.push({ text, cx, cy: py, anchorId: tab.anchorId, tabKey: tab.tabKey, alpha: nodeProgress * labelAlpha })
}
@@ -55,6 +55,9 @@
@media (hover: hover) and (pointer: fine) { .nav-add:hover { background: #3a3530; } }
.nav-add.is-open,
.nav-add.is-editing { box-shadow: 0 0 0 4px var(--pz-a100); }
/* « Modifier » en clair dans la démo, quand « Créer mon arbre » prend le noir à côté */
.nav-add--soft { background: var(--pz-line-soft); color: var(--pz-ink); }
@media (hover: hover) and (pointer: fine) { .nav-add--soft:hover { background: #e9e3da; } }
/* Rond du compte */
.nav-dropdown-wrapper { position: static; }
@@ -1,6 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import {
Brush, ImagePlus, Plus, UserPlus, User, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search,
Brush, ImagePlus, Plus, UserPlus, User, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search, TreeDeciduous,
} from 'lucide-react'
import { NavMenu, NavMenuGroup, NavMenuItem } from './NavMenu'
import AvatarMenu from './AvatarMenu'
@@ -13,6 +13,7 @@ import { PHONE_QUERY } from '../../utils/sheetDrag'
import './ContextualNavbar.css'
import { initialOf } from '../../utils/initial.js'
import Kbd from '../Kbd.jsx'
import PortraitThumb from '../PortraitThumb.jsx'
const ANNOT_TOOLS = [
{ id: 'pointer', label: t('Sélection'), Icon: MousePointer2 },
@@ -42,6 +43,7 @@ function ContextualNavbar({
userDisplayName = '',
userEmail = '',
userAvatarPhoto = '',
userAvatarCrop = null,
linkedPersonName = '',
activeTreeName = '',
onEditClick,
@@ -54,6 +56,7 @@ function ContextualNavbar({
onCreateTree,
onProfileClick,
canRegister = true,
signupPrompts = false,
onAccountClick,
onSettingsClick,
onLogout,
@@ -148,9 +151,10 @@ function ContextualNavbar({
}
// Création de compte dans l'app
const handleCreateAccountClick = () => {
trackAppEvent('create_account_clicked', accountClickProps)
onProfileClick?.('register')
// via : « ajouter » (menu Ajouter de la famille) ou « compte » (menu du compte)
const handleCreateAccountClick = (via = 'compte') => {
trackAppEvent('create_account_clicked', { ...accountClickProps, via })
onProfileClick?.('register', via)
}
const handleAnnotToolClick = (toolId) => {
@@ -223,6 +227,17 @@ function ContextualNavbar({
addGroups.push({ label: t('Chercher'), items: [{ key: 'search', icon: <Search {...ICON} />, label: t("Quelqu'un ailleurs"), sub: t('décès depuis 1970, archives, presse'), onSelect: onSearchClick }] })
}
}
// La famille sans compte trouve « Faire le mien » en bas du menu qu'elle ouvre, le menu Ajouter, et la ligne quitte
// le menu du compte. Seulement si l'instance invite à créer son arbre (SIGNUP_PROMPTS).
const ownTreeInAddMenu = signupPrompts && canRegister && isFamily && !authenticated && !showMobileAnnot
// Démo sans compte : « Créer mon arbre » devient le bouton noir de la barre, « Modifier » passe en clair à côté
// (planche du 03/11, variante C). Il quitte alors le menu du rond, pour ne pas être proposé deux fois.
// Pas dans la démo intégrée à la landing (?embed) : l'inscription s'ouvrirait dans le cadre, la landing a ses boutons.
const isEmbed = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('embed')
const demoSignupInBar = signupPrompts && canRegister && isDemo && !isEmbed && !authenticated && !showMobileAnnot
if (ownTreeInAddMenu) {
addGroups.push({ label: t('Votre famille'), items: [{ key: 'own-tree', icon: <TreeDeciduous {...ICON} />, label: t('Faire le mien'), sub: t('un arbre pour votre famille, gratuit'), onSelect: () => handleCreateAccountClick('ajouter') }] })
}
const hasAddMenu = addGroups.length > 0
const renderAddMenu = () => (
@@ -361,7 +376,7 @@ function ContextualNavbar({
<button
type="button"
ref={addBtnRef}
className={`nav-add${isPhone ? '' : ' nav-add--label'}${openMenu === 'add' ? ' is-open' : ''}${editModeActive ? ' is-editing' : ''}`}
className={`nav-add${isPhone ? '' : ' nav-add--label'}${demoSignupInBar ? ' nav-add--soft' : ''}${openMenu === 'add' ? ' is-open' : ''}${editModeActive ? ' is-editing' : ''}`}
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
aria-expanded={openMenu === 'add'}
aria-haspopup="menu"
@@ -376,6 +391,17 @@ function ContextualNavbar({
)}
{hasAddMenu && openMenu === 'add' && renderAddMenu()}
{demoSignupInBar && (
<button
type="button"
className="nav-add nav-add--label"
onClick={() => { closeMenus(); handleCreateAccountClick('demo-barre') }}
>
<TreeDeciduous size={18} strokeWidth={2.2} aria-hidden="true" />
<span className="nav-add-label">{t('Créer mon arbre')}</span>
</button>
)}
{/* Rond du compte : icône de profil hors connexion, initiale ou photo connecté */}
<div className="nav-dropdown-wrapper">
<button
@@ -391,7 +417,7 @@ function ContextualNavbar({
{!authenticated ? (
<User size={20} strokeWidth={2} aria-hidden="true" />
) : userAvatarPhoto ? (
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
<PortraitThumb className="nav-avatar-photo" src={userAvatarPhoto} crop={userAvatarCrop} />
) : (
<span className="nav-avatar-initial" aria-hidden="true">{avatarInitial}</span>
)}
@@ -420,8 +446,8 @@ function ContextualNavbar({
onSelectTree={onTreeSelect}
onCreateTree={onCreateTree}
onSettings={onSettingsClick}
onLogin={() => onProfileClick?.('login')}
onRegister={canRegister ? handleCreateAccountClick : undefined}
onLogin={() => onProfileClick?.('login', 'compte')}
onRegister={canRegister && !ownTreeInAddMenu && !demoSignupInBar ? () => handleCreateAccountClick('compte') : undefined}
onLogout={onLogout}
/>
)}
@@ -1,6 +1,7 @@
import './SearchResultsDropdown.css'
import { t } from '../../i18n/index.js'
import { firstNameLabel } from '../../utils/personName.js'
import PortraitThumb from '../PortraitThumb.jsx'
// Raison du match renvoyée par usePersonSearch (valeur française), traduite à l'affichage
const REASON_LABELS = {
@@ -58,7 +59,7 @@ function SearchResultsDropdown({
{/* Photo miniature */}
<div className="search-result-thumb">
{person.photo ? (
<img src={person.photo} alt="" />
<PortraitThumb src={person.photo} crop={person.avatarCrop} />
) : (
<div className="search-result-initials">
{person.firstName?.[0] || '?'}
+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()
+13
View File
@@ -34,6 +34,19 @@ export default {
// App.jsx : liens familiaux
'Lien déjà présent': 'This link already exists',
'Lien familial ajouté (démo locale)': 'Family link added (local demo)',
// Invitations à créer son arbre (SIGNUP_PROMPTS)
'Lien ajouté à la démo.': 'Link added to the demo.',
'Lien modifié dans la démo.': 'Link changed in the demo.',
'Lien retiré de la démo.': 'Link removed from the demo.',
'Votre essai reste dans la démo.': 'Your changes stay in the demo.',
'Pour votre famille,': 'For your own family,',
'créez votre arbre': 'create your tree',
'Votre famille': 'Your family',
'Faire le mien': 'Make my own',
'un arbre pour votre famille, gratuit': 'a tree for your family, free',
// Plusieurs prénoms dans le champ Prénom
'et': 'and',
"{names} sont sur l'arbre, en brouillon. Envoyez quand vous avez fini.": '{names} are on the tree, as a draft. Send when you are done.',
'Lien ajouté à la contribution': 'Link added to your contribution',
'Lien familial ajouté': 'Family link added',
'Lien modifié (démo locale)': 'Link changed (local demo)',
+14 -6
View File
@@ -1,13 +1,21 @@
// Traductions anglaises, clé = texte français exact passé à t().
export default {
'Famille {name}': 'The {name} family',
'{n} personnes': '{n} people',
'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}',
@@ -17,12 +25,12 @@ export default {
// Le lien de parenté affiché au survol se nomme langue par langue dans utils/kinship/
// Glisser pour ajouter une personne (components/ProximityFeedback.jsx)
'Conjoint': 'Spouse',
'Conjoint': 'Partner',
'Frère·Sœur': 'Sibling',
'Lien': 'Link',
// Contrôles
'Contrôle du zoom': 'Zoom control',
'Zoomer': 'Zoom in',
'En cours': 'Loading',
// Annotations
+4
View File
@@ -41,6 +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. 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}',
@@ -171,6 +173,8 @@ export default {
'Modifications en cours': 'Unsaved changes',
'Quitter {em} ?': 'Leave {em}?',
"l'édition": 'edit mode',
'Vos dernières modifications restent dans la démo tant que cette page est ouverte, puis elles disparaissent.':
'Your latest changes stay in the demo while this page is open, then they are gone.',
'Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.':
"Your latest changes aren't saved yet. They stay on this device until you discard them.",
'Abandonner': 'Discard',
+10 -10
View File
@@ -85,10 +85,10 @@ export default {
"Aucun parent pour l'instant": 'No parents yet',
'Ajouter un parent': 'Add a parent',
'Rechercher un parent': 'Search for a parent',
'Conjoint·e·s': 'Spouses',
'Aucun·e conjoint·e': 'No spouse',
'Ajouter un·e conjoint·e': 'Add a spouse',
'Rechercher un·e conjoint·e': 'Search for a spouse',
'Conjoint·e·s': 'Partners',
'Aucun·e conjoint·e': 'No partner',
'Ajouter un·e conjoint·e': 'Add a partner',
'Rechercher un·e conjoint·e': 'Search for a partner',
'Aucun enfant': 'No children',
'Ajouter un enfant': 'Add a child',
'Rechercher un enfant': 'Search for a child',
@@ -178,20 +178,20 @@ export default {
// Ajout d'une personne (AddPersonButton, AddPersonPanel, InlinePersonForm)
'Glisser pour ajouter une personne': 'Drag to add a person',
'Ajouter un conjoint': 'Add a spouse',
'Ajouter un conjoint': 'Add a partner',
'Ajouter un frère/sœur': 'Add a sibling',
'Relâcher pour créer': 'Release to create',
'Ajouter une personne': 'Add a person',
'Enfant': 'Child',
'Conjoint': 'Spouse',
'Conjoint': 'Partner',
'Frère·Sœur': 'Sibling',
'Positionnez la personne': 'Place the person',
'Parent de': 'Parent of',
'Enfant de': 'Child of',
'Conjoint·e de': 'Spouse of',
'Conjoint·e de': 'Partner of',
'Frère·Sœur de': 'Sibling of',
'Parent': 'Parent',
'Conjoint·e': 'Spouse',
'Conjoint·e': 'Partner',
'Nouvelle fiche': 'New profile',
"Ajouter <em>quelqu'un</em>": 'Add <em>someone</em>',
'Marie': 'Mary',
@@ -206,10 +206,10 @@ export default {
'Ajout en cours': 'Adding',
'Ajouter cette personne': 'Add this person',
'Enfant de {name}': 'Child of {name}',
'Conjoint de {name}': 'Spouse of {name}',
'Conjoint de {name}': 'Partner of {name}',
'Frère·Sœur de {name}': 'Sibling of {name}',
'Nouvel enfant': 'New child',
'Nouveau conjoint': 'New spouse',
'Nouveau conjoint': 'New partner',
'Nouveau frère/sœur': 'New sibling',
'Nouvelle personne': 'New person',
'Jean': 'John',
+1
View File
@@ -83,6 +83,7 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-btn--danger-ghost:hover:not(:disabled) { background: var(--pz-danger-bg); }
}
.pz-link { background: none; border: 0; padding: 0; cursor: pointer; font: 500 14px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 3px; }
.pz-inline-link { color: inherit; font-weight: 500; text-decoration: underline; text-underline-offset: 3px; }
/* ── Champs ───────────────────────────────────────────────────── */
.pz-field { display: grid; gap: 7px; }
+26
View File
@@ -0,0 +1,26 @@
import { isYearOnly } from './dateInput.js'
// Âge en années révolues (#198) : l'anniversaire compte une fois passé, pas dès le 1er janvier.
// Une année seule (rangée au 1er janvier) n'a pas de jour : on garde la différence des années.
const partsOf = (value) => {
if (value === null || value === undefined || value === '') return null
if (Number.isFinite(value)) return { year: Number(value), md: null }
const text = String(value).slice(0, 10)
const m = text.match(/^(\d{4})(?:-(\d{2})-(\d{2}))?/)
if (!m) return null
return { year: Number(m[1]), md: m[2] && !isYearOnly(text) ? `${m[2]}-${m[3]}` : null }
}
const today = () => {
const now = new Date()
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
}
// end absent = aujourd'hui ; null si la naissance (ou la fin donnée) manque
export function ageInYears(birth, end = today()) {
const from = partsOf(birth)
const to = partsOf(end)
if (!from || !to) return null
const age = to.year - from.year
return from.md && to.md && to.md < from.md ? age - 1 : age
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { ageInYears } from './age.js'
describe('ageInYears', () => {
it("ne compte l'anniversaire qu'une fois passé (#198)", () => {
expect(ageInYears('1980-12-15', '2026-10-29')).toBe(45)
expect(ageInYears('1980-12-15', '2026-12-15')).toBe(46)
expect(ageInYears('1980-03-02T00:00:00.000Z', '2026-10-29')).toBe(46)
})
it('garde la différence des années pour une année seule', () => {
expect(ageInYears('1980-01-01', '2026-10-29')).toBe(46)
expect(ageInYears(1980, '2026-10-29')).toBe(46)
expect(ageInYears('1920-05-10', 1995)).toBe(75)
})
it('calcule un âge au décès au jour près', () => {
expect(ageInYears('1920-05-10', '1995-05-09T00:00:00.000Z')).toBe(74)
})
it('rend null sans naissance', () => {
expect(ageInYears(null)).toBe(null)
expect(ageInYears('', '2026-10-29')).toBe(null)
})
})
+3 -2
View File
@@ -57,9 +57,10 @@ export function scrubAnalyticsEvent(event) {
// La première source connue gagne, « direct » ne remplace jamais une vraie source.
const SOURCE_KEY = 'parenthese_source'
export function rememberSource() {
// fallback : source donnée par l'app quand l'adresse n'en porte pas (arbre-partage, demo), au même rang
export function rememberSource(fallback = '') {
try {
const fromUrl = new URLSearchParams(window.location.search).get('source')
const fromUrl = new URLSearchParams(window.location.search).get('source') || fallback
const clean = String(fromUrl || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 40)
if (clean && clean !== 'direct' && !localStorage.getItem(SOURCE_KEY)) {
localStorage.setItem(SOURCE_KEY, clean)
+17
View File
@@ -36,3 +36,20 @@ export function getSiblingParentLinks(personId, filiations, unions) {
return links
}
/**
* Parents à rattacher à un nouvel enfant de `parentId` : quand cette personne n'a qu'une union,
* l'enfant est celui du couple (les deux partenaires, avec l'identifiant de l'union).
* Aucune union, ou plusieurs (on ne devine pas l'autre parent) : liste vide, l'enfant reste au seul parent.
*
* @param {string} parentId - Personne à qui l'on ajoute un enfant
* @param {Array} unions - Unions du graphe courant ({ id, partner1Id, partner2Id })
* @returns {Array<{ parentId: string, unionId: string }>}
*/
export function getCoupleParentLinks(parentId, unions) {
const key = String(parentId)
const own = (unions || []).filter((union) => String(union.partner1Id) === key || String(union.partner2Id) === key)
if (own.length !== 1 || !own[0].partner1Id || !own[0].partner2Id) return []
const unionId = String(own[0].id)
return [{ parentId: String(own[0].partner1Id), unionId }, { parentId: String(own[0].partner2Id), unionId }]
}
+21 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { getSiblingParentLinks } from './familyLinks'
import { getCoupleParentLinks, getSiblingParentLinks } from './familyLinks'
describe('getSiblingParentLinks', () => {
const unions = [{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' }]
@@ -31,3 +31,23 @@ describe('getSiblingParentLinks', () => {
expect(getSiblingParentLinks('c1', undefined, undefined)).toEqual([])
})
})
describe('getCoupleParentLinks', () => {
it('gives both partners when the parent has a single union', () => {
const unions = [{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' }]
expect(getCoupleParentLinks('p2', unions)).toEqual([
{ parentId: 'p1', unionId: 'u1' },
{ parentId: 'p2', unionId: 'u1' },
])
})
it('keeps the child with the parent alone when there is no union or several', () => {
expect(getCoupleParentLinks('p1', [])).toEqual([])
expect(getCoupleParentLinks('p1', undefined)).toEqual([])
const unions = [
{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' },
{ id: 'u2', partner1Id: 'p3', partner2Id: 'p1' },
]
expect(getCoupleParentLinks('p1', unions)).toEqual([])
})
})
+21
View File
@@ -0,0 +1,21 @@
// « Paul, Marie, Jean » dans le champ Prénom d'un enfant ou d'un frère : une personne par prénom.
// Sans virgule, ou pour un parent ou un conjoint, le champ reste un seul prénom.
const MAX_NAMES = 20
export function splitFirstNames(value, relations = []) {
const text = String(value || '')
if (!text.includes(',')) return [text]
if (!relations.some((rel) => rel.type === 'child' || rel.type === 'sibling')) return [text]
const names = []
for (const part of text.split(',')) {
const name = part.trim()
if (name && !names.includes(name)) names.push(name)
}
return names.length > 1 ? names.slice(0, MAX_NAMES) : [names[0] || text]
}
// « Paul, Marie et Jean »
export function joinFirstNames(names, and) {
if (names.length < 2) return names[0] || ''
return `${names.slice(0, -1).join(', ')} ${and} ${names[names.length - 1]}`
}
+27
View File
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { joinFirstNames, splitFirstNames } from './firstNames.js'
const child = [{ type: 'child', personId: 1 }]
describe('plusieurs prénoms dans le champ Prénom', () => {
it("découpe pour un enfant ou un frère", () => {
expect(splitFirstNames('Paul, Marie, Jean', child)).toEqual(['Paul', 'Marie', 'Jean'])
expect(splitFirstNames('Paul,Marie', [{ type: 'sibling', personId: 2 }])).toEqual(['Paul', 'Marie'])
})
it('laisse un seul prénom sans virgule, ou pour un parent ou un conjoint', () => {
expect(splitFirstNames('Jean-Pierre', child)).toEqual(['Jean-Pierre'])
expect(splitFirstNames('Paul, Marie', [{ type: 'parent', personId: 1 }])).toEqual(['Paul, Marie'])
expect(splitFirstNames('Paul, Marie', [])).toEqual(['Paul, Marie'])
})
it('ignore les vides et les doublons', () => {
expect(splitFirstNames(' Paul , , Marie, Paul ', child)).toEqual(['Paul', 'Marie'])
expect(splitFirstNames('Paul,', child)).toEqual(['Paul'])
})
it('les relie pour le message', () => {
expect(joinFirstNames(['Paul', 'Marie', 'Jean'], 'et')).toBe('Paul, Marie et Jean')
expect(joinFirstNames(['Paul'], 'et')).toBe('Paul')
})
})
+24
View File
@@ -0,0 +1,24 @@
import { t } from '../i18n/index.js'
import { HELP_URL } from './siteLinks.js'
// Du 1er décembre au 6 janvier, la famille se retrouve : le message d'invitation parle de Noël
export function isChristmasSeason(now = new Date()) {
return now.getMonth() === 11 || (now.getMonth() === 0 && now.getDate() <= 6)
}
// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp. seasonal : l'instance l'autorise (SIGNUP_PROMPTS)
export function buildInviteText(url, password, { seasonal = false, now = new Date() } = {}) {
return [
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. 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 }),
t("Le mot de passe pour l'ouvrir est {password}", { password }),
'',
t("En bas de l'écran, vous pouvez ajouter une photo, un souvenir ou une personne qui manque."),
t("Le mode d'emploi, si besoin {url}", { url: HELP_URL }),
].join('\n')
}
+26
View File
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { buildInviteText, isChristmasSeason } from '../utils/inviteText.js'
// Message d'invitation de Noël (SIGNUP_PROMPTS) : du 1er décembre au 6 janvier, et seulement si l'instance l'autorise
describe("message d'invitation de Noël", () => {
const at = (iso) => new Date(`${iso}T12:00:00`)
it('couvre du 1er décembre au 6 janvier inclus', () => {
expect(isChristmasSeason(at('2026-11-30'))).toBe(false)
expect(isChristmasSeason(at('2026-12-01'))).toBe(true)
expect(isChristmasSeason(at('2027-01-06'))).toBe(true)
expect(isChristmasSeason(at('2027-01-07'))).toBe(false)
})
it('parle de Noël en décembre quand l\'instance l\'autorise', () => {
const text = buildInviteText('https://exemple.test/arbre/x', 'lilas-1928', { seasonal: true, now: at('2026-12-15') })
expect(text).toContain('Pour Noël')
expect(text).toContain('https://exemple.test/arbre/x')
expect(text).toContain('lilas-1928')
})
it('garde le message habituel hors saison ou sur une instance qui ne l\'autorise pas', () => {
expect(buildInviteText('u', 'p', { seasonal: true, now: at('2026-11-15') })).toContain('Je rassemble')
expect(buildInviteText('u', 'p', { seasonal: false, now: at('2026-12-15') })).toContain('Je rassemble')
})
})
+31 -21
View File
@@ -3,11 +3,28 @@ import { kinshipFacts } from './facts.js'
// Mot genré quand le sexe de la personne nommée est connu, forme neutre ou double sinon
const g = (sex, male, female, neutral) => (sex === 'male' ? male : sex === 'female' ? female : neutral)
// 1st, 2nd, 3rd, 4th… 11th, 12th, 13th, 21st
function ordinal(n) {
const words = ['First', 'Second', 'Third']
if (n <= 3) return words[n - 1]
const suffix = n % 100 >= 11 && n % 100 <= 13 ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th')
return `${n}${suffix}`
}
function times(n) {
return ['once', 'twice', 'thrice'][n - 1] || `${n} times`
}
// Règle des G : autant de G que de générations d'écart (grandfather, great-grandfather, great-great-grandfather)
function grule(n) {
return n === 2 ? 'Grand' : `Great-${'great-'.repeat(n - 3)}grand`
}
export default function nameEn(path) {
const { ups, downs, hasSpouse, sex } = kinshipFacts(path)
// « Spouse » reste neutre : husband et wife supposeraient un mariage
if (hasSpouse && ups === 0 && downs === 0) return 'Spouse'
// « Partner » reste neutre au lieu de supposer un mariage
if (hasSpouse && ups === 0 && downs === 0) return 'Partner'
if (hasSpouse) {
// L'anglais distingue l'ordre : le parent du conjoint (in-law) n'est pas le conjoint du parent (step)
@@ -19,28 +36,21 @@ export default function nameEn(path) {
let label
if (downs === 0 && ups > 0) {
if (ups === 1) label = g(sex, 'Father', 'Mother', 'Parent')
else if (ups === 2) label = g(sex, 'Grandfather', 'Grandmother', 'Grandparent')
else if (ups === 3) label = g(sex, 'Great-grandfather', 'Great-grandmother', 'Great-grandparent')
else label = `Ancestor, ${ups} generations up`
label = ups === 1 ? g(sex, 'Father', 'Mother', 'Parent') : `${grule(ups)}${g(sex, 'father', 'mother', 'parent')}`
} else if (ups === 0 && downs > 0) {
if (downs === 1) label = g(sex, 'Son', 'Daughter', 'Child')
else if (downs === 2) label = g(sex, 'Grandson', 'Granddaughter', 'Grandchild')
else if (downs === 3) label = g(sex, 'Great-grandson', 'Great-granddaughter', 'Great-grandchild')
else label = `Descendant, ${downs} generations down`
} else if (ups === 1 && downs === 1) label = g(sex, 'Brother', 'Sister', 'Sibling')
else if (ups === 2 && downs === 1) label = g(sex, 'Uncle', 'Aunt', 'Uncle/Aunt')
else if (ups === 3 && downs === 1) label = g(sex, 'Great-uncle', 'Great-aunt', 'Great-uncle/aunt')
else if (ups === 1 && downs === 2) label = g(sex, 'Nephew', 'Niece', 'Nephew/Niece')
else if (ups === 1 && downs === 3) label = g(sex, 'Grandnephew', 'Grandniece', 'Grandnephew/niece')
else if (ups >= 2 && downs >= 2) {
label = downs === 1 ? g(sex, 'Son', 'Daughter', 'Child') : `${grule(downs)}${g(sex, 'son', 'daughter', 'child')}`
} else if (downs === 1) {
if (ups === 1) label = g(sex, 'Brother', 'Sister', 'Sibling')
else if (ups === 2) label = g(sex, 'Uncle', 'Aunt', 'Uncle/Aunt')
// Great-uncle plutôt que granduncle : c'est la forme courante
else label = `Great-${'great-'.repeat(ups - 3)}${g(sex, 'uncle', 'aunt', 'uncle/aunt')}`
} else if (ups === 1) {
label = downs === 2 ? g(sex, 'Nephew', 'Niece', 'Nibling') : `${grule(downs - 1)}${g(sex, 'nephew', 'niece', 'nibling')}`
} else {
const degree = Math.min(ups, downs) - 1
const removed = Math.abs(ups - downs)
if (degree === 1 && removed === 0) label = 'First cousin'
else if (degree === 1) label = 'First cousin once removed'
else if (removed === 0) label = `Cousin, degree ${degree}`
else label = 'Distant cousin'
} else label = `${ups + downs} steps apart`
label = removed === 0 ? `${ordinal(degree)} cousin` : `${ordinal(degree)} cousin ${times(removed)} removed`
}
return hasSpouse ? `${label} by marriage` : label
}
+13 -2
View File
@@ -56,17 +56,28 @@ describe('nommage français', () => {
describe('nommage anglais', () => {
it('genre le mot quand le sexe est connu', () => {
expect(bothEn(['up'])).toEqual(['Father', 'Mother', 'Parent'])
expect(bothEn(['up', 'up'])).toEqual(['Grandfather', 'Grandmother', 'Grandparent'])
expect(bothEn(['up', 'up', 'up', 'up'])).toEqual(['Great-great-grandfather', 'Great-great-grandmother', 'Great-great-grandparent'])
expect(bothEn(['down', 'down'])).toEqual(['Grandson', 'Granddaughter', 'Grandchild'])
expect(bothEn(['sibling'])).toEqual(['Brother', 'Sister', 'Sibling'])
expect(bothEn(['up', 'sibling'])).toEqual(['Uncle', 'Aunt', 'Uncle/Aunt'])
expect(bothEn(['sibling', 'down'])).toEqual(['Nephew', 'Niece', 'Nephew/Niece'])
expect(bothEn(['sibling', 'down'])).toEqual(['Nephew', 'Niece', 'Nibling'])
expect(bothEn(['spouse', 'up'])).toEqual(['Father-in-law', 'Mother-in-law', 'Parent-in-law'])
expect(bothEn(['up', 'spouse'])).toEqual(['Stepfather', 'Stepmother', 'Stepparent'])
expect(bothEn(['spouse', 'down'])).toEqual(['Stepson', 'Stepdaughter', 'Stepchild'])
expect(bothEn(['down', 'spouse'])).toEqual(['Son-in-law', 'Daughter-in-law', 'Child-in-law'])
expect(bothEn(['spouse', 'sibling'])).toEqual(['Brother-in-law', 'Sister-in-law', 'Brother/sister-in-law'])
expect(bothEn(['spouse'])).toEqual(['Spouse', 'Spouse', 'Spouse'])
expect(bothEn(['spouse'])).toEqual(['Partner', 'Partner', 'Partner'])
expect(bothEn(['up', 'sibling', 'down'])).toEqual(['First cousin', 'First cousin', 'First cousin'])
expect(bothEn(['up', 'up', 'sibling', 'down', 'down'])).toEqual(['Second cousin', 'Second cousin', 'Second cousin'])
expect(bothEn(['up', 'up', 'sibling', 'down'])).toEqual(['First cousin once removed', 'First cousin once removed', 'First cousin once removed'])
expect(bothEn(['up', 'up', 'up', 'up', 'up', 'up', 'sibling', 'down', 'down', 'down'])).toEqual(['Third cousin thrice removed', 'Third cousin thrice removed', 'Third cousin thrice removed'])
expect(bothEn(['up', 'up', 'up', 'sibling', 'down'])).toEqual(['First cousin twice removed', 'First cousin twice removed', 'First cousin twice removed'])
expect(bothEn(['up', 'up', 'up', 'up', 'up', 'sibling', 'down'])).toEqual(['First cousin 4 times removed', 'First cousin 4 times removed', 'First cousin 4 times removed'])
expect(bothEn(['up', 'up', 'up', 'up', 'sibling', 'down', 'down', 'down', 'down'])).toEqual(['4th cousin', '4th cousin', '4th cousin'])
expect(bothEn(['up', 'up', 'sibling'])).toEqual(['Great-uncle', 'Great-aunt', 'Great-uncle/aunt'])
expect(bothEn(['up', 'up', 'up', 'sibling'])).toEqual(['Great-great-uncle', 'Great-great-aunt', 'Great-great-uncle/aunt'])
expect(bothEn(['sibling', 'down', 'down'])).toEqual(['Grandnephew', 'Grandniece', 'Grandnibling'])
})
it('une langue inconnue retombe sur l’anglais', () => {
+11 -5
View File
@@ -8,6 +8,7 @@
import { t, dateLocale } from '../i18n/index.js'
import { firstNameLabel } from './personName.js'
import { formatPlace } from './place.js'
import { ageInYears } from './age.js'
const EVENT_LABELS = {
baptism: t('Baptême'),
@@ -90,7 +91,11 @@ export function describeDate(date, end, precision) {
}
}
const ageAt = (birthYear, year) => (birthYear && year && year > birthYear ? t('{age} ans', { age: year - birthYear }) : '')
// Âge à une date, au jour près quand les deux dates sont complètes (#198), sinon à l'année
const ageAt = (birth, end) => {
const age = ageInYears(birth, end || null)
return age > 0 ? t('{age} ans', { age }) : ''
}
const joinMeta = (...parts) => parts.filter(Boolean).join(' · ')
// Le prénom d'un proche, cliquable dans la frise : il ouvre sa fiche
@@ -123,6 +128,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
const lifeUnions = new Map((life?.unions || []).map((u) => [String(u.id), u]))
const events = life?.events || []
const birthYear = yearOf(person.birthDate) ?? person.birthYear ?? null
const birthDate = person.birthDate || birthYear
const deathYear = yearOf(person.deathDate) ?? person.deathYear ?? null
const lastYear = deathYear ?? new Date().getFullYear()
const within = (year) => year && (!birthYear || year >= birthYear) && year <= lastYear
@@ -165,14 +171,14 @@ export function buildLifeTimeline(person, family = {}, life = null) {
startDatePrecision: detail.startDatePrecision || null, startPlace: detail.startPlace || '',
},
meta: year
? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthYear, year))
? joinMeta(place, detail.startDatePrecision ? describeDate(detail.startDate, null, detail.startDatePrecision) : '', ageAt(birthDate, detail.startDate || year))
: joinMeta(place, t('date inconnue')),
note: extra?.notes || '', sources: extra?.sources || '',
...milestoneFields(extra, { type: 'marriage', unionId: union.id }),
})
const hasEndEvent = events.some((e) => String(e.unionId) === String(union.id) && ['divorce', 'annulment'].includes(e.type))
if (union.endYear && !hasEndEvent) {
out.push({ key: `union-end-${union.id}`, year: union.endYear, order: 3, kind: 'minor', title: t('Fin de l’union avec {name}', { name: union.partner?.firstName || partnerName }), meta: ageAt(birthYear, union.endYear) })
out.push({ key: `union-end-${union.id}`, year: union.endYear, order: 3, kind: 'minor', title: t('Fin de l’union avec {name}', { name: union.partner?.firstName || partnerName }), meta: ageAt(birthDate, union.endYear) })
}
if (union.partner && within(yearOf(union.partner.deathDate) ?? union.partner.deathYear)) {
const year2 = yearOf(union.partner.deathDate) ?? union.partner.deathYear
@@ -190,7 +196,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
}
out.push({
key: `event-${event.id}`, year, order: 1, kind: 'minor', title,
meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthYear, year)),
meta: joinMeta(formatPlace(event.place || ''), year ? describeDate(event.date, event.dateEnd, event.datePrecision) : t('date inconnue'), ageAt(birthDate, event.date || year)),
note: event.notes || '', sources: event.sources || '',
mediaIds: event.mediaIds || [],
// Seuls les moments propres à la personne se modifient depuis l'onglet (pas ceux d'une union)
@@ -219,7 +225,7 @@ export function buildLifeTimeline(person, family = {}, life = null) {
out.push({
key: 'death', year: deathYear, order: 9, kind: 'mile',
title: place ? t('Décès à {place}', { place: place.split(',')[0] }) : t('Décès'),
meta: deathYear ? joinMeta(describeDate(person.deathDate, details.deathDateEnd, details.deathDatePrecision), ageAt(birthYear, deathYear)) : t('date inconnue'),
meta: deathYear ? joinMeta(describeDate(person.deathDate, details.deathDateEnd, details.deathDatePrecision), ageAt(birthDate, person.deathDate || deathYear)) : t('date inconnue'),
note: extra?.notes || '', sources: extra?.sources || '',
...milestoneFields(extra, { type: 'death' }),
})
+178 -93
View File
@@ -8,7 +8,7 @@
"name": "landing",
"version": "0.0.0",
"dependencies": {
"posthog-js": "^1.438.12",
"posthog-js": "^1.444.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
@@ -16,7 +16,7 @@
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-react": "^6.1.2",
"tailwindcss": "^4.3.3",
"vite": "^8.3.4"
"vite": "^8.3.5"
}
},
"node_modules/@jridgewell/gen-mapping": {
@@ -70,9 +70,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.153.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
"version": "0.155.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
"dev": true,
"license": "MIT",
"funding": {
@@ -80,34 +80,35 @@
}
},
"node_modules/@posthog/browser-common": {
"version": "0.9.5",
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.5.tgz",
"integrity": "sha512-oXnBgZvM8UQ84IcBLfGP0WSGd9rA0PCGR37H8Pw0NszJRwxwbJnFXIUih0pvI/ze2eGmzkIEC9i3SwC+bMAgjQ==",
"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.59.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.59.1",
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz",
"integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==",
"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": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
"cpu": [
"arm"
],
@@ -122,9 +123,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
"cpu": [
"arm64"
],
@@ -139,9 +140,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
"cpu": [
"arm64"
],
@@ -156,9 +157,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
"cpu": [
"x64"
],
@@ -173,9 +174,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
"cpu": [
"x64"
],
@@ -190,9 +191,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
"cpu": [
"arm"
],
@@ -207,13 +208,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -224,13 +228,16 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
"cpu": [
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -241,13 +248,16 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
"cpu": [
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -258,13 +268,16 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
"cpu": [
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -275,13 +288,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -292,13 +308,16 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
"cpu": [
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -309,9 +328,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
"cpu": [
"arm64"
],
@@ -326,9 +345,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
"cpu": [
"arm64"
],
@@ -343,9 +362,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
"cpu": [
"x64"
],
@@ -850,6 +869,72 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
@@ -1348,14 +1433,14 @@
}
},
"node_modules/posthog-js": {
"version": "1.438.12",
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.12.tgz",
"integrity": "sha512-hjhvrtqbk6pFlbtNEGk0FrWKKFdeXe/WWA/LppN0f+uDoHKR9/SZJOxUHSVCGYbGTJvNRoDjjQvZuTcq4oKSlg==",
"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.5",
"@posthog/core": "^1.59.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",
@@ -1423,13 +1508,13 @@
}
},
"node_modules/rolldown": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.153.0",
"@oxc-project/types": "=0.155.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
@@ -1439,21 +1524,21 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.13",
"@rolldown/binding-android-arm64": "1.2.13",
"@rolldown/binding-darwin-arm64": "1.2.13",
"@rolldown/binding-darwin-x64": "1.2.13",
"@rolldown/binding-freebsd-x64": "1.2.13",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
"@rolldown/binding-linux-arm64-musl": "1.2.13",
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
"@rolldown/binding-linux-x64-gnu": "1.2.13",
"@rolldown/binding-linux-x64-musl": "1.2.13",
"@rolldown/binding-openharmony-arm64": "1.2.13",
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
"@rolldown/binding-win32-x64-msvc": "1.2.13"
"@rolldown/binding-android-arm-eabi": "1.2.15",
"@rolldown/binding-android-arm64": "1.2.15",
"@rolldown/binding-darwin-arm64": "1.2.15",
"@rolldown/binding-darwin-x64": "1.2.15",
"@rolldown/binding-freebsd-x64": "1.2.15",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
"@rolldown/binding-linux-arm64-musl": "1.2.15",
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
"@rolldown/binding-linux-x64-gnu": "1.2.15",
"@rolldown/binding-linux-x64-musl": "1.2.15",
"@rolldown/binding-openharmony-arm64": "1.2.15",
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
"@rolldown/binding-win32-x64-msvc": "1.2.15"
}
},
"node_modules/scheduler": {
@@ -1542,16 +1627,16 @@
}
},
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"postcss": "^8.5.30",
"rolldown": "~1.2.14",
"tinyglobby": "^0.2.17"
},
"bin": {
@@ -1568,7 +1653,7 @@
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
+3 -3
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.12",
"posthog-js": "^1.444.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
@@ -18,6 +18,6 @@
"@tailwindcss/vite": "^4.3.3",
"@vitejs/plugin-react": "^6.1.2",
"tailwindcss": "^4.3.3",
"vite": "^8.3.4"
"vite": "^8.3.5"
}
}
+1
View File
@@ -170,6 +170,7 @@
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
@@ -162,6 +162,7 @@
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
@@ -210,6 +210,7 @@
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
@@ -128,6 +128,7 @@
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
@@ -0,0 +1,222 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FBFAF7" />
<title>Arbre généalogique en cadeau de Noël, à montrer en famille</title>
<meta name="description" content="Offrir un arbre généalogique à Noël et le montrer à toute la famille. Le préparer à temps, l&#x27;imprimer ou le partager en ligne, et le faire compléter à table." />
<link rel="canonical" href="https://parenthese.io/arbre-genealogique-cadeau-noel/" />
<link rel="alternate" hreflang="fr" href="https://parenthese.io/arbre-genealogique-cadeau-noel/" />
<link rel="alternate" hreflang="en" href="https://parenthese.io/en/family-tree-gift/" />
<link rel="alternate" hreflang="x-default" href="https://parenthese.io/arbre-genealogique-cadeau-noel/" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Parenthèse" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:url" content="https://parenthese.io/arbre-genealogique-cadeau-noel/" />
<meta property="og:title" content="Arbre généalogique en cadeau de Noël, à montrer en famille" />
<meta property="og:description" content="Préparer l&#x27;arbre avant le 24, l&#x27;offrir imprimé ou en ligne, le montrer à table, faire raconter les anciens, envoyer le lien après les fêtes." />
<meta property="og:image" content="https://parenthese.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/guides.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "HowTo",
"name": "Offrir et montrer son arbre généalogique à Noël",
"inLanguage": "fr-FR",
"step": [
{
"@type": "HowToStep",
"name": "Préparer l'arbre avant le 24",
"text": "Trois ou quatre générations autour des personnes qui seront à table, avec un portrait par personne."
},
{
"@type": "HowToStep",
"name": "Choisir le cadeau, imprimé ou en ligne",
"text": "Un arbre imprimé et encadré pour les grands-parents, un arbre en ligne dont toute la famille reçoit le lien."
},
{
"@type": "HowToStep",
"name": "Le montrer à table",
"text": "Sur la télévision du salon, ou en envoyant le lien dans la conversation de famille au moment du dessert."
},
{
"@type": "HowToStep",
"name": "Laisser les anciens raconter",
"text": "Noter les corrections et enregistrer trois minutes de la voix de ceux qui racontent."
},
{
"@type": "HowToStep",
"name": "Faire compléter par la famille",
"text": "Ajouter ce qui manque le soir même, tant que la personne qui sait est là."
},
{
"@type": "HowToStep",
"name": "Renvoyer le lien après les fêtes",
"text": "Mettre les photos de Noël sur l'arbre et renvoyer le lien début janvier."
}
]
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "Quelle idée de cadeau autour de l'arbre généalogique ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Un arbre imprimé et encadré pour les grands-parents, rempli à la main ou à partir d'un modèle. Pour toute la famille, un arbre en ligne dont on offre le lien, avec les photos et les voix de chacun. Les deux se complètent."
}
},
{
"@type": "Question",
"name": "Comment montrer un arbre généalogique à toute la famille en même temps ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Sur la télévision, si elle affiche l'écran d'un téléphone ou d'un ordinateur, ou en envoyant le lien de l'arbre dans la conversation de famille pour que chacun l'ouvre sur son téléphone."
}
},
{
"@type": "Question",
"name": "Faut-il un compte pour voir un arbre généalogique partagé ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Dans Parenthèse, non. La famille ouvre l'arbre avec le lien et le mot de passe que vous lui envoyez, sans créer de compte, et chacun peut proposer un ajout. Seuls ceux qui gèrent l'arbre ont un compte."
}
},
{
"@type": "Question",
"name": "Comment faire un arbre généalogique rapidement avant Noël ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Partez des trois ou quatre générations autour des personnes qui seront à table, ajoutez un portrait par personne, et laissez le reste pour après. Si vous avez déjà un arbre dans un logiciel de généalogie, importez-le en fichier GEDCOM plutôt que de tout ressaisir."
}
},
{
"@type": "Question",
"name": "Peut-on imprimer son arbre généalogique pour l'offrir ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Oui. Les modèles d'arbre généalogique à remplir du site sont gratuits, en PDF A4, de trois à cinq générations, et s'agrandissent chez un imprimeur."
}
},
{
"@type": "Question",
"name": "Est-ce que Parenthèse est gratuit ?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Oui, sans publicité ni abonnement."
}
}
]
}
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/" aria-label="Parenthèse, accueil">
<img src="/logo.svg" alt="" width="166" height="22" />
</a>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=guide-noel">Créer mon arbre</a>
</div>
</header>
<main>
<article class="wrap">
<span class="eyebrow">Guide</span>
<h1>Offrir et montrer son arbre généalogique à Noël</h1>
<p class="lead">Noël est le seul soir de l'année où trois ou quatre générations sont à la même table. C'est le bon moment pour sortir l'arbre généalogique de la famille, le donner à ceux qui ne l'ont jamais vu, et demander aux plus âgés ce qui manque encore.</p>
<h2>1. Préparer l'arbre avant le 24</h2>
<p>Un arbre complet n'est pas nécessaire, un arbre lisible l'est. Visez trois ou quatre générations autour des personnes qui seront à table, les grands-parents, leurs frères et sœurs, leurs parents. C'est là que chacun se retrouve, et que les questions arrivent d'elles-mêmes.</p>
<p>Commencez par ce que vous savez, puis ajoutez les dates et les lieux que vous trouvez dans les livrets de famille. Si vous avez déjà un arbre dans un logiciel ou sur un site de généalogie, exportez-le en fichier GEDCOM. La plupart des outils savent l'importer, et vous gagnez des soirées de saisie.</p>
<p>Gardez une semaine pour les photos. Un portrait par personne change tout, même flou, même tiré d'une photo de groupe. Le guide <a href="/arbre-genealogique-avec-photos/">Faire un arbre généalogique avec des photos</a> explique comment les numériser avec un téléphone.</p>
<h2>2. Le cadeau, imprimé ou en ligne</h2>
<p>Un arbre généalogique imprimé et encadré est un vrai cadeau, surtout pour des grands-parents. Vous pouvez partir d'un de nos <a href="/arbre-genealogique-a-remplir/">modèles d'arbre généalogique à remplir</a>, gratuits, à imprimer en A4 ou à agrandir chez un imprimeur. Remplissez-le à la main, l'écriture fait partie du cadeau.</p>
<p>L'arbre imprimé a une limite. Il reste chez une seule personne et il ne bouge plus. Un arbre en ligne, lui, s'ouvre sur le téléphone de chacun, garde les photos, les vidéos et les voix, et continue de grandir après les fêtes. Les deux vont bien ensemble, l'affiche pour les grands-parents et le lien pour tout le monde.</p>
<h2>3. Le montrer à table</h2>
<p>Évitez le téléphone qui fait le tour de la table pendant que la conversation continue sans lui. Deux façons marchent mieux.</p>
<p>Sur la télévision du salon, si elle peut afficher l'écran d'un téléphone ou d'un ordinateur. Toute la famille voit l'arbre en grand, et quelqu'un qui tient le téléphone ouvre les fiches à la demande. C'est le moment où l'on entend « c'est qui, lui ? ».</p>
<p>Ou en envoyant le lien dans la conversation de famille au moment du dessert. Chacun l'ouvre sur son téléphone, cherche ses parents, ses enfants, sa propre photo d'enfance. Les plus jeunes y vont en premier, et ils montrent aux plus âgés.</p>
<h2>4. Laisser les anciens raconter</h2>
<p>Un arbre affiché fait parler. Une date fausse, un prénom oublié, une photo que personne n'avait vue. Notez tout, et surtout, enregistrez. Trois minutes de la voix d'une grand-mère qui raconte valent plus qu'une page écrite à sa place.</p>
<p>Posez le téléphone sur la table, lancez l'enregistreur, et demandez une histoire précise plutôt qu'un souvenir en général. Le guide <a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a> donne les questions qui marchent le mieux.</p>
<div class="note">
<p><strong>La question qui fait parler.</strong> « Comment se passait Noël quand tu étais petit ? » Chacun a une réponse, et elle dit tout d'une époque.</p>
</div>
<h2>5. Faire compléter par la famille</h2>
<p>Le soir même, quelqu'un dira « il manque la sœur de papi » ou « ce n'est pas la bonne date ». C'est le meilleur moment pour l'ajouter, tant que la personne qui sait est là. Dans un arbre partagé, chacun peut proposer ce qu'il sait depuis son téléphone, et vous validez avant que ce soit visible. L'oncle qui a les photos du mariage les ajoute lui-même, la cousine corrige le lieu de naissance de sa mère.</p>
<h2>6. Après les fêtes, renvoyer le lien</h2>
<p>Les photos de Noël finissent noyées dans la conversation de famille, avec celles des années précédentes. Mettez-les sur l'arbre, chaque photo sur les personnes qu'elle montre, chaque enregistrement sur celle qui parle. Puis renvoyez le lien début janvier, avec ce qui a été ajouté depuis le réveillon. Ceux qui n'étaient pas là le découvrent, et l'arbre continue de s'enrichir jusqu'au Noël suivant.</p>
<p>Parenthèse est fait pour ce moment. C'est un arbre généalogique en ligne où chaque personne a ses photos, ses vidéos et sa voix. La famille l'ouvre avec un lien et un mot de passe, sans créer de compte, et propose ses ajouts. Vous gardez la main sur ce qui est publié. C'est gratuit, et le restera.</p>
<h2>Questions fréquentes</h2>
<div class="faq">
<details>
<summary>Quelle idée de cadeau autour de l'arbre généalogique ?</summary>
<p>Un arbre imprimé et encadré pour les grands-parents, rempli à la main ou à partir d'un modèle. Pour toute la famille, un arbre en ligne dont on offre le lien, avec les photos et les voix de chacun. Les deux se complètent.</p>
</details>
<details>
<summary>Comment montrer un arbre généalogique à toute la famille en même temps ?</summary>
<p>Sur la télévision, si elle affiche l'écran d'un téléphone ou d'un ordinateur, ou en envoyant le lien de l'arbre dans la conversation de famille pour que chacun l'ouvre sur son téléphone.</p>
</details>
<details>
<summary>Faut-il un compte pour voir un arbre généalogique partagé ?</summary>
<p>Dans Parenthèse, non. La famille ouvre l'arbre avec le lien et le mot de passe que vous lui envoyez, sans créer de compte, et chacun peut proposer un ajout. Seuls ceux qui gèrent l'arbre ont un compte.</p>
</details>
<details>
<summary>Comment faire un arbre généalogique rapidement avant Noël ?</summary>
<p>Partez des trois ou quatre générations autour des personnes qui seront à table, ajoutez un portrait par personne, et laissez le reste pour après. Si vous avez déjà un arbre dans un logiciel de généalogie, importez-le en fichier GEDCOM plutôt que de tout ressaisir.</p>
</details>
<details>
<summary>Peut-on imprimer son arbre généalogique pour l'offrir ?</summary>
<p>Oui. Les <a href="/arbre-genealogique-a-remplir/">modèles d'arbre généalogique à remplir</a> du site sont gratuits, en PDF A4, de trois à cinq générations, et s'agrandissent chez un imprimeur.</p>
</details>
<details>
<summary>Est-ce que Parenthèse est gratuit ?</summary>
<p>Oui, sans publicité ni abonnement.</p>
</details>
</div>
<section class="cta">
<h2>Commencez par les grands-parents, le reste viendra à table</h2>
<p>Un compte, deux générations, et la famille complète le soir du réveillon par un lien.</p>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=guide-noel">Créer mon arbre</a>
</section>
</article>
</main>
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/">parenthese.io</a> · <a href="/en/family-tree-gift/" hreflang="en" lang="en">English</a></span>
<nav aria-label="Guides">
<a href="/comment-faire-un-arbre-genealogique/">Comment faire un arbre généalogique</a>
<a href="/arbre-genealogique-a-remplir/">Arbre généalogique à remplir</a>
<a href="/application-arbre-genealogique/">Quelle application choisir</a>
<a href="/arbre-genealogique-avec-photos/">Arbre généalogique avec photos</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
</nav>
</div>
</footer>
</body>
</html>
@@ -194,6 +194,7 @@
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
+1
View File
@@ -151,6 +151,7 @@
<a href="/arbre-genealogique-avec-photos/">Arbre généalogique avec photos</a>
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
@@ -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>
@@ -133,6 +133,7 @@
<a href="/arbre-genealogique-avec-photos/">Arbre généalogique avec photos</a>
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
+1 -1
View File
@@ -144,7 +144,7 @@
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/en/">parenthese.io</a> · <a href="/cousinade/" hreflang="fr" lang="fr">Français</a></span>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a></nav>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a> <a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
+1 -1
View File
@@ -162,7 +162,7 @@
<a href="/en/family-tree-with-photos/">Family tree with photos</a>
<a href="/en/tell-your-family-story/">Telling your family's story</a>
<a href="/en/privacy/">Data and privacy</a>
</nav>
<a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
@@ -0,0 +1,214 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FBFAF7" />
<title>A family tree as a Christmas gift, and how to share it</title>
<meta name="description" content="Give a family tree for Christmas and show it to the whole family. Get it ready in time, print it or share it online, and let everyone fill in the gaps at the table." />
<link rel="canonical" href="https://parenthese.io/en/family-tree-gift/" />
<link rel="alternate" hreflang="fr" href="https://parenthese.io/arbre-genealogique-cadeau-noel/" />
<link rel="alternate" hreflang="en" href="https://parenthese.io/en/family-tree-gift/" />
<link rel="alternate" hreflang="x-default" href="https://parenthese.io/arbre-genealogique-cadeau-noel/" />
<meta property="og:type" content="article" />
<meta property="og:site_name" content="Parenthèse" />
<meta property="og:locale" content="en_US" />
<meta property="og:url" content="https://parenthese.io/en/family-tree-gift/" />
<meta property="og:title" content="A family tree as a Christmas gift, and how to share it" />
<meta property="og:description" content="Build the tree before the 24th, give it printed or online, show it at the table, let the elders talk, send the link after the holidays." />
<meta property="og:image" content="https://parenthese.io/og-image.png" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/guides.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{
"@type": "HowTo",
"name": "Give and share your family tree at Christmas",
"inLanguage": "en",
"step": [
{
"@type": "HowToStep",
"name": "Get the tree ready before the 24th",
"text": "Three or four generations around the people who will be at the table, with one portrait per person."
},
{
"@type": "HowToStep",
"name": "Choose the gift, printed or online",
"text": "A printed, framed tree for the grandparents, and an online tree whose link the whole family receives."
},
{
"@type": "HowToStep",
"name": "Show it at the table",
"text": "On the living room TV, or by sending the link to the family group chat at dessert."
},
{
"@type": "HowToStep",
"name": "Let the elders tell their stories",
"text": "Note the corrections and record three minutes of the voice of whoever is telling the story."
},
{
"@type": "HowToStep",
"name": "Let the family fill it in",
"text": "Add what is missing the same evening, while the person who knows is there."
},
{
"@type": "HowToStep",
"name": "Send the link again after the holidays",
"text": "Put the Christmas photos on the tree and send the link again in early January."
}
]
},
{
"@type": "FAQPage",
"mainEntity": [
{
"@type": "Question",
"name": "What is a good family tree gift idea?",
"acceptedAnswer": {
"@type": "Answer",
"text": "A printed, framed tree for the grandparents, filled in by hand or from a template. For the whole family, an online tree whose link you give, with everyone's photos and voices. The two work well together."
}
},
{
"@type": "Question",
"name": "How do you show a family tree to the whole family at once?",
"acceptedAnswer": {
"@type": "Answer",
"text": "On the TV, if it can show a phone or computer screen, or by sending the link to the tree in the family group chat so everyone opens it on their phone."
}
},
{
"@type": "Question",
"name": "Do you need an account to see a shared family tree?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Not in Parenthèse. The family opens the tree with the link and the password you send them, without creating an account, and anyone can suggest an addition. Only the people who manage the tree have an account."
}
},
{
"@type": "Question",
"name": "How do you make a family tree quickly before Christmas?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Start with the three or four generations around the people who will be at the table, add one portrait per person, and leave the rest for later. If you already have a tree in genealogy software, import it as a GEDCOM file instead of typing everything again."
}
},
{
"@type": "Question",
"name": "Can you print a family tree to give as a gift?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes. The printable family tree templates on this site are free, in A4 PDF, from three to five generations, and can be enlarged at a print shop."
}
},
{
"@type": "Question",
"name": "Is Parenthèse free?",
"acceptedAnswer": {
"@type": "Answer",
"text": "Yes, with no ads and no subscription."
}
}
]
}
]
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/en/" aria-label="Parenthèse, home">
<img src="/logo.svg" alt="" width="166" height="22" />
</a>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=guide-noel&amp;lang=en">Start your tree</a>
</div>
</header>
<main>
<article class="wrap">
<span class="eyebrow">Guide</span>
<h1>Give and share your family tree at Christmas</h1>
<p class="lead">Christmas is the one evening of the year when three or four generations sit at the same table. It is the right moment to bring out the family tree, give it to those who have never seen it, and ask the oldest what is still missing.</p>
<h2>1. Get the tree ready before the 24th</h2>
<p>A complete tree is not necessary, a readable one is. Aim for three or four generations around the people who will be at the table, the grandparents, their brothers and sisters, their parents. That is where everyone finds themselves, and where the questions come up on their own.</p>
<p>Start with what you know, then add the dates and places you find in birth and marriage certificates. If you already have a tree in genealogy software or on a genealogy website, export it as a GEDCOM file. Most tools can import it, and it saves you evenings of typing.</p>
<p>Keep a week for the photos. One portrait per person changes everything, even a blurry one, even one cropped from a group photo. The guide <a href="/en/family-tree-with-photos/">Making a family tree with photos</a> explains how to scan them with a phone.</p>
<h2>2. The gift, printed or online</h2>
<p>A printed, framed family tree is a real gift, especially for grandparents. You can start from one of our <a href="/en/printable-family-tree/">printable family tree templates</a>, free, to print at home or enlarge at a print shop. Fill it in by hand, the handwriting is part of the gift.</p>
<p>A printed tree has one limit. It stays with one person and never changes. An online tree opens on everyone's phone, keeps the photos, the videos and the voices, and keeps growing after the holidays. The two go well together, the poster for the grandparents and the link for everyone else.</p>
<h2>3. Show it at the table</h2>
<p>Avoid the phone that goes around the table while the conversation carries on without it. Two ways work better.</p>
<p>On the living room TV, if it can show a phone or computer screen. The whole family sees the tree on the big screen, and whoever holds the phone opens people as they are asked for. That is when you hear “wait, who is he?”.</p>
<p>Or by sending the link to the family group chat at dessert. Everyone opens it on their phone and looks for their parents, their children, their own childhood photo. The youngest go first, and they show the oldest.</p>
<h2>4. Let the elders tell their stories</h2>
<p>A tree on display gets people talking. A wrong date, a forgotten first name, a photo nobody had seen. Write it all down, and above all, record. Three minutes of a grandmother's voice telling a story are worth more than a page written for her.</p>
<p>Put the phone on the table, start the recorder, and ask for one specific story rather than memories in general. The guide <a href="/en/tell-your-family-story/">Telling your family's story</a> has the questions that work best.</p>
<div class="note">
<p><strong>The question that gets people talking.</strong> “What was Christmas like when you were little?” Everyone has an answer, and it says everything about a time.</p>
</div>
<h2>5. Let the family fill it in</h2>
<p>That same evening, someone will say “grandpa’s sister is missing” or “that is not the right date”. It is the best moment to add it, while the person who knows is there. In a shared tree, anyone can suggest what they know from their phone, and you approve it before it shows. The uncle with the wedding photos adds them himself, the cousin fixes her mother's place of birth.</p>
<h2>6. After the holidays, send the link again</h2>
<p>Christmas photos end up buried in the family group chat, with those of the years before. Put them on the tree, each photo on the people it shows, each recording on the person speaking. Then send the link again in early January, with what was added since Christmas Eve. Those who were not there discover it, and the tree keeps growing until next Christmas.</p>
<p>Parenthèse is made for this moment. It is an online family tree where each person has their photos, their videos and their voice. The family opens it with a link and a password, without creating an account, and suggests additions. You stay in control of what is published. It is free, and it will stay free.</p>
<h2>Frequently asked questions</h2>
<div class="faq">
<details>
<summary>What is a good family tree gift idea?</summary>
<p>A printed, framed tree for the grandparents, filled in by hand or from a template. For the whole family, an online tree whose link you give, with everyone's photos and voices. The two work well together.</p>
</details>
<details>
<summary>How do you show a family tree to the whole family at once?</summary>
<p>On the TV, if it can show a phone or computer screen, or by sending the link to the tree in the family group chat so everyone opens it on their phone.</p>
</details>
<details>
<summary>Do you need an account to see a shared family tree?</summary>
<p>Not in Parenthèse. The family opens the tree with the link and the password you send them, without creating an account, and anyone can suggest an addition. Only the people who manage the tree have an account.</p>
</details>
<details>
<summary>How do you make a family tree quickly before Christmas?</summary>
<p>Start with the three or four generations around the people who will be at the table, add one portrait per person, and leave the rest for later. If you already have a tree in genealogy software, import it as a GEDCOM file instead of typing everything again.</p>
</details>
<details>
<summary>Can you print a family tree to give as a gift?</summary>
<p>Yes. The <a href="/en/printable-family-tree/">printable family tree templates</a> on this site are free, in A4 PDF, from three to five generations, and can be enlarged at a print shop.</p>
</details>
<details>
<summary>Is Parenthèse free?</summary>
<p>Yes, with no ads and no subscription.</p>
</details>
</div>
<section class="cta">
<h2>Start with the grandparents, the rest will come at the table</h2>
<p>An account, two generations, and the family fills in the rest on Christmas Eve through a link.</p>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=guide-noel&amp;lang=en">Start your tree</a>
</section>
</article>
</main>
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/en/">parenthese.io</a> · <a href="/arbre-genealogique-cadeau-noel/" hreflang="fr" lang="fr">Français</a></span>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/family-reunion/">Planning a family reunion</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a></nav>
</div>
</footer>
</body>
</html>
@@ -128,7 +128,7 @@
<a href="/en/family-tree-with-photos/">Family tree with photos</a>
<a href="/en/tell-your-family-story/">Telling your family's story</a>
<a href="/en/privacy/">Data and privacy</a>
</nav>
<a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
+1 -1
View File
@@ -169,7 +169,7 @@
<a href="/en/family-tree-with-photos/">Family tree with photos</a>
<a href="/en/tell-your-family-story/">Telling your family's story</a>
<a href="/en/privacy/">Data and privacy</a>
</nav>
<a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
@@ -194,7 +194,7 @@
<a href="/en/family-tree-with-photos/">Family tree with photos</a>
<a href="/en/tell-your-family-story/">Telling your family's story</a>
<a href="/en/privacy/">Data and privacy</a>
</nav>
<a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
@@ -203,7 +203,7 @@
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/en/">parenthese.io</a> · <a href="/arbre-genealogique-a-remplir/" hreflang="fr" lang="fr">Français</a></span>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a></nav>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a> <a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
+2 -2
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>
@@ -133,7 +133,7 @@
<a href="/en/family-tree-with-photos/">Family tree with photos</a>
<a href="/en/tell-your-family-story/">Telling your family's story</a>
<a href="/en/privacy/">Data and privacy</a>
</nav>
<a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
@@ -134,7 +134,7 @@
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/en/">parenthese.io</a> · <a href="/raconter-histoire-de-famille/" hreflang="fr" lang="fr">Français</a></span>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a></nav>
<nav aria-label="Guides"><a href="/en/how-to-make-a-family-tree/">How to make a family tree</a> <a href="/en/printable-family-tree/">Printable family tree</a> <a href="/en/family-tree-app/">Choosing a family tree app</a> <a href="/en/family-tree-with-photos/">Family tree with photos</a> <a href="/en/tell-your-family-story/">Telling your family's story</a> <a href="/en/privacy/">Data and privacy</a> <a href="/en/family-tree-gift/">A family tree as a Christmas gift</a></nav>
</div>
</footer>
</body>
@@ -141,6 +141,7 @@
<a href="/arbre-genealogique-avec-photos/">Arbre généalogique avec photos</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/donnees-et-vie-privee/">Données et vie privée</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
+18
View File
@@ -108,4 +108,22 @@
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://parenthese.io/arbre-genealogique-cadeau-noel/</loc>
<lastmod>2026-11-01</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://parenthese.io/en/family-tree-gift/</loc>
<lastmod>2026-11-02</lastmod>
<changefreq>monthly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://parenthese.io/video/</loc>
<lastmod>2026-11-03</lastmod>
<changefreq>monthly</changefreq>
<priority>0.6</priority>
</url>
</urlset>
Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

+101
View File
@@ -0,0 +1,101 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#FBFAF7" />
<title>Parenthèse en 30 secondes</title>
<meta name="description" content="Un arbre de famille en ligne où chaque personne a ses photos, sa voix et ses souvenirs, et que toute la famille ouvre avec un lien. La vidéo en 30 secondes." />
<link rel="canonical" href="https://parenthese.io/video/" />
<meta property="og:type" content="video.other" />
<meta property="og:site_name" content="Parenthèse" />
<meta property="og:locale" content="fr_FR" />
<meta property="og:url" content="https://parenthese.io/video/" />
<meta property="og:title" content="Parenthèse en 30 secondes" />
<meta property="og:description" content="L'arbre de famille que toute la famille peut ouvrir, avec les photos et la voix de chacun." />
<meta property="og:image" content="https://parenthese.io/video/apercu-16x9.jpg" />
<meta property="og:video" content="https://parenthese.io/video/parenthese-16x9.mp4" />
<meta property="og:video:type" content="video/mp4" />
<meta property="og:video:width" content="1920" />
<meta property="og:video:height" content="1080" />
<meta name="twitter:card" content="summary_large_image" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="stylesheet" href="/fonts/fonts.css" />
<link rel="stylesheet" href="/guides.css" />
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "VideoObject",
"name": "Parenthèse en 30 secondes",
"description": "Un arbre de famille en ligne où chaque personne a ses photos, sa voix et ses souvenirs, et que toute la famille ouvre avec un lien.",
"thumbnailUrl": "https://parenthese.io/video/apercu-16x9.jpg",
"contentUrl": "https://parenthese.io/video/parenthese-16x9.mp4",
"uploadDate": "2026-11-03",
"duration": "PT31S",
"inLanguage": "fr-FR"
}
</script>
<!-- Pages du site préparées en avance quand on survole un lien (appui sur téléphone) : elles s'ouvrent presque
tout de suite. Seulement les pages, pas les fichiers ; l'application (app.parenthese.io) n'est pas concernée.
Un navigateur qui ne connaît pas ces règles les ignore. -->
<script type="speculationrules">{"prerender":[{"where":{"and":[{"href_matches":"/*"},{"not":{"href_matches":"/*.*"}}]},"eagerness":"moderate"}]}</script>
<style>
.film { margin: 8px 0 32px; }
.film video { display: block; width: 100%; height: auto; border-radius: 16px; background: #2a2622; }
.film .portrait { display: none; max-width: 420px; margin: 0 auto; }
@media (max-width: 640px) {
.film .paysage { display: none; }
.film .portrait { display: block; }
}
</style>
</head>
<body>
<header class="top">
<div class="wrap">
<a class="brand" href="/" aria-label="Parenthèse, accueil">
<img src="/logo.svg" alt="" width="166" height="22" />
</a>
<a class="btn" href="https://app.parenthese.io/?account=register&amp;source=video">Créer mon arbre</a>
</div>
</header>
<main>
<article class="wrap">
<span class="eyebrow">Vidéo</span>
<h1>Parenthèse en 30 secondes</h1>
<p class="lead">Un arbre de famille en ligne où chaque personne a ses photos, sa voix et ses souvenirs, et que toute la famille ouvre avec un lien.</p>
<div class="film">
<video class="paysage" controls playsinline preload="metadata" poster="/video/apercu-16x9.jpg" width="1920" height="1080">
<source src="/video/parenthese-16x9.mp4" type="video/mp4" />
</video>
<video class="portrait" controls playsinline preload="none" poster="/video/apercu-9x16.jpg" width="1080" height="1920">
<source src="/video/parenthese-9x16.mp4" type="video/mp4" />
</video>
</div>
<section class="cta">
<h2>L'arbre d'exemple s'ouvre sans compte</h2>
<p>Parcourez une famille fictive, ouvrez les fiches et les souvenirs, puis créez le vôtre.</p>
<a class="btn" href="https://app.parenthese.io/?demo&amp;lang=fr&amp;source=video">Voir l'arbre d'exemple</a>
</section>
</article>
</main>
<footer>
<div class="wrap">
<span>Parenthèse · <a href="/">parenthese.io</a></span>
<nav aria-label="Guides">
<a href="/comment-faire-un-arbre-genealogique/">Comment faire un arbre généalogique</a>
<a href="/arbre-genealogique-a-remplir/">Arbre généalogique à remplir</a>
<a href="/application-arbre-genealogique/">Quelle application choisir</a>
<a href="/arbre-genealogique-avec-photos/">Arbre généalogique avec photos</a>
<a href="/cousinade/">Organiser une cousinade</a>
<a href="/raconter-histoire-de-famille/">Raconter l'histoire de sa famille</a>
<a href="/arbre-genealogique-cadeau-noel/">Arbre généalogique en cadeau de Noël</a>
</nav>
</div>
</footer>
</body>
</html>
Binary file not shown.
Binary file not shown.
+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' },
},
},
})