122 Commits
Author SHA1 Message Date
IliesAllali 92e3c74673 Revert "Merge pull request #217 from IliesAllali/feat/onglets-nom-et-proches"
This reverts commit 82ae5e289d, reversing
changes made to 09b2acf4fe.
2026-11-07 01:54:20 +01:00
IliesAllali 4a447c7aef Revert "Merge pull request #219 from IliesAllali/feat/onglets-par-nom"
This reverts commit 4544edde6f, reversing
changes made to 82ae5e289d.
2026-11-07 01:54:19 +01:00
IliesAllali 79df032f10 Revert "Merge pull request #220 from IliesAllali/feat/reglages-arbre-onglets"
This reverts commit 97966054ac, reversing
changes made to 4544edde6f.
2026-11-07 01:54:19 +01:00
IliesAllali 97966054ac Merge pull request #220 from IliesAllali/feat/reglages-arbre-onglets
Onglets : « Tout l'arbre » en option et vraiment complet, ancien découpage par largeur retiré
2026-11-07 01:44:43 +01:00
IliesAllaliandClaude Opus 5.5 e787e4488e onglets : « Tout l'arbre » seulement si coché, sinon la famille de la personne de départ s'ouvre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:40:50 +01:00
IliesAllaliandClaude Opus 5.5 1256f09c8f nettoyage : l'ancien découpage par largeur retiré du placement, des pastilles, de la barre et du CSS
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:18:18 +01:00
IliesAllaliandClaude Opus 5.5 5eb1d9e7bd Tout l'arbre : plus aucune belle-famille réduite derrière une pastille quand l'arbre est dessiné en entier
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 01:14:13 +01:00
IliesAllali 4544edde6f Merge pull request #219 from IliesAllali/feat/onglets-par-nom
Onglets de familles : un onglet par nom, seuil réglable au curseur
2026-11-07 00:48:47 +01:00
IliesAllaliandClaude Opus 5.5 b89ceb4e00 réglages : curseur du seuil, onglets créés affichés en direct
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:45:15 +01:00
IliesAllaliandClaude Opus 5.5 61d9bcc328 réglages : « À partir de N personnes au même nom »
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:11:32 +01:00
IliesAllaliandClaude Opus 5.5 8f770371fb réglages : seuil des onglets sur une ligne
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:10:37 +01:00
IliesAllaliandClaude Opus 5.5 8cbe5ce262 onglets par nom : repère pris à l'écran, pas de doublon de Tout l'arbre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:08:43 +01:00
IliesAllaliandClaude Opus 5.5 9b77cca771 onglets : un onglet par nom de famille, seuil réglable, ancien découpage par largeur retiré
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:04:25 +01:00
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
IliesAllaliandClaude Opus 5.5 cc59b7190f 1.5.7 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:29:07 +01:00
IliesAllali ba6f63ff09 Merge pull request #190 from IliesAllali/feat/vie-couples
Life tab: neutral couple label, couple date and place, names link to people
2026-10-28 08:28:51 +01:00
IliesAllali 4e9c0171dd Merge pull request #189 from IliesAllali/fix/camera-telephone
Phone: tree first on arrival, a tapped person moves above the profile (#188)
2026-10-28 08:28:46 +01:00
IliesAllaliandClaude Opus 5.5 1fae061a2d Onglet Vie : « En couple avec » sauf mariage dit, date et lieu d'un couple, prénoms en liens
Un couple naît « mariage » dès qu'un enfant a deux parents : la frise disait « Épouse X » pour tous.
Elle dit maintenant « En couple avec X », et « Mariage avec X » seulement quand c'est dit (date, lieu ou
documents de mariage, case cochée). Toucher la ligne d'un couple (administrateur) ouvre sa fiche avec
« C'était un mariage », la date (même approximative) et le lieu ; PATCH /unions accepte startPlace et
startDatePrecision. Le prénom d'un proche (conjoint, enfant, frère ou sœur, parent) ouvre sa fiche et
amène la caméra sur lui.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:26:24 +01:00
IliesAllaliandClaude Opus 5.5 e39c33f977 Téléphone : l'arbre d'abord à l'arrivée, la personne touchée remonte au-dessus de la fiche (#188)
Sur téléphone, la fiche de la personne de départ ne s'ouvre plus à l'arrivée : elle couvrait le bas de
l'écran et la personne avec, laissant un écran vide. La personne de départ arrive à 40 % de la hauteur
au lieu de 56 %. Toucher quelqu'un dans l'arbre le remonte dans le tiers du haut, au-dessus de la fiche,
comme le fait déjà la recherche (focusPerson). Ordinateur inchangé.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:15:51 +01:00
IliesAllaliandClaude Opus 5.5 cdb75655dd 1.5.6 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 07:41:26 +01:00
IliesAllali c47f0ee459 Merge pull request #187 from IliesAllali/fix/ouverture-lisible
Ouverture : arrivée lisible à 0,95, centrée à côté de la réglette sur téléphone
2026-10-28 00:11:54 +01:00
IliesAllaliandClaude Opus 5.5 8c2c740efe Ouverture : arrivée lisible, à 0,95, et centrée à côté de la réglette sur téléphone
#181 faisait arriver le recul à 0,5 : les prénoms y restent dessinés mais font
6,5 px, et un arbre de 35 personnes s'ouvrait en entier, illisible. Retour à
0,95 (prénoms à 12 px), le recul s'arrête là.

Sous 768 px, la réglette de zoom reste ouverte en bas à gauche : la personne de
départ se centre dans la largeur qu'elle laisse libre au lieu du milieu de
l'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:05:38 +01:00
IliesAllali 0762602cdb Merge pull request #184 from IliesAllali/feat/ctrl-k
Ctrl+K ouvre la recherche, raccourcis affichés
2026-10-28 00:05:25 +01:00
IliesAllali 51ac0de755 Merge pull request #186 from IliesAllali/feat/noms-complets
Tree: optional last name under the first name, maiden name first (#168)
2026-10-28 00:00:54 +01:00
IliesAllali 983c50159d Merge pull request #185 from IliesAllali/fix/bandeau-annuler
Bandeau « Annuler » accordé, sans chevaucher la réglette de zoom ; bouton de zoom caché sous la fiche
2026-10-28 00:00:35 +01:00
IliesAllaliandClaude Opus 5.5 f8a0826dfb Notification sur téléphone : décalée seulement quand la réglette de zoom se voit (centrée sous un panneau ou une fiche)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:35:49 +01:00
IliesAllaliandClaude Opus 5.5 4375fb6ba3 Bandeau « Annuler » : accordé au sexe connu, sans chevaucher la réglette de zoom ; bouton de zoom caché sous la fiche
Relevés par la recette de la 1.5.5 :
- « Odette est supprimé·e » : accordé quand le sexe est connu (supprimée, supprimé), « ·e » seulement quand
  il ne l'est pas ; même chose pour le message d'échec. L'anglais ne change pas (« was deleted »).
- Sous 768 px, le bandeau (et la notification après un ajout, même classe) passait sur la réglette de zoom,
  toujours ouverte en bas à gauche : il commence maintenant à 72 px du bord.
- Sur ordinateur, le bord du bouton de zoom dépassait sous une fiche ouverte : il est caché tant que la
  fiche est là.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 19:21:16 +01:00
IliesAllaliandClaude Opus 5.5 379102d3fe Arbre : réglage « Le nom de famille sous le prénom », celui de naissance s'il est connu (#168)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:49:03 +01:00
IliesAllaliandClaude Opus 5.5 debe51c313 Ctrl+K (⌘K sur Mac) ouvre la recherche ; raccourcis affichés
- Ctrl+K, de n'importe où, ouvre la recherche d'une personne, ou y remet le curseur si elle est ouverte.
- La loupe dit son raccourci au survol : « Rechercher une personne (Ctrl K) », « (⌘K) » sur Mac.
- Dans le menu « Modifier », « Ctrl E » à droite de « Modifier l'arbre » (raccourci qui existait déjà
  sans être dit). Composant Kbd, caché sur écran tactile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:43:26 +01:00
IliesAllaliandClaude Opus 5.5 dbc811d722 1.5.5 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:32:07 +01:00
IliesAllali 7b08996b09 Merge pull request #183 from IliesAllali/feat/landing-prechargement
Landing : pages préchargées au survol d'un lien
2026-10-27 18:28:08 +01:00
IliesAllaliandClaude Opus 5.5 fcfb7d1494 Landing : pages préchargées au survol d'un lien (speculationrules)
- Règles de préchargement dans l'accueil et les 15 guides : une page du site est préparée quand on survole
  son lien (appui sur téléphone), et s'ouvre presque tout de suite. Pages seulement, pas les fichiers ;
  l'application n'est pas concernée. Ignoré par les navigateurs qui ne connaissent pas ces règles.
- Une page préchargée ne compte pas de visite avant d'être vraiment ouverte (PostHog attend
  prerenderingchange), et l'animation du logo se joue à l'ouverture, pas pendant le préchargement.
- CSP de la landing (deploy/nginx.conf) : empreinte des règles, et nouvelle empreinte du script du logo.
  Le mot-clé 'inline-speculation-rules' ne suffisait pas à Chrome.
- Test : chaque script en ligne des pages a son empreinte dans la CSP, chaque page a ses règles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 18:05:12 +01:00
IliesAllali c45bcb0463 Merge pull request #182 from IliesAllali/feat/zones-tap
Zones de tap d'au moins 44 px sur écran tactile
2026-10-27 17:54:37 +01:00
IliesAllaliandClaude Opus 5.5 2b81a2a2d8 Zones de tap d'au moins 44 px sur écran tactile, sans changement visible
Croix de la recherche (36 px) et de la fiche (40 px), onglets de la fiche (34 px de haut), petits boutons,
« + » en pastilles, ajout et retrait d'un lien : une zone invisible déborde autour de chacun sur écran
tactile (pointer: coarse) jusqu'à 44 × 44 px. Rien ne bouge à l'œil, et rien ne change à la souris.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:44:50 +01:00
IliesAllali e2199b0445 Merge pull request #181 from IliesAllali/feat/ouverture-recul
Ouverture d'un arbre : la caméra recule de la personne de départ vers sa famille
2026-10-27 17:36:54 +01:00
IliesAllali 3327a344ff Merge pull request #180 from IliesAllali/fix/barre-familles
Barre des familles : elle ne s'écarte que si la fiche monte jusqu'à elle
2026-10-27 17:36:50 +01:00
IliesAllaliandClaude Opus 5.5 8ef87c7163 Ouverture d'un arbre : la caméra recule de la personne de départ vers sa famille
À la première ouverture d'un arbre, la caméra part serrée sur la personne de
départ et recule en 1,1 s (ease-out quint) jusqu'à la vue d'arrivée. Chaque
personne apparaît quand elle entre dans le champ, la personne de départ dès la
première image. Le premier geste (glisser, molette, recherche) arrête le recul.

- Un grand arbre s'ouvre à 0,5 au lieu de 0,95 : on voit la famille autour,
  les prénoms restent lisibles.
- Ensuite (arbre modifié, autre onglet de famille), rien ne s'anime : l'arbre
  s'affiche net. Avant, la cascade se rejouait à chaque modification.
- Un portrait qui entre part de 92 % de sa taille en fondu, en 360 ms (30 % en
  700 ms avant).
- Mouvements réduits : pas de recul, un fondu de 200 ms.

Avant, la cascade partait des générations les plus anciennes : sur un grand
arbre ouvert sur la personne de départ, l'écran restait vide près de 3 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:18:29 +01:00
IliesAllaliandClaude Opus 5.5 154f8219c5 Barre des familles : elle ne s'écarte que si la fiche monte jusqu'à elle
La fiche, posée en bas à gauche, ne couvre le haut que si son contenu est long.
Une fiche courte poussait la barre à droite pour rien.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:16:11 +01:00
IliesAllali 0f98b8b507 Merge pull request #178 from IliesAllali/feat/squelettes
Blocs gris pendant le chargement (onglet Vie, panneaux de l'arbre)
2026-10-27 17:15:18 +01:00
IliesAllali a11b71a1f4 Merge pull request #179 from IliesAllali/perf/gros-arbres
Gros arbres : ouverture plus rapide, onglets calculés à part
2026-10-27 17:13:39 +01:00
IliesAllaliandClaude Opus 5.5 1925624e2a Onglet Vie : blocs gris seulement quand aucune date n'est connue ; les dates connues restent affichées pendant le chargement
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 17:02:05 +01:00
IliesAllaliandClaude Opus 5.5 5b9e4e2685 Blocs gris pendant le chargement, à la place de « Chargement… » et d'un faux vide
- Onglet Vie : pendant le premier chargement, la place de la frise au lieu de « Pas encore de date
  connue », qui s'affichait puis disparaissait une seconde plus tard.
- Propositions à relire, co-gestionnaires, visites, paramètres de l'arbre, sources de recherche : des
  lignes grises à la forme de ce qui arrive, au lieu d'un « Chargement… ».
- Composant Skeleton : rien avant 300 ms (une réponse rapide ne fait pas clignoter), aucun mouvement,
  libellé pour les lecteurs d'écran.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:42:15 +01:00
IliesAllali a8f37b33d2 Merge pull request #176 from IliesAllali/feat/glisser-souvenir
Souvenir sur téléphone : glisser la photo vers le bas le ferme
2026-10-27 16:27:00 +01:00
IliesAllali 048ce96f9d Merge pull request #177 from IliesAllali/feat/blurhash
Aperçus flous pendant le chargement des photos (BlurHash)
2026-10-27 16:20:28 +01:00
IliesAllaliandClaude Opus 5.5 291dde8073 Aperçus flous (BlurHash) pendant le chargement des photos et des portraits
Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré
vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou
aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu.

- Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001).
- API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres
  chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de
  fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide.
- Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche,
  portraits et souvenirs dessinés sur l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:17:58 +01:00
IliesAllaliandClaude Opus 5.5 ec4b0a1428 outils(layout-bench) : temps et empreinte du placement, temps d'ouverture d'un arbre
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:10:26 +01:00
IliesAllaliandClaude Opus 5.5 d7eac40cff perf(onglets): liste des familles calculée dans un worker
Quand l'arbre affiché est celui de la famille de départ, il ne dépend pas de
la liste des onglets : il s'affiche tout de suite, la liste est calculée dans
galaxy/familyTabsWorker.js et la barre suit, sans geler la page. Les pastilles
prennent le nom et le compte de leur onglet à l'arrivée de la liste.

Calcul sur place comme avant dans un autre onglet, à l'arrivée par le lien
d'un onglet, sans Worker ou si le worker échoue (couples de l'arbre affiché
remis). Une demande en cours est abandonnée dès qu'une autre part.

Royal92 (3 010 personnes) : arbre utilisable en 1,6 s au lieu de 8,7 s,
barre des onglets à 6 s sans bloquer (médianes de 3, machine chargée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:08:04 +01:00
IliesAllali 53737b0718 Merge pull request #175 from IliesAllali/fix/barre-edition-telephone
Téléphone, mode édition : onglets en haut, « Terminé » en toutes lettres, une seule sortie
2026-10-27 16:01:39 +01:00
IliesAllaliandClaude Opus 5.5 c78a102d08 « Terminé » à la hauteur du « + » (46 px), retour de Patch
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:59:06 +01:00
IliesAllaliandClaude Opus 5.5 14f62aee90 perf(placement): ouverture des gros arbres deux fois plus rapide
- elkLayout : le rang des nœuds est tenu à jour à chaque ajout au lieu d'être
  reconstruit pour chaque chaîne (quadratique sur un gros arbre)
- blockLayout : fusion des contours en un tri par rangée au lieu d'insérer
  les intervalles un à un
- la famille de départ n'est plus placée deux fois (liste des onglets puis
  affichage) : takeRootLayout reprend le placement et ses couples
- chemin de parenté au survol : adjacence et index des personnes en cache,
  file lue par index

Placement identique (empreinte des positions, arêtes et onglets) sur royal92,
pres2020, asc-23, asc-61, desc-3, desc-5. Royal92 (3 010 personnes) :
9,3 s -> 4,6 s avant que l'arbre réponde (médiane de 3, machine chargée).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:58:28 +01:00
IliesAllali d5d446bc32 Merge pull request #174 from IliesAllali/feat/idempotence-envois
Envois de la famille sans doublon (Idempotency-Key)
2026-10-27 15:43:38 +01:00
IliesAllaliandClaude Opus 5.5 c712ca8a15 Souvenir sur téléphone : glisser la photo vers le bas le ferme, comme la fiche
- Même geste et mêmes seuils que la fiche (useSheetDrag) : la carte suit le doigt, se ferme passé un tiers ou sur un
  lâcher rapide, revient sinon ; tirée vers le haut, elle résiste. Zone : la photo seulement (.mv-photo-box), la
  légende garde ses boutons et son défilement. Coupé pendant l'identification et quand la carte a défilé
  (nouvelle option canStart du hook).
- L'entrée de la carte ne garde plus sa valeur finale sur téléphone (fill-mode backwards), sinon elle l'emportait sur
  le transform du glisser. Fond plein sous la carte collée en bas (sous 560 px).
- Un toucher simple sur la photo montre toujours les prénoms ; un glisser n'en déclenche pas.

Planche validée par Patch le 27/10 : _bmad-output/parenthese/motion/2026-10-27-glisser-souvenir/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:41:45 +01:00
IliesAllaliandClaude Opus 5.5 16e765c231 Téléphone, mode édition : onglets en haut, « Terminé » en toutes lettres, une seule sortie
- Les onglets de familles restaient abaissés sous un message qui avait lui-même été descendu (68 px) : la bulle
  « Vous proposez » couvrait « Famille X / Famille Y ». Ils restent sur la première ligne, à côté de la loupe.
  Recherche ouverte en mode édition : ils passent sous la bulle.
- La croix à côté du « + » devient « Terminé », le mot qu'emploie déjà la barre pour sortir du dessin. Plus de
  rotation au survol ; pression à 0,97.
- Quand « Terminé » est là, la bulle du haut n'a plus sa propre croix : une seule sortie.

Patch, 27/10 : planche _bmad-output/parenthese/motion/2026-10-27-barre-edition/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:35:09 +01:00
IliesAllaliandClaude Opus 5.5 b978b96864 Tests du client : ceux du délai maximal remis, ceux de l'idempotence à la suite
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:15:37 +01:00
IliesAllaliandClaude Opus 5.5 0132ecb342 Envois de la famille sans doublon : en-tête Idempotency-Key
Sur un réseau faible, une proposition, un souvenir, une photo posée sur l'arbre ou un moment pouvait être
créé deux fois : la réponse se perdait (délai dépassé), la personne renvoyait, le serveur recréait tout.

- Navigateur (api/client.js) : un envoi qui n'a pas abouti garde sa clé, et le même envoi refait (même
  adresse, même contenu) la réutilise. Une réussite libère la clé.
- Serveur (lib/idempotency.ts) : sur les quatre routes de création, une clé déjà vue par la même personne
  renvoie la réponse du premier envoi ; deux essais simultanés n'en traitent qu'un ; un échec n'est pas gardé.
  En mémoire, 24 h, 5 000 clés au plus.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:14:32 +01:00
IliesAllali b866b36ca9 Merge pull request #173 from IliesAllali/feat/visages-discrets
Visages sur la photo : rien par défaut, ronds fins à la demande
2026-10-27 15:05:36 +01:00
IliesAllali e1b25265dd Merge pull request #172 from IliesAllali/feat/annuler-moments-medias
Souvenirs et moments supprimés : Annuler, comme pour une personne
2026-10-27 14:56:10 +01:00
IliesAllali a0afcfeafe Merge pull request #171 from IliesAllali/feat/mouvement-souvenir
Souvenir : grandit depuis sa vignette, et la visionneuse respecte les mouvements réduits
2026-10-27 14:29:16 +01:00
IliesAllaliandClaude Opus 5.5 2aab008766 Visages sur la photo : rien par défaut, ronds fins à la demande
- Les ronds des personnes identifiées ne restent plus posés sur les visages. Ils apparaissent au survol de la photo
  (ordinateur), quand on touche la photo (téléphone, un second toucher les cache), ou pour une seule personne quand
  on survole ou focalise son prénom sous la photo. Toujours visibles pendant l'identification.
- Rond plus large (34 px) et plus fin (1,5 px) : il entoure le visage au lieu de le couvrir.
- Sur téléphone, les prénoms ne s'affichent plus en permanence par-dessus la photo.

Planche validée par Patch le 27/10 (option A) : _bmad-output/parenthese/motion/2026-10-27-visages/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:26:08 +01:00
IliesAllaliandClaude Opus 5.5 dbc6338f9b Souvenirs et moments supprimés : Annuler, comme pour une personne
Un hook commun (hooks/useUndoable.js) porte les trois suppressions annulables : bandeau 6 s,
Ctrl+Z, requête envoyée à la fin du délai ou à la fermeture de la page.
- Souvenir (gestionnaire de souvenirs) : il n'y avait aucune confirmation, la suppression était immédiate.
- Moment (onglet Vie) : plus de « Supprimer / Garder ».

Corrige deux défauts de #169 :
- Les tableaux du graphe étaient mutés en place (splice, push), alors que les index par personne
  (getPersonById, getPersonMedias) ne se reconstruisent qu'au remplacement du tableau. Nouveau
  replaceGraph() dans mockData, utilisé pour la suppression et l'ajout d'une personne en démo.
- Le rechargement après l'envoi vidait la sélection : une fiche ouverte pendant le délai se fermait.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 14:16:30 +01:00
IliesAllaliandClaude Opus 5.5 913de99f9e Souvenir : grandit depuis sa vignette à l'ouverture, et la visionneuse respecte les mouvements réduits
- Une vignette de la fiche touchée est retenue un court instant (utils/openOrigin.js) ; la visionneuse part de là,
  même largeur, et grandit jusqu'à sa place en 280 ms, transform seul, sans fondu. Le point de départ suit
  l'ancrage de la fenêtre : centre sur ordinateur, bord bas sur téléphone (pz-overlay en place-items: end).
- Ouverte autrement (photo de l'arbre, liste), la visionneuse ne change pas. Fermeture inchangée, d'un coup.
- Mouvements réduits : la carte n'avait pas la classe .pz-modal et jouait sa montée quand même ; elle s'affiche
  désormais d'un coup.

Planche validée par Patch le 27/10 : _bmad-output/parenthese/motion/2026-10-27-souvenir/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 13:42:58 +01:00
IliesAllali 0b429e78a9 Merge pull request #169 from IliesAllali/feat/annuler-suppressions
Suppression d'une personne annulable (Annuler à la place de la confirmation)
2026-10-27 13:37:25 +01:00
IliesAllali 00b880708d Merge pull request #170 from IliesAllali/feat/mouvement-fiche
Fiche : apparaît d'un coup sur ordinateur, feuille plus vive, résistance vers le haut, coins continus
2026-10-27 13:37:13 +01:00
IliesAllaliandClaude Opus 5.5 3a18ac9eab Fiche : apparaît d'un coup sur ordinateur, feuille en 240 ms, résistance vers le haut, coins continus
- Ordinateur : plus d'entrée animée (fondu et flou de 260 ms) ; on ouvre une fiche des dizaines de fois par visite.
- Téléphone : la feuille monte en 240 ms au lieu de 400 ; en mouvements réduits, fondu court seulement sur téléphone.
- Geste des feuilles (fiche et menu de la barre) : tirée vers le haut, la feuille cède de moins en moins (40 px au plus)
  et revient au lâcher, au lieu de buter net. Logique pure sheetOffset, testée. Une ombre pleine comble le dessous.
- Coins continus (corner-shape: squircle) sur la fiche, le portrait et les vignettes, seulement là où le navigateur
  les dessine ; ailleurs rien ne change.

Planche validée par Patch le 27/10 : _bmad-output/parenthese/motion/2026-10-27-fiche/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:57:34 +01:00
IliesAllaliandClaude Opus 5.5 a2a2a81a12 Suppression d'une personne annulable : Annuler à la place de la confirmation
La personne disparaît tout de suite et un bandeau propose « Annuler » (ou Ctrl+Z) pendant 6 s.
La requête ne part qu'à la fin du délai, ou à la fermeture de la page (fetch keepalive).
Un rechargement de l'arbre pendant le délai ne fait pas revenir la personne.
Démo : même bandeau, retour à l'état d'avant.

Corrige au passage la recherche, qui gardait des résultats périmés après un ajout ou une
suppression (le tableau des personnes est modifié sur place).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 12:15:17 +01:00
IliesAllaliandClaude Opus 5.5 59d3c89b1c 1.5.4 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:24:53 +01:00
IliesAllaliandClaude Opus 5.5 42e4ed63bb Lien d'un onglet de famille : ?famille=<charnière> ouvre l'arbre sur cette famille, et le lien de « La famille participe » suit l'onglet ouvert (#167)
- Galaxy : l'onglet du lien s'ouvre dès que l'arbre et ses onglets sont connus, et de nouveau si la personne de départ arrive après (visiteur), tant qu'on n'a pas changé d'onglet soi-même ; onglets coupés dans ce navigateur, l'arbre entier s'ouvre sur la charnière
- App : l'adresse de la page porte l'onglet ouvert, le lien à envoyer aussi, avec le nom de la famille dans le titre
- Même mot de passe, même accès : le lien choisit l'onglet d'arrivée, il ne restreint rien (Patch, 26/10)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 16:21:36 +01:00
IliesAllaliandClaude Opus 5.5 b9f301619b 1.5.3 : changelog
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:19:20 +01:00
IliesAllaliandClaude Opus 5.5 20aded7ee0 Frontend : zod 4.6.7 (faille haute < 4.6.6), via eslint-plugin-react-hooks, fichier de verrouillage seul (#166)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:47 +01:00
IliesAllaliandClaude Opus 5.5 3aad835908 Tailwind 4 : app et landing, rien ne bouge à l'écran (#165)
* Tailwind 4 (app) : plugin Vite, remise à zéro de la v3 gardée (main sur les boutons, placeholder, bordure grise), thème et utilitaires inutilisés retirés

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

* Tailwind 4 (landing) : plugin Vite comme l'app, remise à zéro de la v3 gardée, thème inutilisé retiré ; marge gauche du graphe du hero retirée, écrasée par lg:mx-0 en v3 et devenue visible en v4

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:06:33 +01:00
IliesAllaliandClaude Opus 5.5 1af0e6842e Déploiement : le contrôle des dépendances backend ne charge plus @prisma/client, qui n'existe qu'après generate depuis Prisma 7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:03:06 +01:00
IliesAllaliandClaude Opus 5.5 cc01059c47 Prisma 7 : pilote pg, client généré, prisma.config.ts (#164)
* Prisma 7 : client généré dans src/generated, pilote pg, prisma.config.ts

- générateur prisma-client (ESM, extension .js), compilé dans dist : plus de node_modules/.prisma à copier dans les images
- @prisma/adapter-pg, délai de connexion de 5 s comme le moteur de Prisma 6
- prisma.config.ts : adresse de la base et .env pour le CLI, sans dépendre de prisma/config à l'exécution (CLI à part dans les images)
- CI : migrate diff --from-config-datasource --to-schema
- deploy.sh : arrêt avant installation si Node est trop vieux pour Prisma 7
- remplace les PR Dependabot #135 et #137

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

* Prisma 7 : import du client généré dans token-revocation (#163)

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:58:42 +01:00
IliesAllali ef503c570d Comptes : une déconnexion survit au redémarrage de l'API (#163)
* Comptes : une déconnexion reste valable après un redémarrage de l'API (sessions fermées gardées en base)

* Déconnexion durable : sans rechargement des sessions fermées, l'API ne démarre pas ; modèle remonté au-dessus du commentaire des photos
2026-10-26 13:24:06 +01:00
IliesAllaliandClaude Opus 5.5 f1b4ad8db2 Compte à mot de passe : relier le fournisseur OIDC soi-même (#161)
* OIDC : un compte à mot de passe relie lui-même son identité du fournisseur (ticket à usage unique), pour passer une instance en PASSWORD_LOGIN=false sans l'enfermer dehors

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

* Compte : « Se connecter aussi avec … » relie le fournisseur à un compte à mot de passe

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

* README : relier un compte à mot de passe avant PASSWORD_LOGIN=false

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:34:28 +01:00
IliesAllali 856a80d111 Souvenirs : envoi en flux et par plages (Range), pour les vidéos sur Safari (#162)
* Souvenirs : envoi en flux et par plages (Range, 206), pour Safari ; le fichier ne passe plus en entier en mémoire

* Range : fichier vide refusé en 416 ; test d'un souvenir en attente demandé par morceaux
2026-10-26 12:33:24 +01:00
IliesAllali 3bfacc5b1a Déploiement : commande forcée pour la clé de GitHub, qui ne peut plus que déployer un commit de main 2026-10-26 12:24:54 +01:00
IliesAllali 124ceb1c3e Sécurité : bcrypt natif hors de la boucle de l'API ; un souvenir en attente ne se lit que par le gestionnaire et son auteur (#160) 2026-10-26 12:10:55 +01:00
204 changed files with 8130 additions and 3035 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 +) :
+1 -1
View File
@@ -141,7 +141,7 @@ jobs:
run: npx prisma migrate deploy
- name: Schéma Prisma aligné sur les migrations
run: npx prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code
run: npx prisma migrate diff --from-config-datasource --to-schema prisma/schema.prisma --exit-code
- name: Tests
run: npm test
+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 }}
+160
View File
@@ -2,6 +2,166 @@
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
- Opening a tree no longer opens the start person's profile, which covered the bottom of the screen and the start person with it. The start person now sits at 40% of the height instead of 56% (#189, closes #188).
- Tapping someone in the tree moves them into the top third, above their profile (#189).
### Life tab
- A couple reads "Together with X", and "Married X" only when it is said: a date, a place, marriage documents, or the box ticked. Before, every couple read "Marries X", because a couple is stored as a marriage as soon as a child has two parents (#190).
- The date and place of a couple can be set from the Life tab: tap the couple's line, then "They were married", "When?" (year, "about", exact date) and "Where?". `PATCH /trees/:id/unions/:unionId` accepts `startPlace` and `startDatePrecision` (#190).
- The names of a partner, a child, a brother or sister, or a parent open that person's profile and move the tree to them (#190).
## 1.5.6 (2026-10-28)
### Tree
- Optional last name under the first name: the maiden name when it is known, otherwise the current last name. In the tree settings, under Display. Saved in this browser (#186, closes #168).
- Opening a tree: the camera now stops close enough to read the names, and on a phone the tree is centred in the space beside the zoom slider (#187).
### Undo bar
- The message agrees with the person's sex in French ("supprimée", "supprimé"), and "supprimé·e" only when it is unknown (#185).
- On phones and tablets the bar no longer covers the zoom slider. On a computer the zoom button is hidden while a profile is open (#185).
### Shortcuts
- Ctrl+K (⌘K on Mac) opens the person search from anywhere. The search tooltip and the Edit menu show their shortcuts (#184).
## 1.5.5 (2026-10-27)
### Undo instead of confirm
- Deleting a person no longer asks for confirmation. A bar with "Undo" stays for 6 seconds, and Ctrl+Z works too (#169).
- Same for a memory and for a moment in the Life tab. Two fixes to #169 on the way: an open profile no longer closes when the deletion starts, and the tree's internal lists are up to date after an undo (#172).
### Motion
- Profile card: it appears at once on a computer, with no fade or blur. On a phone it still rises as a sheet in 240 ms, resists when pulled up, and has continuous corners where the browser supports `corner-shape` (#170).
- A memory grows from its thumbnail when it opens (280 ms, transform only). The viewer now respects reduced motion (#171).
- On a phone, dragging a memory's photo down closes it, like the profile card (#176).
- Opening a tree for the first time, the camera starts on the start person and pulls back to the family in 1.1 s while the tree draws. After an edit or a tab change, the tree shows without animation. With reduced motion, a plain fade (#181).
### Faces on photos
- Face markers are hidden by default. They show on hover with a mouse, when a name is hovered in the list, in tagging mode, or after a tap on the photo. The circles are thinner (#173).
### Phone
- Edit mode: the family tabs move to the top so the "You suggest" bar no longer covers them. The exit button reads "Done", at the height of the "+", and the bar's own close button is gone (#175).
- The family bar only moves aside when the profile card reaches it (#180).
- Tap targets of at least 44 px on touch screens, with no visible change (#182).
### Loading
- Big trees open faster with the same placement, and the list of family tabs is computed in a worker. A 3,010-person tree is usable in 1.6 s instead of 8.7 s. Tools in `tools/layout-bench` (`layout-time.mjs`, `load-probe.mjs`) (#179).
- Blurred previews (BlurHash) of photos and portraits while they load. Migration `20261027_001_apercus_flous` adds two optional columns, and a background task fills them for existing photos when the API starts (#177).
- Grey blocks while loading, instead of "Loading…" or a false empty state: the Life tab of a person with no known date, and the tree's panels (#178).
### Reliability
- Uploads from the family no longer create duplicates on a weak connection, through an `Idempotency-Key` header (#174).
### Landing
- Pages are prefetched when a link is hovered (`speculationrules`). The landing CSP in `deploy/nginx.conf` gets a second script hash for it (#183).
## 1.5.4 (2026-10-26)
### A link per family tab (#167)
- `?famille=<hinge person id>` opens the tree on that family's tab. The tab opens once the tree and its tabs are known, and again if the start person arrives later (as it does for a visitor), unless the person already picked a tab. With tabs turned off in that browser, the whole tree opens framed on the hinge person. An unknown key is ignored.
- The page address follows the open tab. In the share panel, the link to send and the invitation message follow it too, and the title names the family ("The link to send to the Garnier family").
- Same password and same access: the link picks the landing tab and restricts nothing.
## 1.5.3 (2026-10-26)
### Videos in Safari (#162)
- Media files are streamed and answer byte-range requests (`Accept-Ranges: bytes`, `206` with `Content-Range`, `416` outside the file). Safari on iOS, iPadOS and macOS needs this to play a video. Before, the whole file was read into memory and sent in one block.
### Accounts
- A password account on an OIDC instance can link its provider identity from its account ("Also sign in with …"), through a two-minute, single-use ticket (`POST /auth/oidc/link`, then `/auth/oidc/start?link=`). The identity joins that account whatever its email, and an identity already linked to another account is never moved. This is the way to switch an instance to `PASSWORD_LOGIN=false` without locking out password accounts. `/auth/me` returns `oidcLinked` (#161).
- Signing out, or deleting the account, now survives an API restart: closed sessions are kept in the `revoked_sessions` table until their token expires, and the API refuses to start if it can't load them. Migration `20261026_001_deconnexions` (#163).
### Security
- A media waiting for review is readable only by a tree admin or by the account that uploaded it. Anyone else gets the same 404 as a missing media (#160).
- Passwords are hashed with native bcrypt (`@node-rs/bcrypt`) off the event loop. Existing hashes keep working, no migration (#160).
- Frontend lockfile: zod 4.6.7, a dev dependency (#166).
### Under the hood
- Prisma 7 with the pg driver adapter. The client is generated in `backend/src/generated` and `backend/prisma.config.ts` holds the database URL for the CLI. Without Docker, Node must be ^20.19, ^22.12 or 24+, and `deploy/deploy.sh` stops early on an older Node (#164).
- Tailwind 4 for the app and the landing, through the Vite plugin. The v3 base styles the app relied on are kept, and nothing changes on screen (#165).
## 1.5.2 (2026-10-26)
### Dates follow the browser's region (#152)
+4 -4
View File
@@ -18,12 +18,12 @@ RUN apt-get update \
WORKDIR /app
COPY backend/package.json backend/package-lock.json ./
COPY backend/prisma ./prisma
RUN npm ci --no-audit --no-fund
RUN npx prisma generate
COPY backend/tsconfig.json ./
COPY backend/prisma ./prisma
COPY backend/prisma.config.ts backend/tsconfig.json ./
COPY backend/src ./src
RUN npx prisma generate
RUN npm run build
@@ -68,8 +68,8 @@ RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_mod
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
&& npm cache clean --force
COPY --from=api-build /app/node_modules/.prisma ./node_modules/.prisma
COPY backend/prisma ./prisma
COPY backend/prisma.config.ts ./
COPY --from=api-build /app/dist ./dist
# nginx : la configuration du conteneur web de compose, l'API étant ici sur la même machine.
+1
View File
@@ -7,6 +7,7 @@
!deploy/aio
backend/node_modules
backend/dist
backend/src/generated
backend/.local
backend/storage
backend/test
+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.
+6 -2
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,9 +97,11 @@ 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. `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.
`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.
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
+1
View File
@@ -1,5 +1,6 @@
node_modules
dist
src/generated
.local
storage
test
+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=""
+3
View File
@@ -3,3 +3,6 @@ dist/
.env
.local/
*.log
# Client Prisma généré (prisma generate)
src/generated/
+7 -8
View File
@@ -1,12 +1,12 @@
# syntax=docker/dockerfile:1
# API Parenthèse (Fastify + Prisma). Contexte de build : le dossier backend/.
# Étape 1 : dépendances complètes, client Prisma, compilation TypeScript.
# Étape 1 : dépendances complètes, client Prisma (généré dans src/generated), compilation TypeScript.
# Étape 2 : image finale avec les seules dépendances de production, plus la CLI Prisma
# installée à part (nécessaire à `prisma migrate deploy` au démarrage).
FROM node:24-bookworm-slim AS build
# openssl : requis par les moteurs Prisma (cible debian-openssl-3.0.x).
# openssl : requis par le moteur de migration de Prisma (cible debian-openssl-3.0.x).
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
@@ -14,12 +14,12 @@ RUN apt-get update \
WORKDIR /app
COPY package.json package-lock.json ./
COPY prisma ./prisma
RUN npm ci --no-audit --no-fund
RUN npx prisma generate
COPY tsconfig.json ./
COPY prisma ./prisma
COPY prisma.config.ts tsconfig.json ./
COPY src ./src
RUN npx prisma generate
RUN npm run build
@@ -46,10 +46,9 @@ RUN PRISMA_VERSION="$(node -p "require('./package-lock.json').packages['node_mod
&& npm install --no-audit --no-fund "prisma@${PRISMA_VERSION}" \
&& npm cache clean --force
# Client Prisma généré à l'étape 1 (même image de base, même cible de moteur).
COPY --from=build /app/node_modules/.prisma ./node_modules/.prisma
# Schéma + migrations : indispensables à `prisma migrate deploy`.
# Schéma, migrations et configuration : indispensables à `prisma migrate deploy`. Le client Prisma est compilé dans dist.
COPY prisma ./prisma
COPY prisma.config.ts ./
COPY --from=build /app/dist ./dist
RUN mkdir -p /app/storage/media && chown -R node:node /app/storage
+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:
+1697 -207
View File
File diff suppressed because it is too large. Load diff
+10 -6
View File
@@ -17,21 +17,25 @@
"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",
"@prisma/client": "^6.15.0",
"bcryptjs": "^3.0.2",
"dotenv": "^18.0.8",
"@node-rs/bcrypt": "^2.0.0",
"@prisma/adapter-pg": "^7.10.1",
"@prisma/client": "^7.10.1",
"blurhash": "^2.0.5",
"dotenv": "^18.0.9",
"fastify": "^5.12.7",
"openid-client": "^6.8.8",
"pg": "^8.23.1",
"sharp": "^0.35.5",
"zod": "^4.6.7"
},
"devDependencies": {
"@types/node": "^24.19.2",
"prisma": "^6.15.0",
"@types/pg": "^8.23.1",
"prisma": "^7.10.1",
"tsx": "^4.23.15",
"typescript": "^7.0.2",
"vitest": "^5.0.5"
+13
View File
@@ -0,0 +1,13 @@
import { config } from 'dotenv'
import type { PrismaConfig } from 'prisma/config'
// Configuration du CLI Prisma (generate, migrate). Depuis Prisma 7, le CLI ne lit plus .env tout seul. Seul un import
// de type vient de prisma/config : dans les images, le CLI est installé à part (/opt/prisma) et ce fichier doit se
// charger sans lui. Sans DATABASE_URL (generate pendant un build), l'adresse reste vide.
config({ quiet: true })
export default {
schema: 'prisma/schema.prisma',
migrations: { path: 'prisma/migrations' },
datasource: { url: process.env.DATABASE_URL ?? '' },
} satisfies PrismaConfig
@@ -0,0 +1,12 @@
-- Sessions fermées (déconnexion, compte supprimé), gardées en base pour survivre au redémarrage de l'API.
-- Migration additive, une table neuve.
-- CreateTable
CREATE TABLE "revoked_sessions" (
"jti" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "revoked_sessions_pkey" PRIMARY KEY ("jti")
);
-- CreateIndex
CREATE INDEX "revoked_sessions_expiresAt_idx" ON "revoked_sessions"("expiresAt");
@@ -0,0 +1,7 @@
-- Aperçus flous (BlurHash) des portraits et des photos, montrés pendant le chargement.
-- Migration additive : deux colonnes facultatives, remplies ensuite par l'API (lib/blurhash.ts).
-- AlterTable
ALTER TABLE "persons" ADD COLUMN "avatarBlurhash" TEXT;
-- AlterTable
ALTER TABLE "media_items" ADD COLUMN "blurhash" TEXT;
+19 -2
View File
@@ -1,10 +1,14 @@
// Client généré dans src/generated (ignoré par git), compilé avec le reste dans dist : rien à copier depuis
// node_modules dans les images. L'adresse de la base vient de prisma.config.ts.
generator client {
provider = "prisma-client-js"
provider = "prisma-client"
output = "../src/generated/prisma"
moduleFormat = "esm"
importFileExtension = "js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
enum MembershipRole {
@@ -215,6 +219,15 @@ model TreeInvitation {
@@map("tree_invitations")
}
// Sessions fermées (déconnexion, compte supprimé), gardées jusqu'à la fin de leur jeton
model RevokedSession {
jti String @id
expiresAt DateTime
@@index([expiresAt])
@@map("revoked_sessions")
}
// Photo posée sur l'arbre par la famille, en attente de relecture (1.5.1). La ligne vit tant que la photo n'est
// ni acceptée (ligne retirée, fichier gardé) ni refusée (fichier et ligne effacés). Un envoi jamais proposé est
// effacé après 7 jours, la durée d'un brouillon. Les photos des gestionnaires n'ont pas de ligne.
@@ -297,6 +310,8 @@ model Person {
// { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière
// recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1).
avatarCrop Json?
// Aperçu flou du portrait (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
avatarBlurhash String?
sex String?
notes String?
createdBy String?
@@ -357,6 +372,8 @@ model MediaItem {
mimeType String
filePath String
thumbPath String?
// Aperçu flou d'une photo (BlurHash), montré pendant le chargement. Null : pas encore calculé ; vide : illisible
blurhash String?
caption String?
source String?
sizeBytes Int
+16 -3
View File
@@ -4,18 +4,20 @@ import cookie from '@fastify/cookie'
import cors from '@fastify/cors'
import jwt from '@fastify/jwt'
import rateLimit from '@fastify/rate-limit'
import { Prisma } from '@prisma/client'
import { Prisma } from './generated/prisma/client.js'
import { env } from './config/env.js'
import { prisma } from './lib/prisma.js'
import { isUserTokenRevoked } from './lib/token-revocation.js'
import { isUserTokenRevoked, loadRevokedTokens } from './lib/token-revocation.js'
import { registerIdempotency } from './lib/idempotency.js'
import { startBlurhashFiller } from './lib/blurhash.js'
import { authRoutes } from './routes/auth.js'
import { contributionRoutes } from './routes/contributions.js'
import { graphRoutes } from './routes/graph.js'
import { immichRoutes } from './routes/immich.js'
import { managerRoutes } from './routes/managers.js'
import { searchSourceRoutes } from './routes/search-sources.js'
import { mediaRoutes } from './routes/media.js'
import { getStorageRoot, mediaRoutes } from './routes/media.js'
import { oidcRoutes } from './routes/oidc.js'
import { publicRoutes } from './routes/public.js'
import { annotationRoutes } from './routes/annotations.js'
@@ -58,6 +60,14 @@ export function createApp() {
})
app.decorate('prisma', prisma)
// Les déconnexions d'avant le redémarrage reviennent en mémoire avant la première requête (onReady passe avant
// listen). Sans elles, l'API ne démarre pas : chaque session fermée redeviendrait valable sans que personne le
// voie, et pm2 comme Docker relancent l'API.
app.addHook('onReady', async () => {
await loadRevokedTokens(app.prisma)
})
// Aperçus flous des photos et portraits qui n'en ont pas encore (lib/blurhash.ts) ; pas pendant les tests
if (env.NODE_ENV !== 'test') app.addHook('onReady', async () => startBlurhashFiller(app, getStorageRoot()))
app.decorateRequest('actor', null)
app.register(cors, {
@@ -150,6 +160,9 @@ export function createApp() {
app.get('/health', async () => ({ ok: true }))
// Après l'identification : une clé d'idempotence ne vaut que pour la personne qui l'a envoyée
registerIdempotency(app)
app.register(authRoutes, { prefix: '/auth' })
app.register(oidcRoutes, { prefix: '/auth/oidc' })
app.register(publicRoutes)
+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
@@ -1,6 +1,6 @@
import path from 'node:path'
import { Prisma, type PrismaClient } from '@prisma/client'
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
import { z } from 'zod'
import { InvalidAnnotationContentError, sanitizeAnnotationContentForWrite } from './annotation-content.js'
+7 -4
View File
@@ -1,15 +1,18 @@
import bcrypt from 'bcryptjs'
// bcrypt natif (binaire précompilé) : le calcul passe sur les fils de libuv, la boucle de l'API garde la main.
// bcryptjs calculait en JavaScript : 8 vérifications simultanées retardaient chaque requête de 0,7 s en moyenne.
// Mêmes empreintes $2a$/$2b$ : les mots de passe enregistrés restent valables.
import { hash, verify } from '@node-rs/bcrypt'
import { env } from '../config/env.js'
const HASH_ROUNDS = 12
export async function hashPassword(value: string): Promise<string> {
return bcrypt.hash(value, HASH_ROUNDS)
return hash(value, { cost: HASH_ROUNDS })
}
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
return bcrypt.compare(value, hash)
export async function verifyPassword(value: string, storedHash: string): Promise<boolean> {
return verify(value, storedHash)
}
// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte.
+92
View File
@@ -0,0 +1,92 @@
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { encode } from 'blurhash'
import sharp from 'sharp'
import type { FastifyInstance } from 'fastify'
// Aperçus flous (BlurHash) : une courte chaîne par photo, que le navigateur décode en image floue aux bonnes
// couleurs pendant que la vraie se charge. Les photos partent en taille réelle, sans miniature : sur un réseau
// lent, une vignette restait un carré vide.
//
// Calculé à l'envoi d'une photo ou d'un portrait (routes/media.ts). Les autres chemins (Immich, import GEDCOM,
// propositions acceptées) et les photos d'avant cette version sont rattrapés en tâche de fond. Une image
// illisible reçoit une chaîne vide, pour ne pas être relue à chaque passage.
// Assez petit pour un calcul rapide ; l'aperçu garde les proportions de la photo
const SAMPLE_MAX = 32
const COMPONENTS_X = 4
const COMPONENTS_Y = 3
export async function computeBlurhash(buf: Buffer): Promise<string | null> {
try {
const { data, info } = await sharp(buf, { limitInputPixels: 50_000_000, failOn: 'error' })
.rotate()
.resize(SAMPLE_MAX, SAMPLE_MAX, { fit: 'inside' })
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true })
return encode(new Uint8ClampedArray(data), info.width, info.height, COMPONENTS_X, COMPONENTS_Y)
} catch {
return null
}
}
async function hashStoredFile(storageRoot: string, relativePath: string): Promise<string> {
const absolutePath = path.resolve(storageRoot, relativePath)
if (!absolutePath.startsWith(storageRoot + path.sep)) return ''
const buf = await readFile(absolutePath).catch(() => null)
return (buf && await computeBlurhash(buf)) || ''
}
const BATCH = 25
const PAUSE_MS = 60_000
// Un lot de photos puis de portraits sans aperçu ; renvoie le nombre traité
export async function fillMissingBlurhashes(app: FastifyInstance, storageRoot: string): Promise<number> {
const medias = await app.prisma.mediaItem.findMany({
where: { type: 'photo', blurhash: null, deletedAt: null, NOT: { mimeType: 'video/youtube' } },
select: { id: true, filePath: true },
take: BATCH,
})
for (const media of medias) {
const blurhash = await hashStoredFile(storageRoot, media.filePath)
await app.prisma.mediaItem.updateMany({ where: { id: media.id, blurhash: null }, data: { blurhash } })
}
const persons = await app.prisma.person.findMany({
where: { avatarPath: { not: null }, avatarBlurhash: null, deletedAt: null },
select: { id: true, avatarPath: true },
take: BATCH,
})
for (const person of persons) {
const avatarBlurhash = await hashStoredFile(storageRoot, person.avatarPath as string)
// Le portrait a pu changer pendant le calcul : on n'écrit que s'il est toujours le même
await app.prisma.person.updateMany({ where: { id: person.id, avatarPath: person.avatarPath, avatarBlurhash: null }, data: { avatarBlurhash } })
}
return medias.length + persons.length
}
// Tâche de fond : enchaîne les lots tant qu'il en reste, puis repasse toutes les minutes
export function startBlurhashFiller(app: FastifyInstance, storageRoot: string) {
let timer: NodeJS.Timeout | null = null
let stopped = false
const run = async () => {
let done = 0
try {
done = await fillMissingBlurhashes(app, storageRoot)
} catch (error) {
app.log.warn({ err: error }, 'aperçus flous : lot en échec')
}
if (!stopped) {
timer = setTimeout(run, done >= BATCH ? 0 : PAUSE_MS)
timer.unref()
}
}
timer = setTimeout(run, 5_000)
timer.unref()
app.addHook('onClose', async () => {
stopped = true
if (timer) clearTimeout(timer)
})
}
+2 -2
View File
@@ -1,4 +1,4 @@
import { Prisma, type PrismaClient } from '@prisma/client'
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
type Db = PrismaClient | Prisma.TransactionClient
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
await db.person.updateMany({
where: { id: personId, treeId, deletedAt: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null },
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, avatarBlurhash: null, immichPersonId: null },
})
const unions = await db.union.findMany({
+112
View File
@@ -0,0 +1,112 @@
import { createHash } from 'node:crypto'
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
// Envois rejoués sans doublon (en-tête Idempotency-Key). Sur un réseau faible, la famille renvoie une proposition
// ou une photo dont la réponse s'est perdue : le serveur avait déjà tout créé, et l'arbre recevait deux fois la
// même chose. Le navigateur tire une clé au début d'un envoi et la garde pour chaque nouvel essai ; le serveur
// répond au second essai avec la réponse du premier, sans rien recréer. Deux essais simultanés : le second attend
// le premier.
//
// En mémoire, sur une seule instance de l'API (le cas de Parenthèse et de l'auto-hébergement) : un redémarrage
// oublie les clés, ce qui ne recrée de doublon que si un essai tombe pile pendant le redémarrage.
// Les créations faites par la famille : propositions, souvenirs, photos posées sur l'arbre, moments
const IDEMPOTENT_ROUTES = new Set([
'POST /trees/:id/contributions/sessions',
'POST /trees/:id/persons/:personId/media',
'POST /trees/:id/annotation-photos',
'POST /trees/:id/persons/:personId/life/events',
])
const TTL_MS = 24 * 60 * 60 * 1000
const MAX_ENTRIES = 5000
const KEY_PATTERN = /^[A-Za-z0-9_-]{16,100}$/
type Stored = { statusCode: number, contentType: string | undefined, payload: string }
type Entry = { expires: number, done: Stored | null, settled: Promise<void>, settle: () => void }
const entries = new Map<string, Entry>()
function prune(now: number) {
for (const [scope, entry] of entries) {
if (entry.expires <= now) entries.delete(scope)
}
// Au-delà du plafond, les plus anciennes partent d'abord (la Map garde l'ordre d'insertion)
while (entries.size > MAX_ENTRIES) {
const oldest = entries.keys().next().value
if (oldest === undefined) break
entries.get(oldest)?.settle()
entries.delete(oldest)
}
}
// Une clé ne vaut que pour la même route, la même adresse et la même personne : celle d'un autre ne rejoue rien
function scopeOf(request: FastifyRequest, key: string) {
const who = createHash('sha256').update(request.headers.authorization ?? `ip:${request.ip}`).digest('hex').slice(0, 32)
return `${request.method} ${request.url} ${who} ${key}`
}
function replay(reply: FastifyReply, stored: Stored) {
if (stored.contentType) reply.header('content-type', stored.contentType)
return reply.code(stored.statusCode).header('idempotent-replayed', 'true').send(stored.payload)
}
export function registerIdempotency(app: FastifyInstance) {
app.addHook('preHandler', async (request, reply) => {
if (!IDEMPOTENT_ROUTES.has(`${request.method} ${request.routeOptions.url}`)) return
const key = request.headers['idempotency-key']
if (typeof key !== 'string' || !KEY_PATTERN.test(key)) return
const now = Date.now()
prune(now)
const scope = scopeOf(request, key)
// Un essai précédent est en cours : on attend sa fin, puis on rejoue sa réponse s'il a abouti
for (let existing = entries.get(scope); existing; existing = entries.get(scope)) {
if (existing.done) return replay(reply, existing.done)
await existing.settled
}
let settle = () => {}
const settled = new Promise<void>((resolve) => { settle = resolve })
entries.set(scope, { expires: now + TTL_MS, done: null, settled, settle })
request.idempotencyScope = scope
})
app.addHook('onSend', async (request, reply, payload) => {
const scope = request.idempotencyScope
if (!scope) return payload
const entry = entries.get(scope)
if (!entry) return payload
// Seule une réussite se rejoue ; un échec laisse le prochain essai refaire la requête
if (reply.statusCode < 300 && typeof payload === 'string') {
const contentType = reply.getHeader('content-type')
entry.done = { statusCode: reply.statusCode, contentType: typeof contentType === 'string' ? contentType : undefined, payload }
} else {
entries.delete(scope)
}
entry.settle()
return payload
})
// Requête coupée avant toute réponse : l'essai suivant ne doit pas attendre pour rien
app.addHook('onResponse', async (request) => {
const scope = request.idempotencyScope
const entry = scope ? entries.get(scope) : undefined
if (entry && !entry.done) {
entries.delete(scope as string)
entry.settle()
}
})
}
declare module 'fastify' {
interface FastifyRequest {
idempotencyScope?: string
}
}
// Tests
export function resetIdempotency() {
entries.clear()
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash } from 'node:crypto'
import type { PrismaClient } from '@prisma/client'
import type { PrismaClient } from '../generated/prisma/client.js'
import { env } from '../config/env.js'
import { decryptImmichApiKey } from './immich-secret.js'
+49 -2
View File
@@ -1,7 +1,8 @@
import { unlink } from 'node:fs/promises'
import { createReadStream } from 'node:fs'
import { stat, unlink } from 'node:fs/promises'
import path from 'node:path'
import type { FastifyReply } from 'fastify'
import type { FastifyReply, FastifyRequest } from 'fastify'
import sharp from 'sharp'
// Fichiers envoyés : le type vient des premiers octets, jamais de ce que déclare le navigateur.
@@ -235,3 +236,49 @@ export async function removeStoredFile(storageRoot: string, relativePath: string
// déjà absent
}
}
// Plage demandée par « Range: bytes=début-fin » (une seule plage), bornée à la taille du fichier. null : pas de
// plage lisible, on envoie tout ; 'unsatisfiable' : la plage tombe hors du fichier.
export function parseByteRange(header: string | undefined, size: number): { start: number; end: number } | 'unsatisfiable' | null {
const match = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim())
if (!match || (match[1] === '' && match[2] === '')) return null
if (match[1] === '') {
// Les n derniers octets
const suffix = Number(match[2])
if (suffix === 0 || size === 0) return 'unsatisfiable'
return { start: Math.max(0, size - suffix), end: size - 1 }
}
const start = Number(match[1])
const end = match[2] === '' ? size - 1 : Math.min(Number(match[2]), size - 1)
if (start >= size || end < start) return 'unsatisfiable'
return { start, end }
}
// Taille d'un fichier stocké, null s'il manque
export async function storedFileSize(absolutePath: string): Promise<number | null> {
try {
const info = await stat(absolutePath)
return info.isFile() ? info.size : null
} catch {
return null
}
}
// Envoie un fichier en flux, par morceaux si le navigateur le demande. Safari ne lit une vidéo que si le serveur
// répond aux plages (206) ; avant, le fichier entier passait en mémoire puis partait d'un bloc. À appeler après
// applyMediaResponseHeaders.
export function sendFileWithRanges(request: FastifyRequest, reply: FastifyReply, absolutePath: string, size: number): FastifyReply {
reply.header('Accept-Ranges', 'bytes')
const range = parseByteRange(request.headers.range, size)
if (range === 'unsatisfiable') {
return reply.code(416).header('Content-Range', `bytes */${size}`).send()
}
if (range) {
reply.code(206)
reply.header('Content-Range', `bytes ${range.start}-${range.end}/${size}`)
reply.header('Content-Length', String(range.end - range.start + 1))
return reply.send(createReadStream(absolutePath, { start: range.start, end: range.end }))
}
reply.header('Content-Length', String(size))
return reply.send(createReadStream(absolutePath))
}
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma } from '@prisma/client'
import type { Prisma } from '../generated/prisma/client.js'
// Renumérote les souvenirs acceptés d'une personne et met en avant les trois premiers.
// Les souvenirs en attente de relecture ne comptent pas.
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
import { z } from 'zod'
type Db = PrismaClient | Prisma.TransactionClient
+3
View File
@@ -11,6 +11,9 @@ export type OidcPending = {
nonce: string
codeVerifier: string
returnTo: string
// Rattachement demandé depuis un compte connecté (ticket de /auth/oidc/link) : l'identité du fournisseur rejoint
// ce compte, au lieu d'en chercher un par email
linkUserId?: string
}
export type OidcIdentity = {
+5 -8
View File
@@ -1,12 +1,9 @@
import pkg from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import { env } from '../config/env.js'
import { PrismaClient } from '../generated/prisma/client.js'
const { PrismaClient } = pkg
// Prisma 7 passe par le pilote pg. Délai de connexion de 5 s, comme le moteur de Prisma 6 (pg attend sans fin par défaut)
export const prisma = new PrismaClient({
datasources: {
db: {
url: env.DATABASE_URL,
},
},
adapter: new PrismaPg({ connectionString: env.DATABASE_URL, connectionTimeoutMillis: 5_000 }),
})
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
import { env } from '../config/env.js'
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
type LinkReader = Pick<PrismaClient, 'parentChildLink'> | Pick<Prisma.TransactionClient, 'parentChildLink'>
+29 -1
View File
@@ -1,11 +1,21 @@
import type { PrismaClient } from '../generated/prisma/client.js'
// Sessions fermées par « Se déconnecter » ou par la suppression du compte, jusqu'à la fin de leur jeton.
// Gardées en mémoire pour ne coûter aucune requête par appel, et en base pour survivre au redémarrage de l'API :
// avant, chaque déploiement les oubliait et un jeton déconnecté redevenait valable jusqu'à 7 jours.
// L'API tourne en un seul processus (pm2 fork, un conteneur) : la mémoire suffit entre deux redémarrages.
const revokedUserTokens = new Map<string, number>()
function nowSeconds(): number {
return Math.floor(Date.now() / 1000)
}
export function revokeUserToken(jti: string, exp: number): void {
type RevocationStore = Pick<PrismaClient, 'revokedSession'>
export async function revokeUserToken(store: RevocationStore, jti: string, exp: number): Promise<void> {
revokedUserTokens.set(jti, exp)
const expiresAt = new Date(exp * 1000)
await store.revokedSession.upsert({ where: { jti }, create: { jti, expiresAt }, update: {} })
}
export function isUserTokenRevoked(jti: string): boolean {
@@ -22,3 +32,21 @@ export function isUserTokenRevoked(jti: string): boolean {
return true
}
// Au démarrage : les déconnexions encore valables reviennent en mémoire, les expirées sont effacées. L'effacement
// n'a lieu qu'ici : la table grossit entre deux redémarrages, d'une ligne par déconnexion, ce qui reste minuscule
// avec un déploiement par jour ou presque.
export async function loadRevokedTokens(store: RevocationStore): Promise<number> {
const now = new Date()
await store.revokedSession.deleteMany({ where: { expiresAt: { lte: now } } })
const rows = await store.revokedSession.findMany({ where: { expiresAt: { gt: now } }, select: { jti: true, expiresAt: true } })
for (const row of rows) {
revokedUserTokens.set(row.jti, Math.floor(row.expiresAt.getTime() / 1000))
}
return rows.length
}
// Tests : repartir d'un processus neuf, comme après un redémarrage
export function forgetRevokedTokensInMemory(): void {
revokedUserTokens.clear()
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { createHash, randomBytes } from 'node:crypto'
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
// Co-gestion d'un arbre (issue #145). Le propriétaire invite une adresse ; Parenthèse n'envoie pas de mail, il
// donne un lien à transmettre. Seule l'empreinte du jeton est gardée en base.
+1 -1
View File
@@ -1,7 +1,7 @@
import { rm } from 'node:fs/promises'
import path from 'node:path'
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
import { env } from '../config/env.js'
+3 -3
View File
@@ -50,9 +50,9 @@ export function resetTreeUnlockGuard(): void {
attemptsByTree.clear()
}
// bcryptjs calcule en JavaScript sur le processus de l'API (≈ 250 ms par comparaison au coût 12) : des essais
// lancés en parallèle depuis quelques adresses suffisaient à le saturer pour toutes les familles. Les
// comparaisons de mot de passe de partage passent une par une, le reste de l'API garde la main entre deux.
// Une comparaison bcrypt coûte ≈ 250 ms au coût 12. Même calculée hors de la boucle (bcrypt natif), des essais
// lancés en parallèle depuis quelques adresses occuperaient tous les fils de calcul, connexions comprises. Les
// comparaisons de mot de passe de partage passent une par une.
// Au-delà de MAX_WAITING comparaisons en attente, on refuse tout de suite (null) plutôt que d'allonger la file.
const MAX_WAITING = 20
let queue: Promise<unknown> = Promise.resolve()
+1 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import {
+16 -10
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(
@@ -254,14 +259,15 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
const user = await app.prisma.user.findUnique({
where: { id: request.actor.userId },
select: { id: true, email: true, firstName: true, passwordHash: true },
select: { id: true, email: true, firstName: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
})
if (!user) {
return reply.code(401).send({ error: 'authentication_required' })
}
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } })
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe.
// oidcLinked : le compte se connecte déjà par le fournisseur ; sinon le compte propose « Relier à … »
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash), oidcLinked: (user.identities?.length ?? 0) > 0 } })
})
app.get('/me/last-tree', async (request, reply) => {
@@ -450,7 +456,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
})
if (payload.kind === 'user' && typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
await revokeUserToken(app.prisma, payload.jti, payload.exp)
}
await removeTreeMediaDirectories(ownedTreeIds, request.log)
@@ -470,7 +476,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
if (typeof payload.exp === 'number') {
revokeUserToken(payload.jti, payload.exp)
await revokeUserToken(app.prisma, payload.jti, payload.exp)
}
return reply.send({ ok: true })
+2 -2
View File
@@ -1,7 +1,7 @@
import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync } from 'fastify'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import { env, UPLOAD_BODY_LIMIT } from '../config/env.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
@@ -250,7 +250,7 @@ function stripAnnotationUrl<T>(value: T): T {
function toPrismaJson(
value: unknown,
): Prisma.InputJsonValue | Prisma.NullTypes.DbNull | Prisma.NullTypes.JsonNull | undefined {
): Prisma.InputJsonValue | typeof Prisma.DbNull | typeof Prisma.JsonNull | undefined {
if (value === undefined) {
return undefined
}
+5 -1
View File
@@ -1,4 +1,4 @@
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import path from 'node:path'
import { randomUUID } from 'node:crypto'
@@ -16,6 +16,7 @@ import {
ImmichUnavailableError,
type ImmichPersonSummary,
} from '../lib/immich.js'
import { computeBlurhash } from '../lib/blurhash.js'
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile } from './media.js'
@@ -539,6 +540,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
avatarMimeType: prepared.mimeType,
// Nouveau portrait, nouveau cadrage à faire
avatarCrop: Prisma.DbNull,
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
immichPersonId,
updatedBy: request.actor.userId,
},
@@ -619,11 +621,13 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
avatarPath?: null
avatarMimeType?: null
avatarCrop?: typeof Prisma.DbNull
avatarBlurhash?: null
} = { immichPersonId: null, updatedBy: request.actor.userId }
if (avatarFromImmich) {
updateData.avatarPath = null
updateData.avatarMimeType = null
updateData.avatarCrop = Prisma.DbNull
updateData.avatarBlurhash = null
}
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
+34 -7
View File
@@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
import { z } from 'zod'
@@ -16,10 +16,13 @@ import {
removeStoredFile,
sanitizeImage,
scrubMp4Location,
sendFileWithRanges,
sniffMimeType,
storedFileSize,
type BinaryMediaType,
} from '../lib/media-files.js'
import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js'
import { computeBlurhash } from '../lib/blurhash.js'
import { syncPersonMediaOrder } from '../lib/media-order.js'
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
@@ -241,6 +244,18 @@ async function canReadMedia(app: FastifyRequest['server'], treeId: string, reque
return token ? canReadTreeWithMediaToken(app, treeId, token) : false
}
// Compte qui demande un média : celui de la session, sinon celui du jeton médias (lien de partage : aucun)
async function mediaRequesterUserId(app: FastifyRequest['server'], request: FastifyRequest, token: string | undefined): Promise<string | null> {
if (request.actor?.kind === 'user') return request.actor.userId
if (!token) return null
try {
const payload = await app.jwt.verify<{ kind?: string; userId?: string }>(token)
return payload.kind === 'media' && payload.userId ? payload.userId : null
} catch {
return null
}
}
export const mediaRoutes: FastifyPluginAsync = async (app) => {
app.post('/trees/:id/persons/:personId/media', UPLOAD_RATE_LIMIT, async (request, reply) => {
const actor = request.actor
@@ -487,6 +502,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
status: isPending ? 'pending' : 'approved',
contributionSessionId: contributionSession?.id ?? null,
lifeEventId,
blurhash: payload.data.type === 'photo' ? await computeBlurhash(prepared.buffer) ?? '' : null,
},
})
@@ -606,6 +622,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
avatarMimeType: prepared.mimeType,
// Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci
avatarCrop: Prisma.DbNull,
avatarBlurhash: await computeBlurhash(prepared.buffer) ?? '',
updatedBy: request.actor.userId,
// Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association
// survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne
@@ -662,6 +679,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
avatarPath: null,
avatarMimeType: null,
avatarCrop: Prisma.DbNull,
avatarBlurhash: null,
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
immichPersonId: null,
updatedBy: request.actor.userId,
@@ -1079,6 +1097,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
select: {
filePath: true,
mimeType: true,
status: true,
uploadedBy: true,
},
})
@@ -1086,6 +1106,16 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(404).send({ error: 'media_not_found' })
}
// Un souvenir qui attend la relecture ne se lit que par qui gère l'arbre ou par qui l'a envoyé, pas par toute la
// famille qui connaîtrait son identifiant. Même réponse qu'un média absent : rien n'indique qu'il existe.
if (media.status === 'pending') {
const userId = await mediaRequesterUserId(app, request, query.data.token)
const allowed = Boolean(userId) && (media.uploadedBy === userId || Boolean(await requireAdminMembership(app, userId as string, params.data.id)))
if (!allowed) {
return reply.code(404).send({ error: 'media_not_found' })
}
}
// YouTube videos: filePath is the YouTube URL, redirect the browser
if (media.mimeType === 'video/youtube') {
return reply.code(302).redirect(media.filePath)
@@ -1097,16 +1127,13 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_media_path' })
}
let fileBuffer: Buffer
try {
fileBuffer = await readFile(absolutePath)
} catch {
const size = await storedFileSize(absolutePath)
if (size === null) {
return reply.code(404).send({ error: 'media_file_missing' })
}
applyMediaResponseHeaders(reply, media.mimeType, path.basename(media.filePath))
return reply.send(fileBuffer)
return sendFileWithRanges(request, reply, absolutePath, size)
})
// ---- Annotation Photos ----
+68 -6
View File
@@ -1,6 +1,6 @@
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
import { randomUUID } from 'node:crypto'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import { env } from '../config/env.js'
@@ -46,8 +46,42 @@ const pendingSchema = z.object({
nonce: z.string().min(1),
codeVerifier: z.string().min(43),
returnTo: z.string(),
linkUserId: z.string().min(1).optional(),
})
// Rattacher une identité du fournisseur à un compte à mot de passe. Le compte connecté demande un ticket (court, à
// usage unique), le départ OIDC le porte, et le retour relie l'identité à ce compte. La personne prouve les deux :
// son mot de passe (session) et son compte chez le fournisseur. C'est le chemin pour passer une instance en
// PASSWORD_LOGIN=false sans enfermer dehors les comptes à mot de passe : le rattachement automatique par email reste
// refusé pour eux (resolveAccount), parce que l'inscription par mot de passe ne vérifie pas l'adresse.
const LINK_TICKET_TTL_S = 120
type LinkTicketPayload = { kind: 'oidc_link'; sub: string; jti: string; exp?: number }
const usedLinkTickets = new Map<string, number>()
function consumeLinkTicket(jti: string, expSeconds: number): boolean {
const now = Date.now()
for (const [key, expiresAt] of usedLinkTickets) {
if (expiresAt < now) usedLinkTickets.delete(key)
}
if (usedLinkTickets.has(jti)) return false
usedLinkTickets.set(jti, expSeconds * 1000)
return true
}
type LinkResult = { userId: string } | { error: 'oidc_identity_taken' | 'oidc_failed' }
async function linkIdentity(app: FastifyInstance, userId: string, identity: OidcIdentity): Promise<LinkResult> {
const user = await app.prisma.user.findUnique({ where: { id: userId }, select: { id: true } })
if (!user) return { error: 'oidc_failed' }
const linked = await app.prisma.userIdentity.findUnique({
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
select: { userId: true },
})
// Déjà reliée à ce compte : rien à faire. Reliée à un autre : jamais déplacée d'un compte à l'autre
if (linked) return linked.userId === userId ? { userId } : { error: 'oidc_identity_taken' }
await app.prisma.userIdentity.create({ data: { userId, issuer: identity.issuer, subject: identity.subject } })
return { userId }
}
const exchangeSchema = z.object({
code: z.string().min(1).max(4096),
})
@@ -114,7 +148,18 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
'/start',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
async (request, reply) => {
const returnTo = safeReturnTo((request.query as Record<string, unknown> | undefined)?.returnTo)
const query = (request.query as Record<string, unknown> | undefined) ?? {}
// Ticket de rattachement : vérifié et consommé ici, le compte voyage dans le cookie signé
let linkUserId: string | undefined
if (typeof query.link === 'string') {
let ticket: LinkTicketPayload | null = null
try { ticket = app.jwt.verify<LinkTicketPayload>(query.link) } catch { ticket = null }
if (!ticket || ticket.kind !== 'oidc_link' || typeof ticket.exp !== 'number' || !consumeLinkTicket(ticket.jti, ticket.exp)) {
return redirectToApp(reply, '/', 'oidc_error=oidc_link_expired')
}
linkUserId = ticket.sub
}
const returnTo = linkUserId ? '/' : safeReturnTo(query.returnTo)
let start: Awaited<ReturnType<typeof startAuthorization>>
try {
start = await startAuthorization(returnTo)
@@ -123,7 +168,7 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
}
reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), {
reply.setCookie(PENDING_COOKIE, JSON.stringify(linkUserId ? { ...start.pending, linkUserId } : start.pending), {
path: '/',
httpOnly: true,
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
@@ -167,13 +212,13 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
}
let result: AccountResult
let result: AccountResult | LinkResult
try {
result = await resolveAccount(app, identity)
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
} catch (error) {
// Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
result = await resolveAccount(app, identity)
result = pending.linkUserId ? await linkIdentity(app, pending.linkUserId, identity) : await resolveAccount(app, identity)
} else {
throw error
}
@@ -195,6 +240,23 @@ export const oidcRoutes: FastifyPluginAsync = async (app) => {
},
)
// Ticket de rattachement pour le compte connecté (session par en-tête) : la navigation vers /start ne peut pas
// porter l'en-tête, le ticket le remplace, deux minutes et une seule fois
app.post(
'/link',
{ config: { rateLimit: { max: 10, timeWindow: '1 minute' } } },
async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const ticket = app.jwt.sign(
{ kind: 'oidc_link', sub: request.actor.userId, jti: randomUUID() },
{ expiresIn: `${LINK_TICKET_TTL_S}s` },
)
return reply.send({ ticket })
},
)
app.post(
'/exchange',
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
+6 -1
View File
@@ -1,7 +1,7 @@
import path from 'node:path'
import type { FastifyInstance, FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { Prisma } from '@prisma/client'
import { Prisma } from '../generated/prisma/client.js'
import { z } from 'zod'
import { env } from '../config/env.js'
@@ -143,6 +143,9 @@ const patchUnionSchema = z
partner2PersonId: z.string().min(1).optional().nullable(),
unionType: z.string().max(50).optional().nullable(),
startDate: z.string().regex(isoDateRegex).optional().nullable(),
// Lieu et précision de la date du couple, saisis dans l'onglet Vie (« vers 1980 »)
startPlace: z.string().trim().max(300).optional().nullable(),
startDatePrecision: z.enum(['about', 'estimated', 'calculated', 'before', 'after']).optional().nullable(),
endDate: z.string().regex(isoDateRegex).optional().nullable(),
displayOrder: z.number().int().positive().optional(),
})
@@ -1780,6 +1783,8 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
},
data: {
...unionPrecisionResets(existingUnion, { startDate }),
...(payload.data.startDatePrecision !== undefined ? { startDatePrecision: payload.data.startDatePrecision } : {}),
startPlace: payload.data.startPlace !== undefined ? payload.data.startPlace || null : undefined,
partner1PersonId: payload.data.partner1PersonId,
partner2PersonId: payload.data.partner2PersonId,
unionType: payload.data.unionType,
+9 -1
View File
@@ -35,7 +35,15 @@ export type OidcHandoffJwtPayload = {
jti: string
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload
// Ticket de rattachement d'une identité OIDC au compte connecté (120 s, à usage unique, porté par /auth/oidc/start) :
// jamais une session, le décodeur de l'en-tête ne retient que les sortes user et tree_access
export type OidcLinkJwtPayload = {
kind: 'oidc_link'
sub: string
jti: string
}
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload | OidcLinkJwtPayload
export type UserActor = {
kind: 'user'
+1 -1
View File
@@ -1,4 +1,4 @@
import type { PrismaClient } from '@prisma/client'
import type { PrismaClient } from '../generated/prisma/client.js'
import type { Actor, AnyJwtPayload } from './auth.js'
declare module '@fastify/jwt' {
+5 -1
View File
@@ -1,4 +1,4 @@
import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '@prisma/client'
import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '../generated/prisma/client.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
@@ -26,6 +26,7 @@ export type GraphPayload = {
notes: string | null
avatarFromImmich: boolean
avatarCrop: AvatarCrop | null
avatarBlurhash: string | null
}>
unions: Array<{
id: string
@@ -54,6 +55,7 @@ export type GraphPayload = {
source: string | null
displayOrder: number
isFeatured: boolean
blurhash: string | null
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
people: Array<{ personId: string; x: number | null; y: number | null }>
}>
@@ -117,6 +119,7 @@ export function buildGraphPayload(
notes: person.notes,
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
avatarBlurhash: (person.avatarPath && person.avatarBlurhash) || null,
})),
unions: unions.map((union) => ({
id: union.id,
@@ -145,6 +148,7 @@ export function buildGraphPayload(
source: media.source,
displayOrder: media.displayOrder,
isFeatured: media.isFeatured,
blurhash: media.blurhash || null,
people: peopleByMedia.get(media.id) ?? [],
})),
annotations: annotations.map((ann) => ({
+1 -1
View File
@@ -1,4 +1,4 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import type { Prisma, PrismaClient } from '../generated/prisma/client.js'
type RelationReader = Pick<PrismaClient, 'parentChildLink' | 'union'>
+95
View File
@@ -0,0 +1,95 @@
import { mkdtempSync, readFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Aperçus flous (lib/blurhash.ts), sur une vraie base : calculés à l'envoi d'une photo et d'un portrait,
// publiés dans le graphe, et rattrapés en tâche de fond pour ce qui n'en a pas (photos d'avant la migration,
// imports). Lancé seulement avec TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/apercus-flous.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
let ip = 0
const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
const storage = mkdtempSync(path.join(tmpdir(), 'pth-apercus-'))
const PHOTO_BYTES = readFileSync(fileURLToPath(new URL('../../frontend/public/demo/souvenir-mariage-1918.webp', import.meta.url)))
const PHOTO = PHOTO_BYTES.toString('base64')
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
let body = null
try { body = res.body ? res.json() : null } catch { body = null }
return { status: res.statusCode, body }
}
describe.skipIf(!TEST_DB)('aperçus flous, vraie base', () => {
let token: string
let treeId: string
let personId: string
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.MEDIA_STORAGE_PATH = storage
;({ env } = await import('../src/config/env.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
env.MEDIA_STORAGE_PATH = storage
const owner = await call('POST', '/auth/register', undefined, { email: `apercus-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Patch' })
expect(owner.status).toBe(201)
token = owner.body.token
const tree = await call('POST', '/trees', token, { name: 'Aperçus', slug: `apercus-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
treeId = tree.body.tree?.id ?? tree.body.id
const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' })
personId = person.body.person?.id ?? person.body.id
})
afterAll(async () => {
await app?.close()
})
const graph = async () => (await call('GET', `/trees/${treeId}/graph`, token)).body
it('une photo et un portrait envoyés ont leur aperçu tout de suite, publié dans le graphe', async () => {
const media = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, { type: 'photo', fileName: 'mariage.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
expect(media.status).toBe(201)
const avatar = await call('POST', `/trees/${treeId}/persons/${personId}/avatar`, token, { fileName: 'portrait.webp', mimeType: 'image/webp', sizeBytes: PHOTO_BYTES.length, dataBase64: PHOTO })
expect(avatar.status).toBeLessThan(300)
const g = await graph()
expect(g.graph?.medias?.[0]?.blurhash ?? g.medias?.[0]?.blurhash).toMatch(/^[0-9A-Za-z#$%*+,-.:;=?@[\]^_{|}~]{28}$/)
const person = (g.graph?.persons ?? g.persons).find((p: { id: string }) => p.id === personId)
expect(person.avatarBlurhash).toHaveLength(28)
})
it('la tâche de fond rattrape une photo et un portrait sans aperçu', async () => {
await app.prisma.mediaItem.updateMany({ where: { treeId }, data: { blurhash: null } })
await app.prisma.person.updateMany({ where: { id: personId }, data: { avatarBlurhash: null } })
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
const done = await fillMissingBlurhashes(app, path.resolve(storage))
expect(done).toBeGreaterThanOrEqual(2)
expect((await app.prisma.mediaItem.findFirst({ where: { treeId } }))?.blurhash).toHaveLength(28)
expect((await app.prisma.person.findUnique({ where: { id: personId } }))?.avatarBlurhash).toHaveLength(28)
})
it('un fichier illisible reçoit un aperçu vide, pour ne pas être relu à chaque passage', async () => {
const media = await app.prisma.mediaItem.findFirst({ where: { treeId } })
await app.prisma.mediaItem.update({ where: { id: media!.id }, data: { blurhash: null, filePath: `${treeId}/absent.webp` } })
const { fillMissingBlurhashes } = await import('../src/lib/blurhash.js')
await fillMissingBlurhashes(app, path.resolve(storage))
expect((await app.prisma.mediaItem.findUnique({ where: { id: media!.id } }))?.blurhash).toBe('')
})
})
+6
View File
@@ -7,6 +7,12 @@ import { parseGedcom } from '../src/routes/gedcom'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, verifyPassword } from '../src/lib/auth.js'
// bcrypt natif à la place de bcryptjs : les empreintes déjà en base restent valables
describe('mots de passe, bcrypt natif', () => {
it('lit une empreinte faite par bcryptjs', async () => {
const fromBcryptjs = '$2b$10$4uBVFoNEA8gO3uIGq/RqMueaq4w2Vl9VzPFiSj9v9dc0oI1DcR.YS'
expect(await verifyPassword('123soleil 🌞', fromBcryptjs)).toBe(true)
expect(await verifyPassword('123soleil', fromBcryptjs)).toBe(false)
})
it('fait une empreinte au coût 12 qui se relit', async () => {
const stored = await hashPassword('mot-de-passe-solide')
expect(stored).toMatch(/^\$2[aby]\$12\$/)
expect(await verifyPassword('mot-de-passe-solide', stored)).toBe(true)
expect(await verifyPassword('mot-de-passe-faux', stored)).toBe(false)
})
})
+7 -1
View File
@@ -4,6 +4,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
@@ -111,7 +117,7 @@ describe('prénom du compte', () => {
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' })
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
expect(me.statusCode).toBe(200)
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true, oidcLinked: false })
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
expect(anonymous.statusCode).toBe(401)
+7 -1
View File
@@ -2,13 +2,19 @@ import { randomUUID } from 'node:crypto'
import { mkdtemp, rm } from 'node:fs/promises'
import os from 'node:os'
import path from 'node:path'
import { Prisma } from '@prisma/client'
import { Prisma } from '../src/generated/prisma/client.js'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
+26
View File
@@ -0,0 +1,26 @@
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { decode, isBlurhashValid } from 'blurhash'
import { describe, expect, it } from 'vitest'
import { computeBlurhash } from '../src/lib/blurhash.js'
// Aperçu flou calculé à l'envoi d'une photo : une chaîne valide, aux couleurs de la photo
const demo = (name: string) => fileURLToPath(new URL(`../../frontend/public/demo/${name}`, import.meta.url))
describe('computeBlurhash', () => {
it('donne un aperçu valide pour une vraie photo', async () => {
const hash = await computeBlurhash(await readFile(demo('souvenir-mariage-1918.webp')))
expect(hash).toBeTruthy()
expect(isBlurhashValid(hash as string).result).toBe(true)
// 4 × 3 composantes : 4 + 2 × 4 × 3 - 2 = 28 caractères
expect(hash).toHaveLength(28)
// Photo en noir et blanc : l'aperçu reste gris (rouge, vert et bleu proches)
const px = decode(hash as string, 8, 8)
const [r, g, b] = [px[0], px[1], px[2]]
expect(Math.max(r, g, b) - Math.min(r, g, b)).toBeLessThan(24)
})
it('renvoie null pour un fichier qui n’est pas une image', async () => {
expect(await computeBlurhash(Buffer.from('pas une image'))).toBeNull()
})
})
+6
View File
@@ -8,6 +8,12 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi }
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn(),
+79
View File
@@ -0,0 +1,79 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Une session fermée reste fermée après un redémarrage de l'API (un déploiement), sur une vraie base. Avant, la
// déconnexion ne vivait qu'en mémoire et le jeton redevenait valable jusqu'à 7 jours. Lancé seulement avec
// TEST_DATABASE_URL (base jetable, migrée) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/deconnexion.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let ip = 0
const nextIp = () => `10.5.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
return { status: res.statusCode, body: res.body ? res.json() : null }
}
// Nouveau processus : la mémoire des déconnexions repart de zéro, puis l'API la recharge depuis la base
async function restart() {
await app.close()
const { forgetRevokedTokensInMemory } = await import('../src/lib/token-revocation.js')
forgetRevokedTokensInMemory()
const { createApp } = await import('../src/app.js')
app = createApp()
await app.ready()
}
async function login(name: string) {
const email = `${name}-${run}@exemple.fr`
const reg = await call('POST', '/auth/register', undefined, { email, password: 'mot-de-passe-solide', firstName: name })
expect(reg.status).toBe(201)
return reg.body.token as string
}
describe.skipIf(!TEST_DB)('déconnexion durable, vraie base', () => {
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
const { env } = await import('../src/config/env.js')
env.ALLOW_REGISTRATION = true
const { createApp } = await import('../src/app.js')
app = createApp()
})
afterAll(async () => {
await app?.close()
})
it('un jeton déconnecté reste refusé après un redémarrage', async () => {
const token = await login('deconnecte')
const other = await login('reste')
expect((await call('GET', '/auth/me', token)).status).toBe(200)
expect((await call('POST', '/auth/logout', token)).status).toBe(200)
expect((await call('GET', '/auth/me', token)).status).toBe(401)
await restart()
expect((await call('GET', '/auth/me', token)).status).toBe(401)
// Une autre session, jamais fermée, passe toujours
expect((await call('GET', '/auth/me', other)).status).toBe(200)
})
it('une déconnexion expirée est effacée au démarrage', async () => {
const { prisma } = await import('../src/lib/prisma.js')
const jti = `expire-${run}`
await prisma.revokedSession.create({ data: { jti, expiresAt: new Date(Date.now() - 1000) } })
await restart()
expect(await prisma.revokedSession.findUnique({ where: { jti } })).toBeNull()
})
})
+6
View File
@@ -14,6 +14,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+87
View File
@@ -0,0 +1,87 @@
import Fastify, { type FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { registerIdempotency, resetIdempotency } from '../src/lib/idempotency.js'
// Un envoi rejoué avec la même clé (réponse perdue sur un réseau faible) ne crée rien une seconde fois
let app: FastifyInstance
let created = 0
let failNext = false
const KEY = 'a1b2c3d4-e5f6-4789-9abc-def012345678'
const send = (headers: Record<string, string> = {}, url = '/trees/t1/contributions/sessions') =>
app.inject({ method: 'POST', url, headers: { authorization: 'Bearer famille', ...headers }, payload: { title: 'x' } })
beforeAll(async () => {
app = Fastify()
registerIdempotency(app)
app.post('/trees/:id/contributions/sessions', async (_request, reply) => {
await new Promise((resolve) => setTimeout(resolve, 30))
if (failNext) {
failNext = false
return reply.code(503).send({ error: 'busy' })
}
created += 1
return reply.code(201).send({ session: { id: `s${created}` } })
})
app.post('/trees/:id/autre', async () => {
created += 1
return { ok: created }
})
await app.ready()
})
afterAll(async () => {
await app.close()
})
beforeEach(() => {
created = 0
failNext = false
resetIdempotency()
})
describe('Idempotency-Key', () => {
it('rejoue la première réponse sans recréer', async () => {
const first = await send({ 'idempotency-key': KEY })
const second = await send({ 'idempotency-key': KEY })
expect(created).toBe(1)
expect(second.statusCode).toBe(201)
expect(second.json()).toEqual(first.json())
expect(second.headers['idempotent-replayed']).toBe('true')
})
it('deux essais simultanés : un seul est traité', async () => {
const [a, b] = await Promise.all([send({ 'idempotency-key': KEY }), send({ 'idempotency-key': KEY })])
expect(created).toBe(1)
expect(a.json()).toEqual(b.json())
})
it('un échec n’est pas gardé : l’essai suivant refait la requête', async () => {
failNext = true
const first = await send({ 'idempotency-key': KEY })
const second = await send({ 'idempotency-key': KEY })
expect(first.statusCode).toBe(503)
expect(second.statusCode).toBe(201)
expect(created).toBe(1)
})
it('la clé d’une autre personne ne rejoue rien', async () => {
await send({ 'idempotency-key': KEY })
await send({ 'idempotency-key': KEY, authorization: 'Bearer quelquun-dautre' })
expect(created).toBe(2)
})
it('sans clé, ou avec une clé mal formée, chaque envoi compte', async () => {
await send()
await send()
await send({ 'idempotency-key': 'court' })
await send({ 'idempotency-key': 'court' })
expect(created).toBe(4)
})
it('les autres routes ne sont pas concernées', async () => {
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
await send({ 'idempotency-key': KEY }, '/trees/t1/autre')
expect(created).toBe(2)
})
})
+6
View File
@@ -7,6 +7,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
+6
View File
@@ -10,6 +10,12 @@ import { beforeAll, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn().mockResolvedValue(1),
},
+6
View File
@@ -9,6 +9,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
count: vi.fn(),
},
+128
View File
@@ -0,0 +1,128 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Souvenir en attente de relecture, sur une vraie base : seuls qui gère l'arbre et qui l'a envoyé le lisent,
// la famille qui connaîtrait son identifiant reçoit la même réponse qu'un média absent. Lancé seulement avec
// TEST_DATABASE_URL (base jetable, migrée, en UTF-8) :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/media-attente.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
let ip = 0
const nextIp = () => `10.3.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
const mail = (name: string) => `${name}-${run}@exemple.fr`
// PNG d'un pixel
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
const storage = mkdtempSync(path.join(tmpdir(), 'pth-media-attente-'))
async function call(method: string, url: string, token?: string, payload?: unknown) {
const res = await app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: token ? { authorization: `Bearer ${token}` } : {},
remoteAddress: nextIp(),
})
let body = null
try { body = res.body ? res.json() : null } catch { body = null }
return { status: res.statusCode, body }
}
async function account(name: string) {
const res = await call('POST', '/auth/register', undefined, { email: mail(name), password: 'mot-de-passe-solide', firstName: name })
expect(res.status).toBe(201)
return { token: res.body.token as string, id: res.body.user.id as string }
}
describe.skipIf(!TEST_DB)('souvenir en attente, vraie base', () => {
let owner: { token: string; id: string }
let member: { token: string; id: string }
let reader: { token: string; id: string }
let shareToken: string
let treeId: string
let personId: string
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.MEDIA_STORAGE_PATH = storage
;({ env } = await import('../src/config/env.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
env.MEDIA_STORAGE_PATH = storage
owner = await account('proprio')
const tree = await call('POST', '/trees', owner.token, { name: 'Arbre de démo', slug: `attente-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
expect(tree.status).toBe(201)
treeId = tree.body.tree?.id ?? tree.body.id
const person = await call('POST', `/trees/${treeId}/persons`, owner.token, { firstName: 'Odette', lastName: 'Duval' })
expect(person.status).toBe(201)
personId = person.body.person?.id ?? person.body.id
// Deux comptes de la famille, rattachés par le mot de passe de partage, et le lien de partage seul
member = await account('membre')
reader = await account('lecteur')
for (const who of [member, reader]) {
expect((await call('POST', `/trees/${treeId}/access/unlock`, who.token, { password: 'partage-demo-1' })).status).toBe(200)
}
const shared = await call('POST', `/trees/${treeId}/access/unlock`, undefined, { password: 'partage-demo-1' })
expect(shared.status).toBe(200)
shareToken = shared.body.token
})
afterAll(async () => {
await app?.close()
})
const upload = (token: string) => call('POST', `/trees/${treeId}/persons/${personId}/media`, token, {
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})
const read = (mediaId: string, token?: string) => call('GET', `/trees/${treeId}/media/${mediaId}`, token)
// Jeton médias, celui que porte l'adresse d'une image (<img src>) : émis avec le graphe de l'arbre
const mediaToken = async (token: string) => (await call('GET', `/trees/${treeId}/graph`, token)).body?.mediaToken as string
it('un souvenir envoyé par un membre attend la relecture, et seuls le gestionnaire et son auteur le lisent', async () => {
const sent = await upload(member.token)
expect(sent.status).toBe(201)
const media = sent.body.media ?? sent.body
expect(media.status).toBe('pending')
expect((await read(media.id, owner.token)).status).toBe(200)
expect((await read(media.id, member.token)).status).toBe(200)
expect((await read(media.id, reader.token)).status).toBe(404)
expect((await read(media.id, shareToken)).status).toBe(404)
expect((await read(media.id)).status).toBe(401)
// Par l'adresse d'une image : le jeton médias du gestionnaire ouvre, celui du lecteur non
expect((await read(`${media.id}?token=${await mediaToken(owner.token)}`)).status).toBe(200)
expect((await read(`${media.id}?token=${await mediaToken(member.token)}`)).status).toBe(200)
expect((await read(`${media.id}?token=${await mediaToken(reader.token)}`)).status).toBe(404)
expect((await read(`${media.id}?token=${await mediaToken(shareToken)}`)).status).toBe(404)
})
it('un souvenir accepté se lit par toute la famille', async () => {
const sent = await upload(member.token)
const media = sent.body.media ?? sent.body
expect(media.status).toBe('pending')
const session = sent.body.contributionSessionId ?? media.contributionSessionId
expect(session).toBeTruthy()
const reviewed = await call('POST', `/trees/${treeId}/contributions/sessions/${session}/review`, owner.token, { decision: 'approved' })
expect(reviewed.status).toBe(200)
expect((await read(media.id, reader.token)).status).toBe(200)
expect((await read(media.id, shareToken)).status).toBe(200)
})
it('un souvenir posé par le gestionnaire est visible tout de suite', async () => {
const sent = await upload(owner.token)
expect(sent.status).toBe(201)
const media = sent.body.media ?? sent.body
expect(media.status).toBe('approved')
expect((await read(media.id, reader.token)).status).toBe(200)
})
})
+6
View File
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+109
View File
@@ -0,0 +1,109 @@
import { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
// Un souvenir se lit par morceaux (Range), sur une vraie base. Lancé seulement avec TEST_DATABASE_URL :
// TEST_DATABASE_URL=postgresql://postgres:postgres@localhost:5432/genealogy_test npx vitest run test/media-range.db.test.ts
const TEST_DB = process.env.TEST_DATABASE_URL
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean; MEDIA_STORAGE_PATH: string }
let ip = 0
const nextIp = () => `10.4.${Math.floor(++ip / 250)}.${ip % 250 + 1}`
const run = Date.now().toString(36)
// PNG d'un pixel
const PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
const storage = mkdtempSync(path.join(tmpdir(), 'pth-media-range-'))
async function call(method: string, url: string, token?: string, payload?: unknown, headers: Record<string, string> = {}) {
return app.inject({
method: method as 'GET',
url,
payload: payload as Record<string, unknown>,
headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), ...headers },
remoteAddress: nextIp(),
})
}
describe.skipIf(!TEST_DB)('souvenir lu par morceaux, vraie base', () => {
let token: string
let url: string
let treeId: string
let personId: string
let whole: Buffer
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = TEST_DB
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.MEDIA_STORAGE_PATH = storage
;({ env } = await import('../src/config/env.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
env.ALLOW_REGISTRATION = true
env.MEDIA_STORAGE_PATH = storage
const owner = await call('POST', '/auth/register', undefined, { email: `range-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: 'Hélène' })
token = owner.json().token
const tree = await call('POST', '/trees', token, { name: 'Arbre de démo', slug: `range-${run}`, visitorPassword: 'partage-demo-1', contributorPassword: 'partage-demo-1' })
treeId = tree.json().tree?.id ?? tree.json().id
const person = await call('POST', `/trees/${treeId}/persons`, token, { firstName: 'Odette', lastName: 'Duval' })
personId = person.json().person?.id ?? person.json().id
const sent = await call('POST', `/trees/${treeId}/persons/${personId}/media`, token, {
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})
expect(sent.statusCode).toBe(201)
const media = sent.json().media ?? sent.json()
url = `/trees/${treeId}/media/${media.id}`
const full = await call('GET', url, token)
expect(full.statusCode).toBe(200)
whole = full.rawPayload
})
afterAll(async () => {
await app?.close()
})
it('sans plage : tout le fichier, et l annonce des plages', async () => {
const res = await call('GET', url, token)
expect(res.statusCode).toBe(200)
expect(res.headers['accept-ranges']).toBe('bytes')
expect(Number(res.headers['content-length'])).toBe(whole.length)
expect(res.headers['content-type']).toBe('image/png')
})
it('les deux premiers octets, comme Safari avant une vidéo', async () => {
const res = await call('GET', url, token, undefined, { range: 'bytes=0-1' })
expect(res.statusCode).toBe(206)
expect(res.headers['content-range']).toBe(`bytes 0-1/${whole.length}`)
expect(res.rawPayload.equals(whole.subarray(0, 2))).toBe(true)
})
it('la fin du fichier, et une plage hors du fichier', async () => {
const tail = await call('GET', url, token, undefined, { range: 'bytes=-10' })
expect(tail.statusCode).toBe(206)
expect(tail.rawPayload.equals(whole.subarray(whole.length - 10))).toBe(true)
const out = await call('GET', url, token, undefined, { range: `bytes=${whole.length}-` })
expect(out.statusCode).toBe(416)
expect(out.headers['content-range']).toBe(`bytes */${whole.length}`)
})
it('les droits passent avant la plage, souvenir en attente compris', async () => {
expect((await call('GET', url, undefined, undefined, { range: 'bytes=0-1' })).statusCode).toBe(401)
// Un membre envoie un souvenir, qui attend la relecture ; un autre membre le demande par morceaux
const register = async (name: string) => (await call('POST', '/auth/register', undefined, { email: `${name}-${run}@exemple.fr`, password: 'mot-de-passe-solide', firstName: name })).json().token as string
const author = await register('auteur')
const reader = await register('lecteur')
for (const t of [author, reader]) expect((await call('POST', `/trees/${treeId}/access/unlock`, t, { password: 'partage-demo-1' })).statusCode).toBe(200)
const sent = await call('POST', `/trees/${treeId}/persons/${personId}/media`, author, {
type: 'photo', fileName: 'photo.png', mimeType: 'image/png', sizeBytes: Buffer.from(PIXEL, 'base64').length, dataBase64: PIXEL,
})
const pending = sent.json().media ?? sent.json()
expect(pending.status).toBe('pending')
const pendingUrl = `/trees/${treeId}/media/${pending.id}`
expect((await call('GET', pendingUrl, reader, undefined, { range: 'bytes=0-1' })).statusCode).toBe(404)
expect((await call('GET', pendingUrl, author, undefined, { range: 'bytes=0-1' })).statusCode).toBe(206)
})
})
+31
View File
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest'
import { parseByteRange } from '../src/lib/media-files.js'
// Plages demandées par les navigateurs pour lire une vidéo (Safari ne lit rien sans elles)
describe('parseByteRange', () => {
it('lit les plages que demandent Safari et Chrome', () => {
expect(parseByteRange('bytes=0-1', 1000)).toEqual({ start: 0, end: 1 })
expect(parseByteRange('bytes=0-', 1000)).toEqual({ start: 0, end: 999 })
expect(parseByteRange('bytes=500-', 1000)).toEqual({ start: 500, end: 999 })
expect(parseByteRange('bytes=-100', 1000)).toEqual({ start: 900, end: 999 })
})
it('borne la fin à la taille du fichier', () => {
expect(parseByteRange('bytes=900-5000', 1000)).toEqual({ start: 900, end: 999 })
expect(parseByteRange('bytes=-5000', 1000)).toEqual({ start: 0, end: 999 })
})
it('refuse une plage hors du fichier', () => {
expect(parseByteRange('bytes=1000-', 1000)).toBe('unsatisfiable')
expect(parseByteRange('bytes=5-2', 1000)).toBe('unsatisfiable')
expect(parseByteRange('bytes=-0', 1000)).toBe('unsatisfiable')
expect(parseByteRange('bytes=-5', 0)).toBe('unsatisfiable')
})
it('envoie tout sans plage lisible', () => {
expect(parseByteRange(undefined, 1000)).toBeNull()
expect(parseByteRange('bytes=-', 1000)).toBeNull()
expect(parseByteRange('bytes=0-1,5-9', 1000)).toBeNull()
expect(parseByteRange('items=0-1', 1000)).toBeNull()
})
})
+6
View File
@@ -4,6 +4,12 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
count: vi.fn().mockResolvedValue(1),
+90 -1
View File
@@ -6,6 +6,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
// fournisseur à part. Ce fichier vérifie ce que Parenthèse décide avec ce que le fournisseur renvoie.
const { prismaMock, oidcMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
@@ -108,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 () => {
@@ -337,6 +343,89 @@ describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () =>
}, 15000)
})
describe('rattacher le fournisseur à un compte à mot de passe (avant PASSWORD_LOGIN=false)', () => {
const sessionOf = (userId: string) => app.jwt.sign({ kind: 'user', sub: userId, jti: `s-${userId}`, userId, email: 'anais@example.com' })
async function ticketFor(userId: string) {
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', headers: { authorization: `Bearer ${sessionOf(userId)}` }, remoteAddress: nextAddress() })
expect(res.statusCode).toBe(200)
return res.json().ticket as string
}
async function startLink(ticket: string) {
currentAddress = nextAddress()
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?link=${encodeURIComponent(ticket)}&returnTo=${encodeURIComponent('/arbre/abc')}`, remoteAddress: currentAddress })
return { res, cookie: String(res.headers['set-cookie']).split(';')[0] }
}
it('sans session, pas de ticket', async () => {
const res = await app.inject({ method: 'POST', url: '/auth/oidc/link', remoteAddress: nextAddress() })
expect(res.statusCode).toBe(401)
})
it("relie l'identité au compte qui a demandé le ticket, même avec un mot de passe et sans regarder l'email", async () => {
const ticket = await ticketFor('user-pw')
const { res: started, cookie } = await startLink(ticket)
expect(started.statusCode).toBe(302)
// Le retour de l'app ne vient pas de la requête : un rattachement revient toujours à la racine
expect(oidcMock.startAuthorization).toHaveBeenCalledWith('/')
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'autre@example.com', emailVerified: false })
const res = await callback(cookie)
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-pw', issuer: ISSUER, subject: 'sub-1' } })
const code = fragmentOf(res.headers.location).get('oidc') as string
expect(app.jwt.decode<{ sub: string }>(code)?.sub).toBe('user-pw')
// Le rattachement ne passe jamais par la recherche d'un compte par email ni par la création d'un compte
expect(prismaMock.user.create).not.toHaveBeenCalled()
})
it("une identité déjà reliée à un autre compte n'est jamais déplacée", async () => {
const ticket = await ticketFor('user-pw')
const { cookie } = await startLink(ticket)
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-other' })
const res = await callback(cookie)
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_identity_taken')
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
})
it('déjà reliée à ce compte : connexion, rien de recréé', async () => {
const ticket = await ticketFor('user-pw')
const { cookie } = await startLink(ticket)
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-pw' })
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-pw' })
const res = await callback(cookie)
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
})
it('le ticket sert une fois, et un faux ticket ou une session ne lancent rien', async () => {
const ticket = await ticketFor('user-pw')
expect((await startLink(ticket)).res.statusCode).toBe(302)
const again = await startLink(ticket)
expect(fragmentOf(again.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
const forged = await startLink('nimportequoi')
expect(fragmentOf(forged.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
const session = await startLink(sessionOf('user-pw'))
expect(fragmentOf(session.res.headers.location).get('oidc_error')).toBe('oidc_link_expired')
})
it("le ticket n'ouvre aucune session", async () => {
const ticket = await ticketFor('user-pw')
const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${ticket}` } })
expect(asBearer.statusCode).toBe(401)
})
it('compte supprimé entre le ticket et le retour : refusé', async () => {
const ticket = await ticketFor('user-pw')
const { cookie } = await startLink(ticket)
prismaMock.user.findUnique.mockResolvedValue(null)
const res = await callback(cookie)
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
})
})
describe('journaux', () => {
it("le code d'autorisation du retour n'est pas journalisé", async () => {
const { redactUrl } = await import('../src/app.js')
+28 -2
View File
@@ -5,6 +5,12 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vites
// se connectent, et une instance neuve accepte quand même son premier compte.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: {
findUnique: vi.fn(),
create: vi.fn(),
@@ -21,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' }
@@ -44,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' })
@@ -96,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 () => {
@@ -139,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)
})
})
+50
View File
@@ -0,0 +1,50 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
// Les sessions fermées se rechargent avant la première requête : sans elles, l'API refuse de démarrer plutôt que
// de rouvrir chaque session fermée sans que personne le voie.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
revokedSession: {
upsert: vi.fn(),
findMany: vi.fn(),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
describe('sessions fermées au démarrage', () => {
let createApp: typeof import('../src/app.js')['createApp']
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
;({ createApp } = await import('../src/app.js'))
})
afterEach(() => {
vi.clearAllMocks()
})
it('recharge les sessions fermées, et un jeton fermé est refusé dès la première requête', async () => {
prismaMock.revokedSession.findMany.mockResolvedValue([{ jti: 'ferme-1', expiresAt: new Date(Date.now() + 3600_000) }])
const app = createApp()
await app.ready()
const { isUserTokenRevoked } = await import('../src/lib/token-revocation.js')
expect(isUserTokenRevoked('ferme-1')).toBe(true)
expect(prismaMock.revokedSession.deleteMany).toHaveBeenCalled()
await app.close()
})
it('ne démarre pas si la base ne répond pas', async () => {
prismaMock.revokedSession.findMany.mockRejectedValue(new Error('base injoignable'))
const app = createApp()
await expect(app.ready()).rejects.toThrow('base injoignable')
})
})
+6
View File
@@ -7,6 +7,12 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
// Sessions fermées (lib/token-revocation.ts), rechargées au démarrage
revokedSession: {
upsert: vi.fn().mockResolvedValue({}),
findMany: vi.fn().mockResolvedValue([]),
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
},
user: { count: vi.fn().mockResolvedValue(1) },
treeMembership: { findUnique: vi.fn() },
userTreeAccess: { findUnique: vi.fn().mockResolvedValue(null) },
+17
View File
@@ -20,6 +20,7 @@ Chaque push sur `main` lance d'abord la CI (`.github/workflows/ci.yml`). Si elle
- `/etc/parenthese/build.env` : variables de build du front et de la landing (`VITE_POSTHOG_KEY`, `VITE_POSTHOG_HOST`)
- `/etc/cron.d/parenthese-backup` : sauvegarde nocturne par `deploy/backup.sh`
- `/etc/genealogy/backup.env` (facultatif) : rétention et envoi S3 des sauvegardes
- `/usr/local/sbin/parenthese-ssh-deploy` : copie de `deploy/ssh-deploy.sh`, commande forcée de la clé de déploiement (voir plus bas)
## Prérequis du serveur
@@ -28,3 +29,19 @@ Node 20, `pm2` en global, PostgreSQL joignable par `DATABASE_URL`, nginx configu
## Déployer à la main
`bash deploy/deploy.sh --sync` depuis le dépôt sur le serveur. `FORCE_FULL=1` pour tout reconstruire. Ne pas le lancer pendant un déploiement GitHub : le verrou le fera attendre.
## Clé de déploiement restreinte
La clé de GitHub ne doit pouvoir que déployer un commit de `main`, jamais lancer une autre commande sur le serveur. On la lie à `deploy/ssh-deploy.sh`, copié hors du dépôt parce que le déploiement réécrit le dépôt :
```
install -m 755 -o root -g root /var/www/parenthese/deploy/ssh-deploy.sh /usr/local/sbin/parenthese-ssh-deploy
```
Puis, dans `authorized_keys`, devant la ligne de la clé `github-actions-parenthese-public` :
```
command="/usr/local/sbin/parenthese-ssh-deploy",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… github-actions-parenthese-public
```
Le workflow n'a pas à changer. Après une mise à jour de `deploy/ssh-deploy.sh`, refaire l'`install`.
+5 -1
View File
@@ -133,8 +133,12 @@ predeploy_dump() {
BACKEND_CHANGED=0
if changed backend ecosystem.config.cjs; then
BACKEND_CHANGED=1
# Prisma 7 demande Node ^20.19, ^22.12 ou 24+ : avec un Node plus vieux, on s'arrête avant toute installation
node -e "const [a, b] = process.versions.node.split('.').map(Number); process.exit((a === 20 && b >= 19) || (a === 22 && b >= 12) || a >= 24 ? 0 : 1)" \
|| { echo "ERROR: Node $(node -v) trop ancien pour Prisma 7 (^20.19, ^22.12 ou 24+)"; exit 1; }
install_deps backend
(cd "$REPO_DIR/backend" && node -e "require('fastify'); require('zod'); require('@prisma/client')") \
# Le client Prisma n'existe qu'après prisma:generate (src/generated) : on vérifie ses dépendances, pas le client
(cd "$REPO_DIR/backend" && node -e "require('fastify'); require('zod'); require('@prisma/adapter-pg'); require.resolve('@prisma/client/runtime/client')") \
|| { echo "ERROR: dépendances backend manquantes après installation"; exit 1; }
log "Build backend"
(cd "$REPO_DIR/backend" && NODE_ENV='' npm run prisma:generate && NODE_ENV='' npm run build)
+5 -1
View File
@@ -33,12 +33,16 @@ map $host $parenthese_app_csp {
# script-src 'sha256-...' script en ligne du <head> (animation du logo, dans index.html et en/index.html).
# S'il change, recalculer l'empreinte : base64(sha256(contenu exact entre
# <script> et </script>)), sinon l'animation ne se joue plus (sans autre effet).
# 2e 'sha256-...' règles de préchargement des pages (<script type="speculationrules">, le même
# texte dans index.html et chaque guide). Même calcul s'il change, sinon les pages
# ne sont plus préchargées (sans autre effet). Le mot-clé 'inline-speculation-rules'
# ne suffisait pas à Chrome (essayé le 27/10).
# posthog comme pour l'app
# style-src 'unsafe-inline' attributs style du rendu serveur React
# frame-src démonstration de l'app en iframe
# frame-ancestors 'none' équivalent de X-Frame-Options DENY
map $host $parenthese_landing_csp {
default "default-src 'self'; script-src 'self' 'sha256-Av5a1/yoW/tQ2ZQWqH5FnaL896Bubcs9ivdFvsKZBrU='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
default "default-src 'self'; script-src 'self' 'sha256-v68KpM0KHUkaezvOmKtoCY2RsfbwoEC9EZYD682DCiU=' 'sha256-eflWcNn8MfPvX2YD4hXJnR3x9nRGu0f7tIEq2j/yfPo='; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; worker-src 'self' blob:; frame-src https://app.parenthese.io; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'";
}
# Réponses de l'API relayées (JSON, médias des familles) : intégrables seulement par l'app (PDF en iframe).
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Commande forcée de la clé de déploiement GitHub, posée devant elle dans /root/.ssh/authorized_keys :
# command="/usr/local/sbin/parenthese-ssh-deploy",no-port-forwarding,no-agent-forwarding,no-X11-forwarding,no-pty ssh-ed25519 AAAA… github-actions-parenthese-public
# Avec elle, la clé ne peut plus que déployer un commit qui appartient déjà à main : une clé volée ne lance aucune
# autre commande. Le workflow « Deploy Hostinger » n'a pas à changer : il envoie
# FORCE_FULL=<0|1> DEPLOY_SHA=<sha> bash -s
# suivi d'un script sur l'entrée standard. Seuls FORCE_FULL et DEPLOY_SHA sont lus, le script envoyé est ignoré,
# et la suite reprend la même logique (verrou, commit de main, pas de retour en arrière, deploy.sh).
# Installé hors du dépôt, parce que le déploiement réécrit le dépôt pendant qu'il tourne :
# install -m 755 -o root -g root /var/www/parenthese/deploy/ssh-deploy.sh /usr/local/sbin/parenthese-ssh-deploy
set -euo pipefail
if ! [[ "${SSH_ORIGINAL_COMMAND:-}" =~ ^FORCE_FULL=([01])\ DEPLOY_SHA=([0-9a-f]{40})\ bash\ -s$ ]]; then
echo "Commande refusée : cette clé ne sert qu'à déployer" >&2
exit 1
fi
FORCE_FULL="${BASH_REMATCH[1]}"
DEPLOY_SHA="${BASH_REMATCH[2]}"
# Le script envoyé par le workflow n'est pas exécuté
cat > /dev/null
cd /var/www/parenthese
exec flock -w 900 /tmp/parenthese-deploy.lock env PARENTHESE_DEPLOY_LOCKED=1 FORCE_FULL="$FORCE_FULL" DEPLOY_SHA="$DEPLOY_SHA" bash -c '
set -euo pipefail
git fetch -q origin main
# Le commit validé doit appartenir à main
if ! git merge-base --is-ancestor "$DEPLOY_SHA" origin/main; then
echo "Commit $DEPLOY_SHA absent de main : rien n est déployé" >&2
exit 1
fi
# Un déploiement plus récent est déjà en ligne : pas de retour en arrière
if [ "$(git rev-parse HEAD)" != "$DEPLOY_SHA" ] && git merge-base --is-ancestor "$DEPLOY_SHA" HEAD; then
echo "Version plus récente déjà déployée, $DEPLOY_SHA ignoré"
exit 0
fi
git reset -q --hard "$DEPLOY_SHA"
bash deploy/deploy.sh
'
+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>
+785 -927
View File
File diff suppressed because it is too large. Load diff
+10 -10
View File
@@ -12,28 +12,28 @@
"test:watch": "vitest"
},
"dependencies": {
"@lucide/icons": "^1.59.0",
"lucide-react": "^1.59.0",
"posthog-js": "^1.438.12",
"@lucide/icons": "^1.66.0",
"blurhash": "^2.0.5",
"lucide-react": "^1.66.0",
"posthog-js": "^1.444.0",
"react": "^19.3.1",
"react-dom": "^19.3.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
"@vitejs/plugin-react": "^6.1.2",
"autoprefixer": "^10.6.1",
"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",
"postcss": "^8.5.31",
"tailwindcss": "^3.4.19",
"vite": "^8.3.4",
"globals": "^17.14.0",
"tailwindcss": "^4.3.3",
"vite": "^8.3.5",
"vitest": "^5.0.5"
},
"overrides": {
"postcss-selector-parser": "^7.1.6"
"minimatch": "^11.0.4"
}
}
-6
View File
@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+422 -229
View File
@@ -5,6 +5,7 @@ import PersonCard from './components/PersonCard.jsx'
import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx'
import { HELP_URL } from './utils/siteLinks.js'
import { firstNameLabel } from './utils/personName.js'
import { useUndoable } from './hooks/useUndoable.js'
import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
import MediaViewer from './components/MediaViewer'
import MediaManagerPanel from './components/MediaManagerPanel'
@@ -38,6 +39,7 @@ import {
createUnion,
batchAnnotations,
deletePersonAvatar,
deletePersonMedia,
getTree,
importGedcom,
linkTreeAccess,
@@ -54,7 +56,7 @@ import {
uploadPersonMedia,
} from './api/treeApi'
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { fetchAuthOptions, oidcStartUrl } from './api/authApi'
import { fetchAuthOptions, oidcLinkUrl, oidcStartUrl, requestOidcLinkTicket } from './api/authApi'
import { useAuth } from './hooks/useAuth'
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
import { useMediaManager } from './hooks/useMediaManager'
@@ -65,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 { filiations, getChildren, getParents, getPersonUnions, medias, persons, unions } from './data/mockData'
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,
@@ -507,6 +511,28 @@ function readInvitationToken() {
const INVITATION_TOKEN = readInvitationToken()
let oidcSignIn = null
// Retire une personne du graphe en mémoire, avec ses unions, ses liens et ses médias. Renvoie les tableaux tels
// qu'ils étaient avant le retrait, ou null si la personne n'y était plus
function removePersonFromGraph(personId) {
const id = String(personId)
if (!persons.some((candidate) => String(candidate.id) === id)) return null
const snapshot = { persons, unions, filiations, medias }
const removedUnionIds = new Set(unions
.filter((union) => String(union.partner1Id) === id || String(union.partner2Id) === id)
.map((union) => union.id))
replaceGraph({
persons: persons.filter((candidate) => String(candidate.id) !== id),
unions: unions.filter((union) => !removedUnionIds.has(union.id)),
filiations: filiations.filter((filiation) => (
String(filiation.childId) !== id
&& String(filiation.parentId) !== id
&& !(filiation.unionId && removedUnionIds.has(filiation.unionId))
)),
medias: medias.filter((media) => String(media.personId) !== id),
})
return snapshot
}
function App() {
const auth = useAuth()
const tree = useTreeAccess()
@@ -544,9 +570,23 @@ function App() {
})
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
// Onglet de famille ouvert : l'adresse de la page le porte (?famille=<charnière>), et le lien à envoyer à la
// famille aussi, pour qu'il ouvre l'arbre sur cette famille
const [initialFamilyKey] = useState(() => new URLSearchParams(window.location.search).get('famille'))
const [openFamily, setOpenFamily] = useState(null)
const handleFamilyChange = useCallback((family) => {
setOpenFamily(family)
const url = new URL(window.location.href)
if (family) url.searchParams.set('famille', family.key)
else url.searchParams.delete('famille')
window.history.replaceState(window.history.state, '', `${url.pathname}${url.search}${url.hash}`)
}, [])
// Grandes belles-familles dans des onglets (réglage de ce navigateur, voir Paramètres de l'arbre)
const [familyTabs, setFamilyTabsState] = useState(() => { try { return localStorage.getItem('parenthese.familyTabs') !== 'off' } catch { return true } })
const setFamilyTabs = useCallback((on) => { setFamilyTabsState(on); try { localStorage.setItem('parenthese.familyTabs', on ? 'on' : 'off') } catch { /* navigateur sans stockage */ } }, [])
// Nom de famille sous le prénom dans l'arbre, celui de naissance s'il est connu (#168). Réglage de ce navigateur.
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
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [], bundledFiles: [] })
const photoStartDirtyRef = useRef(false)
@@ -569,8 +609,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)
@@ -700,7 +754,7 @@ function App() {
const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode
// Recherche de personnes avec scoring
const search = usePersonSearch(persons)
const search = usePersonSearch(persons, tree.graphRevision)
// Mode édition avec auto-save
const editMode = useEditMode({
@@ -975,6 +1029,8 @@ function App() {
setAddPersonPanelVisible(false)
tree.setSelectedPerson(freshPerson)
// Sur téléphone, la fiche couvre le bas : la personne touchée remonte dans le tiers du haut (#188)
if (freshPerson && window.matchMedia?.('(max-width: 768px)').matches) galaxyZoomApiRef.current?.focusPerson?.(freshPerson.id)
trackTreeInteraction('text')
trackDemoInteraction('person')
}, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
@@ -1067,7 +1123,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()
@@ -1089,9 +1146,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])
@@ -1185,192 +1243,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',
}
persons.push(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 {
@@ -1432,6 +1532,7 @@ function App() {
editMode.deactivate()
setContribSubmitError('')
setShowContribModal(false)
if (signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.')))
return
}
@@ -1583,7 +1684,6 @@ function App() {
setShowContribModal(false)
setContribSubmitError('')
setContribSuccess(true)
trackAppEvent('proposal_sent', { changes: changes.length, tree_id: opaqueTreeId })
setDraftNotice('')
setTimeout(() => setContribSuccess(false), 6000)
} catch (err) {
@@ -1908,59 +2008,75 @@ function App() {
}
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
// Suppression personne (mode édition, admin)
// Suppressions annulables (personne, souvenir, moment) : l'écran change tout de suite, « Annuler » reste
// possible quelques secondes, et la requête ne part qu'ensuite (voir hooks/useUndoable.js)
const undoable = useUndoable()
// Un rechargement de l'arbre vide la sélection : la requête part après le délai, quand une autre fiche
// peut être ouverte, et c'est celle-là qu'on garde
const selectedPersonIdRef = useRef(null)
useEffect(() => { selectedPersonIdRef.current = tree.selectedPerson?.id ?? null }, [tree.selectedPerson])
const refreshKeepingSelection = useCallback(
(options) => refreshTreeAndKeepSelection(selectedPersonIdRef.current, options),
[refreshTreeAndKeepSelection],
)
// Un rechargement de l'arbre pendant le délai ramènerait ce qui vient d'être supprimé : on le retire à nouveau
useEffect(() => {
if (undoable.reapply()) tree.bumpGraphRevision()
}, [tree.graphRevision]) // eslint-disable-line react-hooks/exhaustive-deps
const handlePersonDelete = useCallback(async (personId) => {
if (!tree.treeContext.treeId && !isDemoMode) return
if (isDemoMode) {
for (let idx = persons.length - 1; idx >= 0; idx -= 1) {
if (String(persons[idx].id) === String(personId)) {
persons.splice(idx, 1)
}
}
const removedUnionIds = new Set()
for (let idx = unions.length - 1; idx >= 0; idx -= 1) {
const union = unions[idx]
if (
String(union.partner1Id) === String(personId)
|| String(union.partner2Id) === String(personId)
) {
removedUnionIds.add(union.id)
unions.splice(idx, 1)
}
}
for (let idx = filiations.length - 1; idx >= 0; idx -= 1) {
const filiation = filiations[idx]
const touchesPerson = (
String(filiation.childId) === String(personId)
|| String(filiation.parentId) === String(personId)
)
const touchesRemovedUnion = filiation.unionId && removedUnionIds.has(filiation.unionId)
if (touchesPerson || touchesRemovedUnion) {
filiations.splice(idx, 1)
}
}
for (let idx = medias.length - 1; idx >= 0; idx -= 1) {
if (String(medias[idx].personId) === String(personId)) {
medias.splice(idx, 1)
}
}
const ownTree = !isDemoMode && tree.canEditCurrentTree
if (isDemoMode || ownTree) {
const accessToken = ownTree ? (tree.treeContext.accessToken || auth.userAuth.token) : null
if (ownTree && !accessToken) return
const target = persons.find((candidate) => String(candidate.id) === String(personId))
const name = target ? firstNameLabel(target) : ''
// Accordé quand le sexe est connu, « supprimé·e » seulement quand il ne l'est pas
const sex = target?.sex
const snapshot = removePersonFromGraph(personId)
tree.setSelectedPerson(null)
tree.bumpGraphRevision()
return
}
if (!snapshot) return
if (tree.canEditCurrentTree) {
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
if (!accessToken) return
await deletePerson(tree.treeContext.treeId, personId, accessToken)
await refreshTreeAndKeepSelection(null, { relayout: true })
tree.setSelectedPerson(null)
const treeId = ownTree ? tree.treeContext.treeId : null
undoable.start({
message: !name
? t("La personne est supprimée de l'arbre.")
: sex === 'female'
? t("{name} est supprimée de l'arbre.", { name })
: sex === 'male'
? t("{name} est supprimé de l'arbre.", { name })
: t("{name} est supprimé·e de l'arbre.", { name }),
reapply: () => Boolean(removePersonFromGraph(personId)),
commit: async ({ keepalive = false } = {}) => {
if (!treeId) return
try {
await deletePerson(treeId, personId, accessToken, { keepalive })
if (!keepalive) await refreshKeepingSelection({ relayout: true })
} catch {
if (keepalive) return
await refreshKeepingSelection({ relayout: true })
setDraftNotice(sex === 'female'
? t("{name} n'a pas pu être supprimée. Réessayez.", { name })
: sex === 'male'
? t("{name} n'a pas pu être supprimé. Réessayez.", { name })
: t("{name} n'a pas pu être supprimé·e. Réessayez.", { name }))
}
},
undo: async () => {
if (treeId) {
await refreshTreeAndKeepSelection(personId, { relayout: true })
return
}
replaceGraph(snapshot)
tree.bumpGraphRevision()
tree.setSelectedPerson(persons.find((candidate) => String(candidate.id) === String(personId)) || null)
},
})
return
}
@@ -1970,7 +2086,35 @@ function App() {
editMode.updateDraft({ deletedPersons })
tree.setSelectedPerson(null)
}, [auth.userAuth.token, refreshTreeAndKeepSelection, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
}, [auth.userAuth.token, undoable.start, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
const handleMediaDelete = useCallback((mediaId) => {
const personId = tree.selectedPerson?.id
const { treeId, accessToken } = tree.treeContext
if (!tree.canEditCurrentTree || !personId || !treeId || !accessToken) return
const removeMedia = () => {
if (!medias.some((candidate) => String(candidate.id) === String(mediaId))) return false
replaceGraph({ medias: medias.filter((candidate) => String(candidate.id) !== String(mediaId)) })
return true
}
if (!removeMedia()) return
tree.bumpGraphRevision()
undoable.start({
message: t('Le souvenir est supprimé.'),
reapply: removeMedia,
commit: async ({ keepalive = false } = {}) => {
try {
await deletePersonMedia(treeId, personId, mediaId, accessToken, { keepalive })
if (!keepalive) await refreshKeepingSelection({ relayout: false })
} catch {
if (keepalive) return
await refreshKeepingSelection({ relayout: false })
setDraftNotice(t('La suppression n’a pas abouti. Réessayez dans un instant.'))
}
},
undo: () => refreshKeepingSelection({ relayout: false }),
})
}, [tree.selectedPerson, tree.canEditCurrentTree, tree.treeContext, undoable.start, refreshKeepingSelection]) // eslint-disable-line react-hooks/exhaustive-deps
// Ajout d'une relation (mode édition)
const handleAddRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
@@ -2025,7 +2169,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
}
@@ -2090,7 +2234,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
}
@@ -2167,7 +2311,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
}
@@ -2319,6 +2463,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
}
@@ -2355,10 +2501,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
@@ -2508,7 +2660,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')
}
@@ -2551,8 +2703,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,
@@ -2569,7 +2727,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()
@@ -2617,7 +2775,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
@@ -2627,7 +2785,7 @@ function App() {
tree.setTreeContext(buildEmptyTreeContext(pending))
tree.setGateError('')
tree.setBootState('need-access')
return true
return 'gate'
} finally {
pendingSharedTreeRef.current = null
setPendingSharedTreeName('')
@@ -2679,7 +2837,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
}
@@ -3181,6 +3343,15 @@ function App() {
oidcName={authOptions.oidc?.name || ''}
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(getSlugFromPathname() ? window.location.pathname : '/')) : undefined}
hasPassword={auth.userAuth.user?.hasPassword !== false}
oidcLinked={auth.userAuth.user?.oidcLinked === true}
onLinkOidc={authOptions.oidc && auth.userAuth.user?.oidcLinked === false ? async () => {
// Relier le fournisseur au compte : le serveur donne un ticket court, le départ OIDC le porte
try {
window.location.assign(oidcLinkUrl(await requestOidcLinkTicket(auth.userAuth.token)))
} catch (error) {
auth.setAccountError(getAccountErrorMessage(error))
}
} : undefined}
pendingTreeName={pendingSharedTreeName}
onLogin={handleLogin}
onRegister={handleRegister}
@@ -3281,6 +3452,7 @@ function App() {
onContinue={handleConfirmContinue}
onSaveAndQuit={handleConfirmSaveAndQuit}
onDiscard={handleConfirmDiscard}
demo={isDemoMode}
/>
{/* Slide-in Ajouter Personne */}
<AddPersonPanel
@@ -3330,7 +3502,13 @@ function App() {
onAddPerson={handleSouvenirPickerAddPerson}
onClose={() => setSouvenirPickerVisible(false)}
/>
{draftNotice && !contribSuccess && !showContribModal && (
{undoable.message && (
<div className="contrib-success-toast undo-toast" role="status">
<span>{undoable.message}</span>
<button type="button" className="undo-toast-btn" onClick={undoable.undo} title={t('Annuler (Ctrl+Z)')}>{t('Annuler')}</button>
</div>
)}
{draftNotice && !undoable.message && !contribSuccess && !showContribModal && (
<div className="contrib-success-toast" role="status">{draftNotice}</div>
)}
{contribSuccess && (
@@ -3355,12 +3533,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 ? {
@@ -3391,10 +3571,13 @@ function App() {
reorderEnabled={canReorderSiblings}
onReorderSiblings={handleReorderSiblings}
familyTabs={familyTabs}
fullNames={fullNames}
annotationViewRef={annotationViewRef}
onAnnotationsAnchored={persistAnnotationAnchors}
selfPersonId={linkedSelfPersonId || null}
ghosts={ghosts}
initialFamilyKey={initialFamilyKey}
onFamilyChange={handleFamilyChange}
/>
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
{selectedGhost && !tree.selectedPerson && (
@@ -3408,6 +3591,7 @@ function App() {
{tree.selectedPerson && (
<PersonCard
person={tree.selectedPerson}
onUndoable={undoable.start}
onClose={() => chooseSelectedPerson(null)}
onMediaSelect={handleMediaSelectFresh}
saveOnLeaveRef={personSaveOnLeaveRef}
@@ -3441,6 +3625,11 @@ function App() {
onMoveSibling={(delta) => galaxyZoomApiRef.current?.moveSibling?.(tree.selectedPerson.id, delta)}
onUploadMomentFile={tree.canEditCurrentTree || tree.canSubmitContribution ? media.uploadMomentFile : undefined}
onMomentSaved={media.refreshMomentMedia}
onOpenPerson={async (personId) => {
const target = persons.find((p) => String(p.id) === String(personId))
if (!target || !(await chooseSelectedPerson(target))) return
galaxyZoomApiRef.current?.focusPerson?.(target.id)
}}
onQuickEdit={tree.canEditCurrentTree && userRole.isAdmin && !isDemoMode ? handleEditToggle : undefined}
/>
)}
@@ -3493,7 +3682,7 @@ function App() {
onClose={media.handleCloseMediaManager}
onUpload={media.handleUploadPersonMedia}
onMove={media.handleMovePersonMedia}
onDelete={media.handleDeletePersonMedia}
onDelete={handleMediaDelete}
/>
<ContributionPanel
visible={contrib.contribState.visible}
@@ -3501,7 +3690,8 @@ function App() {
canModerate={tree.canEditCurrentTree}
loading={contrib.contribState.loading}
errorMessage={contrib.contribState.error}
inviteShareUrl={contribInviteState.shareUrl}
inviteShareUrl={contribInviteState.shareUrl && openFamily ? `${contribInviteState.shareUrl}?famille=${encodeURIComponent(openFamily.key)}` : contribInviteState.shareUrl}
inviteFamilyName={openFamily?.label || ''}
inviteKnownPasswords={contribInviteState.knownPasswords}
inviteSaving={contribInviteState.loading}
inviteError={contribInviteState.error}
@@ -3513,6 +3703,7 @@ function App() {
onRotatePasswords={handleContributionPasswordRotate}
onRememberPassword={handleContributionPasswordRemember}
onReviewSession={contrib.handleReviewContributionSession}
seasonalInvite={signupPrompts}
/>
<SearchSourcesPanel
visible={searchPanelVisible}
@@ -3561,6 +3752,8 @@ function App() {
onClose={() => setAdminPanelVisible(false)}
familyTabs={familyTabs}
onFamilyTabsChange={setFamilyTabs}
fullNames={fullNames}
onFullNamesChange={setFullNames}
/>
<PhotoStartPanel
visible={photoStart.visible}
+16
View File
@@ -88,6 +88,22 @@ export function oidcStartUrl(returnTo = '/') {
return `${API_BASE_URL}/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`
}
// Relier le fournisseur au compte connecté : un ticket de deux minutes, à usage unique, puis le départ OIDC qui le porte
export async function requestOidcLinkTicket(token) {
const data = await apiRequest('/auth/oidc/link', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.ticket
}
export function oidcLinkUrl(ticket) {
return `${API_BASE_URL}/auth/oidc/start?link=${encodeURIComponent(ticket)}`
}
// Jeton de passage reçu au retour du fournisseur, échangé une seule fois contre une session
export async function exchangeOidcCode(code) {
return apiRequest('/auth/oidc/exchange', {
+33 -1
View File
@@ -38,13 +38,44 @@ function timeoutSignal(options) {
return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS))
}
// Envois sans doublon (backend/src/lib/idempotency.ts). Un envoi qui n'a pas abouti (délai dépassé, réseau coupé,
// erreur) garde sa clé, et le même envoi refait (même adresse, même contenu) la réutilise : si le premier avait en
// fait abouti, le serveur renvoie sa réponse au lieu de tout recréer. Une réussite libère la clé, et renvoyer le même
// contenu plus tard (un souvenir supprimé puis remis) est un nouvel envoi.
const unsettledKeys = new Map()
// Empreinte rapide du contenu (cyrb53) : crypto.subtle n'existe pas hors HTTPS (auto-hébergement en réseau local)
function fingerprint(text) {
let h1 = 0xdeadbeef
let h2 = 0x41c6ce57
for (let i = 0; i < text.length; i += 1) {
const ch = text.charCodeAt(i)
h1 = Math.imul(h1 ^ ch, 2654435761)
h2 = Math.imul(h2 ^ ch, 1597334677)
}
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
return `${text.length}:${(h2 >>> 0).toString(36)}${(h1 >>> 0).toString(36)}`
}
// getRandomValues existe aussi hors HTTPS, contrairement à crypto.randomUUID
function newIdempotencyKey() {
const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes)
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('')
}
async function apiRequest(path, options = {}) {
const { timeoutMs: _timeoutMs, ...fetchOptions } = options
const { timeoutMs: _timeoutMs, idempotent = false, ...fetchOptions } = options
const providedHeaders = options.headers || {}
const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type')
const print = idempotent ? `${path}|${fingerprint(typeof options.body === 'string' ? options.body : '')}` : null
if (print && !unsettledKeys.has(print)) unsettledKeys.set(print, newIdempotencyKey())
const headers = {
...(options.body !== undefined && !hasExplicitContentType ? { 'Content-Type': 'application/json' } : {}),
...(print ? { 'Idempotency-Key': unsettledKeys.get(print) } : {}),
...providedHeaders,
}
@@ -68,6 +99,7 @@ async function apiRequest(path, options = {}) {
if (!response.ok) {
throw new ApiError(payload?.error || `request_failed_${response.status}`, response.status, payload)
}
if (print) unsettledKeys.delete(print)
return payload
}
+49
View File
@@ -34,3 +34,52 @@ describe('apiRequest', () => {
expect(error).not.toBeInstanceOf(ApiError)
})
})
// Clé d'idempotence : gardée tant que l'envoi n'a pas abouti, libérée par une réussite
describe('apiRequest, envois idempotents', () => {
const keysSent = []
const respond = (ok) => vi.fn(async (_url, init) => {
keysSent.push(init.headers['Idempotency-Key'])
if (ok === 'network') throw new TypeError('Failed to fetch')
return new Response('{}', { status: ok ? 201 : 503 })
})
const send = (body = '{"a":1}') => apiRequest('/trees/t/annotation-photos', { method: 'POST', idempotent: true, body })
afterEach(() => {
keysSent.length = 0
})
it('un envoi refait après une coupure ou une erreur garde la même clé', async () => {
vi.stubGlobal('fetch', respond('network'))
await expect(send()).rejects.toThrow()
vi.stubGlobal('fetch', respond(false))
await expect(send()).rejects.toThrow()
vi.stubGlobal('fetch', respond(true))
await send()
expect(keysSent).toHaveLength(3)
expect(new Set(keysSent).size).toBe(1)
expect(keysSent[0]).toMatch(/^[0-9a-f]{32}$/)
})
it('après une réussite, le même contenu repart avec une clé neuve', async () => {
vi.stubGlobal('fetch', respond(true))
await send('{"b":2}')
await send('{"b":2}')
expect(keysSent[0]).not.toBe(keysSent[1])
})
it('un autre contenu a sa propre clé, et un appel ordinaire n’en envoie pas', async () => {
vi.stubGlobal('fetch', respond('network'))
await expect(send('{"c":3}')).rejects.toThrow()
await expect(send('{"c":4}')).rejects.toThrow()
await expect(apiRequest('/trees/t', { method: 'POST', body: '{}' })).rejects.toThrow()
expect(keysSent[0]).not.toBe(keysSent[1])
expect(keysSent[2]).toBeUndefined()
})
it('l’option idempotent ne part pas dans fetch', async () => {
vi.stubGlobal('fetch', respond(true))
await send('{"d":5}')
expect(fetch.mock.calls[0][1]).not.toHaveProperty('idempotent')
})
})
+23 -3
View File
@@ -71,6 +71,7 @@ export async function fetchPersonLife(treeId, personId, token) {
export async function createLifeEvent(treeId, personId, payload, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life/events`, {
method: 'POST',
idempotent: true,
headers: { Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
})
@@ -84,9 +85,10 @@ export async function updateLifeEvent(treeId, eventId, payload, token) {
})
}
export async function deleteLifeEvent(treeId, eventId, token) {
export async function deleteLifeEvent(treeId, eventId, token, { keepalive = false } = {}) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
keepalive,
headers: { Authorization: `Bearer ${token}` },
})
}
@@ -178,9 +180,10 @@ export async function updatePerson(treeId, personId, payload, token) {
return data.person
}
export async function deletePerson(treeId, personId, token) {
export async function deletePerson(treeId, personId, token, { keepalive = false } = {}) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -213,6 +216,19 @@ export async function createParentChildLink(treeId, payload, token) {
return data.link
}
// Type, date et lieu d'un couple, depuis l'onglet Vie
export async function updateUnion(treeId, unionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(payload),
})
return data.union
}
export async function deleteUnion(treeId, unionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'DELETE',
@@ -371,6 +387,7 @@ export async function listTreeVisits(treeId, token) {
export async function submitContributionSession(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -419,6 +436,7 @@ export async function fetchContributionStatus(treeId, sessionId, token) {
export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -431,6 +449,7 @@ export async function uploadAnnotationPhoto(treeId, payload, token) {
export async function uploadPersonMedia(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -452,11 +471,12 @@ export async function reorderPersonMedia(treeId, personId, orderedMediaIds, toke
return data
}
export async function deletePersonMedia(treeId, personId, mediaId, token) {
export async function deletePersonMedia(treeId, personId, mediaId, token, { keepalive = false } = {}) {
const data = await apiRequest(
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
{
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
@@ -29,6 +29,10 @@ const AccountDashboard = ({
oidcName = '',
onOidcLogin,
hasPassword = true,
// Compte à mot de passe sur une instance OIDC : « Se connecter aussi avec … » relie le fournisseur, pour que le compte
// reste accessible quand l'instance passe en connexion par le fournisseur seule. oidcLinked : déjà fait
onLinkOidc,
oidcLinked = false,
pendingTreeName = '',
onLogin,
onRegister,
@@ -305,7 +309,14 @@ const AccountDashboard = ({
{t('Essayer la démo')}
</button>
)}
{onLinkOidc && !oidcLinked && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm account-oidc-link" onClick={onLinkOidc} disabled={loading}>
<LogIn size={15} aria-hidden="true" />
{t('Se connecter aussi avec {name}', { name: oidcName })}
</button>
)}
</div>
{oidcLinked && oidcName && <p className="account-oidc-linked">{t('Connexion avec {name} reliée à ce compte.', { name: oidcName })}</p>}
</div>
<div className="pz-account-foot">
+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>}
@@ -70,3 +70,6 @@
/* Arbre avec une icône choisie : l'emoji seul, sans la pastille de couleur de la lettre */
.pz-tree-mono--emoji { background: transparent; font-size: 22px; line-height: 1; }
.invite-tree-icon { font-size: 30px; line-height: 1; }
/* Connexion par le fournisseur reliée au compte (OIDC) */
.account-oidc-linked { margin: 8px 0 0; font: 400 13px var(--pz-sans); color: var(--pz-muted); }
+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) && (
+16 -1
View File
@@ -24,6 +24,7 @@ import { removeManager } from '../api/managersApi.js'
import { t } from '../i18n/index.js'
import { readGedcomFile } from '../utils/gedcomText.js'
import { readGedcomBundle, zipErrorMessage } from '../utils/gedcomBundle.js'
import Skeleton from './Skeleton.jsx'
function normalizeSlug(value) {
return String(value || '')
@@ -40,6 +41,8 @@ const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '
const AdminPanel = ({
familyTabs = true,
onFamilyTabsChange = null,
fullNames = false,
onFullNamesChange = null,
visible,
treeId,
authToken,
@@ -442,7 +445,7 @@ const AdminPanel = ({
<div className="ts-content">
{loading ? (
<p className="pz-small ts-loading">{t('Chargement…')}</p>
<Skeleton variant="lines" count={4} />
) : (
<>
{errorMessage && <div className="pz-error" role="alert">{errorMessage}</div>}
@@ -544,6 +547,18 @@ const AdminPanel = ({
<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>
</span>
</label>
<label className={`ts-choice ${fullNames ? 'is-active' : ''}`}>
<input
type="checkbox"
name="fullNames"
checked={fullNames}
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>
</span>
</label>
</fieldset>
</form>
)}
+23 -23
View File
@@ -7,7 +7,10 @@ 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'
// ─── helpers ────────────────────────────────────────────────────────────────
@@ -216,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 = ({
@@ -240,6 +228,8 @@ const ContributionPanel = ({
loading,
errorMessage,
inviteShareUrl,
// Onglet de famille ouvert : le lien ouvre l'arbre sur cette famille
inviteFamilyName = '',
inviteKnownPasswords,
inviteSaving,
inviteError,
@@ -251,6 +241,7 @@ const ContributionPanel = ({
onRotatePasswords,
onRememberPassword,
onReviewSession,
seasonalInvite = false,
}) => {
const [decisionMap, setDecisionMap] = useState({})
const [collapsedSessions, setCollapsedSessions] = useState(new Set())
@@ -352,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')) }
}
@@ -403,12 +403,12 @@ const ContributionPanel = ({
{/* ── Section invitation ── */}
<div className="contrib-invite">
<div className="contrib-invite-top">
<strong className="contrib-invite-title">{t('Le lien à envoyer à la famille')}</strong>
<strong className="contrib-invite-title">{inviteFamilyName ? t('Le lien à envoyer à la famille {name}', { name: inviteFamilyName }) : t('Le lien à envoyer à la famille')}</strong>
{copyFeedback && <span className="contrib-copy-feedback">{copyFeedback}</span>}
</div>
<div className="contrib-invite-link">
<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">
@@ -422,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>
</>
@@ -437,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>
@@ -469,7 +469,7 @@ const ContributionPanel = ({
{errorMessage && <div className="contrib-error">{errorMessage}</div>}
{loading ? (
<div className="contrib-empty">{t('Chargement…')}</div>
<Skeleton variant="rows" count={3} />
) : sessions.length === 0 ? (
<div className="contrib-empty">{t("Rien à relire pour l'instant. Les propositions de la famille apparaîtront ici.")}</div>
) : (
+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">
@@ -58,6 +58,10 @@
.edit-mode-toast--contrib .edit-mode-toast-short { display: inline; }
}
/* « Terminé » dans la barre du bas fait déjà sortir de l'édition : pas de seconde croix dans la bulle (Patch, 27/10) */
body:has(.nav-annot-exit) .edit-mode-toast-close { display: none; }
body:has(.nav-annot-exit) .edit-mode-toast:not(.edit-mode-toast--with-submit) { padding-right: 16px; }
/* Téléphone : le texte de l'éditeur se coupait en « Vous modifi… » à côté du bouton, qui suffit à lui seul.
L'invité garde son texte court, qui tient sur une ligne. */
@media (max-width: 480px) {
+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>
+15 -4
View File
@@ -100,18 +100,29 @@
se limite à la moitié gauche sur grand écran et descend sous le message sur téléphone */
.family-tabs--lowered { max-width: calc(50vw - 200px); }
/* Fiche ouverte sur grand écran (à gauche, 384 px depuis 24 px) : la barre passe à sa droite */
/* Fiche ouverte sur grand écran (à gauche, 384 px depuis 24 px, posée en bas) : la barre passe à sa droite
seulement quand la fiche monte jusqu'à elle (data-fiche-haute, posé par PersonCard) */
@media (min-width: 769px) {
body:has(.person-card) .family-tabs { left: 424px; max-width: calc(100vw - 424px - 96px); }
body[data-fiche-haute] .family-tabs { left: 424px; max-width: calc(100vw - 424px - 96px); }
/* Recherche ouverte (environ 540 px à droite) : la barre s'arrête avant elle et se fond au bord */
body:has(.expandable-search.expanded) .family-tabs { max-width: calc(100vw - 16px - 560px); }
body:has(.person-card):has(.expandable-search.expanded) .family-tabs { max-width: calc(100vw - 424px - 560px); }
body[data-fiche-haute]:has(.expandable-search.expanded) .family-tabs { max-width: calc(100vw - 424px - 560px); }
}
@media (max-width: 768px) {
.family-tabs { top: 10px; left: 10px; max-width: calc(100vw - 84px); }
.family-tabs--lowered { top: 64px; max-width: calc(100vw - 20px); }
/* Le message « Vous proposez » est sous la première ligne sur téléphone (EditModeOverlay.css, top: 68px) :
la barre reste en haut, à côté de la loupe, au lieu de passer dessous (Patch, 27/10) */
.family-tabs--lowered { top: 10px; max-width: calc(100vw - 84px); }
body:has(.edit-mode-toast):has(.expandable-search.expanded) .family-tabs { top: 124px; }
/* Recherche ouverte sur téléphone : elle prend toute la largeur du haut, la barre passe dessous */
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; }
+289 -42
View File
@@ -1,9 +1,9 @@
import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } from 'react'
import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as graphPersons, unions as graphUnions, filiations as graphFiliations } from '../data/mockData'
import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_GEN_DELAY, ENTRANCE_NODE_DURATION, ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_GEN_SPAN, ENTRANCE_MAX_NODE_SPAN, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants'
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 } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf } from './galaxy/blockLayout'
import { computeLayout, generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, 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.
@@ -32,7 +32,11 @@ const MAX_SCALE = 3.2
// Carte des familles : l'arbre s'efface entre ces deux échelles, les tuiles de familles apparaissent
// Ouverture sur la personne de départ quand l'arbre entier serait illisible
const FOCUS_MIN_READABLE_SCALE = 0.5
// Prénoms à 12 px (13 px à l'échelle 1) : en dessous, ils restent dessinés mais ne se lisent plus
const FOCUS_SCALE = 0.95
// Sous 768 px, la réglette de zoom reste ouverte en bas à gauche (ZoomControl.css) : la personne de départ se
// centre dans la largeur qu'elle laisse libre
const PHONE_SLIDER_BAND = 64
// Réordonner une fratrie : seuil avant de soulever à la souris, appui long au doigt
const REORDER_MOUSE_THRESHOLD = 4
@@ -42,9 +46,38 @@ const REORDER_LONG_PRESS_MS = 320
const SHIFT_SPRING = { stiffness: 560, dampingRatio: 0.92 }
const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
const centerX = (node) => node.x + node.width / 2
// Recul de la caméra à l'ouverture : départ immédiat, long freinage (ease-out quint)
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, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = 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, 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
@@ -53,17 +86,68 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const [tabs, setTabs] = useState([])
const [activeKey, setActiveKey] = useState(focusPersonId != null ? String(focusPersonId) : null)
const tabsCacheRef = useRef({ rev: -1, start: undefined, tabs: [] })
// Liste des onglets calculée dans un worker (galaxy/familyTabsWorker.js) quand l'arbre affiché est celui de la
// famille de départ : il ne dépend pas de cette liste, il s'affiche tout de suite et la barre suit. Une demande
// en cours est abandonnée (worker arrêté) dès qu'une autre part ou qu'on calcule la liste sur place.
const tabsRequestRef = useRef(null) // { rev, start, worker }
const cancelTabsRequest = useCallback(() => {
tabsRequestRef.current?.worker.terminate()
tabsRequestRef.current = null
}, [])
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)
const pendingFamilyRef = useRef(initialFamilyKey != null ? String(initialFamilyKey) : null)
// Famille du lien sans onglet dans ce navigateur (onglets coupés) : l'arbre entier s'ouvre sur sa charnière
const frameOnRef = useRef(null)
const onFamilyChangeRef = useRef(onFamilyChange)
useEffect(() => { onFamilyChangeRef.current = onFamilyChange }, [onFamilyChange])
const activeTabRef = useRef(null)
const familyTabsRef = useRef(familyTabs)
useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs])
// Propositions pas encore relues (utils/ghosts.js) : placées et dessinées en transparence, jamais dans les données
const ghostsRef = useRef(ghosts)
const fullNamesRef = useRef(fullNames)
useEffect(() => { fullNamesRef.current = fullNames; needsRedrawRef.current = true }, [fullNames])
useEffect(() => { ghostsRef.current = ghosts; needsRedrawRef.current = true }, [ghosts])
const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored)
useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored])
const startRefRef = useRef(focusPersonId)
useEffect(() => { startRefRef.current = focusPersonId; setActiveKey(focusPersonId != null ? String(focusPersonId) : null) }, [focusPersonId])
useEffect(() => {
startRefRef.current = focusPersonId
if (linkFamilyRef.current != null) pendingFamilyRef.current = linkFamilyRef.current
setActiveKey(focusPersonId != null ? String(focusPersonId) : null)
}, [focusPersonId])
// Onglet choisi par la personne (barre, badge, recherche) : le lien d'arrivée ne s'impose plus
// 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
@@ -83,7 +167,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
return () => window.removeEventListener('resize', updateTabsEdges)
}, [activeKey, tabs, updateTabsEdges])
// Clic sur un badge : l'onglet de cette famille (ou, depuis un onglet, celui d'où l'on vient)
const openFamily = useCallback((tabKey) => { if (tabKey != null) setActiveKey(String(tabKey)) }, [])
const openFamily = useCallback((tabKey) => { if (tabKey != null) chooseTab(String(tabKey)) }, [chooseTab])
// Propriétaire : un portrait vide survolé montre « + photo »
const [layoutReady, setLayoutReady] = useState(false)
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
@@ -102,6 +186,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const mediaHoverState = useRef(new Map())
const dragLiftProgress = useRef(0)
const entranceRef = useRef(null)
// Arbre déjà ouvert dans cette page (sa première personne) : l'entrée ne se joue qu'à la première ouverture,
// un arbre modifié ou un autre onglet s'affiche net
const openedTreeRef = useRef(null)
const selectedPersonIdRef = useRef(null)
const selectedGlowScales = useRef(new Map())
const searchGlowScales = useRef(new Map())
@@ -418,7 +505,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
const id = String(personId)
const all = tabsCacheRef.current.tabs
const home = all.find((tb) => tb.personIds?.has(id) && !(tb.hinges || []).map(String).includes(id)) || all.find((tb) => tb.personIds?.has(id)) || all[0]
if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; setActiveKey(home.key) }
if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; chooseTab(home.key) }
return
}
if (!canvas || !node) return
@@ -430,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,
@@ -479,18 +586,89 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}
}, [])
// Liste arrivée : l'arbre affiché (famille de départ) ne change pas, ses pastilles prennent le nom et le compte de
// l'onglet où elles mènent
const applyTabs = useCallback((req, list) => {
if (tabsRequestRef.current !== req) return
tabsRequestRef.current = null
tabsCacheRef.current = { rev: req.rev, start: req.start, tabs: list }
setTabs(list)
for (const n of layoutDataRef.current?.children || []) {
const target = n._familyTab && list.find((tb) => tb.key === n._familyTab.tabKey)
if (target) n._familyTab = { ...n._familyTab, label: target.label, count: target.count }
}
needsRedrawRef.current = true
}, [])
// Lance le calcul de la liste dans un worker ; faux si le navigateur ne le permet pas (on calcule alors sur place)
const requestTabs = useCallback((rev, start, referenceId) => {
cancelTabsRequest()
if (typeof Worker === 'undefined') return false
let worker
try {
worker = new Worker(new URL('./galaxy/familyTabsWorker.js', import.meta.url), { type: 'module' })
worker.postMessage({ graph: { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, referenceId })
} catch {
worker?.terminate()
return false
}
const req = { rev, start, worker }
tabsRequestRef.current = req
// Échec du worker : la liste est calculée sur place, sans toucher aux couples de l'arbre affiché
const fallback = () => {
if (tabsRequestRef.current !== req) return
const meta = coupleBarMeta
const list = familyTabsOf(null, referenceId)
restoreCoupleBarMeta(meta)
applyTabs(req, list)
}
worker.onmessage = ({ data }) => {
worker.terminate()
if (data.error) fallback()
else applyTabs(req, data.tabs)
}
worker.onerror = () => { worker.terminate(); fallback() }
return true
}, [cancelTabsRequest, applyTabs])
// Layout
useEffect(() => {
// Onglets : la liste des familles, une fois par version du graphe et par personne de départ. Calculée
// avant l'arbre affiché, parce que chaque calcul de placement remplit les couples du module (coupleBarMeta)
const start = startRefRef.current != null ? String(startRefRef.current) : null
const cache = tabsCacheRef.current
let freshTabs = null
// Liste en attente du worker : l'arbre affiché est celui de la famille de départ
let tabsPending = false
if (USE_BLOCK_LAYOUT && familyTabs && (cache.rev !== graphRevision || cache.start !== start)) {
tabsCacheRef.current = { rev: graphRevision, start, tabs: familyTabsOf(null, startRefRef.current) }
setTabs(tabsCacheRef.current.tabs)
const pending = tabsRequestRef.current
// Dans un onglet d'une autre famille, ou à l'arrivée par le lien d'un onglet, l'arbre affiché dépend de la
// liste : elle est calculée sur place
const inOtherTab = Boolean(cache.tabs.find((tb) => tb.key === activeKey)?.personIds)
if (!inOtherTab && pendingFamilyRef.current == null) {
tabsPending = (pending && pending.rev === graphRevision && pending.start === start) || requestTabs(graphRevision, start, startRefRef.current)
}
if (!tabsPending) {
cancelTabsRequest()
freshTabs = familyTabsOf(null, startRefRef.current)
tabsCacheRef.current = { rev: graphRevision, start, tabs: freshTabs }
setTabs(freshTabs)
}
}
if (!familyTabs && tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
const tab = familyTabs ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null
if (!familyTabs) {
cancelTabsRequest()
if (tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
}
// La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur sa charnière
if (pendingFamilyRef.current != null && graphPersons.length) {
const key = pendingFamilyRef.current
pendingFamilyRef.current = null
if (familyTabs && tabsCacheRef.current.tabs.some((tb) => tb.key === key)) {
frameOnRef.current = null
if (key !== activeKey) { setActiveKey(key); return }
} else frameOnRef.current = key
}
const tab = familyTabs && !tabsPending ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null
activeTabRef.current = tab
// La personne de départ (ou la charnière de l'onglet) sert de référence aux rangées
focusPersonIdRef.current = tab ? tab.referenceId : startRefRef.current
@@ -505,7 +683,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current)
// Famille de départ sans propositions : son placement vient d'être calculé avec la liste des onglets
const kept = USE_BLOCK_LAYOUT && !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || (USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current))
if (legacy) {
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
@@ -523,7 +703,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 }
}
}
@@ -554,7 +734,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Grand arbre : vu en entier, les portraits deviennent des pastilles illisibles.
// On ouvre sur la personne de départ, à une taille où l'on reconnaît les visages.
const focusId = focusPersonIdRef.current
const focusId = frameOnRef.current ?? focusPersonIdRef.current
const personNodes = result.children.filter((n) => n._type === 'person')
let focusNode = focusId != null
? personNodes.find((n) => String(n._data?.id) === String(focusId))
@@ -571,9 +751,19 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}
if (focusNode && fitScale < FOCUS_MIN_READABLE_SCALE) {
scale = FOCUS_SCALE
offsetX = cssW / 2 - (focusNode.x + focusNode.width / 2) * scale
// Sur un écran étroit, la barre des familles couvre le haut : la personne de départ un peu plus bas
offsetY = cssH * (cssW < 768 ? 0.56 : 0.42) - (focusNode.y + focusNode.height / 2) * scale
offsetX = (cssW < 768 ? (PHONE_SLIDER_BAND + cssW) / 2 : cssW / 2) - (focusNode.x + focusNode.width / 2) * scale
// Sur un écran étroit, la barre des familles couvre le haut : la personne de départ un peu plus bas. Plus de
// fiche ouverte à l'arrivée sur téléphone (#188) : 0,4 et non plus 0,56, sinon le haut de l'écran reste vide
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 }
@@ -582,32 +772,50 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
targetTransformRef.current = init
nodeRandomDataRef.current = generateNodeRandomData(result.children, result.edges)
// Construire le schedule d'animation d'entrée
const genGroups = new Map()
result.children.forEach(node => {
const gen = node._generation ?? 0
if (!genGroups.has(gen)) genGroups.set(gen, [])
genGroups.get(gen).push(node)
})
genGroups.forEach(group => group.sort((a, b) => a.x - b.x))
// Entrée, à la première ouverture de l'arbre seulement. La caméra part serrée sur la personne de départ et
// recule jusqu'à la vue d'arrivée ; chaque personne apparaît quand elle entre dans le champ, la personne de
// départ dès la première image. On reprend la main à tout moment (le recul s'arrête au premier geste).
// Mouvements réduits : pas de recul, un fondu court. Ensuite (modification, autre onglet) : rien ne s'anime.
const nodeSchedule = new Map()
const coupleSchedule = new Map()
const edgeSchedule = new Map()
const orbitSchedule = new Map()
let maxEndMs = 0
// Délais réduits sur les grands arbres pour que l'entrée reste courte (voir constants.js)
const genDelay = Math.min(ENTRANCE_GEN_DELAY, ENTRANCE_MAX_GEN_SPAN / Math.max(1, genGroups.size - 1))
genGroups.forEach((nodes, gen) => {
const genStart = gen * genDelay
const nodeStagger = Math.min(ENTRANCE_NODE_STAGGER, ENTRANCE_MAX_NODE_SPAN / Math.max(1, nodes.length - 1))
nodes.forEach((node, idx) => {
const startMs = genStart + idx * nodeStagger + Math.random() * 40
nodeSchedule.set(node.id, { startMs, duration: ENTRANCE_NODE_DURATION })
let camera = null
const treeKey = String(graphPersons[0]?.id ?? '')
const firstOpen = treeKey !== '' && openedTreeRef.current !== treeKey
if (treeKey) openedTreeRef.current = treeKey
if (!firstOpen) {
for (const n of result.children) nodeSchedule.set(n.id, { startMs: -Infinity, duration: 1 })
} else if (prefersReducedMotion() || !focusNode) {
for (const n of result.children) nodeSchedule.set(n.id, { startMs: 0, duration: ENTRANCE_REDUCED_DURATION })
maxEndMs = ENTRANCE_REDUCED_DURATION
} else {
const fx = focusNode.x + focusNode.width / 2, fy = focusNode.y + focusNode.height / 2
const sx = fx * init.scale + init.x, sy = fy * init.scale + init.y
const at = (sc) => ({ scale: sc, x: sx - fx * sc, y: sy - fy * sc })
camera = { from: at(Math.min(MAX_SCALE, Math.max(ENTRANCE_CAMERA_FROM_SCALE, init.scale * 1.6))), to: init, durationMs: ENTRANCE_CAMERA_DURATION }
camera.last = camera.from
// Instant où le recul fait entrer la personne dans le champ (marge de 40 px), ou null si elle reste dehors
const entersAt = (n) => {
const nx = n.x + n.width / 2 - fx, ny = n.y + n.height / 2 - fy
for (let i = 0; i <= 60; i++) {
const sc = camera.from.scale + (init.scale - camera.from.scale) * easeOutQuint(i / 60)
const px = sx + nx * sc, py = sy + ny * sc
if (px > -40 && px < cssW + 40 && py > -40 && py < cssH + 40) return (i / 60) * camera.durationMs
}
return null
}
for (const n of result.children) {
const t = n === focusNode ? 0 : entersAt(n)
if (t == null) { nodeSchedule.set(n.id, { startMs: -Infinity, duration: 1 }); continue }
const startMs = t === 0 ? 0 : Math.max(0, t - 120) + Math.random() * 40
nodeSchedule.set(n.id, { startMs, duration: ENTRANCE_NODE_DURATION })
maxEndMs = Math.max(maxEndMs, startMs + ENTRANCE_NODE_DURATION)
})
})
}
transformRef.current = camera.from
targetTransformRef.current = camera.from
}
coupleBarMeta.forEach(({ elkId, p1Key, p2Key }) => {
const s1 = nodeSchedule.get(p1Key)
@@ -645,6 +853,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
})
entranceRef.current = {
camera,
startTime: -1,
nodeSchedule, coupleSchedule, edgeSchedule, orbitSchedule,
totalDurationMs: maxEndMs,
@@ -661,7 +870,19 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
pendingFocusRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
}
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts])
if (pendingFrameRef.current) {
const ids = pendingFrameRef.current
pendingFrameRef.current = null
requestAnimationFrame(() => zoomApiRef?.current?.focusPersons?.(ids))
}
}, [graphRevision, activeKey, familyTabs, 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(() => {
if (pendingFamilyRef.current != null) return
const open = familyTabs && tabs.length > 1 ? tabs.find((tb) => tb.key === activeKey) : null
onFamilyChangeRef.current?.(open && open !== tabs[0] ? { key: open.key, label: open.label } : null)
}, [activeKey, tabs, familyTabs])
// Sync selectedPersonId
useEffect(() => {
@@ -669,7 +890,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])
@@ -705,6 +928,24 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Pendant l'entrée et le fondu des annotations qui la suit, on redessine chaque frame
if (entrance && elapsed < entrance.totalDurationMs + 500) dirty = true
// Recul de la caméra à l'ouverture, abandonné dès qu'on déplace ou zoome soi-même l'arbre
const cam = entrance?.camera
if (cam) {
if (targetTransformRef.current !== cam.last) entrance.camera = null
else {
const k = easeOutQuint(Math.min(1, Math.max(0, elapsed / cam.durationMs)))
const t = {
scale: cam.from.scale + (cam.to.scale - cam.from.scale) * k,
x: cam.from.x + (cam.to.x - cam.from.x) * k,
y: cam.from.y + (cam.to.y - cam.from.y) * k,
}
transformRef.current = t
targetTransformRef.current = t
cam.last = t
dirty = true
if (k >= 1) entrance.camera = null
}
}
// Lissage du transform
const target = targetTransformRef.current
const prev = transformRef.current
@@ -938,6 +1179,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
view,
liftScales: liftScalesRef.current,
ghostPatches: ghostsRef.current?.patches || null,
fullNames: fullNamesRef.current,
})
drawRenvoiPills(ctx)
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus.
@@ -1106,18 +1348,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={() => setActiveKey(tb.key)}
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>
)
})}
+10
View File
@@ -0,0 +1,10 @@
import { shortcutKeys } from '../utils/shortcuts.js'
// Raccourci clavier affiché à droite d'une entrée de menu ; caché sur écran tactile (styles/pz.css, .pz-kbd)
export default function Kbd({ mod, k }) {
return (
<span className="pz-kbd" aria-hidden="true">
{shortcutKeys(mod, k).map((key) => <kbd key={key}>{key}</kbd>)}
</span>
)
}
+10
View File
@@ -79,3 +79,13 @@
.lt-notice { margin: 0 0 10px; }
.lt-add { margin-top: 14px; }
.pc-empty .lt-add { margin-top: 12px; }
/* Prénom d'un proche dans la frise : ouvre sa fiche (Patch, 28/10) */
.lt-person {
appearance: none; border: 0; background: none; padding: 0; margin: 0; cursor: pointer;
font: inherit; color: var(--pz-a700); border-radius: 4px;
text-decoration: underline; text-decoration-color: var(--pz-a200); text-underline-offset: 3px;
transition: text-decoration-color 160ms ease;
}
@media (hover: hover) and (pointer: fine) { .lt-person:hover { text-decoration-color: var(--pz-a500); } }
.lt-person:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
Loaded 100 of 204 files, more files were not shown because too many files have changed in this diff. Show more