mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
Compare commits
No files matched your search
@@ -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 +) :
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
+130
@@ -2,6 +2,136 @@
|
||||
|
||||
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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -65,6 +65,8 @@ To open the instance to your family over the Internet, put an HTTPS reverse prox
|
||||
|
||||
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
|
||||
|
||||
Your instance doesn't push anyone to create an account. On app.parenthese.io, the hosted instance, the app suggests starting your own tree: relatives who open a shared tree find « Faire le mien » ("Make my own") in the Add menu, the demo's messages offer to sign up, and from December 1 to January 6 the invitation message mentions Christmas. These prompts are off by default. To turn them on, set `SIGNUP_PROMPTS=true` in `.env` (they stay off while registration is closed).
|
||||
|
||||
#### Search for someone in public records
|
||||
|
||||
The "+" menu offers "Someone elsewhere": a panel on the right of the tree where you type a name and see what public records hold about that person. Links to sites without a search we can query from here (old newspapers on Gallica, military registers on FranceArchives, Geneanet, FamilySearch and a few others, France first) always work: your browser opens the site with the name already typed in, and nothing goes through the server.
|
||||
@@ -83,7 +85,7 @@ Only the first name, last name and birth year leave the server, and the panel sa
|
||||
|
||||
#### Sign in with your identity provider (OIDC)
|
||||
|
||||
*Experimental in 1.2.0: tested against a certified OpenID provider, feedback from real setups is welcome in the issues.*
|
||||
*Used with Pocket ID on a real setup (#153).*
|
||||
|
||||
Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`:
|
||||
|
||||
@@ -95,6 +97,8 @@ OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback
|
||||
OIDC_PROVIDER_NAME=Authentik # shown on the button
|
||||
```
|
||||
|
||||
The `api` container talks to the provider itself, to read its configuration and exchange the sign-in code. If your Docker network is marked `internal`, give `api` a network that can reach the provider, not just `web`.
|
||||
|
||||
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
|
||||
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. Accounts that already have a password are not linked to the provider by email: before switching, each owner opens their account and clicks « Se connecter aussi avec … » ("Also sign in with …"), then signs in with the provider once. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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:
|
||||
|
||||
Generated
+87
-128
@@ -8,14 +8,15 @@
|
||||
"name": "backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cookie": "^11.1.5",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.5",
|
||||
"@fastify/jwt": "^10.2.6",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
"@node-rs/bcrypt": "^2.0.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"dotenv": "^18.0.8",
|
||||
"blurhash": "^2.0.5",
|
||||
"dotenv": "^18.0.9",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
@@ -61,17 +62,6 @@
|
||||
"@electric-sql/pglite": "0.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
|
||||
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.3",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
|
||||
@@ -82,16 +72,6 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
|
||||
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
@@ -556,9 +536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie": {
|
||||
"version": "11.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.3.tgz",
|
||||
"integrity": "sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==",
|
||||
"version": "11.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.5.tgz",
|
||||
"integrity": "sha512-hs7e0otf7n/lJMpt63EFdQb5/u8kO6DeexEtLPVTybSdItC6SwJ1O0vtTyWvKNR70LZcHLlsUr/y3jFpqtyWRA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -660,9 +640,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@fastify/jwt": {
|
||||
"version": "10.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.5.tgz",
|
||||
"integrity": "sha512-pObTiMwxYNYdTZ2E/aoU3REIQAMCFXtXMBcUTP1r60nbPqxQEDbN9zh/SQUEefL7gn+KBOaXcoU/Qmza4NxANA==",
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/jwt/-/jwt-10.2.6.tgz",
|
||||
"integrity": "sha512-tnhl0U6foEHz9v/eGh8zh+tMiJlX867h+yv0sCpWAvWcXYCdjt2Ji/szfD1gDWLy/EQKwYt4OZCKCKIjvenwXw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1280,22 +1260,10 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "0.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||
"integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.4.3",
|
||||
"@emnapi/runtime": "^1.4.3",
|
||||
"@tybys/wasm-util": "^0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.10.7.tgz",
|
||||
"integrity": "sha512-1wk0gHsUQC/ap0j6SJa2K34qNhomxXRcEe3T8cI5s+g6fgHBgLTN7U9LzWTG/HE6G4+2tWWLeCabk1wiYGEQSA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-2.0.0.tgz",
|
||||
"integrity": "sha512-perB+8PsvBsJeUJ0pE2GgOFAZZ3IXSfKhQ3Pkr+y/L5yZm6W727659g+ZtHmlhIi9obs3y2euUMjVqRB/DVlvA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@@ -1305,26 +1273,25 @@
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@node-rs/bcrypt-android-arm-eabi": "1.10.7",
|
||||
"@node-rs/bcrypt-android-arm64": "1.10.7",
|
||||
"@node-rs/bcrypt-darwin-arm64": "1.10.7",
|
||||
"@node-rs/bcrypt-darwin-x64": "1.10.7",
|
||||
"@node-rs/bcrypt-freebsd-x64": "1.10.7",
|
||||
"@node-rs/bcrypt-linux-arm-gnueabihf": "1.10.7",
|
||||
"@node-rs/bcrypt-linux-arm64-gnu": "1.10.7",
|
||||
"@node-rs/bcrypt-linux-arm64-musl": "1.10.7",
|
||||
"@node-rs/bcrypt-linux-x64-gnu": "1.10.7",
|
||||
"@node-rs/bcrypt-linux-x64-musl": "1.10.7",
|
||||
"@node-rs/bcrypt-wasm32-wasi": "1.10.7",
|
||||
"@node-rs/bcrypt-win32-arm64-msvc": "1.10.7",
|
||||
"@node-rs/bcrypt-win32-ia32-msvc": "1.10.7",
|
||||
"@node-rs/bcrypt-win32-x64-msvc": "1.10.7"
|
||||
"@node-rs/bcrypt-android-arm-eabi": "2.0.0",
|
||||
"@node-rs/bcrypt-android-arm64": "2.0.0",
|
||||
"@node-rs/bcrypt-darwin-arm64": "2.0.0",
|
||||
"@node-rs/bcrypt-darwin-x64": "2.0.0",
|
||||
"@node-rs/bcrypt-freebsd-x64": "2.0.0",
|
||||
"@node-rs/bcrypt-linux-arm-gnueabihf": "2.0.0",
|
||||
"@node-rs/bcrypt-linux-arm64-gnu": "2.0.0",
|
||||
"@node-rs/bcrypt-linux-arm64-musl": "2.0.0",
|
||||
"@node-rs/bcrypt-linux-x64-gnu": "2.0.0",
|
||||
"@node-rs/bcrypt-linux-x64-musl": "2.0.0",
|
||||
"@node-rs/bcrypt-win32-arm64-msvc": "2.0.0",
|
||||
"@node-rs/bcrypt-win32-ia32-msvc": "2.0.0",
|
||||
"@node-rs/bcrypt-win32-x64-msvc": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-android-arm-eabi": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.10.7.tgz",
|
||||
"integrity": "sha512-8dO6/PcbeMZXS3VXGEtct9pDYdShp2WBOWlDvSbcRwVqyB580aCBh0BEFmKYtXLzLvUK8Wf+CG3U6sCdILW1lA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-2.0.0.tgz",
|
||||
"integrity": "sha512-BGxkS6uXxDphPoMWevOVCXd3A07x45o1p4uYoXTjcvG7K+6iZXaXzOL5ZrGMUP71BAJ6b89/QStjuhGlMmuxOQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1338,9 +1305,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-android-arm64": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.10.7.tgz",
|
||||
"integrity": "sha512-UASFBS/CucEMHiCtL/2YYsAY01ZqVR1N7vSb94EOvG5iwW7BQO06kXXCTgj+Xbek9azxixrCUmo3WJnkJZ0hTQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-2.0.0.tgz",
|
||||
"integrity": "sha512-aKS02ZDZ26mLM5resokCCXHFhmlixytN0G7GbR1Xm3kq76mL9munu7vS0xk/5OszEls69o7oHH+NDe8xWyolnQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1354,9 +1321,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-darwin-arm64": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.10.7.tgz",
|
||||
"integrity": "sha512-DgzFdAt455KTuiJ/zYIyJcKFobjNDR/hnf9OS7pK5NRS13Nq4gLcSIIyzsgHwZHxsJWbLpHmFc1H23Y7IQoQBw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-2.0.0.tgz",
|
||||
"integrity": "sha512-gHKuT+80v2jAeJujfmJPbbDt5zzE/ne5x/eoG52Twyfx/1bCWAN+cZoDEnzuD0Yduw484bDFtgq5PKXBPV2PYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1370,9 +1337,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-darwin-x64": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.10.7.tgz",
|
||||
"integrity": "sha512-SPWVfQ6sxSokoUWAKWD0EJauvPHqOGQTd7CxmYatcsUgJ/bruvEHxZ4bIwX1iDceC3FkOtmeHO0cPwR480n/xA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-2.0.0.tgz",
|
||||
"integrity": "sha512-v8xBU+AZshfQ5fDNUvUDQsmc1mYGASnHXD2yVxjuDvKu/gaEHEz5gHtTFqzkNR9NPdWx3YxJ1UiW8fwSuN4XCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1386,9 +1353,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-freebsd-x64": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.10.7.tgz",
|
||||
"integrity": "sha512-gpa+Ixs6GwEx6U6ehBpsQetzUpuAGuAFbOiuLB2oo4N58yU4AZz1VIcWyWAHrSWRs92O0SHtmo2YPrMrwfBbSw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-2.0.0.tgz",
|
||||
"integrity": "sha512-XSFyizSOH0CWw+60XL4xaeOIRn5F9bgd919EvaIXQcjV7mWuhWbtGc2Z1eOcI8o7Yzh6LSkcIbZJ4fT094S7ww==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1402,9 +1369,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.10.7.tgz",
|
||||
"integrity": "sha512-kYgJnTnpxrzl9sxYqzflobvMp90qoAlaX1oDL7nhNTj8OYJVDIk0jQgblj0bIkjmoPbBed53OJY/iu4uTS+wig==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-2.0.0.tgz",
|
||||
"integrity": "sha512-YYXkdl4rQuTayFB7MeQva8/H5AVeaaIkzuTNtD0RDEjBSQCKYMalM23pP673ScCvBAWLuiYHtviMITdubiER6Q==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1418,12 +1385,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-linux-arm64-gnu": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.10.7.tgz",
|
||||
"integrity": "sha512-7cEkK2RA+gBCj2tCVEI1rDSJV40oLbSq7bQ+PNMHNI6jCoXGmj9Uzo7mg7ZRbNZ7piIyNH5zlJqutjo8hh/tmA==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-jLbuJ/MVa4xvFV5lmd71xx6DOfkQvpj4zR0X0AuOopofP0z6SmyA/zKQOrVrfDUCbBwAHoi22VD9umfU8HsrGA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1434,12 +1404,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-linux-arm64-musl": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.10.7.tgz",
|
||||
"integrity": "sha512-X7DRVjshhwxUqzdUKDlF55cwzh+wqWJ2E/tILvZPboO3xaNO07Um568Vf+8cmKcz+tiZCGP7CBmKbBqjvKN/Pw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-2.0.0.tgz",
|
||||
"integrity": "sha512-31ORIUbGuBPGyqvZMppQc99l7hdKZrhDxtfsqFcWyeQ5ie4iR7QHv7FGXj+uTLQtJDFVBQQFpVIXTL4jNW7OPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1450,12 +1423,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-linux-x64-gnu": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.10.7.tgz",
|
||||
"integrity": "sha512-LXRZsvG65NggPD12hn6YxVgH0W3VR5fsE/o1/o2D5X0nxKcNQGeLWnRzs5cP8KpoFOuk1ilctXQJn8/wq+Gn/Q==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-2.0.0.tgz",
|
||||
"integrity": "sha512-Kyo2iGkvtGDcfPcqjRQXS1DYDucpAMJbIw0aPmdiKFLAt8tdOeafK2+zfwuWq8/BDs/n9jKV2ajlda53AqFHMA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1466,12 +1442,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-linux-x64-musl": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.10.7.tgz",
|
||||
"integrity": "sha512-tCjHmct79OfcO3g5q21ME7CNzLzpw1MAsUXCLHLGWH+V6pp/xTvMbIcLwzkDj6TI3mxK6kehTn40SEjBkZ3Rog==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-2.0.0.tgz",
|
||||
"integrity": "sha512-NkQN7yL4yRQOPpK80SAgHDlYE352QJmnSP3oKp1SXHS/G92b1IwjGlTB2+9ZKIGEDdqxnyI3dRzP0P/Sabv9zg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1481,26 +1460,10 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-wasm32-wasi": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-wasm32-wasi/-/bcrypt-wasm32-wasi-1.10.7.tgz",
|
||||
"integrity": "sha512-4qXSihIKeVXYglfXZEq/QPtYtBUvR8d3S85k15Lilv3z5B6NSGQ9mYiNleZ7QHVLN2gEc5gmi7jM353DMH9GkA==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "^0.2.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-win32-arm64-msvc": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.10.7.tgz",
|
||||
"integrity": "sha512-FdfUQrqmDfvC5jFhntMBkk8EI+fCJTx/I1v7Rj+Ezlr9rez1j1FmuUnywbBj2Cg15/0BDhwYdbyZ5GCMFli2aQ==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-7BzjgPzfE9GktBafMaOTQR5klfcQ4SnzLS5dPKKdXEJ/rHilKbqm8EBszhGimlBMkutIBd53OE7MAqFVhtgIZw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1514,9 +1477,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-win32-ia32-msvc": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.10.7.tgz",
|
||||
"integrity": "sha512-lZLf4Cx+bShIhU071p5BZft4OvP4PGhyp542EEsb3zk34U5GLsGIyCjOafcF/2DGewZL6u8/aqoxbSuROkgFXg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-nbbwy8LOd4NxSJ2HrLTrXq5zNy0B6+LtX9wberLp9+DOQZVBfZF7PHlH4KlXi9aHrOpZHoXZNGIhWANtOpJlNA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -1530,9 +1493,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/bcrypt-win32-x64-msvc": {
|
||||
"version": "1.10.7",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.10.7.tgz",
|
||||
"integrity": "sha512-hdw7tGmN1DxVAMTzICLdaHpXjy+4rxaxnBMgI8seG1JL5e3VcRGsd1/1vVDogVp2cbsmgq+6d6yAY+D9lW/DCg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-2.0.0.tgz",
|
||||
"integrity": "sha512-9KdovFbXWVUhhdsUjtMR8WLw7+U4dCFMyYFRogmdpe+a4SMy8IcN8rv3azxmGYgtoH5wW63BQ0COKZq6C7O96g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2233,16 +2196,6 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.4",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.4.tgz",
|
||||
"integrity": "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
@@ -3033,6 +2986,12 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/blurhash": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.5",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz",
|
||||
@@ -3357,9 +3316,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/dotenv": {
|
||||
"version": "18.0.8",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.8.tgz",
|
||||
"integrity": "sha512-2KDeoFoK/zRtcnbunCDlLG5VWVMcyqeQc6iK65o231nfNAcuGrKoqUQfJFUtBI+QlShk5HKIHxwebBtUl3rEfA==",
|
||||
"version": "18.0.9",
|
||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.9.tgz",
|
||||
"integrity": "sha512-3fVOW4cB02R066PUSwn8O4TJyz9NckQl7F81blIbaq9jKBm2tSmOKky7ZHIXzGvkVPXCSn6jkpziBPZdGFs8Tw==",
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"dotenv": "dist/index.cjs"
|
||||
|
||||
@@ -17,14 +17,15 @@
|
||||
"prisma:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cookie": "^11.1.5",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.5",
|
||||
"@fastify/jwt": "^10.2.6",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@node-rs/bcrypt": "^1.10.7",
|
||||
"@node-rs/bcrypt": "^2.0.0",
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
"@prisma/client": "^7.10.1",
|
||||
"dotenv": "^18.0.8",
|
||||
"blurhash": "^2.0.5",
|
||||
"dotenv": "^18.0.9",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"pg": "^8.23.1",
|
||||
|
||||
@@ -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;
|
||||
@@ -310,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?
|
||||
@@ -370,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
|
||||
|
||||
+8
-1
@@ -9,13 +9,15 @@ import { Prisma } from './generated/prisma/client.js'
|
||||
import { env } from './config/env.js'
|
||||
import { prisma } from './lib/prisma.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'
|
||||
@@ -64,6 +66,8 @@ export function createApp() {
|
||||
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, {
|
||||
@@ -156,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)
|
||||
|
||||
@@ -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(''),
|
||||
|
||||
@@ -8,7 +8,7 @@ import { env } from '../config/env.js'
|
||||
const HASH_ROUNDS = 12
|
||||
|
||||
export async function hashPassword(value: string): Promise<string> {
|
||||
return hash(value, HASH_ROUNDS)
|
||||
return hash(value, { cost: HASH_ROUNDS })
|
||||
}
|
||||
|
||||
export async function verifyPassword(value: string, storedHash: string): Promise<boolean> {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
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'
|
||||
@@ -501,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,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -620,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
|
||||
@@ -676,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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -114,7 +114,7 @@ function fragmentOf(location: string | undefined) {
|
||||
describe('départ et retour', () => {
|
||||
it("annonce le fournisseur à l'écran de connexion", async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' } })
|
||||
expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' }, signupPrompts: false })
|
||||
})
|
||||
|
||||
it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => {
|
||||
|
||||
@@ -27,7 +27,7 @@ vi.mock('../src/lib/prisma.js', () => ({
|
||||
}))
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean }
|
||||
let env: { ALLOW_REGISTRATION: boolean, SIGNUP_PROMPTS: boolean }
|
||||
|
||||
const newAccount = { firstName: 'Camille', email: 'camille@example.com', password: 'securepass123' }
|
||||
|
||||
@@ -50,6 +50,7 @@ afterAll(async () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
env.ALLOW_REGISTRATION = false
|
||||
env.SIGNUP_PROMPTS = false
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.user.count.mockResolvedValue(3)
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: newAccount.email, firstName: 'Camille' })
|
||||
@@ -102,7 +103,7 @@ describe('inscription fermée', () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null })
|
||||
expect(res.json()).toEqual({ registration: false, passwordLogin: true, oidc: null, signupPrompts: false })
|
||||
})
|
||||
|
||||
it('instance neuve : le premier compte se crée, sous verrou', async () => {
|
||||
@@ -145,3 +146,22 @@ describe('inscription ouverte (par défaut)', () => {
|
||||
expect(prismaMock.user.count).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
// Invitations à créer son arbre (SIGNUP_PROMPTS) : éteintes par défaut, jamais annoncées quand l'instance ne crée plus de compte
|
||||
describe('invitations à créer son arbre', () => {
|
||||
it('éteintes par défaut', async () => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false)
|
||||
})
|
||||
|
||||
it('allumées avec SIGNUP_PROMPTS et une inscription ouverte', async () => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.SIGNUP_PROMPTS = true
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(true)
|
||||
})
|
||||
|
||||
it("coupées quand l'inscription est fermée, même allumées", async () => {
|
||||
env.SIGNUP_PROMPTS = true
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().signupPrompts).toBe(false)
|
||||
})
|
||||
})
|
||||
+5
-1
@@ -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).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
Generated
+198
-116
@@ -8,9 +8,10 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"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"
|
||||
},
|
||||
@@ -20,12 +21,12 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint": "^10.14.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"globals": "^17.14.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.3.4",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
}
|
||||
},
|
||||
@@ -482,6 +483,13 @@
|
||||
"url": "https://github.com/sponsors/nzakas"
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/nonrecursive-glob-match": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
|
||||
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0"
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
@@ -553,15 +561,15 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lucide/icons": {
|
||||
"version": "1.59.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.59.0.tgz",
|
||||
"integrity": "sha512-olGAqAKEtttGTAyjz+pmKEppYLu7X0jUtpLwt6JBvl9TMZaHnG7q6ECWVXh7rvt1ZLAsRASsuvtE4JtWU0Q2Ow==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/@lucide/icons/-/icons-1.66.0.tgz",
|
||||
"integrity": "sha512-Qrf8DOz1zKZlsxRY8eDSVEoBwGXc84VnvOzAoosKUhu5VpcxhM/ivhkDemwHV9U6/LkS6+hApB0YWLvdzw3+uQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.152.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz",
|
||||
"integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==",
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -569,34 +577,35 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/browser-common": {
|
||||
"version": "0.9.5",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.9.5.tgz",
|
||||
"integrity": "sha512-oXnBgZvM8UQ84IcBLfGP0WSGd9rA0PCGR37H8Pw0NszJRwxwbJnFXIUih0pvI/ze2eGmzkIEC9i3SwC+bMAgjQ==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.10.2.tgz",
|
||||
"integrity": "sha512-ieHSMmY4xG+HfkMTrT9yZoG0NMBWyA4aXMFPCuOOV+xRe706+zoPuwqmNHXv7LGXfi6CvOv4qksEoPNWfP359Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/core": "^1.59.1",
|
||||
"@posthog/types": "^1.416.0"
|
||||
"@posthog/core": "^1.61.2",
|
||||
"@posthog/types": "^1.418.0",
|
||||
"preact": "^10.29.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.59.1.tgz",
|
||||
"integrity": "sha512-P39YgzQVDh6oz6wAzQXEnoHgTYpM7gifonmVKq1scAJco79h31XmmNVurRq9tTmmyIrr1uX8FHT0riCsyfV9AQ==",
|
||||
"version": "1.62.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.62.0.tgz",
|
||||
"integrity": "sha512-KjNwGC7s0141wnWWHubNMdg1lOhZjqKRw2OTNstuzdVvNDfvv2RbFJQSSvvABmbCmTXwlMpdnbb/M/aAigDnNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@posthog/types": "^1.416.0"
|
||||
"@posthog/types": "^1.418.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@posthog/types": {
|
||||
"version": "1.416.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.416.0.tgz",
|
||||
"integrity": "sha512-0/9+02s/0uDyrH4G/qUg1yyl6gXUg8Uuxw/PlDad3KLzo9FFS9zS/kAQaEshx/saVGupj1aAy/4EUBxWisBogw==",
|
||||
"version": "1.419.0",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.419.0.tgz",
|
||||
"integrity": "sha512-XX7zRYt/vK8AzaD6V+oDquAzacZf5exJv971dja9En8Qf6g84Qvc3rpT0w6hDYYLWrjaSSkdjFfvvoXI0ouJDg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.12.tgz",
|
||||
"integrity": "sha512-dB/a1214qKfHMXCpgqR4OZT+jS4kTyEXbQGJPqzobt5EwH5rX080pxE37alt3RzvR1bf1Yz/yGqRfrYAxuPw0A==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -611,9 +620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-7KHFgQ5VJxIHcLlrwrc3Xbds7oTNQT7Pgi9gQCJKrd2VGab/UksIOYp6VD8MzCstGxOKMgNamPwUCfxPdP1OHg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -628,9 +637,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-3YIhqHD96nA5SaYNRBR16HnGv4oavZvXfD/ayHM+oYZ0WD/8lBAtf6zQua4kEyAvpqrluKXl0lnOBoiNby7x9w==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -645,9 +654,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-UuuJ35MFw4gmFOrE9pEqIV+K3syIKveph+Qc1/ljHZVdoDW4pz/JHR/eMVom+TZGl/5OOvGJOWaOCVt3ZfqhxA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -662,9 +671,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.12.tgz",
|
||||
"integrity": "sha512-uMvssit0a4W+/7D8CbHUvG719mH3R2jwXAlh/XcPvuHTE0g++LymF88DCGNX0HM2rBOn0xrzgXktIB6fLSJBTQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -679,9 +688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.12.tgz",
|
||||
"integrity": "sha512-XcFu0R0xWnwzSf4IQgFH1rJIckPN1pLy2R+4r9IDB7Yfu/ys9cVqfa4pBrMHj7a3gl8mIR4nRNPg0e5IvEVs6g==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -696,9 +705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-260UrKgn8tz39ak+SMDOirKzr7V04M9dWPw5llW00SwBivCZoWcRBKV1d8cXnRkUmSZA3BdiUmBHWk7734Ulpw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -713,9 +722,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-5YK1I9SqDkbPgc1IA8BgDl34suqUS2q0KWnBrirm0E51YjOs6eo6dV6jbQfNE/argHRSvd0QUGgtpIoYx+WWpw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -730,9 +739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Rkcrmp7eFRg74yL5fXEU91JEWbdEPLevWwGtXpmhbjlD1StScbWTmO94Bhly+Mo+ketKYkdmM1vNUKeWSlx8cQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -747,9 +756,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-qvK4DuAsQc2BSjlx+Xr+IzOIvvxbGZqxFwdWfG6F518Erj0GGISyQbJ6pIappnOxlNPzNHvo/L0BwB30GZ+zVw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -764,9 +773,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.12.tgz",
|
||||
"integrity": "sha512-Q9uLBO53Xd4QIq1WOycVQyPP1O4HhraEV2qqb3uTrnVw6QZih9duY4vNXOivL1xoUS1/z+W8eF4NMfl2a8Sdjw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -781,9 +790,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.12.tgz",
|
||||
"integrity": "sha512-3IBxWFMjbOZskDPKv8Lf9BCnahlKuHthWkYnyIxOH/QcJrFcS4EmcenthApkwr/5+nEqZlLzeYbxeMaX7A5u4g==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -798,9 +807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.12.tgz",
|
||||
"integrity": "sha512-xtX61xg4LKPkPWilZU1ynKClz5Gj4bf74LML4r3eVLWumKnGjoEr1OSHQhMdbBDoYTi+yjrujvpZe2pUnqCrrA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -815,9 +824,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-At7fPB6PCaIjzgIhEZFxuT+BBFqiQibJDT4d3PhiR3f4E7bbMZF4aKblbFfEM3sETRDd1YiQx/+U/g/B/ou5Ew==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -832,9 +841,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.12.tgz",
|
||||
"integrity": "sha512-WIw2haVKwjuYdXkHaoC0mF8Le71TuCBxjrdKqLbJGctbBABj+ClfmNvtbOnzpq3RokNo5+V1qhtSzJyXorsklQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1349,6 +1358,72 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.2",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.11.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
@@ -1605,6 +1680,12 @@
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/blurhash": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/blurhash/-/blurhash-2.0.5.tgz",
|
||||
"integrity": "sha512-cRygWd7kGBQO3VEhPiTgq4Wc43ctsM+o46urrmPOiuAe+07fzlSB9OJVdpgDL0jPqXUVQ9ht7aq7kxOeJHRK+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||
@@ -1830,9 +1911,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "10.13.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.13.0.tgz",
|
||||
"integrity": "sha512-UohDjD4zSzEwhf58M3O7vc/ycc8O1I7dNlhtD0WxE8Px8EHNJKtUwzBqrzeGhisRuv2ag4sAYswszCJRgKXYUg==",
|
||||
"version": "10.14.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-10.14.0.tgz",
|
||||
"integrity": "sha512-T0pNsxkb9IKmtQjhpK3d7nWOpFv+YHuMbTJnSVMoQblF0onvXfErrtp4V1jHtsQPan7dKaGVD0q1WCXnzMF31A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
@@ -1845,7 +1926,7 @@
|
||||
"@eslint/config-helpers": "^0.7.0",
|
||||
"@eslint/core": "^1.2.1",
|
||||
"@eslint/plugin-kit": "^0.7.3",
|
||||
"@humanfs/node": "^0.16.6",
|
||||
"@humanfs/node": "^0.16.8",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@humanwhocodes/retry": "^0.4.2",
|
||||
"@types/estree": "^1.0.6",
|
||||
@@ -1866,7 +1947,7 @@
|
||||
"imurmurhash": "^0.1.4",
|
||||
"is-glob": "^4.0.0",
|
||||
"json-stable-stringify-without-jsonify": "^1.0.1",
|
||||
"minimatch": "^10.2.5",
|
||||
"minimatch": "^10.2.6",
|
||||
"natural-compare": "^1.4.0",
|
||||
"optionator": "^0.9.3"
|
||||
},
|
||||
@@ -2155,9 +2236,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "17.13.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.13.0.tgz",
|
||||
"integrity": "sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==",
|
||||
"version": "17.14.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-17.14.0.tgz",
|
||||
"integrity": "sha512-UtdkTVyAJePQv0J2M9Xe4L0ruNSDV314XiIJsADI2Zf40bcdAonE/D/OhAU8AT99oij9mhLByb+NvA3QWZYgCg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2620,9 +2701,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.59.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.59.0.tgz",
|
||||
"integrity": "sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==",
|
||||
"version": "1.66.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.66.0.tgz",
|
||||
"integrity": "sha512-8zjbi/Us+ETXqfBwmXF/FHrWGIqOdE6zT1yUdRJLQVoptomSkB0zdFWVIM667t9K8djWDaXdkBkqi2qVTuDNiw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
@@ -2645,16 +2726,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
|
||||
"version": "11.0.4",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
|
||||
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^5.0.8"
|
||||
"@isaacs/nonrecursive-glob-match": "^1.0.3",
|
||||
"brace-expansion": "^5.0.12"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
@@ -2829,14 +2911,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.438.12",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.438.12.tgz",
|
||||
"integrity": "sha512-hjhvrtqbk6pFlbtNEGk0FrWKKFdeXe/WWA/LppN0f+uDoHKR9/SZJOxUHSVCGYbGTJvNRoDjjQvZuTcq4oKSlg==",
|
||||
"version": "1.444.0",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.444.0.tgz",
|
||||
"integrity": "sha512-4m+QsAFi1h5Zt8eFq6ciJGD8bcLMt1P/O1cLlo26RrX5F4oMOIKZsNIlf1SIMZWlRNNCFemSpzn6HhwtkZMrnA==",
|
||||
"license": "(Apache-2.0 AND MIT)",
|
||||
"dependencies": {
|
||||
"@posthog/browser-common": "^0.9.5",
|
||||
"@posthog/core": "^1.59.1",
|
||||
"@posthog/types": "^1.416.0",
|
||||
"@posthog/browser-common": "^0.10.2",
|
||||
"@posthog/core": "^1.62.0",
|
||||
"@posthog/types": "^1.419.0",
|
||||
"core-js": "^3.49.0",
|
||||
"dompurify": "^3.4.13",
|
||||
"fflate": "^0.4.8",
|
||||
@@ -2943,13 +3025,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.12",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
|
||||
"integrity": "sha512-8wafseiaG80xmXSfqidUNqZcylTlhmPZZt+za2m+js2sFZ8dTNlhIOV2WcbIPx2hgwPBJpEUGFAMZ9bgBBLTSQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.152.0",
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2959,21 +3041,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.12",
|
||||
"@rolldown/binding-android-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.12",
|
||||
"@rolldown/binding-darwin-x64": "1.2.12",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.12",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.12",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.12",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.12",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.12",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.12",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.12"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
@@ -3142,16 +3224,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@@ -3168,7 +3250,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
+10
-6
@@ -12,9 +12,10 @@
|
||||
"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"
|
||||
},
|
||||
@@ -24,12 +25,15 @@
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^6.1.2",
|
||||
"eslint": "^10.13.0",
|
||||
"eslint": "^10.14.0",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^17.13.0",
|
||||
"globals": "^17.14.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"vite": "^8.3.4",
|
||||
"vite": "^8.3.5",
|
||||
"vitest": "^5.0.5"
|
||||
},
|
||||
"overrides": {
|
||||
"minimatch": "^11.0.4"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 219 KiB |
+403
-227
@@ -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,
|
||||
@@ -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()
|
||||
@@ -558,6 +584,12 @@ function App() {
|
||||
// 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.
|
||||
// Proches des autres familles dessinés, estompés, autour d'un onglet : 0 (ma ligne seulement) à 3 liens
|
||||
const [tabKinDepth, setTabKinDepthState] = useState(() => { try { const v = localStorage.getItem('parenthese.tabKinDepth'); return ['0', '1', '2', '3'].includes(v) ? Number(v) : 1 } catch { return 1 } })
|
||||
const setTabKinDepth = useCallback((d) => { setTabKinDepthState(d); try { localStorage.setItem('parenthese.tabKinDepth', String(d)) } catch { /* navigateur sans stockage */ } }, [])
|
||||
const [fullNames, setFullNamesState] = useState(() => { try { return localStorage.getItem('parenthese.fullNames') === 'on' } catch { return false } })
|
||||
const setFullNames = useCallback((on) => { setFullNamesState(on); try { localStorage.setItem('parenthese.fullNames', on ? 'on' : 'off') } catch { /* navigateur sans stockage */ } }, [])
|
||||
// Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre
|
||||
const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [], bundledFiles: [] })
|
||||
const photoStartDirtyRef = useRef(false)
|
||||
@@ -580,8 +612,22 @@ function App() {
|
||||
const [accountEntryMode, setAccountEntryMode] = useState('login')
|
||||
// Options de l'instance. Inscription fermée (ALLOW_REGISTRATION=false, #113) ou mot de passe désactivé
|
||||
// (PASSWORD_LOGIN=false, #115) : plus de « Créer un compte » nulle part. oidc : le bouton du fournisseur.
|
||||
const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null })
|
||||
const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null, signupPrompts: false })
|
||||
const registrationOpen = authOptions.registration !== false && authOptions.passwordLogin !== false
|
||||
// Invitations à créer son arbre (SIGNUP_PROMPTS, allumé sur l'instance hébergée) : « Faire le mien » pour la
|
||||
// famille, l'inscription au bout des messages de la démo, le message d'invitation de Noël
|
||||
const signupPrompts = authOptions.signupPrompts === true && registrationOpen
|
||||
// Message de la démo suivi de l'invitation ; le lien ouvre l'inscription sans recharger la page
|
||||
const demoSignupMessage = (lead) => (
|
||||
<span>
|
||||
{lead} {t('Pour votre famille,')}{' '}
|
||||
<a
|
||||
href="/?account=register&source=demo"
|
||||
className="pz-inline-link"
|
||||
onClick={(event) => { event.preventDefault(); handleUseAccount('register', 'demo') }}
|
||||
>{t('créez votre arbre')}</a>.
|
||||
</span>
|
||||
)
|
||||
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
|
||||
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
|
||||
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
|
||||
@@ -711,7 +757,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({
|
||||
@@ -986,6 +1032,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
|
||||
@@ -1078,7 +1126,8 @@ function App() {
|
||||
if (!left) return
|
||||
editMode.saveDraft()
|
||||
editMode.deactivate()
|
||||
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
if (isDemoMode && signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.')))
|
||||
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate, isDemoMode, signupPrompts]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
editMode.clearDraft()
|
||||
@@ -1100,9 +1149,10 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (!relationFeedback.success) return undefined
|
||||
|
||||
// Le message de la démo qui porte l'invitation (lien vers l'inscription) reste le temps de le lire et de cliquer
|
||||
const timeout = setTimeout(() => {
|
||||
setRelationFeedback((current) => ({ ...current, success: '' }))
|
||||
}, 2500)
|
||||
}, typeof relationFeedback.success === 'string' ? 2500 : 6000)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [relationFeedback.success])
|
||||
@@ -1196,192 +1246,234 @@ function App() {
|
||||
siblingParentLinks.push(...links)
|
||||
}
|
||||
}
|
||||
// « Paul, Marie, Jean » pour un enfant ou un frère : une personne par prénom, mêmes liens pour chacune
|
||||
const firstNames = splitFirstNames(formData.firstName, formData.relations || [])
|
||||
const batch = firstNames.length > 1 ? { batch: firstNames.length } : {}
|
||||
// Un enfant ajouté à une personne qui n'a qu'un conjoint est l'enfant du couple : il passe par les liens
|
||||
// du couple, comme un frère ou une sœur. Une adoption reste au seul parent choisi.
|
||||
const relationCount = (formData.relations || []).length
|
||||
formData = {
|
||||
...formData,
|
||||
relations: (formData.relations || []).filter((rel) => {
|
||||
if (rel.type !== 'child' || rel.adopted) return true
|
||||
const coupleLinks = getCoupleParentLinks(rel.personId, unions)
|
||||
if (!coupleLinks.length) return true
|
||||
siblingParentLinks.push(...coupleLinks)
|
||||
return false
|
||||
}),
|
||||
}
|
||||
|
||||
if (!tree.canEditCurrentTree) {
|
||||
if (isDemoMode) {
|
||||
const personId = makeLocalId('local-person')
|
||||
const birthYear = extractYearFromIsoDate(formData.birthDate)
|
||||
const deathYear = extractYearFromIsoDate(formData.deathDate)
|
||||
const newPerson = {
|
||||
id: personId,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName || '',
|
||||
birthName: formData.birthName || null,
|
||||
nickname: formData.nickname || null,
|
||||
sex: formData.sex || null,
|
||||
birthDate: formData.birthDate || null,
|
||||
deathDate: formData.deathDate || null,
|
||||
birthYear,
|
||||
deathYear,
|
||||
isAlive: !deathYear,
|
||||
photo: null,
|
||||
frameType: 'round',
|
||||
}
|
||||
const addOne = async (formData) => {
|
||||
if (!tree.canEditCurrentTree) {
|
||||
if (isDemoMode) {
|
||||
const personId = makeLocalId('local-person')
|
||||
const birthYear = extractYearFromIsoDate(formData.birthDate)
|
||||
const deathYear = extractYearFromIsoDate(formData.deathDate)
|
||||
const newPerson = {
|
||||
id: personId,
|
||||
firstName: formData.firstName,
|
||||
lastName: formData.lastName || '',
|
||||
birthName: formData.birthName || null,
|
||||
nickname: formData.nickname || null,
|
||||
sex: formData.sex || null,
|
||||
birthDate: formData.birthDate || null,
|
||||
deathDate: formData.deathDate || null,
|
||||
birthYear,
|
||||
deathYear,
|
||||
isAlive: !deathYear,
|
||||
photo: null,
|
||||
frameType: 'round',
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1443,6 +1535,7 @@ function App() {
|
||||
editMode.deactivate()
|
||||
setContribSubmitError('')
|
||||
setShowContribModal(false)
|
||||
if (signupPrompts) setDraftNotice(demoSignupMessage(t('Votre essai reste dans la démo.')))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1594,7 +1687,6 @@ function App() {
|
||||
setShowContribModal(false)
|
||||
setContribSubmitError('')
|
||||
setContribSuccess(true)
|
||||
trackAppEvent('proposal_sent', { changes: changes.length, tree_id: opaqueTreeId })
|
||||
setDraftNotice('')
|
||||
setTimeout(() => setContribSuccess(false), 6000)
|
||||
} catch (err) {
|
||||
@@ -1919,59 +2011,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
|
||||
}
|
||||
|
||||
@@ -1981,7 +2089,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) => {
|
||||
@@ -2036,7 +2172,7 @@ function App() {
|
||||
const addedRelations = [...(draft.addedRelations || []), { sourcePersonId, targetPersonId, relationType }]
|
||||
editMode.updateDraft({ addedRelations })
|
||||
tree.bumpGraphRevision()
|
||||
setRelationFeedback({ loading: false, success: t('Lien familial ajouté (démo locale)'), error: '' })
|
||||
setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien ajouté à la démo.')) : t('Lien familial ajouté (démo locale)'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2101,7 +2237,7 @@ function App() {
|
||||
if (isDemoMode) {
|
||||
for (const link of childLinks) link.parentageType = parentageType
|
||||
tree.bumpGraphRevision()
|
||||
setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' })
|
||||
setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien modifié dans la démo.')) : t('Lien modifié (démo locale)'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2178,7 +2314,7 @@ function App() {
|
||||
const removedRelations = [...(draft.removedRelations || []), { sourcePersonId, targetPersonId, relationType }]
|
||||
editMode.updateDraft({ removedRelations })
|
||||
tree.bumpGraphRevision()
|
||||
setRelationFeedback({ loading: false, success: t('Lien familial supprimé (démo locale)'), error: '' })
|
||||
setRelationFeedback({ loading: false, success: signupPrompts ? demoSignupMessage(t('Lien retiré de la démo.')) : t('Lien familial supprimé (démo locale)'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2330,6 +2466,8 @@ function App() {
|
||||
// Explicit demo modes (landing iframe / forced demo)
|
||||
if (query.has('embed') || query.has('demo')) {
|
||||
tree.handleUseDemo()
|
||||
// Sans attendre : la démo s'ouvre tout de suite, les options (inscription, invitations) arrivent ensuite
|
||||
fetchAuthOptions().then((options) => { if (active && options) setAuthOptions(options) }).catch(() => {})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2366,10 +2504,16 @@ function App() {
|
||||
const resolvedTreeOwnerEmail = ''
|
||||
|
||||
// Root app:
|
||||
// - not connected, arrivé par un lien de diffusion (?source=…) => la démo, comme avant le 18/10 : ces liens
|
||||
// promettent l'arbre d'exemple et ont été envoyés sans ?demo (presse, créateurs, groupes)
|
||||
// - not connected => accueil à deux choix (variante B, 18/10)
|
||||
// - connected => continue bootstrap to open the user's tree
|
||||
if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) {
|
||||
const userToken = auth.restoreUserToken()
|
||||
if (!userToken && query.has('source')) {
|
||||
tree.handleUseDemo()
|
||||
return
|
||||
}
|
||||
if (!userToken) {
|
||||
await authOptionsReady
|
||||
if (!active) return
|
||||
@@ -2519,7 +2663,7 @@ function App() {
|
||||
// ?account=register (CTA de la landing) : écran compte directement en création
|
||||
if (query.get('account') === 'register' && registrationAllowed) {
|
||||
setAccountEntryMode('register')
|
||||
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: getSource() })
|
||||
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, via: 'lien', source: getSource() })
|
||||
}
|
||||
tree.setBootState('account')
|
||||
}
|
||||
@@ -2562,8 +2706,14 @@ function App() {
|
||||
}
|
||||
}, [tree.bootState, auth.authenticated, auth.accountLoading, auth.accountTrees.length, accountFromTree]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleUseAccount = (mode = 'login') => {
|
||||
const ownTreeAfterSignupRef = useRef(false)
|
||||
// via : la porte qui a mené à l'écran de compte (ajouter, compte, demo), pour les mesures
|
||||
const handleUseAccount = (mode = 'login', via = 'autre') => {
|
||||
const ctx = tree.treeContext
|
||||
// « Faire le mien » : après l'inscription, l'assistant de création s'ouvre au-dessus de l'arbre de la famille
|
||||
ownTreeAfterSignupRef.current = mode === 'register' && via === 'ajouter'
|
||||
// Inscription depuis un arbre reçu ou depuis la démo, sans source dans l'adresse : on le note
|
||||
if (mode === 'register' && ctx.treeId && ctx.accessMode !== 'member') rememberSource(ctx.accessMode === 'demo' ? 'demo' : 'arbre-partage')
|
||||
pendingSharedTreeRef.current = ctx.treeId && ctx.accessMode !== 'demo' && ctx.accessMode !== 'member'
|
||||
? {
|
||||
treeId: ctx.treeId,
|
||||
@@ -2580,7 +2730,7 @@ function App() {
|
||||
setAccountPanelVisible(false)
|
||||
const entryMode = mode === 'register' && registrationOpen ? 'register' : 'login'
|
||||
setAccountEntryMode(entryMode)
|
||||
trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
|
||||
trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), via, source: getSource() })
|
||||
media.resetMediaManager()
|
||||
setAdminPanelVisible(false)
|
||||
contrib.resetContribState()
|
||||
@@ -2628,7 +2778,7 @@ function App() {
|
||||
const entry = trees.find((candidate) => String(candidate.id) === String(pending.treeId))
|
||||
if (entry) {
|
||||
await openAccountTree(entry, token, { promptWizard: false })
|
||||
return true
|
||||
return 'opened'
|
||||
}
|
||||
} catch {
|
||||
// Jeton expiré ou refusé : on repasse par le mot de passe
|
||||
@@ -2638,7 +2788,7 @@ function App() {
|
||||
tree.setTreeContext(buildEmptyTreeContext(pending))
|
||||
tree.setGateError('')
|
||||
tree.setBootState('need-access')
|
||||
return true
|
||||
return 'gate'
|
||||
} finally {
|
||||
pendingSharedTreeRef.current = null
|
||||
setPendingSharedTreeName('')
|
||||
@@ -2690,7 +2840,11 @@ function App() {
|
||||
trackAppEvent('account_registered', { from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId), source: getSource() })
|
||||
|
||||
const token = auth.restoreUserToken()
|
||||
if (token && await resumePendingSharedTree(token)) {
|
||||
const resumed = token ? await resumePendingSharedTree(token) : false
|
||||
if (resumed) {
|
||||
// L'arbre de la famille est rattaché au compte et rouvert ; « Faire le mien » ouvre aussi l'assistant
|
||||
if (resumed === 'opened' && ownTreeAfterSignupRef.current) setTreeWizardVisible(true)
|
||||
ownTreeAfterSignupRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3301,6 +3455,7 @@ function App() {
|
||||
onContinue={handleConfirmContinue}
|
||||
onSaveAndQuit={handleConfirmSaveAndQuit}
|
||||
onDiscard={handleConfirmDiscard}
|
||||
demo={isDemoMode}
|
||||
/>
|
||||
{/* Slide-in Ajouter Personne */}
|
||||
<AddPersonPanel
|
||||
@@ -3350,7 +3505,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 && (
|
||||
@@ -3375,12 +3536,14 @@ function App() {
|
||||
onCreateTree={handleOpenTreeWizard}
|
||||
onProfileClick={handleUseAccount}
|
||||
canRegister={registrationOpen}
|
||||
signupPrompts={signupPrompts}
|
||||
onAccountClick={() => setAccountPanelVisible(true)}
|
||||
onSettingsClick={() => setAdminPanelVisible(true)}
|
||||
onLogout={handleLogout}
|
||||
userDisplayName={accountDisplayName}
|
||||
userEmail={accountEmail}
|
||||
userAvatarPhoto={accountAvatarPhoto}
|
||||
userAvatarCrop={linkedSelfPerson?.avatarCrop ?? null}
|
||||
linkedPersonName={linkedSelfPerson ? `${linkedSelfPerson.firstName || ''} ${linkedSelfPerson.lastName || ''}`.trim() : ''}
|
||||
activeTreeName={activeAccountTreeName}
|
||||
annotationTools={editMode.isActive ? {
|
||||
@@ -3411,6 +3574,8 @@ function App() {
|
||||
reorderEnabled={canReorderSiblings}
|
||||
onReorderSiblings={handleReorderSiblings}
|
||||
familyTabs={familyTabs}
|
||||
tabKinDepth={tabKinDepth}
|
||||
fullNames={fullNames}
|
||||
annotationViewRef={annotationViewRef}
|
||||
onAnnotationsAnchored={persistAnnotationAnchors}
|
||||
selfPersonId={linkedSelfPersonId || null}
|
||||
@@ -3430,6 +3595,7 @@ function App() {
|
||||
{tree.selectedPerson && (
|
||||
<PersonCard
|
||||
person={tree.selectedPerson}
|
||||
onUndoable={undoable.start}
|
||||
onClose={() => chooseSelectedPerson(null)}
|
||||
onMediaSelect={handleMediaSelectFresh}
|
||||
saveOnLeaveRef={personSaveOnLeaveRef}
|
||||
@@ -3463,6 +3629,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}
|
||||
/>
|
||||
)}
|
||||
@@ -3515,7 +3686,7 @@ function App() {
|
||||
onClose={media.handleCloseMediaManager}
|
||||
onUpload={media.handleUploadPersonMedia}
|
||||
onMove={media.handleMovePersonMedia}
|
||||
onDelete={media.handleDeletePersonMedia}
|
||||
onDelete={handleMediaDelete}
|
||||
/>
|
||||
<ContributionPanel
|
||||
visible={contrib.contribState.visible}
|
||||
@@ -3536,6 +3707,7 @@ function App() {
|
||||
onRotatePasswords={handleContributionPasswordRotate}
|
||||
onRememberPassword={handleContributionPasswordRemember}
|
||||
onReviewSession={contrib.handleReviewContributionSession}
|
||||
seasonalInvite={signupPrompts}
|
||||
/>
|
||||
<SearchSourcesPanel
|
||||
visible={searchPanelVisible}
|
||||
@@ -3584,6 +3756,10 @@ function App() {
|
||||
onClose={() => setAdminPanelVisible(false)}
|
||||
familyTabs={familyTabs}
|
||||
onFamilyTabsChange={setFamilyTabs}
|
||||
tabKinDepth={tabKinDepth}
|
||||
onTabKinDepthChange={setTabKinDepth}
|
||||
fullNames={fullNames}
|
||||
onFullNamesChange={setFullNames}
|
||||
/>
|
||||
<PhotoStartPanel
|
||||
visible={photoStart.visible}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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}`,
|
||||
},
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
@@ -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,10 @@ const TREE_ICONS = ['🌳', '🌿', '🌻', '🏡', '❤️', '🧡', '⭐', '
|
||||
const AdminPanel = ({
|
||||
familyTabs = true,
|
||||
onFamilyTabsChange = null,
|
||||
tabKinDepth = 1,
|
||||
onTabKinDepthChange = null,
|
||||
fullNames = false,
|
||||
onFullNamesChange = null,
|
||||
visible,
|
||||
treeId,
|
||||
authToken,
|
||||
@@ -442,7 +447,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>}
|
||||
@@ -532,6 +537,7 @@ const AdminPanel = ({
|
||||
|
||||
<fieldset className="ts-policy">
|
||||
<legend className="pz-eyebrow">{t('Affichage')}</legend>
|
||||
<p className="pz-small">{t('Réglages de ce navigateur.')}</p>
|
||||
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -540,8 +546,31 @@ const AdminPanel = ({
|
||||
onChange={(event) => onFamilyTabsChange?.(event.target.checked)}
|
||||
/>
|
||||
<span className="ts-choice-text">
|
||||
<strong>{t('Les grandes familles par alliance dans des onglets')}</strong>
|
||||
<small>{t("Une belle-famille plus large qu'un écran s'ouvre dans son propre onglet au lieu d'être dessinée dans l'arbre. Réglage de ce navigateur.")}</small>
|
||||
<strong>{t('Séparer les belles-familles en onglets')}</strong>
|
||||
<small>{t("Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.")}</small>
|
||||
</span>
|
||||
</label>
|
||||
<div className="pz-field">
|
||||
<span className="pz-label" id="settingsTabKinLabel">{t("Dans un onglet, montrer aussi les proches de l'autre côté")}</span>
|
||||
<div className="ts-icons" role="radiogroup" aria-labelledby="settingsTabKinLabel">
|
||||
{[[0, t('Ma ligne seulement')], [1, t('1 lien')], [2, t('2 liens')], [3, t('3 liens')]].map(([d, text]) => (
|
||||
<button key={d} type="button" role="radio" aria-checked={tabKinDepth === d} disabled={!familyTabs} className="ts-icon ts-icon--letter" onClick={() => onTabKinDepthChange?.(d)}>
|
||||
{text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="pz-hint">{t('Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.')}</p>
|
||||
</div>
|
||||
<label className={`ts-choice ${fullNames ? 'is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="fullNames"
|
||||
checked={fullNames}
|
||||
onChange={(event) => onFullNamesChange?.(event.target.checked)}
|
||||
/>
|
||||
<span className="ts-choice-text">
|
||||
<strong>{t('Afficher le nom de famille')}</strong>
|
||||
<small>{t("Sous chaque prénom. Le nom de naissance s'il est connu.")}</small>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
@@ -7,7 +7,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 = ({
|
||||
@@ -253,6 +241,7 @@ const ContributionPanel = ({
|
||||
onRotatePasswords,
|
||||
onRememberPassword,
|
||||
onReviewSession,
|
||||
seasonalInvite = false,
|
||||
}) => {
|
||||
const [decisionMap, setDecisionMap] = useState({})
|
||||
const [collapsedSessions, setCollapsedSessions] = useState(new Set())
|
||||
@@ -354,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')) }
|
||||
}
|
||||
|
||||
@@ -410,7 +408,7 @@ const ContributionPanel = ({
|
||||
</div>
|
||||
<div className="contrib-invite-link">
|
||||
<code className="contrib-link-url">{inviteShareUrl || t('Lien indisponible')}</code>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!inviteShareUrl} onClick={() => handleCopy(inviteShareUrl, t('Lien copié'))}><Copy size={14} strokeWidth={2} />{t('Copier')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" disabled={!inviteShareUrl} onClick={() => handleCopy(inviteShareUrl, t('Lien copié'), 'link')}><Copy size={14} strokeWidth={2} />{t('Copier')}</button>
|
||||
</div>
|
||||
<div className="contrib-invite-passwords">
|
||||
<div className="contrib-role-row">
|
||||
@@ -424,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>
|
||||
</>
|
||||
@@ -439,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>
|
||||
@@ -471,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>
|
||||
) : (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
@@ -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, kinAround, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
|
||||
import { hasGhosts, withGhosts } from '../utils/ghosts'
|
||||
|
||||
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
|
||||
@@ -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, initialFamilyKey = null, onFamilyChange = null }) => {
|
||||
// Barre des familles rangée comme l'arbre (Patch, 28/10, planche A variante 2 « le trait d'union ») : chaque
|
||||
// famille juste après celle par laquelle on y entre, la famille de départ en tête. Les onglets d'une même branche
|
||||
// (sous-arbre d'un enfant direct de la famille de départ) se serrent à 10 px, 16 px entre les branches ; un trait
|
||||
// relie deux voisins quand le second part du premier (jamais vers la famille de départ, sa place en tête suffit).
|
||||
// Le trait qui mène à l'onglet ouvert est terracotta, les autres couleur fil.
|
||||
function barTabs(tabs, activeKey) {
|
||||
if (!tabs.length) return []
|
||||
const root = tabs[0]
|
||||
const kids = new Map()
|
||||
for (const tb of tabs.slice(1)) { const pk = tabs.some((x) => x.key === tb.parentKey) ? tb.parentKey : root.key; if (!kids.has(pk)) kids.set(pk, []); kids.get(pk).push(tb) }
|
||||
const order = []
|
||||
const visit = (tb) => { order.push(tb); for (const c of kids.get(tb.key) || []) visit(c) }
|
||||
visit(root)
|
||||
for (const tb of tabs) if (!order.includes(tb)) order.push(tb)
|
||||
const byKey = new Map(tabs.map((tb) => [tb.key, tb]))
|
||||
const pathTo = (tb) => { const out = []; for (let t = tb; t; t = byKey.get(t.parentKey)) out.unshift(t.key); return out }
|
||||
const branchOf = (tb) => { const p = pathTo(tb); return p[1] || p[0] }
|
||||
const active = byKey.get(activeKey) || root
|
||||
const lit = new Set(pathTo(active))
|
||||
return order.map((tb, i) => {
|
||||
const prev = order[i - 1]
|
||||
const gap = i === 0 ? 0 : (prev !== root && branchOf(tb) === branchOf(prev) ? 10 : 16)
|
||||
const trait = i > 0 && prev !== root && tb.parentKey === prev.key ? (lit.has(tb.key) ? 'on' : 'off') : null
|
||||
return { tb, gap, trait }
|
||||
})
|
||||
}
|
||||
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, tabKinDepth = 1, fullNames = false, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null, initialFamilyKey = null, onFamilyChange = null }) => {
|
||||
const canvasRef = useRef(null)
|
||||
const layoutDataRef = useRef(null)
|
||||
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
|
||||
@@ -53,7 +86,17 @@ 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)
|
||||
@@ -67,6 +110,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
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])
|
||||
@@ -77,7 +122,32 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
setActiveKey(focusPersonId != null ? String(focusPersonId) : null)
|
||||
}, [focusPersonId])
|
||||
// Onglet choisi par la personne (barre, badge, recherche) : le lien d'arrivée ne s'impose plus
|
||||
const chooseTab = useCallback((key) => { linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
|
||||
// Repère du changement d'onglet (Patch, 28/10 : « on perd un peu rapidement le fil ») : la personne qui relie
|
||||
// les deux familles garde sa place et son zoom à l'écran, seules les familles autour changent
|
||||
const anchorRef = useRef(null)
|
||||
const linkPeople = (key) => {
|
||||
const list = tabsCacheRef.current.tabs
|
||||
const target = list.find((tb) => tb.key === String(key)), cur = activeTabRef.current || list[0]
|
||||
return [target?.referenceId, ...(target?.hinges || []), cur?.referenceId, ...(cur?.hinges || [])].filter((x) => x != null).map(String)
|
||||
}
|
||||
const rememberAnchor = (key) => {
|
||||
const layout = layoutDataRef.current, t = transformRef.current
|
||||
if (!layout || !t) return
|
||||
const ids = linkPeople(key)
|
||||
const node = layout.children.find((n) => n._type === 'person' && ids.includes(String(n._data?.id)))
|
||||
anchorRef.current = node ? { id: String(node._data.id), sx: (node.x + node.width / 2) * t.scale + t.x, sy: (node.y + node.height / 2) * t.scale + t.y, scale: t.scale } : null
|
||||
}
|
||||
const chooseTab = useCallback((key) => { rememberAnchor(key); linkFamilyRef.current = null; pendingFamilyRef.current = null; frameOnRef.current = null; setActiveKey(key) }, [])
|
||||
// Survol d'un onglet : la personne par qui l'on y passe s'allume dans l'arbre affiché, sans texte (Patch : « que ça les suggère »)
|
||||
const hoverTab = (key) => {
|
||||
const layout = layoutDataRef.current
|
||||
if (!layout) return
|
||||
const present = new Set(layout.children.filter((n) => n._type === 'person').map((n) => String(n._data?.id)))
|
||||
const ids = linkPeople(key).filter((id) => present.has(id))
|
||||
searchHighlightSetRef.current = new Set([...searchHighlightIdsRef.current.map((id) => `p-${id}`), ...ids.map((id) => `p-${id}`)])
|
||||
needsRedrawRef.current = true
|
||||
}
|
||||
const leaveTab = () => { searchHighlightSetRef.current = new Set(searchHighlightIdsRef.current.map((id) => `p-${id}`)); needsRedrawRef.current = true }
|
||||
// Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché
|
||||
const focusPersonIdRef = useRef(focusPersonId)
|
||||
// La barre défile quand les onglets débordent : l'onglet ouvert se met en vue (sans animation) et les bords
|
||||
@@ -116,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())
|
||||
@@ -444,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,
|
||||
@@ -493,17 +586,79 @@ 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) {
|
||||
cancelTabsRequest()
|
||||
if (tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
|
||||
}
|
||||
if (!familyTabs && tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
|
||||
// La famille du lien : son onglet s'il existe dans ce navigateur, sinon l'arbre entier ouvert sur sa charnière
|
||||
if (pendingFamilyRef.current != null && graphPersons.length) {
|
||||
const key = pendingFamilyRef.current
|
||||
@@ -513,13 +668,19 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (key !== activeKey) { setActiveKey(key); return }
|
||||
} else frameOnRef.current = key
|
||||
}
|
||||
const tab = familyTabs ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null
|
||||
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
|
||||
// Dans l'onglet d'une autre famille, la personne commune (« C'est moi », sinon la personne de départ) reste
|
||||
// là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps »)
|
||||
const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set()
|
||||
// Autour, estompés aussi : l'autre parent des porteurs du nom venus d'ailleurs, et les proches de l'autre côté
|
||||
// jusqu'à la profondeur choisie dans les réglages (Patch, 06/11)
|
||||
if (tab?.personIds) {
|
||||
for (const id of tab.faded || []) line.add(id)
|
||||
for (const id of kinAround(tab.personIds, tabKinDepth)) line.add(id)
|
||||
}
|
||||
const tabGraph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
|
||||
const graph = hasGhosts(ghosts)
|
||||
? withGhosts(tabGraph || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghosts, !tabGraph)
|
||||
@@ -528,7 +689,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 })
|
||||
@@ -546,7 +709,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const parent = tabsCacheRef.current.tabs.find((tb) => tb.key === tab.parentKey)
|
||||
for (const h of (tab.hinges || [tab.referenceId])) {
|
||||
const node = result.children.find((n) => n.id === `p-${h}`)
|
||||
if (parent && node) node._familyTab = { anchorId: node.id, label: parent.label, count: parent.count, tabKey: parent.key, place: 'above' }
|
||||
if (parent && node) node._familyTab = { anchorId: node.id, label: parent.label, count: parent.count, tabKey: parent.key, place: 'above', back: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -594,9 +757,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 }
|
||||
@@ -605,32 +778,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)
|
||||
@@ -668,6 +859,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
})
|
||||
|
||||
entranceRef.current = {
|
||||
camera,
|
||||
startTime: -1,
|
||||
nodeSchedule, coupleSchedule, edgeSchedule, orbitSchedule,
|
||||
totalDurationMs: maxEndMs,
|
||||
@@ -684,7 +876,12 @@ 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, tabKinDepth, selfPersonId, ghosts, requestTabs, cancelTabsRequest])
|
||||
|
||||
// Onglet ouvert, remonté pour l'adresse de la page et le lien de partage ; null pour la famille de départ
|
||||
useEffect(() => {
|
||||
@@ -699,7 +896,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
}, [selectedPersonId])
|
||||
|
||||
// Sync searchHighlightIds
|
||||
const searchHighlightIdsRef = useRef(searchHighlightIds)
|
||||
useEffect(() => {
|
||||
searchHighlightIdsRef.current = searchHighlightIds
|
||||
searchHighlightSetRef.current = new Set(searchHighlightIds.map(id => `p-${id}`))
|
||||
}, [searchHighlightIds])
|
||||
|
||||
@@ -735,6 +934,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
|
||||
@@ -968,6 +1185,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.
|
||||
@@ -1136,18 +1354,23 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
)}
|
||||
{familyTabs && tabs.length > 1 && (
|
||||
<nav ref={tabsBarRef} onScroll={updateTabsEdges} className={`family-tabs${annotationHandlers ? ' family-tabs--lowered' : ''}${tabsEdges}`} aria-label={tr('Familles')}>
|
||||
{tabs.map((tb) => {
|
||||
{barTabs(tabs, activeKey).map(({ tb, gap, trait }) => {
|
||||
const active = tb.key === (tabs.some((x) => x.key === activeKey) ? activeKey : tabs[0].key)
|
||||
return (
|
||||
<button
|
||||
key={tb.key}
|
||||
type="button"
|
||||
className={`family-tab${active ? ' is-active' : ''}`}
|
||||
style={gap ? { marginLeft: gap } : undefined}
|
||||
className={`family-tab${active ? ' is-active' : ''}${trait ? ` family-tab--suite family-tab--trait-${trait}` : ''}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={() => chooseTab(tb.key)}
|
||||
onMouseEnter={() => { if (!active) hoverTab(tb.key) }}
|
||||
onMouseLeave={leaveTab}
|
||||
onFocus={() => { if (!active) hoverTab(tb.key) }}
|
||||
onBlur={leaveTab}
|
||||
>
|
||||
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
|
||||
<span className="family-tab-count">{tb.count}</span>
|
||||
<span className="family-tab-count" title={tr('{n} personnes', { n: tb.count })}>{tb.count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -3,6 +3,8 @@ import { Plus, Play, FileText, Mic, Paperclip } from 'lucide-react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { buildLifeTimeline } from '../utils/lifeTimeline.js'
|
||||
import './LifeTimeline.css'
|
||||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||||
import Skeleton from './Skeleton.jsx'
|
||||
|
||||
// Onglet Vie de la fiche : naissance, unions et décès en grands repères, les autres événements
|
||||
// plus petits entre eux, la famille proche en retrait. Les sources restent repliées.
|
||||
@@ -24,6 +26,31 @@ function Sources({ text }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Titre d'une ligne : le prénom d'un proche ouvre sa fiche ; le reste du titre ouvre la modification du moment
|
||||
// (administrateur). Un bouton ne peut pas en contenir un autre : les deux sont côte à côte.
|
||||
function Title({ entry, editable, onEdit, onOpenPerson }) {
|
||||
const editLabel = t('Modifier « {title} »', { title: entry.title })
|
||||
const link = onOpenPerson ? entry.link : null
|
||||
const at = link ? entry.title.indexOf(link.name) : -1
|
||||
if (at < 0) {
|
||||
return editable ? (
|
||||
<button type="button" className="lt-title lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={editLabel}>{entry.title}</button>
|
||||
) : <div className="lt-title">{entry.title}</div>
|
||||
}
|
||||
const before = entry.title.slice(0, at)
|
||||
const after = entry.title.slice(at + link.name.length)
|
||||
const edit = (text) => (editable && text.trim()
|
||||
? <button type="button" className="lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={editLabel}>{text}</button>
|
||||
: text)
|
||||
return (
|
||||
<div className="lt-title">
|
||||
{edit(before.trimEnd())}{/\s$/.test(before) ? ' ' : ''}
|
||||
<button type="button" className="lt-person" onClick={() => onOpenPerson(link.personId)}>{link.name}</button>
|
||||
{before.trim() ? after : edit(after)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const FILE_LABELS = { video: t('Vidéo'), audio: t('Enregistrement'), document: t('Document') }
|
||||
|
||||
// Souvenirs rattachés au moment : les photos en vignettes, le reste (vidéo, acte, voix) en pastilles
|
||||
@@ -37,7 +64,7 @@ function Attachments({ medias, onOpen }) {
|
||||
<div className="lt-photos">
|
||||
{photos.map((media) => (
|
||||
<button key={media.id} type="button" className="lt-photo" onClick={() => onOpen?.(media)} aria-label={t('Voir « {title} »', { title: media.label })}>
|
||||
<img src={media.url} alt="" loading="lazy" />
|
||||
<img src={media.url} alt="" loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -57,7 +84,7 @@ function Attachments({ medias, onOpen }) {
|
||||
|
||||
export default function LifeTimeline({
|
||||
person, family, life, loading = false, failed = false, medias = [],
|
||||
canAdd = false, canEdit = false, notice = '', onAdd, onEdit, onOpenMedia,
|
||||
canAdd = false, canEdit = false, notice = '', onAdd, onEdit, onOpenMedia, onOpenPerson,
|
||||
}) {
|
||||
const entries = buildLifeTimeline(person, family, life)
|
||||
const mediaById = new Map(medias.map((m) => [String(m.id), m]))
|
||||
@@ -70,6 +97,9 @@ export default function LifeTimeline({
|
||||
const noticeLine = notice && <p className="pz-success lt-notice" role="status">{notice}</p>
|
||||
|
||||
if (entries.length === 0) {
|
||||
// Premier chargement, aucune date connue sur la fiche : la place de la frise, au lieu d'un « pas encore de
|
||||
// date » qui serait faux une seconde après. Avec des dates connues, elles s'affichent tout de suite.
|
||||
if (loading && !life) return <Skeleton variant="timeline" count={3} label={t('Chargement de la frise')} />
|
||||
return (
|
||||
<div className="pc-empty">
|
||||
<p className="pz-sub">{t('Pas encore de date connue pour {name}.', { name: person.firstName })}</p>
|
||||
@@ -91,13 +121,7 @@ export default function LifeTimeline({
|
||||
<li key={entry.key} className={`lt-ev lt-ev--${entry.kind}`}>
|
||||
<span className="lt-year">{entry.year ?? ''}</span>
|
||||
<div className="lt-body">
|
||||
{editable ? (
|
||||
<button type="button" className="lt-title lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={t('Modifier « {title} »', { title: entry.title })}>
|
||||
{entry.title}
|
||||
</button>
|
||||
) : (
|
||||
<div className="lt-title">{entry.title}</div>
|
||||
)}
|
||||
<Title entry={entry} editable={editable} onEdit={onEdit} onOpenPerson={onOpenPerson} />
|
||||
{entry.meta && <div className="lt-meta">{entry.meta}</div>}
|
||||
{entry.note && <p className="lt-note">{entry.note}</p>}
|
||||
{entry.sources && <Sources text={entry.sources} />}
|
||||
|
||||
@@ -8,6 +8,7 @@ import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
import ImmichAssetPicker from './ImmichAssetPicker.jsx'
|
||||
import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||||
|
||||
const MEDIA_TYPES = [
|
||||
{ value: 'photo', label: t('Photo'), Icon: Image },
|
||||
@@ -358,7 +359,7 @@ const MediaManagerPanel = ({
|
||||
<li key={media.id} className="mm-item">
|
||||
<span className="mm-preview" aria-hidden="true">
|
||||
{media.type === 'photo' && media.url ? (
|
||||
<img src={media.url} alt="" />
|
||||
<img src={media.url} alt="" {...blurPlaceholder(media.blurhash)} />
|
||||
) : media.type === 'geojson' || media.type === 'gpx' ? (
|
||||
<GeoMediaThumbnail media={media} className="media-manager-modal-preview-map" />
|
||||
) : (
|
||||
|
||||
@@ -3,19 +3,27 @@
|
||||
.mp-avatar { width: 24px; height: 24px; flex: none; border-radius: 50%; object-fit: cover; }
|
||||
.mp-avatar--initial { display: grid; place-items: center; background: var(--pz-a100); color: var(--pz-a700); font: 400 12px/1 var(--pz-serif); }
|
||||
|
||||
/* Point sur un visage : rond blanc cerclé, le prénom apparaît au survol ou au focus (toujours en identification) */
|
||||
/* Rond sur un visage : fil fin, assez large pour entourer le visage sans le couvrir. Caché par défaut, la photo d'abord
|
||||
(Patch, 27/10) : visible au survol de la photo, quand on la touche (.show-names), quand son prénom est survolé
|
||||
dessous (.is-hot), et toujours en identification. Apparition d'un coup, sans fondu. */
|
||||
.mp-marker {
|
||||
position: absolute;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: -13px 0 0 -13px;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
margin: -17px 0 0 -17px;
|
||||
padding: 0;
|
||||
border: 2px solid #fff;
|
||||
border: 1.5px solid rgba(255, 255, 255, .9);
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
box-shadow: 0 0 0 1px rgba(42, 38, 34, .3), 0 2px 8px rgba(42, 38, 34, .25);
|
||||
box-shadow: 0 0 0 1px rgba(42, 38, 34, .18);
|
||||
cursor: pointer;
|
||||
visibility: hidden;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) { .mv-photo-box:hover .mp-marker { visibility: visible; } }
|
||||
.mv-photo-box.is-tagging .mp-marker,
|
||||
.mv-photo-box.show-names .mp-marker,
|
||||
.mp-marker.is-hot { visibility: visible; }
|
||||
.mp-marker.is-hot { border-color: #fff; box-shadow: 0 0 0 1px rgba(42, 38, 34, .25), 0 0 0 4px rgba(255, 255, 255, .25); }
|
||||
.mp-marker.is-active { border-color: var(--pz-a500); box-shadow: 0 0 0 3px rgba(210, 105, 74, .35), 0 2px 8px rgba(42, 38, 34, .25); }
|
||||
.mp-marker:focus-visible { outline: 2px solid var(--pz-a700); outline-offset: 3px; }
|
||||
.mp-marker-name {
|
||||
@@ -33,10 +41,9 @@
|
||||
visibility: hidden;
|
||||
}
|
||||
.mp-marker:hover .mp-marker-name,
|
||||
.mp-marker:focus-visible .mp-marker-name,
|
||||
.mp-marker.is-hot .mp-marker-name,
|
||||
.mv-photo-box.is-tagging .mp-marker-name,
|
||||
.mv-photo-box.show-names .mp-marker-name { visibility: visible; }
|
||||
@media (hover: none) { .mp-marker-name { visibility: visible; } }
|
||||
|
||||
/* Photo cliquable pendant le placement d'un point */
|
||||
.mv-photo-box.is-placing img { cursor: crosshair; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Check, Search, UserPlus, X } from 'lucide-react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel, fullNameLabel } from '../utils/personName.js'
|
||||
import './MediaPeople.css'
|
||||
import PortraitThumb from './PortraitThumb.jsx'
|
||||
|
||||
const MAX_RESULTS = 6
|
||||
|
||||
@@ -10,13 +11,13 @@ const normalize = (value) => String(value || '').normalize('NFD').replace(/[̀-
|
||||
|
||||
function PersonAvatar({ person }) {
|
||||
return person?.photo
|
||||
? <img className="mp-avatar" src={person.photo} alt="" />
|
||||
? <PortraitThumb className="mp-avatar" src={person.photo} crop={person.avatarCrop} />
|
||||
: <span className="mp-avatar mp-avatar--initial" aria-hidden="true">{person?.firstName?.[0] || '?'}</span>
|
||||
}
|
||||
|
||||
// Points posés sur la photo : un rond par visage, le prénom au survol ou au focus. En identification, le point de
|
||||
// la personne choisie est mis en avant.
|
||||
export function MediaPeopleMarkers({ people, getPerson, activeId = null, onOpenPerson }) {
|
||||
// Ronds posés sur les visages, cachés tant qu'on ne les demande pas (survol de la photo, photo touchée, prénom survolé
|
||||
// dessous : `hotId`). En identification, ils restent visibles et le rond de la personne choisie est mis en avant.
|
||||
export function MediaPeopleMarkers({ people, getPerson, activeId = null, hotId = null, onOpenPerson }) {
|
||||
return people.filter((tag) => tag.x != null && tag.y != null).map((tag) => {
|
||||
const person = getPerson(tag.personId)
|
||||
if (!person) return null
|
||||
@@ -25,7 +26,7 @@ export function MediaPeopleMarkers({ people, getPerson, activeId = null, onOpenP
|
||||
<button
|
||||
key={tag.personId}
|
||||
type="button"
|
||||
className={`mp-marker${activeId === tag.personId ? ' is-active' : ''}`}
|
||||
className={`mp-marker${activeId === tag.personId ? ' is-active' : ''}${hotId === tag.personId ? ' is-hot' : ''}`}
|
||||
style={{ left: `${tag.x * 100}%`, top: `${tag.y * 100}%` }}
|
||||
onClick={(event) => { event.stopPropagation(); onOpenPerson?.(tag.personId) }}
|
||||
aria-label={t('Voir la fiche de {name}', { name })}
|
||||
@@ -49,6 +50,7 @@ export function MediaPeopleList({
|
||||
onAdd,
|
||||
onRemove,
|
||||
onOpenPerson,
|
||||
onHover,
|
||||
}) {
|
||||
const [query, setQuery] = useState('')
|
||||
const taken = useMemo(() => new Set(people.map((tag) => tag.personId)), [people])
|
||||
@@ -69,7 +71,16 @@ export function MediaPeopleList({
|
||||
<div className="mp-list" aria-label={t('Sur la photo')}>
|
||||
<span className="mp-list-label">{t('Sur la photo')}</span>
|
||||
{known.map(({ person }) => (
|
||||
<button key={person.id} type="button" className="mp-chip" onClick={() => onOpenPerson?.(person.id)}>
|
||||
<button
|
||||
key={person.id}
|
||||
type="button"
|
||||
className="mp-chip"
|
||||
onClick={() => onOpenPerson?.(person.id)}
|
||||
onPointerEnter={() => onHover?.(person.id)}
|
||||
onPointerLeave={() => onHover?.(null)}
|
||||
onFocus={() => onHover?.(person.id)}
|
||||
onBlur={() => onHover?.(null)}
|
||||
>
|
||||
<PersonAvatar person={person} />
|
||||
<span>{firstNameLabel(person)}</span>
|
||||
</button>
|
||||
|
||||
@@ -508,3 +508,26 @@
|
||||
.mv-edit-quote { font-family: var(--pz-serif); font-size: 18px; resize: vertical; }
|
||||
.mv-edit-error { margin: 0; font: 400 13px var(--pz-sans); color: var(--pz-a700); }
|
||||
.mv-edit-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* Mouvements réduits : la carte n'a pas la classe .pz-modal que vise pz.css, elle jouait donc sa montée quand même */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-viewer-card { animation: none; }
|
||||
}
|
||||
|
||||
/* Téléphone : la photo se glisse vers le bas pour fermer (useSheetDrag, zone .mv-photo-box). L'entrée ne garde pas sa
|
||||
valeur finale (fill-mode backwards) : sinon elle l'emporterait sur le transform du glisser, posé en ligne. */
|
||||
@media (max-width: 768px) {
|
||||
.media-viewer-card {
|
||||
animation-fill-mode: backwards;
|
||||
transition: transform 240ms var(--pz-ease-drawer);
|
||||
}
|
||||
.media-viewer-card[data-dragging] { transition: none; }
|
||||
.media-viewer-card .mv-photo-box { touch-action: none; }
|
||||
}
|
||||
/* Collée en bas (pz.css, sous 560 px) : un fond plein sous la carte, pour qu'elle tirée vers le haut ne découvre pas l'arbre */
|
||||
@media (max-width: 560px) {
|
||||
.media-viewer-card { box-shadow: 0 40px 0 var(--pz-surface), var(--pz-shadow-lg); }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.media-viewer-card { transition-duration: 1ms; }
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Pencil, UserPlus, X } from 'lucide-react'
|
||||
import { MediaPeopleList, MediaPeopleMarkers } from './MediaPeople.jsx'
|
||||
import {
|
||||
@@ -13,6 +13,9 @@ import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { eventTitle } from '../utils/lifeTimeline.js'
|
||||
import { getAccountErrorMessage } from '../utils/errorMessages.js'
|
||||
import { growFromTransform, takeOrigin } from '../utils/openOrigin.js'
|
||||
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag.js'
|
||||
import PortraitThumb from './PortraitThumb.jsx'
|
||||
|
||||
const HTTP_URL_RE = /^https?:\/\/\S+$/i
|
||||
const MAX_TITLE_LENGTH = 280
|
||||
@@ -42,6 +45,10 @@ const MediaViewer = ({
|
||||
const [tagging, setTagging] = useState(false)
|
||||
const [draftPeople, setDraftPeople] = useState([])
|
||||
const [activeTagId, setActiveTagId] = useState(null)
|
||||
// Visages identifiés : rien sur la photo par défaut (Patch, 27/10). Toucher la photo montre ou cache les prénoms,
|
||||
// survoler un prénom sous la photo entoure son visage.
|
||||
const [showNames, setShowNames] = useState(false)
|
||||
const [hotPersonId, setHotPersonId] = useState(null)
|
||||
const [savingPeople, setSavingPeople] = useState(false)
|
||||
const [peopleError, setPeopleError] = useState('')
|
||||
const [peopleNotice, setPeopleNotice] = useState('')
|
||||
@@ -309,12 +316,16 @@ const MediaViewer = ({
|
||||
// La boîte épouse l'image : les points (x, y en proportion) tombent pile sur les visages
|
||||
return (
|
||||
<div className="media-photo-frame">
|
||||
<div className={`mv-photo-box${tagging ? ' is-tagging' : ''}${activeTagId ? ' is-placing' : ''}`} onClick={placeActiveTag}>
|
||||
<div
|
||||
className={`mv-photo-box${tagging ? ' is-tagging' : ''}${activeTagId ? ' is-placing' : ''}${showNames ? ' show-names' : ''}`}
|
||||
onClick={(event) => (tagging ? placeActiveTag(event) : setShowNames((shown) => !shown))}
|
||||
>
|
||||
<img src={mediaUrl} alt={media?.label || t('Photo')} />
|
||||
<MediaPeopleMarkers
|
||||
people={shownPeople}
|
||||
getPerson={getPerson}
|
||||
activeId={activeTagId}
|
||||
hotId={tagging ? null : hotPersonId}
|
||||
onOpenPerson={tagging ? (id) => setActiveTagId(id) : onOpenPerson}
|
||||
/>
|
||||
</div>
|
||||
@@ -562,11 +573,43 @@ const MediaViewer = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Ouvert depuis une vignette de la fiche : la carte part de la vignette et grandit jusqu'à sa place (Patch, 27/10).
|
||||
// Avant la peinture, pour ne jamais montrer la carte à sa place une image plus tôt.
|
||||
const cardRef = useRef(null)
|
||||
const mediaId = media?.id
|
||||
useLayoutEffect(() => {
|
||||
const card = cardRef.current
|
||||
const from = takeOrigin()
|
||||
if (!card || !from) return
|
||||
card.style.animation = 'none'
|
||||
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return
|
||||
// Sur téléphone, la fenêtre est collée en bas (pz.css, place-items: end) : on part du bord bas
|
||||
const anchor = getComputedStyle(card.parentElement).alignItems.includes('end') ? 'bottom' : 'center'
|
||||
const start = growFromTransform(from, card.getBoundingClientRect().width, { width: window.innerWidth, height: window.innerHeight }, anchor)
|
||||
if (!start) return
|
||||
card.style.transformOrigin = start.origin
|
||||
card.animate?.([{ transform: start.transform }, { transform: 'none' }], { duration: 280, easing: 'cubic-bezier(.23, 1, .32, 1)' })
|
||||
}, [mediaId])
|
||||
|
||||
// Téléphone : glisser la photo vers le bas ferme le souvenir, comme la fiche (Patch, 27/10). Pas pendant
|
||||
// l'identification (on touche la photo pour placer un visage), ni quand la carte a défilé (on remonte d'abord).
|
||||
const sheetMode = useSheetMode()
|
||||
const drag = useSheetDrag({
|
||||
enabled: sheetMode && !tagging,
|
||||
zone: '.mv-photo-box',
|
||||
onClose,
|
||||
canStart: () => (cardRef.current?.scrollTop ?? 0) <= 0,
|
||||
})
|
||||
|
||||
if (!media) return null
|
||||
|
||||
return (
|
||||
<div className="pz-overlay media-viewer-overlay" onClick={handleOverlayClick}>
|
||||
<div
|
||||
ref={cardRef}
|
||||
style={drag.style}
|
||||
data-dragging={drag.dragging || undefined}
|
||||
{...drag.bind}
|
||||
className={`media-viewer-card media-viewer-card--${mediaType}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@@ -606,7 +649,7 @@ const MediaViewer = ({
|
||||
<div className="mv-meta">
|
||||
<span className="mv-person">
|
||||
{person?.photo ? (
|
||||
<img className="mv-person-avatar" src={person.photo} alt="" />
|
||||
<PortraitThumb className="mv-person-avatar" src={person.photo} crop={person.avatarCrop} />
|
||||
) : (
|
||||
<span className="mv-person-avatar mv-person-initial">{person?.firstName?.[0] || '?'}</span>
|
||||
)}
|
||||
@@ -646,7 +689,7 @@ const MediaViewer = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="mv-people">
|
||||
<MediaPeopleList people={savedPeople} getPerson={getPerson} onOpenPerson={onOpenPerson} />
|
||||
<MediaPeopleList people={savedPeople} getPerson={getPerson} onOpenPerson={onOpenPerson} onHover={setHotPersonId} />
|
||||
{canTag && onSavePeople && (
|
||||
<button type="button" className="pz-btn pz-btn--soft pz-btn--sm mv-people-btn" onClick={startTagging}>
|
||||
<UserPlus size={15} strokeWidth={2} aria-hidden="true" />
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createPortal } from 'react-dom'
|
||||
import { X, Paperclip, Images, FileText, Mic, Play, Check } from 'lucide-react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { createLifeEvent, updateLifeEvent, deleteLifeEvent } from '../api/treeApi.js'
|
||||
import { createLifeEvent, updateLifeEvent, deleteLifeEvent, updateUnion } from '../api/treeApi.js'
|
||||
import { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js'
|
||||
import { inferMediaTypeFromMimeType } from '../utils/mediaUpload.js'
|
||||
import { readContributorName, saveContributorName } from '../utils/contributorName.js'
|
||||
@@ -14,7 +14,8 @@ import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
// Fiche « Ajouter un moment » de l'onglet Vie : quoi, quand, où, et des souvenirs (photo, vidéo, acte en
|
||||
// PDF, enregistrement). La date exacte, la fin, la source et la note restent dans « Plus de détails ».
|
||||
// Trois usages : ajouter (administrateur ou famille), modifier un moment (administrateur), et compléter une
|
||||
// naissance, un mariage ou un décès avec ses documents (administrateur, la date se change dans Infos).
|
||||
// naissance ou un décès avec ses documents (administrateur, la date se change dans Infos). Pour un couple, la
|
||||
// fiche dit aussi si c'est un mariage, et en garde la date et le lieu (onglet Vie, 28/10).
|
||||
|
||||
// Suggestions touchables : le texte exact devient le type correspondant (exporté en GEDCOM sous sa balise)
|
||||
const SUGGESTIONS = [
|
||||
@@ -36,15 +37,19 @@ function FileThumb({ type, url }) {
|
||||
return <span className="ms-file-icon"><Icon size={18} strokeWidth={1.8} /></span>
|
||||
}
|
||||
|
||||
export default function MomentSheet({ person, entry = null, treeId, token, medias = [], isAdmin = false, onUploadFile, onClose, onSaved }) {
|
||||
export default function MomentSheet({ person, entry = null, treeId, token, medias = [], isAdmin = false, onUploadFile, onClose, onSaved, onDelete, onUnionSaved }) {
|
||||
const milestone = entry?.milestone || null
|
||||
const event = entry?.event || null
|
||||
// Couple : sa date et son lieu vivent sur le couple, pas sur le moment
|
||||
const union = milestone && entry?.union ? entry.union : null
|
||||
const editing = Boolean(event)
|
||||
const dated = union ? { date: union.startDate, datePrecision: union.startDatePrecision, place: union.startPlace } : event
|
||||
const [what, setWhat] = useState(() => (event && !milestone ? eventTitle(event) : ''))
|
||||
const [year, setYear] = useState(() => yearOf(event?.date))
|
||||
const [approx, setApprox] = useState(() => ['about', 'estimated', 'calculated', 'between'].includes(event?.datePrecision))
|
||||
const [place, setPlace] = useState(event?.place || '')
|
||||
const [day, setDay] = useState(() => exactDay(event?.date))
|
||||
const [married, setMarried] = useState(() => Boolean(union?.married))
|
||||
const [year, setYear] = useState(() => yearOf(dated?.date))
|
||||
const [approx, setApprox] = useState(() => ['about', 'estimated', 'calculated', 'between'].includes(dated?.datePrecision))
|
||||
const [place, setPlace] = useState(dated?.place || '')
|
||||
const [day, setDay] = useState(() => exactDay(dated?.date))
|
||||
const [endYear, setEndYear] = useState(() => yearOf(event?.dateEnd))
|
||||
const [sources, setSources] = useState(event?.sources || '')
|
||||
const [notes, setNotes] = useState(event?.notes || '')
|
||||
@@ -68,6 +73,8 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
const created = useRef(null)
|
||||
const fileRef = useRef(null)
|
||||
const hasDetails = Boolean(day || endYear || sources || notes)
|
||||
// Les champs « Quand ? » et « Où ? » : un moment à soi, ou un couple
|
||||
const withWhen = !milestone || Boolean(union)
|
||||
|
||||
useEffect(() => { firstFieldRef.current?.focus() }, [])
|
||||
useEffect(() => {
|
||||
@@ -151,9 +158,17 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
// Un fichier refusé au milieu de l'envoi : le moment est déjà créé, on ne le recrée pas en réessayant
|
||||
let eventId = event?.id || created.current?.id || null
|
||||
let status = created.current?.status || 'approved'
|
||||
if (union) {
|
||||
const start = day || (year ? `${year}-01-01` : null)
|
||||
const unionType = married ? 'mariage' : 'union'
|
||||
await updateUnion(treeId, union.id, { unionType, startDate: start, startDatePrecision: start && approx && !day ? 'about' : null, startPlace: place.trim() || null }, token)
|
||||
onUnionSaved?.(union.id, { unionType, startYear: start ? Number(start.slice(0, 4)) : null })
|
||||
}
|
||||
// Un couple qui n'est pas un mariage, sans source, note ni souvenir : rien à garder à côté de lui
|
||||
const needsRow = !union || married || Boolean(sources.trim() || notes.trim() || linkedIds.length || files.length)
|
||||
if (editing) {
|
||||
await updateLifeEvent(treeId, eventId, buildPayload(), token)
|
||||
} else if (!created.current) {
|
||||
} else if (!created.current && needsRow) {
|
||||
const response = await createLifeEvent(treeId, person.id, buildPayload(), token)
|
||||
eventId = response?.event?.id
|
||||
status = response?.event?.status || 'approved'
|
||||
@@ -178,6 +193,11 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
}
|
||||
|
||||
const remove = async () => {
|
||||
// Suppression annulable : la fiche s'en charge
|
||||
if (onDelete) {
|
||||
onDelete(event)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
@@ -199,14 +219,20 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
</button>
|
||||
<div className="pz-modal-head">
|
||||
<h2 id="ms-title" className="pz-title ms-title">{title}</h2>
|
||||
{milestone && <p className="pz-small">{t('Ajoutez ses documents, un acte en PDF ou une photo, et la source. La date et le lieu se changent dans l’onglet Infos.')}</p>}
|
||||
{milestone && !union && <p className="pz-small">{t('Ajoutez ses documents, un acte en PDF ou une photo, et la source. La date et le lieu se changent dans l’onglet Infos.')}</p>}
|
||||
{!isAdmin && <p className="pz-small">{t('Ce que vous ajoutez apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.')}</p>}
|
||||
</div>
|
||||
|
||||
<form className="ms-form" onSubmit={(e) => { e.preventDefault(); save() }}>
|
||||
{!milestone && (
|
||||
{union && (
|
||||
<label className="ms-check ms-married">
|
||||
<input type="checkbox" checked={married} onChange={(e) => setMarried(e.target.checked)} />
|
||||
{t('C’était un mariage')}
|
||||
</label>
|
||||
)}
|
||||
{withWhen && (
|
||||
<>
|
||||
<div className="pz-field">
|
||||
{!milestone && <div className="pz-field">
|
||||
<label htmlFor="ms-what">{t('Quoi ?')}</label>
|
||||
<input id="ms-what" ref={firstFieldRef} value={what} maxLength={120} onChange={(e) => setWhat(e.target.value)} placeholder={t('Service militaire, premier emploi, départ pour Paris…')} />
|
||||
{!editing && (
|
||||
@@ -216,12 +242,12 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
<div className="ms-row">
|
||||
<div className="pz-field ms-year">
|
||||
<label htmlFor="ms-year">{t('Quand ?')}</label>
|
||||
<input id="ms-year" inputMode="numeric" maxLength={4} value={year} onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!yearValid || undefined} disabled={Boolean(day)} />
|
||||
<input id="ms-year" ref={union ? firstFieldRef : undefined} inputMode="numeric" maxLength={4} value={year} onChange={(e) => setYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!yearValid || undefined} disabled={Boolean(day)} />
|
||||
</div>
|
||||
<label className="ms-check">
|
||||
<input type="checkbox" checked={approx} onChange={(e) => setApprox(e.target.checked)} disabled={Boolean(day) && !endYear} />
|
||||
@@ -259,7 +285,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
)}
|
||||
<div className="ms-file-actions">
|
||||
{isAdmin && immichConfigured && <button type="button" className="ms-file-add" disabled={busy} onClick={() => setPickingImmich((value) => !value)}>{t('Depuis Immich')}</button>}
|
||||
<button type="button" ref={milestone ? firstFieldRef : undefined} className="ms-file-add" onClick={() => fileRef.current?.click()} disabled={busy || !onUploadFile}>
|
||||
<button type="button" ref={milestone && !union ? firstFieldRef : undefined} className="ms-file-add" onClick={() => fileRef.current?.click()} disabled={busy || !onUploadFile}>
|
||||
<Paperclip size={15} strokeWidth={1.8} />
|
||||
{t('Ajouter un fichier')}
|
||||
</button>
|
||||
@@ -294,19 +320,19 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
</div>
|
||||
)}
|
||||
|
||||
<details className="ms-more" open={hasDetails || milestone ? true : undefined}>
|
||||
<summary>{milestone ? t('Source et note') : t('Plus de détails')}</summary>
|
||||
<details className="ms-more" open={hasDetails || (milestone && !union) ? true : undefined}>
|
||||
<summary>{milestone && !union ? t('Source et note') : t('Plus de détails')}</summary>
|
||||
<div className="ms-more-body">
|
||||
{!milestone && (
|
||||
{withWhen && (
|
||||
<div className="ms-row">
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ms-day">{t('Date exacte')}</label>
|
||||
<input id="ms-day" type="date" value={day} onChange={(e) => { setDay(e.target.value); if (e.target.value) setYear(e.target.value.slice(0, 4)) }} />
|
||||
</div>
|
||||
<div className="pz-field ms-year">
|
||||
{!union && <div className="pz-field ms-year">
|
||||
<label htmlFor="ms-end">{t('Jusqu’en')}</label>
|
||||
<input id="ms-end" inputMode="numeric" maxLength={4} value={endYear} onChange={(e) => setEndYear(e.target.value.replace(/\D/g, ''))} placeholder={t('Année')} aria-invalid={!endValid || undefined} />
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="pz-field">
|
||||
@@ -329,7 +355,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={() => setConfirmDelete(false)} disabled={busy}>{t('Garder')}</button>
|
||||
</span>
|
||||
) : (
|
||||
<button type="button" className="pz-btn pz-btn--ghost ms-delete" onClick={() => setConfirmDelete(true)} disabled={busy}>{t('Supprimer ce moment')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost ms-delete" onClick={onDelete ? remove : () => setConfirmDelete(true)} disabled={busy}>{t('Supprimer ce moment')}</button>
|
||||
))}
|
||||
<span className="ms-actions-end">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose} disabled={busy}>{t('Annuler')}</button>
|
||||
|
||||
@@ -21,15 +21,10 @@
|
||||
box-shadow: var(--pz-shadow-lg);
|
||||
font-family: var(--pz-sans);
|
||||
color: var(--pz-ink);
|
||||
animation: pc-in 260ms var(--pz-ease) both;
|
||||
/* Pas d'entrée animée sur ordinateur : on ouvre une fiche des dizaines de fois par visite, elle apparaît d'un coup (Patch, 27/10) */
|
||||
}
|
||||
.person-card--edit-mode { border-color: var(--pz-a300); }
|
||||
|
||||
@keyframes pc-in {
|
||||
from { opacity: 0; transform: translateX(-12px) scale(.985); filter: blur(3px); }
|
||||
to { opacity: 1; transform: none; filter: none; }
|
||||
}
|
||||
|
||||
.pc-grab { display: none; }
|
||||
.pc-close { position: absolute; top: 12px; right: 12px; }
|
||||
.pc-hidden-input { display: none; }
|
||||
@@ -241,8 +236,6 @@
|
||||
.pc-foot { flex: none; display: flex; justify-content: space-between; align-items: center; gap: 8px; padding-top: 14px; border-top: 1px solid var(--pz-line); }
|
||||
.pc-foot .pc-save { margin-left: auto; }
|
||||
.pc-save.is-saved { background: var(--pz-success); }
|
||||
.pc-delete-confirm { display: grid; gap: 10px; width: 100%; font: 500 14px var(--pz-sans); color: var(--pz-danger); }
|
||||
.pc-delete-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* ── Téléphone : panneau qui monte par le bas ── */
|
||||
@media (max-width: 768px) {
|
||||
@@ -250,8 +243,10 @@
|
||||
left: 0; right: 0; bottom: 0; width: auto;
|
||||
max-height: 78dvh; padding: 10px 18px calc(18px + env(safe-area-inset-bottom));
|
||||
border-radius: 26px 26px 0 0; border-bottom: 0;
|
||||
/* Ombre pleine sous la feuille, en premier pour passer au-dessus de l'ombre floue : tirée vers le haut (40 px au plus, sheetOffset), elle ne laisse pas voir l'arbre dessous */
|
||||
box-shadow: 0 40px 0 var(--pz-surface), var(--pz-shadow-lg);
|
||||
/* Pas de fill-mode « both » : une fois l'entrée finie, le glisser (style inline) reprend la main sur transform */
|
||||
animation: pc-sheet 400ms var(--pz-ease-drawer) backwards;
|
||||
animation: pc-sheet 240ms var(--pz-ease-drawer) backwards;
|
||||
transition: transform 240ms var(--pz-ease-drawer);
|
||||
}
|
||||
/* Pendant le glisser, la feuille suit le doigt sans transition */
|
||||
@@ -265,11 +260,22 @@
|
||||
.pc-name { font-size: 24px; }
|
||||
@keyframes pc-sheet { from { transform: translateY(100%); } to { transform: none; } }
|
||||
}
|
||||
/* Coins continus là où le navigateur sait les dessiner (Chromium) ; ailleurs, les arcs de cercle d'avant. Le rayon grandit
|
||||
parce qu'un squircle de même rayon paraît plus anguleux. */
|
||||
@supports (corner-shape: squircle) {
|
||||
.person-card { corner-shape: squircle; border-radius: 34px; }
|
||||
.pc-photo { corner-shape: squircle; border-radius: 38%; }
|
||||
.pc-tile, .pc-list-item { corner-shape: squircle; border-radius: 18px; }
|
||||
@media (max-width: 768px) { .person-card { border-radius: 40px 40px 0 0; } }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.person-card { animation: pz-fade 200ms ease both; }
|
||||
.person-card { transition-duration: 1ms; }
|
||||
}
|
||||
/* Mouvements réduits : la feuille du téléphone ne monte plus, elle s'affiche en fondu court */
|
||||
@media (prefers-reduced-motion: reduce) and (max-width: 768px) {
|
||||
.person-card { animation: pz-fade 200ms ease both; }
|
||||
}
|
||||
|
||||
/* Prénom inconnu (issue Anaïs, 18/10) : libellé en italique, case à cocher sous les champs de nom */
|
||||
.pc-name-unknown { font-style: italic; font-weight: 300; }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect, useRef, forwardRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight, Pencil } from 'lucide-react'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, getPersonTaggedMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
|
||||
import { fetchPersonLife } from '../api/treeApi.js'
|
||||
import { deleteLifeEvent, fetchPersonLife } from '../api/treeApi.js'
|
||||
import LifeTimeline from './LifeTimeline.jsx'
|
||||
import MomentSheet from './MomentSheet.jsx'
|
||||
import { fetchImmichConfig } from '../api/immichApi.js'
|
||||
@@ -11,17 +11,21 @@ import ImmichFacePanel from './ImmichFacePanel.jsx'
|
||||
import './PersonCard.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
|
||||
import { rememberOrigin } from '../utils/openOrigin'
|
||||
import PortraitCropModal from './PortraitCropModal.jsx'
|
||||
import { imagePlacement } from '../utils/portraitCrop.js'
|
||||
import { t, dateLocale } from '../i18n/index.js'
|
||||
import { formatPlace } from '../utils/place.js'
|
||||
import { firstNameLabel, fullNameLabel, isFirstNameUnknown } from '../utils/personName.js'
|
||||
import { isYearOnly } from '../utils/dateInput.js'
|
||||
import { ageInYears } from '../utils/age.js'
|
||||
import DateField from './DateField.jsx'
|
||||
import { runOnce } from '../utils/saveOnLeave.js'
|
||||
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
|
||||
import { ProposalBanner } from './ProposalCard.jsx'
|
||||
import { initialsOf } from '../utils/initial.js'
|
||||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||||
import PortraitThumb from './PortraitThumb.jsx'
|
||||
|
||||
// Aucune modification en cours (référence stable)
|
||||
const NO_EDITS = Object.freeze({})
|
||||
@@ -80,6 +84,10 @@ const PersonCard = ({
|
||||
onMoveSibling,
|
||||
onUploadMomentFile,
|
||||
onMomentSaved,
|
||||
// Prénom d'un proche touché dans l'onglet Vie : sa fiche s'ouvre
|
||||
onOpenPerson,
|
||||
// Suppression annulable (App, hooks/useUndoable.js)
|
||||
onUndoable,
|
||||
saveOnLeaveRef = null,
|
||||
// Administrateur, hors démo : la fiche passe en modification d'un clic, sans chercher le crayon de l'arbre
|
||||
onQuickEdit,
|
||||
@@ -130,7 +138,6 @@ const PersonCard = ({
|
||||
const [saveError, setSaveError] = useState('')
|
||||
// Enregistrement en cours : un second appel (bouton, puis changement de fiche) reçoit la même promesse
|
||||
const saveInFlight = useRef(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [showImmichPanel, setShowImmichPanel] = useState(false)
|
||||
// null : connexion Immich de l'arbre inconnue, puis booléen. Le menu de source ne se propose
|
||||
// qu'à qui peut agir (owner/admin de l'arbre, comme pour toute action Immich) et seulement
|
||||
@@ -157,6 +164,22 @@ const PersonCard = ({
|
||||
// Feuille du bas (téléphone) : on la ferme en la glissant par la poignée ou l'en-tête, comme la croix
|
||||
const sheetMode = useSheetMode()
|
||||
const drag = useSheetDrag({ enabled: sheetMode, zone: '.pc-grab, .pc-head, .pc-close', onClose })
|
||||
// La barre des familles (en haut à gauche) ne s'écarte que si la fiche, posée en bas, monte jusqu'à elle
|
||||
const cardRef = useRef(null)
|
||||
useEffect(() => {
|
||||
const el = cardRef.current
|
||||
if (!el) return undefined
|
||||
const update = () => document.body.toggleAttribute('data-fiche-haute', el.getBoundingClientRect().top < 72)
|
||||
update()
|
||||
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update)
|
||||
observer?.observe(el)
|
||||
window.addEventListener('resize', update)
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', update)
|
||||
document.body.removeAttribute('data-fiche-haute')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset edits quand la personne change
|
||||
useEffect(() => {
|
||||
@@ -164,7 +187,6 @@ const PersonCard = ({
|
||||
setUnknownFirstName(isFirstNameUnknown(person))
|
||||
setFirstNameMissing(false)
|
||||
setLastNameMissing(false)
|
||||
setShowDeleteConfirm(false)
|
||||
setActiveRelationSearch(null)
|
||||
setRelationSearchQuery('')
|
||||
setSaveSuccess(false)
|
||||
@@ -393,7 +415,6 @@ const PersonCard = ({
|
||||
const handleDelete = () => {
|
||||
if (!onDelete) return
|
||||
onDelete(person.id)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
|
||||
const handleAvatarFileChange = async (event) => {
|
||||
@@ -429,9 +450,9 @@ const PersonCard = ({
|
||||
const hasDeathYear = Number.isFinite(deathYearNumber)
|
||||
const age = hasBirthYear
|
||||
? person.isAlive
|
||||
? new Date().getFullYear() - birthYearNumber
|
||||
? ageInYears(person.birthDate ?? birthYearNumber)
|
||||
: hasDeathYear
|
||||
? deathYearNumber - birthYearNumber
|
||||
? ageInYears(person.birthDate ?? birthYearNumber, person.deathDate ?? deathYearNumber)
|
||||
: null
|
||||
: null
|
||||
|
||||
@@ -495,6 +516,9 @@ const PersonCard = ({
|
||||
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
|
||||
})),
|
||||
}
|
||||
// Un moment du couple (mariage, divorce…) se montre chez les deux conjoints, avec ses souvenirs,
|
||||
// même quand la photo a été ajoutée depuis la fiche de l'autre (#209)
|
||||
const lifeMedias = [...personMedias, ...lifeFamily.unions.flatMap((union) => (union.partner ? getPersonMedias(union.partner.id) : []))]
|
||||
|
||||
// Filter persons for relation search (exclude current person + already linked)
|
||||
const getFilteredPersonsForRelation = (relationType) => {
|
||||
@@ -548,7 +572,7 @@ const PersonCard = ({
|
||||
const renderMediaIcon = (media) => {
|
||||
if (media.type === 'photo') {
|
||||
return media.url
|
||||
? <img src={media.url} alt="" />
|
||||
? <img src={media.url} alt="" {...blurPlaceholder(media.blurhash)} />
|
||||
: <span>📷</span>
|
||||
}
|
||||
|
||||
@@ -611,7 +635,9 @@ const PersonCard = ({
|
||||
// <bdi> : un lieu en arabe ou en hébreu ne retourne pas l'ordre de la ligne (« 1980, الدار البيضاء · 46 ans »)
|
||||
if (born) parts.push(person.birthPlace ? <>{born}, <bdi>{formatPlace(person.birthPlace)}</bdi></> : born)
|
||||
if (!person.isAlive) parts.push(died || '?')
|
||||
return parts.length ? parts.reduce((line, part) => <>{line} – {part}</>) : null
|
||||
// « – 1995 » passe entier à la ligne : Chrome coupe après un tiret même suivi d'une espace insécable,
|
||||
// le gluon U+2060 l'en empêche
|
||||
return parts.length ? parts.reduce((line, part) => <>{line} –{'\u2060\u00a0'}{part}</>) : null
|
||||
})()
|
||||
const ageLabel = age === null ? null : person.isAlive ? t('{age} ans', { age }) : t('{age} ans au décès', { age })
|
||||
// Une seule ligne sous le nom : surnom, repères de vie, âge, séparés par un point médian
|
||||
@@ -625,7 +651,7 @@ const PersonCard = ({
|
||||
const renderRelationRow = (target, relationType, meta, removable = true) => (
|
||||
<div key={`${relationType}-${target?.id}`} className="pc-rel-row">
|
||||
<span className="pc-rel-avatar" aria-hidden="true">
|
||||
{target?.photo ? <img src={target.photo} alt="" /> : <span>{target?.firstName?.[0] || '?'}</span>}
|
||||
{target?.photo ? <PortraitThumb src={target.photo} crop={target.avatarCrop} /> : <span>{target?.firstName?.[0] || '?'}</span>}
|
||||
</span>
|
||||
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
|
||||
{meta && <span className="pc-rel-meta">{meta}</span>}
|
||||
@@ -665,6 +691,7 @@ const PersonCard = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`}
|
||||
role="dialog"
|
||||
aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}
|
||||
@@ -691,9 +718,10 @@ const PersonCard = ({
|
||||
/>
|
||||
)}
|
||||
{/* Hors modification, le portrait se regarde : la photo s'ajoute ou se change en mode modification */}
|
||||
<div className="pc-photo">
|
||||
{/* Aperçu flou du portrait sur le cadre pendant le chargement (le recadrage s'applique à l'image) */}
|
||||
<div className="pc-photo" style={person.photo ? blurPlaceholder(person.avatarBlurhash).style : undefined}>
|
||||
{person.photo
|
||||
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })} style={photoStyle} />
|
||||
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => { event.currentTarget.parentElement.style.backgroundImage = 'none'; setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight }) }} style={photoStyle} />
|
||||
: <span className="pc-photo-initials">{initials}</span>}
|
||||
</div>
|
||||
{editMode && onAvatarUpload && (
|
||||
@@ -852,11 +880,11 @@ const PersonCard = ({
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`pc-tile pc-tile--${media.type}`}
|
||||
onClick={() => onMediaSelect?.(media, person)}
|
||||
onClick={(e) => { rememberOrigin(e.currentTarget); onMediaSelect?.(media, person) }}
|
||||
title={media.label || TYPE_LABELS[media.type]}
|
||||
>
|
||||
{media.type === 'photo' && media.url
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" />
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||||
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
|
||||
{media.label && <span className="pc-tile-label">{media.label}</span>}
|
||||
</button>
|
||||
@@ -880,11 +908,11 @@ const PersonCard = ({
|
||||
key={media.id}
|
||||
type="button"
|
||||
className={`pc-tile pc-tile--${media.type}`}
|
||||
onClick={() => owner && onMediaSelect?.(media, owner)}
|
||||
onClick={(e) => { if (!owner) return; rememberOrigin(e.currentTarget); onMediaSelect?.(media, owner) }}
|
||||
title={media.label || TYPE_LABELS[media.type]}
|
||||
>
|
||||
{media.type === 'photo' && media.url
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" />
|
||||
? <img src={media.url} alt={media.label || ''} loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||||
: <span className="pc-tile-video"><Play size={18} fill="currentColor" strokeWidth={0} /></span>}
|
||||
{media.label && <span className="pc-tile-label">{media.label}</span>}
|
||||
</button>
|
||||
@@ -925,13 +953,14 @@ const PersonCard = ({
|
||||
life={lifeState.personId === person.id ? lifeState.data : null}
|
||||
loading={lifeState.personId !== person.id || lifeState.loading}
|
||||
failed={lifeState.personId === person.id && lifeState.failed}
|
||||
medias={personMedias}
|
||||
medias={lifeMedias}
|
||||
canAdd={canAddMoment}
|
||||
canEdit={canEditMoments}
|
||||
notice={lifeNotice}
|
||||
onAdd={() => { setLifeNotice(''); setMomentSheet({ entry: null }) }}
|
||||
onEdit={(entry) => { setLifeNotice(''); setMomentSheet({ entry }) }}
|
||||
onOpenMedia={(media) => onMediaSelect?.(media, person)}
|
||||
onOpenMedia={(media) => onMediaSelect?.(media, media.personId === person.id ? person : (getPersonById(media.personId) || person))}
|
||||
onOpenPerson={onOpenPerson}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1154,32 +1183,20 @@ const PersonCard = ({
|
||||
{editMode && saveError && <p className="pc-save-error" role="alert">{saveError}</p>}
|
||||
{editMode && (
|
||||
<footer className="pc-foot">
|
||||
{showDeleteConfirm ? (
|
||||
<div className="pc-delete-confirm">
|
||||
<span>{t("Supprimer {name} de l'arbre ?", { name: firstNameLabel(person) })}</span>
|
||||
<div className="pc-delete-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(false)}>{t('Annuler')}</button>
|
||||
<button type="button" className="pz-btn pz-btn--danger pz-btn--sm" onClick={handleDelete}>{t('Supprimer')}</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{isAdmin && (
|
||||
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={() => setShowDeleteConfirm(true)}>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
{t('Supprimer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
|
||||
disabled={(!hasChanges && !saveSuccess) || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
|
||||
</button>
|
||||
</>
|
||||
{isAdmin && (
|
||||
<button type="button" className="pz-btn pz-btn--danger-ghost pz-btn--sm" onClick={handleDelete}>
|
||||
<Trash2 size={15} strokeWidth={2} />
|
||||
{t('Supprimer')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={`pz-btn pz-btn--primary pc-save ${saveSuccess ? 'is-saved' : ''}`}
|
||||
disabled={(!hasChanges && !saveSuccess) || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{saveSuccess ? (<><Check size={16} strokeWidth={2.5} />{t('Enregistré')}</>) : (<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}><Save size={16} strokeWidth={2} />{t('Enregistrer')}</PzBusy>)}
|
||||
</button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
@@ -1262,10 +1279,33 @@ const PersonCard = ({
|
||||
entry={momentSheet.entry}
|
||||
treeId={treeId}
|
||||
token={immichToken}
|
||||
medias={personMedias}
|
||||
medias={momentSheet.entry?.milestone?.unionId ? lifeMedias : personMedias}
|
||||
isAdmin={canEditMoments}
|
||||
onUploadFile={onUploadMomentFile}
|
||||
onClose={() => setMomentSheet(null)}
|
||||
onDelete={onUndoable && treeId && immichToken ? (event) => {
|
||||
// Le moment quitte la frise tout de suite ; la requête part à la fin du délai d'annulation
|
||||
setMomentSheet(null)
|
||||
setLifeState((prev) => (prev.data?.events ? { ...prev, data: { ...prev.data, events: prev.data.events.filter((e) => e.id !== event.id) } } : prev))
|
||||
onUndoable({
|
||||
message: t('Le moment est supprimé.'),
|
||||
commit: async ({ keepalive = false } = {}) => {
|
||||
try {
|
||||
await deleteLifeEvent(treeId, event.id, immichToken, { keepalive })
|
||||
} catch {
|
||||
if (keepalive) return
|
||||
setLifeRevision((r) => r + 1)
|
||||
setLifeNotice(t('La suppression n’a pas abouti. Réessayez dans un instant.'))
|
||||
}
|
||||
},
|
||||
undo: () => setLifeRevision((r) => r + 1),
|
||||
})
|
||||
} : undefined}
|
||||
onUnionSaved={(unionId, { unionType, startYear }) => {
|
||||
// Le couple du graphe suit tout de suite (onglet Infos, frise), sans recharger l'arbre
|
||||
const union = personUnions.find((u) => String(u.id) === String(unionId))
|
||||
if (union) Object.assign(union, { unionType, startYear })
|
||||
}}
|
||||
onSaved={({ pending, uploaded }) => {
|
||||
setMomentSheet(null)
|
||||
if (pending) setLifeNotice(t('Merci, c’est envoyé. Le moment apparaîtra dans sa vie quand la personne qui gère l’arbre l’aura accepté.'))
|
||||
@@ -1317,7 +1357,7 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
|
||||
<li key={p.id}>
|
||||
<button type="button" className="pc-search-item" onClick={() => onSelect(p)}>
|
||||
<span className="pc-rel-avatar" aria-hidden="true">
|
||||
{p.photo ? <img src={p.photo} alt="" /> : <span>{p.firstName[0]}</span>}
|
||||
{p.photo ? <PortraitThumb src={p.photo} crop={p.avatarCrop} /> : <span>{p.firstName[0]}</span>}
|
||||
</span>
|
||||
<span className="pc-rel-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
|
||||
{p.birthYear && <span className="pc-rel-meta">{p.birthYear}</span>}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* Cadre de la miniature : remplit son conteneur, sauf si une classe passée (mp-avatar, nav-avatar-photo…) en décide
|
||||
autrement. :where ne pèse rien, la classe passée l'emporte toujours. */
|
||||
:where(.portrait-thumb) { position: relative; display: block; width: 100%; height: 100%; border-radius: inherit; }
|
||||
.portrait-thumb { overflow: hidden; }
|
||||
.portrait-thumb > img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { imagePlacement } from '../utils/portraitCrop.js'
|
||||
import './PortraitThumb.css'
|
||||
|
||||
// Portrait en miniature (listes, puces, visionneuse, menu du compte) avec le recadrage enregistré :
|
||||
// le même cadrage que la fiche et l'arbre (portraitCrop.js). Le cadre est carré ; l'image se place
|
||||
// une fois sa taille connue, et reste en « cover » centré jusque-là ou sans cadrage.
|
||||
export default function PortraitThumb({ src, crop = null, alt = '', className = '' }) {
|
||||
const [size, setSize] = useState(null)
|
||||
useEffect(() => { setSize(null) }, [src])
|
||||
const placement = size && crop ? imagePlacement(size.w, size.h, 1, crop) : null
|
||||
const style = placement
|
||||
? { position: 'absolute', maxWidth: 'none', left: `${placement.left}%`, top: `${placement.top}%`, width: `${placement.width}%`, height: `${placement.height}%` }
|
||||
: undefined
|
||||
return (
|
||||
<span className={`portrait-thumb${className ? ` ${className}` : ''}`}>
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
|
||||
style={style}
|
||||
/>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import { fetchSearchSources, searchSource } from '../api/searchSourcesApi.js'
|
||||
import { SEARCH_LINKS, SEARCH_LINK_REGIONS } from '../data/searchLinks.js'
|
||||
import { changesFromResult, personFromResult } from '../utils/searchResultChanges.js'
|
||||
import { fullNameLabel } from '../utils/personName.js'
|
||||
import Skeleton from './Skeleton.jsx'
|
||||
import PortraitThumb from './PortraitThumb.jsx'
|
||||
|
||||
// Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche
|
||||
const SOURCE_INFO = {
|
||||
@@ -283,7 +285,7 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
|
||||
|
||||
<div className="apn-section sp-sources">
|
||||
<p className="pz-label">{t('Où chercher')}</p>
|
||||
{sources === null && <p className="pz-hint">{t('Chargement…')}</p>}
|
||||
{sources === null && <Skeleton variant="lines" count={3} />}
|
||||
{sources?.length === 0 && (
|
||||
<p className="pz-hint">{t("Ce serveur n'interroge aucune source : aucun nom ne quitte l'instance. Les liens plus bas ouvrent les sites dans votre navigateur.")}</p>
|
||||
)}
|
||||
@@ -386,7 +388,7 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
|
||||
{candidates.map(({ person, yearOff }) => (
|
||||
<li key={person.id}>
|
||||
<button type="button" className={`apn-dropdown-item${picker.relative?.id === person.id ? ' is-selected' : ''}`} aria-pressed={picker.mode === 'new' ? picker.relative?.id === person.id : undefined} onClick={() => handlePick(person)} disabled={picker.saving}>
|
||||
<span className="apn-avatar" aria-hidden="true">{person.photo ? <img src={person.photo} alt="" /> : (person.firstName || '?').charAt(0)}</span>
|
||||
<span className="apn-avatar" aria-hidden="true">{person.photo ? <PortraitThumb src={person.photo} crop={person.avatarCrop} /> : (person.firstName || '?').charAt(0)}</span>
|
||||
<span className="apn-dropdown-name">{person.firstName} <em>{person.lastName}</em></span>
|
||||
<span className={`apn-dropdown-date${yearOff && picker.mode === 'existing' ? ' sp-year-off' : ''}`} title={yearOff && picker.mode === 'existing' ? t("Pas la même année de naissance que ce résultat") : undefined}>{personYears(person)}</span>
|
||||
</button>
|
||||
|
||||
@@ -62,6 +62,7 @@
|
||||
.ts-icon--letter { width: auto; padding: 0 12px; border-radius: 999px; font: 500 13px var(--pz-sans); color: var(--pz-ink-2); }
|
||||
.ts-icon[aria-checked="true"] { border-color: var(--pz-a500); background: var(--pz-a50); box-shadow: 0 0 0 3px var(--pz-a100); }
|
||||
.ts-icon:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
.ts-icon:disabled { opacity: 0.5; cursor: default; }
|
||||
|
||||
/* Cinq onglets : ils rétrécissent (texte sur deux lignes) au lieu de déborder du panneau sur un petit téléphone */
|
||||
.ts-tabs .pz-tab { min-width: 0; }
|
||||
@@ -276,6 +277,24 @@
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
/* Sur deux lignes, une pilule se déforme : des coins arrondis simples */
|
||||
/* Suppression annulable : le bouton Annuler doit rester cliquable */
|
||||
.undo-toast { display: flex; align-items: center; gap: 14px; padding-right: 8px; pointer-events: auto; }
|
||||
.undo-toast-btn {
|
||||
position: relative;
|
||||
padding: 6px 12px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--pz-bg) 16%, transparent);
|
||||
color: var(--pz-bg);
|
||||
font: 600 14px/1.2 var(--pz-sans);
|
||||
cursor: pointer;
|
||||
}
|
||||
/* Zone de clic d'au moins 44 px sans grossir le bouton */
|
||||
.undo-toast-btn::before { content: ''; position: absolute; inset: -8px; }
|
||||
.undo-toast-btn:hover { background: color-mix(in srgb, var(--pz-bg) 26%, transparent); }
|
||||
.undo-toast-btn:focus-visible { outline: 2px solid var(--pz-bg); outline-offset: 2px; }
|
||||
/* Le lien d'une invitation (démo) reste cliquable, le reste de la bulle laisse passer les clics */
|
||||
.contrib-success-toast a { pointer-events: auto; }
|
||||
@media (max-width: 768px) { .contrib-success-toast { bottom: 78px; padding: 11px 16px; border-radius: 16px; font-size: 13px; } }
|
||||
|
||||
@media (max-width: 560px) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
/* Blocs gris de chargement (Skeleton.jsx) : immobiles, couleur des séparateurs */
|
||||
.pz-skel { display: grid; gap: 14px; padding: 4px 0; }
|
||||
.pz-skel-row { display: flex; align-items: center; gap: 12px; }
|
||||
.pz-skel-text { display: grid; gap: 7px; flex: 1; min-width: 0; }
|
||||
.pz-skel-bar { display: block; height: 12px; border-radius: 6px; background: var(--pz-line); }
|
||||
.pz-skel-bar--thin { height: 9px; background: var(--pz-line-soft); }
|
||||
.pz-skel-dot { flex: none; width: 32px; height: 32px; border-radius: 50%; background: var(--pz-line); }
|
||||
|
||||
/* Frise : année, point sur le rail, texte */
|
||||
.pz-skel--timeline { gap: 22px; padding: 8px 0 4px; }
|
||||
.pz-skel--timeline .pz-skel-year { flex: none; width: 34px; height: 11px; }
|
||||
.pz-skel--timeline .pz-skel-dot { width: 10px; height: 10px; }
|
||||
|
||||
.pz-skel--lines .pz-skel-bar { height: 10px; }
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import './Skeleton.css'
|
||||
|
||||
// Blocs gris à la forme de ce qui arrive, à la place d'un « Chargement… ». Rien avant SHOW_AFTER : une réponse
|
||||
// rapide ne fait pas clignoter l'écran. Aucun mouvement (pas de reflet qui passe), juste la place réservée.
|
||||
const SHOW_AFTER = 300
|
||||
|
||||
// variant : rows (avatar et deux lignes : personnes, visites, propositions), timeline (frise de l'onglet Vie),
|
||||
// lines (paragraphes)
|
||||
export default function Skeleton({ variant = 'rows', count = 3, label = '' }) {
|
||||
const [shown, setShown] = useState(false)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setShown(true), SHOW_AFTER)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={`pz-skel pz-skel--${variant}`} role="status" aria-busy="true">
|
||||
<span className="pz-sr">{label || t('Chargement…')}</span>
|
||||
{shown && Array.from({ length: count }, (_, i) => (
|
||||
<div key={i} className="pz-skel-row" aria-hidden="true">
|
||||
{variant === 'rows' && <span className="pz-skel-dot" />}
|
||||
{variant === 'timeline' && <span className="pz-skel-bar pz-skel-year" />}
|
||||
{variant === 'timeline' && <span className="pz-skel-dot" />}
|
||||
<span className="pz-skel-text">
|
||||
<span className="pz-skel-bar" style={{ width: `${[62, 48, 70, 55][i % 4]}%` }} />
|
||||
{variant !== 'lines' && <span className="pz-skel-bar pz-skel-bar--thin" style={{ width: `${[38, 30, 44, 34][i % 4]}%` }} />}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import { inviteManager, invitationUrl, listManagers, removeManager, renewInvitat
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { dateLocale, t } from '../i18n/index.js'
|
||||
import { initialOf } from '../utils/initial.js'
|
||||
import Skeleton from './Skeleton.jsx'
|
||||
import { toOpaqueTreeId, trackAppEvent } from '../utils/analytics.js'
|
||||
|
||||
// Onglet Gestion des paramètres de l'arbre (issue #145). Le propriétaire invite des co-gestionnaires par leur
|
||||
// adresse : Parenthèse n'envoie pas de mail, il donne un lien à transmettre. Les co-gestionnaires voient la liste.
|
||||
@@ -41,6 +43,8 @@ const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName
|
||||
const url = invitationUrl(token)
|
||||
let copied
|
||||
try { await navigator.clipboard.writeText(url); copied = true } catch { copied = false }
|
||||
// Le lien créé compte comme un partage même si le presse-papiers est refusé (il est alors montré en clair)
|
||||
trackAppEvent('share_copied', { what: 'manager_invite', copied, tree_id: toOpaqueTreeId(treeId) })
|
||||
setLink({ url, email: forEmail, copied })
|
||||
}
|
||||
|
||||
@@ -102,7 +106,7 @@ const TreeManagersSection = ({ treeId, authToken, currentUserId = null, oidcName
|
||||
}
|
||||
}
|
||||
|
||||
if (state.loading) return <p className="pz-small ts-loading">{t('Chargement…')}</p>
|
||||
if (state.loading) return <Skeleton variant="rows" count={2} />
|
||||
if (state.error) return <div className="pz-error" role="alert">{state.error}</div>
|
||||
|
||||
const { canManage, managers, invitations } = state.data
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { listTreeVisits } from '../api/treeApi'
|
||||
import { dateLocale, t } from '../i18n/index.js'
|
||||
import Skeleton from './Skeleton.jsx'
|
||||
|
||||
// Onglet Visites des paramètres de l'arbre : qui est venu, combien de fois.
|
||||
// Le journal est tenu par le serveur à chaque ouverture de l'arbre (backend/src/lib/tree-visits.ts).
|
||||
@@ -37,7 +38,7 @@ const TreeVisitsSection = ({ treeId, authToken }) => {
|
||||
return () => { active = false }
|
||||
}, [treeId, authToken])
|
||||
|
||||
if (state.loading) return <p className="pz-small ts-loading">{t('Chargement…')}</p>
|
||||
if (state.loading) return <Skeleton variant="rows" count={4} />
|
||||
if (state.error) return <div className="pz-error" role="alert">{state.error}</div>
|
||||
|
||||
const { summary, recent } = state.data
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
animation: zoomControlSlideIn 0.6s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Fiche ouverte sur ordinateur : elle recouvre le bouton, dont le bord dépassait en bas à gauche (fiche à 25 px
|
||||
du bord, bouton à 20 px). Il revient quand la fiche se ferme. */
|
||||
@media (min-width: 769px) {
|
||||
body:has(.person-card) .zoom-control { visibility: hidden; }
|
||||
}
|
||||
|
||||
@keyframes zoomControlSlideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -179,16 +185,4 @@
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
/* Slider toujours visible sur mobile */
|
||||
.zoom-slider-panel.visible {
|
||||
max-height: 240px;
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.zoom-slider-track {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,8 @@ export default function ZoomControl({ zoomApiRef }) {
|
||||
document.addEventListener('pointerup', onUp)
|
||||
}, [zoomApiRef])
|
||||
|
||||
const panelVisible = isHovered || isMobile
|
||||
// Sur téléphone, on zoome à deux doigts : la réglette n'apparaît jamais, la loupe seule reste
|
||||
const panelVisible = isHovered && !isMobile
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -112,7 +113,7 @@ export default function ZoomControl({ zoomApiRef }) {
|
||||
onMouseEnter={showSlider}
|
||||
onMouseLeave={hideSlider}
|
||||
>
|
||||
{/* Panel slider — hover sur desktop, toujours visible sur mobile */}
|
||||
{/* Panel slider — au survol sur ordinateur, jamais sur téléphone */}
|
||||
<div
|
||||
className={`zoom-slider-panel ${panelVisible ? 'visible' : ''}`}
|
||||
onMouseEnter={showSlider}
|
||||
@@ -139,7 +140,7 @@ export default function ZoomControl({ zoomApiRef }) {
|
||||
<button
|
||||
className="zoom-button"
|
||||
onClick={stepZoom}
|
||||
aria-label={t('Contrôle du zoom')}
|
||||
aria-label={t('Zoomer')}
|
||||
>
|
||||
<ZoomIn size={20} strokeWidth={1.5} className="zoom-btn-icon" />
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computeLayout, componentOrderPerGen, chainGap } from './elkLayout'
|
||||
import { computeLayout, componentOrderPerGen, chainGap, coupleBarMeta, restoreCoupleBarMeta } from './elkLayout'
|
||||
import { persons as globalPersons, unions as globalUnions, filiations as globalFiliations } from '../../data/mockData'
|
||||
|
||||
// ============================================================
|
||||
@@ -72,23 +72,19 @@ export function filCroiseAutreFamille(nodes, childrenOfUnion, spouseKeysOf, u, c
|
||||
&& n.x + n.width > lo && n.x < hi)
|
||||
}
|
||||
|
||||
// Contours : par rangée, une liste d'intervalles [gauche, droite] triés et fusionnés
|
||||
function addInterval(list, l, r) {
|
||||
const out = []
|
||||
let nl = l, nr = r, inserted = false
|
||||
for (const [a, b] of list) {
|
||||
if (b < nl - 1) { out.push([a, b]); continue }
|
||||
if (a > nr + 1) { if (!inserted) { out.push([nl, nr]); inserted = true } out.push([a, b]); continue }
|
||||
nl = Math.min(nl, a); nr = Math.max(nr, b)
|
||||
}
|
||||
if (!inserted) out.push([nl, nr])
|
||||
return out
|
||||
}
|
||||
// Contours : par rangée, une liste d'intervalles [gauche, droite] triés et fusionnés. Les intervalles qui se
|
||||
// touchent (écart ≤ 1) sont réunis. Un seul tri par rangée : insérer les intervalles un à un recopiait la
|
||||
// liste à chaque fois, quadratique sur un gros arbre.
|
||||
function mergeRows(target, source, dx) {
|
||||
for (const [g, list] of source) {
|
||||
let cur = target.get(g) || []
|
||||
for (const [l, r] of list) cur = addInterval(cur, l + dx, r + dx)
|
||||
target.set(g, cur)
|
||||
const all = (target.get(g) || []).concat(list.map(([l, r]) => [l + dx, r + dx])).sort((x, y) => x[0] - y[0])
|
||||
const out = []
|
||||
for (const [a, b] of all) {
|
||||
const last = out[out.length - 1]
|
||||
if (last && a <= last[1] + 1) last[1] = Math.max(last[1], b)
|
||||
else out.push([a, b])
|
||||
}
|
||||
target.set(g, out)
|
||||
}
|
||||
}
|
||||
const extentOf = (rows) => {
|
||||
@@ -131,6 +127,20 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
|
||||
const result = computeLayout(graphData, referenceId)
|
||||
const nodes = result.children
|
||||
const byId = new Map(nodes.map(n => [n.id, n]))
|
||||
// Nom d'une famille : celui que portent le plus ses membres (nom de naissance, sinon nom), pas celui de la seule
|
||||
// personne de référence. Ex. : 175 personnes dont 5 portent le nom de la référence. Égalité : le nom
|
||||
// de secours (celui de la référence) s'il est parmi les premiers, sinon le premier rencontré.
|
||||
const nomMajoritaire = (ids, secours = '') => {
|
||||
const n = new Map()
|
||||
for (const id of ids) {
|
||||
const d = byId.get(id)?._type === 'person' ? byId.get(id)._data : null
|
||||
const nom = (d?.birthName || d?.lastName || '').trim()
|
||||
if (nom) n.set(nom, (n.get(nom) || 0) + 1)
|
||||
}
|
||||
let best = '', max = 0
|
||||
for (const [nom, c] of n) if (c > max) { best = nom; max = c }
|
||||
return secours && n.get(secours) === max ? secours : (best || secours)
|
||||
}
|
||||
|
||||
// Chaînes de couples (composantes d'une rangée) telles que computeLayout les a ordonnées
|
||||
const chainOf = new Map()
|
||||
@@ -420,8 +430,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
|
||||
unplace(block.members)
|
||||
forgetExternal(snap.externalBefore, snap.externalAfter)
|
||||
const A = byId.get(id)
|
||||
const label = A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || ''
|
||||
const members = new Set([...extra, ...nested].filter(m => !keep.has(m)))
|
||||
const label = nomMajoritaire(members, A._data?.birthName || byId.get(pcPersons[0])?._data?.lastName || A._data?.lastName || '')
|
||||
registerTab({ anchorId: pcPersons[0], pu: parentUnionOf.get(pcPersons[0]) || pu, label, count: members.size, members, hinges: pcPersons.slice(1), hingeEdges: [] })
|
||||
for (const x of pcPersons) byId.get(x)._familyTab = { anchorId: pcPersons[0], label, count: members.size, tabKey: pcPersons[0].replace(/^p-/, ''), place: 'above' }
|
||||
const saved = anchor
|
||||
@@ -465,7 +475,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
|
||||
anchor = saved
|
||||
if (block.width > FAMILY_TAB_PX && !familyTabs) {
|
||||
const A = byId.get(id)
|
||||
bigFamilies.push({ anchorId: id, label: A._data?.birthName || A._data?.lastName || '', count: [...block.members].filter(m => byId.get(m)?._type === 'person').length })
|
||||
bigFamilies.push({ anchorId: id, label: nomMajoritaire(block.members, A._data?.birthName || A._data?.lastName || ''), count: [...block.members].filter(m => byId.get(m)?._type === 'person').length })
|
||||
}
|
||||
if (block.width > FAMILY_TAB_PX && familyTabs) {
|
||||
// Trop large pour cet arbre : la famille devient un onglet et n'est pas dessinée ici. Sa construction ne
|
||||
@@ -478,8 +488,8 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
|
||||
forgetExternal(externalBefore, new Set(externalEdges))
|
||||
const A = byId.get(id)
|
||||
const partners = [byId.get(pu)?._data?.p1Key, byId.get(pu)?._data?.p2Key].map(k => byId.get(k)?._data).filter(Boolean)
|
||||
const label = A._data?.birthName || partners.find(d => d.lastName)?.lastName || A._data?.lastName || ''
|
||||
const members = new Set([...[...block.members].filter(m => byId.get(m)?._type === 'person'), ...nested])
|
||||
const label = nomMajoritaire(members, A._data?.birthName || partners.find(d => d.lastName)?.lastName || A._data?.lastName || '')
|
||||
registerTab({ anchorId: id, pu, label, count: members.size, members, hinges: [], hingeEdges: [[pu, id]] })
|
||||
A._familyTab = { anchorId: id, label, count: members.size, tabKey: id.replace(/^p-/, ''), place: 'above' }
|
||||
continue
|
||||
@@ -822,7 +832,7 @@ function layoutOnce(graphData, referenceId, forcedSide, familyTabs = true) {
|
||||
// la barre d'onglets ; un onglet est le même arbre recalculé avec le conjoint comme référence.
|
||||
const drawn = nodes.filter(n => n._type === 'person')
|
||||
result.families = [
|
||||
{ id: 'main', label: referenceNode?._data?.lastName || drawn[0]?._data?.lastName || '', count: drawn.length, referenceId: result.referenceId ?? null },
|
||||
{ id: 'main', label: nomMajoritaire(drawn.map(n => n.id), referenceNode?._data?.lastName || drawn[0]?._data?.lastName || ''), count: drawn.length, referenceId: result.referenceId ?? null },
|
||||
...tabs.map(tb => ({ id: tb.anchorId, anchorId: tb.anchorId, label: tb.label, count: tb.count, members: [...tb.members].map(m => m.replace(/^p-/, '')), hinges: [tb.anchorId, ...tb.hinges].map(m => m.replace(/^p-/, '')) })),
|
||||
...bigFamilies.map(f => ({ id: f.anchorId, anchorId: f.anchorId, label: f.label, count: f.count, members: [], drawn: true })),
|
||||
]
|
||||
@@ -844,34 +854,35 @@ export function subgraphOf(personIds, graphData = null) {
|
||||
return { persons, unions, filiations }
|
||||
}
|
||||
|
||||
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
|
||||
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
|
||||
// sans fratries autour. Chaque pas d'un enfant à un parent garde ses deux parents (le fil part de leur union,
|
||||
// et sans l'autre parent le placement dessine un conjoint « Inconnu »).
|
||||
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
|
||||
export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const self = selfId != null ? String(selfId) : null
|
||||
if (self == null || members.has(self)) return new Set()
|
||||
const persons = graphData?.persons || globalPersons
|
||||
if (!persons.some(p => String(p.id) === self)) return new Set()
|
||||
// La parenté d'un arbre : pour chaque personne, ses voisins (conjoints, parents, enfants), chacun avec les personnes
|
||||
// que ce pas fait dessiner en plus. Chaque pas entre un enfant et un parent garde l'autre parent (le fil part de
|
||||
// leur union, et sans l'autre parent le placement dessine un conjoint « Inconnu »).
|
||||
function kinshipOf(graphData = null) {
|
||||
const unions = graphData?.unions || globalUnions
|
||||
const filiations = graphData?.filiations || globalFiliations
|
||||
const unionById = new Map(unions.map(u => [String(u.id), u]))
|
||||
// Voisins : [voisin, personnes que ce pas fait dessiner en plus]
|
||||
const next = new Map()
|
||||
const link = (a, b, extra = []) => { if (!next.has(a)) next.set(a, []); next.get(a).push([b, extra]) }
|
||||
const partnersOf = new Map()
|
||||
for (const u of unions) {
|
||||
const a = String(u.partner1Id), b = String(u.partner2Id)
|
||||
link(a, b); link(b, a)
|
||||
if (!partnersOf.has(a)) partnersOf.set(a, new Set())
|
||||
if (!partnersOf.has(b)) partnersOf.set(b, new Set())
|
||||
partnersOf.get(a).add(b); partnersOf.get(b).add(a)
|
||||
}
|
||||
const parentsOf = new Map()
|
||||
const childrenOf = new Map()
|
||||
for (const f of filiations) {
|
||||
const child = String(f.childId)
|
||||
const u = f.unionId ? unionById.get(String(f.unionId)) : null
|
||||
const parents = u ? [String(u.partner1Id), String(u.partner2Id)] : f.parentId != null ? [String(f.parentId)] : []
|
||||
if (!parentsOf.has(child)) parentsOf.set(child, new Set())
|
||||
for (const p of parents) parentsOf.get(child).add(p)
|
||||
for (const p of parents) {
|
||||
parentsOf.get(child).add(p)
|
||||
if (!childrenOf.has(p)) childrenOf.set(p, new Set())
|
||||
childrenOf.get(p).add(child)
|
||||
}
|
||||
}
|
||||
for (const [child, parents] of parentsOf) {
|
||||
for (const p of parents) {
|
||||
@@ -879,6 +890,20 @@ export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
link(child, p, couple); link(p, child, couple)
|
||||
}
|
||||
}
|
||||
return { next, parentsOf, childrenOf, partnersOf }
|
||||
}
|
||||
|
||||
// La ligne directe d'une personne vers une famille (Patch, 21/10 : « je veux me voir tout le temps ») : le plus
|
||||
// court chemin de parenté (parents, enfants, conjoints) de `selfId` jusqu'au premier membre de `memberIds`,
|
||||
// sans fratries autour, chaque pas d'un enfant à un parent gardant ses deux parents.
|
||||
// Rend les personnes à ajouter (ni `selfId` déjà membre, ni chemin : ensemble vide).
|
||||
export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const self = selfId != null ? String(selfId) : null
|
||||
if (self == null || members.has(self)) return new Set()
|
||||
const persons = graphData?.persons || globalPersons
|
||||
if (!persons.some(p => String(p.id) === self)) return new Set()
|
||||
const { next } = kinshipOf(graphData)
|
||||
const from = new Map([[self, null]])
|
||||
const queue = [self]
|
||||
let hit = null
|
||||
@@ -899,6 +924,60 @@ export function lineToFamily(selfId, memberIds, graphData = null) {
|
||||
return line
|
||||
}
|
||||
|
||||
// Les porteurs du nom d'une famille (Patch, 06/11 : « si un gonz a un nom de famille il fait partie de la famille
|
||||
// au même nom ») : un enfant d'un membre qui porte le nom de l'onglet en devient membre, puis ses enfants au même
|
||||
// nom, de génération en génération. Nom de naissance s'il est connu, sinon nom d'usage (une femme mariée notée au
|
||||
// nom de son mari reste chez elle). Seuls les enfants d'un membre comptent : un homonyme sans lien reste dehors.
|
||||
// Le couple d'un porteur est dessiné entier (`added` prend ses conjoints) ; l'autre parent d'un porteur ajouté,
|
||||
// sans le nom, est rendu à part (`coParents`) pour être estompé, comme un proche de l'autre côté.
|
||||
const nomCle = (s) => (s || '').trim().toLocaleLowerCase('fr')
|
||||
export function bearersOfName(memberIds, name, graphData = null) {
|
||||
const added = new Set()
|
||||
const coParents = new Set()
|
||||
const target = nomCle(name)
|
||||
if (!target) return { added, coParents }
|
||||
const persons = graphData?.persons || globalPersons
|
||||
const nameOf = new Map(persons.map(p => [String(p.id), nomCle(p.birthName || p.lastName)]))
|
||||
const { parentsOf, childrenOf, partnersOf } = kinshipOf(graphData)
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const bearers = []
|
||||
const queue = [...members]
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
for (const c of childrenOf.get(queue[i]) || []) {
|
||||
if (members.has(c) || added.has(c) || nameOf.get(c) !== target) continue
|
||||
added.add(c); bearers.push(c); queue.push(c)
|
||||
}
|
||||
}
|
||||
const inTab = (id) => members.has(id) || added.has(id)
|
||||
for (const b of bearers) for (const p of partnersOf.get(b) || []) if (!inTab(p)) added.add(p)
|
||||
for (const b of bearers) for (const p of parentsOf.get(b) || []) if (!inTab(p)) coParents.add(p)
|
||||
return { added, coParents }
|
||||
}
|
||||
|
||||
// Les proches de l'autre côté d'un onglet (réglage « Proches des autres familles ») : les personnes à `depth` pas
|
||||
// de parenté au plus d'un membre, chaque pas d'un enfant à un parent gardant l'autre parent. Estompées à l'écran.
|
||||
export function kinAround(memberIds, depth, graphData = null) {
|
||||
const around = new Set()
|
||||
if (!(depth > 0)) return around
|
||||
const members = new Set([...memberIds].map(String))
|
||||
const { next } = kinshipOf(graphData)
|
||||
const dist = new Map([...members].map(id => [id, 0]))
|
||||
const queue = [...members]
|
||||
for (let i = 0; i < queue.length; i++) {
|
||||
const d = dist.get(queue[i])
|
||||
if (d >= depth) continue
|
||||
for (const [nb, extra] of next.get(queue[i]) || []) {
|
||||
for (const e of extra) if (!members.has(e)) around.add(e)
|
||||
if (dist.has(nb)) continue
|
||||
dist.set(nb, d + 1)
|
||||
around.add(nb)
|
||||
queue.push(nb)
|
||||
}
|
||||
}
|
||||
for (const id of members) around.delete(id)
|
||||
return around
|
||||
}
|
||||
|
||||
// Toutes les familles d'un arbre, de proche en proche : l'arbre de la personne de départ, puis chaque grande
|
||||
// belle-famille qu'il cache (un onglet = ce bloc caché plus le conjoint charnière, posé avec lui comme
|
||||
// référence), puis celles que ces onglets cachent à leur tour. Chaque personne est dans un seul onglet, sauf
|
||||
@@ -915,28 +994,56 @@ export function familyTabsOf(graphData = null, referenceId = null) {
|
||||
if (fams[0]) { tab.label = fams[0].label; tab.count = fams[0].count }
|
||||
// La personne de départ réellement retenue (l'arbre sans personne de départ en choisit une)
|
||||
if (tab === root) root.resolvedRef = result.referenceId != null ? String(result.referenceId).replace(/^p-/, '') : null
|
||||
// Le placement de la famille de départ est celui que l'arbre affiche ensuite : gardé pour takeRootLayout,
|
||||
// il n'est pas recalculé (plusieurs secondes sur un arbre de quelques milliers de personnes)
|
||||
if (tab === root && !graphData) Object.defineProperty(tabs, 'rootLayout', { value: { result, coupleBarMeta, referenceId }, writable: true })
|
||||
for (const f of fams.slice(1)) {
|
||||
if (f.drawn) continue
|
||||
const key = String(f.anchorId).replace(/^p-/, '')
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
tabs.push({ key, referenceId: key, parentKey: tab.key, personIds: new Set([...f.members, ...f.hinges]), hinges: f.hinges, label: f.label, count: f.count })
|
||||
const base = new Set([...f.members, ...f.hinges])
|
||||
const { added, coParents } = bearersOfName(base, f.label, graphData)
|
||||
tabs.push({ key, referenceId: key, parentKey: tab.key, personIds: new Set([...base, ...added]), faded: coParents, hinges: f.hinges, label: f.label, count: f.count + added.size })
|
||||
}
|
||||
}
|
||||
// Deux onglets au même nom (homonymes, ou une branche sortie de sa propre lignée) : sauf le plus grand, chacun
|
||||
// prend les prénoms de sa charnière, ceux que porte la pastille qui y mène (Patch, 20/10 : « plusieurs fois
|
||||
// les mêmes familles »)
|
||||
const firstName = new Map((graphData?.persons || globalPersons).map(p => [String(p.id), p.firstName]))
|
||||
// 28/10 (Patch : « ptet le Joseph et Marie ») : l'onglet le plus petit prend d'abord le nom suivant le plus
|
||||
// porté chez lui et pas encore pris par un autre onglet ; les prénoms de la charnière ne servent qu'en dernier recours.
|
||||
const persons = graphData?.persons || globalPersons
|
||||
const firstName = new Map(persons.map(p => [String(p.id), p.firstName]))
|
||||
const familyName = new Map(persons.map(p => [String(p.id), (p.birthName || p.lastName || '').trim()]))
|
||||
const namesByCount = (tb) => {
|
||||
const n = new Map()
|
||||
for (const id of tb.personIds || []) { const nom = familyName.get(String(id).replace(/^p-/, '')); if (nom) n.set(nom, (n.get(nom) || 0) + 1) }
|
||||
return [...n].sort((x, y) => y[1] - x[1]).map(([nom]) => nom)
|
||||
}
|
||||
const byLabel = new Map()
|
||||
for (const tb of tabs) { if (!byLabel.has(tb.label)) byLabel.set(tb.label, []); byLabel.get(tb.label).push(tb) }
|
||||
const taken = new Set(tabs.map(tb => tb.label))
|
||||
for (const group of byLabel.values()) {
|
||||
if (group.length < 2) continue
|
||||
const biggest = group.reduce((a, b) => (b.count > a.count ? b : a))
|
||||
for (const tb of group) {
|
||||
for (const tb of [...group].sort((a, b) => b.count - a.count)) {
|
||||
if (tb === biggest) continue
|
||||
const next = namesByCount(tb).find(nom => !taken.has(nom))
|
||||
if (next) { tb.label = next; taken.add(next); continue }
|
||||
const names = [...new Set((tb.hinges || [tb.referenceId]).map(id => firstName.get(String(id))).filter(Boolean))].slice(0, 2)
|
||||
if (names.length) tb.label = `${tb.label} · ${names.join(' et ')}`
|
||||
}
|
||||
}
|
||||
return tabs
|
||||
}
|
||||
|
||||
// Le placement de la famille de départ calculé par familyTabsOf (arbre du module, onglets allumés), rendu une
|
||||
// seule fois : l'arbre affiché le modifie ensuite (pastilles, ligne directe, réordonnancement)
|
||||
export function takeRootLayout(tabs, referenceId) {
|
||||
const kept = tabs?.rootLayout
|
||||
if (!kept) return null
|
||||
tabs.rootLayout = null
|
||||
if (String(kept.referenceId ?? '') !== String(referenceId ?? '')) return null
|
||||
restoreCoupleBarMeta(kept.coupleBarMeta)
|
||||
return kept.result
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { computeBlockLayout, filCroiseAutreFamille, lineToFamily } from './blockLayout'
|
||||
import { computeBlockLayout, familyTabsOf, filCroiseAutreFamille, lineToFamily, takeRootLayout } from './blockLayout'
|
||||
import * as elk from './elkLayout'
|
||||
import * as mockData from '../../data/mockData'
|
||||
import { generate } from '../../../../tools/layout-bench/gen-fr.mjs'
|
||||
import { COMPACT_W, PERSON_W } from './constants'
|
||||
|
||||
const person = (id) => ({ id, firstName: id, lastName: '' })
|
||||
@@ -168,3 +171,31 @@ describe('lineToFamily, ligne directe vers un onglet de famille', () => {
|
||||
expect(drawn.has('p-oncle')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('familyTabsOf, placement de la famille de départ gardé', () => {
|
||||
it('rend le même placement et les mêmes couples qu’un calcul neuf, une seule fois', () => {
|
||||
const tree = generate('desc', 5)
|
||||
mockData.loadGraphData(tree.graph, { apiBaseUrl: '' })
|
||||
const fresh = computeBlockLayout(null, tree.rootPersonId)
|
||||
const freshMeta = JSON.stringify(elk.coupleBarMeta)
|
||||
const tabs = familyTabsOf(null, tree.rootPersonId)
|
||||
expect(tabs.length).toBeGreaterThan(1)
|
||||
const kept = takeRootLayout(tabs, tree.rootPersonId)
|
||||
expect(JSON.stringify(elk.coupleBarMeta)).toBe(freshMeta)
|
||||
expect(kept.children.map((n) => [n.id, n.x, n.y])).toEqual(fresh.children.map((n) => [n.id, n.x, n.y]))
|
||||
expect(takeRootLayout(tabs, tree.rootPersonId)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('familyTabsOf, graphe passé en entier (worker)', () => {
|
||||
it('rend la même liste d’onglets sans les données du module', () => {
|
||||
const tree = generate('desc', 5)
|
||||
mockData.loadGraphData(tree.graph, { apiBaseUrl: '' })
|
||||
const graph = structuredClone({ persons: mockData.persons, unions: mockData.unions, filiations: mockData.filiations })
|
||||
const expected = familyTabsOf(null, tree.rootPersonId)
|
||||
mockData.replaceGraph({ persons: [], unions: [], filiations: [] })
|
||||
const fromWorker = familyTabsOf(graph, tree.rootPersonId)
|
||||
expect(fromWorker.length).toBeGreaterThan(1)
|
||||
expect(fromWorker).toEqual(expected)
|
||||
})
|
||||
})
|
||||
@@ -45,14 +45,13 @@ export const REPULSION_STRENGTH = 2
|
||||
export const ANGLE_VARIATION = Math.PI / 6
|
||||
|
||||
// --- Constantes animation d'entrée ---
|
||||
// Cascade génération par génération, puis nœud par nœud de gauche à droite. Les délais unitaires
|
||||
// s'appliquent aux petits arbres ; sur un grand arbre ils sont réduits pour que la cascade tienne
|
||||
// dans ENTRANCE_MAX_GEN_SPAN (toutes générations) + ENTRANCE_MAX_NODE_SPAN (nœuds d'une génération).
|
||||
export const ENTRANCE_GEN_DELAY = 500
|
||||
export const ENTRANCE_NODE_DURATION = 700
|
||||
export const ENTRANCE_NODE_STAGGER = 80
|
||||
export const ENTRANCE_MAX_GEN_SPAN = 1600
|
||||
export const ENTRANCE_MAX_NODE_SPAN = 700
|
||||
// À la première ouverture d'un arbre, la caméra part serrée sur la personne de départ (au moins
|
||||
// ENTRANCE_CAMERA_FROM_SCALE) et recule en ENTRANCE_CAMERA_DURATION ; chaque personne apparaît en
|
||||
// ENTRANCE_NODE_DURATION quand elle entre dans le champ. Mouvements réduits : fondu de ENTRANCE_REDUCED_DURATION.
|
||||
export const ENTRANCE_CAMERA_DURATION = 1100
|
||||
export const ENTRANCE_CAMERA_FROM_SCALE = 1.3
|
||||
export const ENTRANCE_NODE_DURATION = 360
|
||||
export const ENTRANCE_REDUCED_DURATION = 200
|
||||
export const ENTRANCE_LINE_DELAY = 200
|
||||
export const ENTRANCE_LINE_DURATION = 500
|
||||
export const ENTRANCE_ORBIT_DELAY = 300
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
// État partagé — peuplé par computeLayout, lu par renderers
|
||||
// ============================================================
|
||||
export let coupleBarMeta = []
|
||||
// Remet les couples d'un placement déjà calculé (placement repris d'un cache, voir familyTabsOf)
|
||||
export function restoreCoupleBarMeta(meta) { coupleBarMeta = meta }
|
||||
export let componentOrderPerGen = new Map()
|
||||
|
||||
// ============================================================
|
||||
@@ -372,9 +374,12 @@ export function computeLayout(graphData = null, referenceId = null) {
|
||||
const personByElkId = new Map(persons.map(p => [`p-${p.id}`, p]))
|
||||
const unionEntryByElkId = new Map()
|
||||
|
||||
// Rang de chaque nœud dans allNodes, tenu à jour à chaque ajout (le reconstruire à chaque chaîne coûtait N² sur un gros arbre)
|
||||
const nodeIdx = new Map()
|
||||
function pushNode(node) {
|
||||
if (pushedNodeIds.has(node.id)) return
|
||||
pushedNodeIds.add(node.id)
|
||||
nodeIdx.set(node.id, allNodes.length)
|
||||
allNodes.push(node)
|
||||
}
|
||||
|
||||
@@ -482,7 +487,6 @@ export function computeLayout(graphData = null, referenceId = null) {
|
||||
|
||||
// Si plusieurs extrémités, choisir celle dont les enfants sont le plus à gauche
|
||||
if (leaves.length > 1) {
|
||||
const nodeIdx = new Map(allNodes.map((n, i) => [n.id, i]))
|
||||
let bestIdx = Number.MAX_SAFE_INTEGER
|
||||
for (const leafKey of leaves) {
|
||||
const children = childrenByParentKey.get(leafKey) || []
|
||||
@@ -555,7 +559,6 @@ export function computeLayout(graphData = null, referenceId = null) {
|
||||
|
||||
// Heuristique barycentrique : trier par position des parents déjà placés
|
||||
if (gen > 0) {
|
||||
const nodeIdx = new Map(allNodes.map((n, i) => [n.id, i]))
|
||||
const getAvgParentPos = (comp) => {
|
||||
let sum = 0, count = 0
|
||||
for (const key of comp) {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Liste des onglets de familles calculée hors du fil principal : sur un arbre de quelques milliers de personnes,
|
||||
// familyTabsOf place chaque onglet pour trouver ceux qu'il cache, plusieurs secondes pendant lesquelles la page
|
||||
// ne répondrait plus. Le graphe arrive avec la demande (ce module n'a pas les données de l'arbre).
|
||||
import { familyTabsOf } from './blockLayout'
|
||||
|
||||
self.onmessage = ({ data: { id, graph, referenceId } }) => {
|
||||
try {
|
||||
self.postMessage({ id, tabs: familyTabsOf(graph, referenceId) })
|
||||
} catch (error) {
|
||||
self.postMessage({ id, error: String(error?.message || error) })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { bearersOfName, kinAround } from './blockLayout'
|
||||
|
||||
// Henri Garnier (membre de l'onglet Garnier) et Odette, née Morel, notée au nom de son mari.
|
||||
// Leurs enfants : Paul Garnier (marié à Julie Petit, une fille Lou Garnier) et Anne, au nom de sa mère.
|
||||
// Un Garnier sans lien, ailleurs dans l'arbre.
|
||||
const p = (id, firstName, lastName, birthName = null) => ({ id, firstName, lastName, birthName })
|
||||
const graph = {
|
||||
persons: [
|
||||
p(1, 'Henri', 'Garnier'), p(2, 'Odette', 'Garnier', 'Morel'), p(3, 'Paul', 'Garnier'), p(4, 'Anne', 'Morel'),
|
||||
p(5, 'Julie', 'Petit'), p(6, 'Lou', 'Garnier'), p(7, 'Jean', 'Garnier'), p(8, 'Marc', 'Morel'),
|
||||
],
|
||||
unions: [{ id: 'u1', partner1Id: 1, partner2Id: 2 }, { id: 'u2', partner1Id: 3, partner2Id: 5 }],
|
||||
filiations: [
|
||||
{ childId: 3, unionId: 'u1' }, { childId: 4, unionId: 'u1' }, { childId: 6, unionId: 'u2' }, { childId: 2, parentId: 8 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('bearersOfName', () => {
|
||||
const { added, coParents } = bearersOfName(['1'], 'Garnier', graph)
|
||||
it('prend les enfants au nom, de génération en génération, avec leurs conjoints', () => {
|
||||
expect([...added].sort()).toEqual(['3', '5', '6'])
|
||||
})
|
||||
it("laisse dehors l'enfant au nom de sa mère, l'homonyme sans lien, et la mère notée au nom du mari", () => {
|
||||
expect(added.has('4')).toBe(false)
|
||||
expect(added.has('7')).toBe(false)
|
||||
expect(added.has('2')).toBe(false)
|
||||
})
|
||||
it("rend l'autre parent à estomper", () => {
|
||||
expect([...coParents]).toEqual(['2'])
|
||||
})
|
||||
it('ignore la casse du nom', () => {
|
||||
expect(bearersOfName(['1'], 'GARNIER', graph).added.has('3')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('kinAround', () => {
|
||||
it('ma ligne seulement : personne autour', () => {
|
||||
expect(kinAround(['1'], 0, graph).size).toBe(0)
|
||||
})
|
||||
it('1 lien : conjoint et enfants, et l\'autre parent', () => {
|
||||
expect([...kinAround(['1'], 1, graph)].sort()).toEqual(['2', '3', '4'])
|
||||
})
|
||||
it('2 liens : petits-enfants, conjoints des enfants, parents du conjoint', () => {
|
||||
expect([...kinAround(['1'], 2, graph)].sort()).toEqual(['2', '3', '4', '5', '6', '8'])
|
||||
})
|
||||
})
|
||||
@@ -8,11 +8,12 @@ import { nameKinship, normalizeSex } from '../../utils/kinship/index.js'
|
||||
// noeuds intermédiaires invisibles ; seuls les noeuds personne
|
||||
// comptent comme "étapes".
|
||||
// ============================================================
|
||||
// persons : les personnes à jour (sexe modifié sans recalcul du placement) ; à défaut, celles du placement
|
||||
export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
|
||||
if (!layoutData || fromElkId === toElkId) return null
|
||||
|
||||
// Construire l'adjacence
|
||||
// Adjacence d'un placement, calculée une fois par placement et par jeu de couples (le survol la redemandait
|
||||
// à chaque personne survolée)
|
||||
const adjacencyCache = new WeakMap()
|
||||
function adjacencyOf(layoutData) {
|
||||
const cached = adjacencyCache.get(layoutData)
|
||||
if (cached && cached.meta === coupleBarMeta && cached.edges === layoutData.edges) return cached.adj
|
||||
const adj = new Map()
|
||||
const ensure = (id) => { if (!adj.has(id)) adj.set(id, []) }
|
||||
|
||||
@@ -32,14 +33,32 @@ export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
|
||||
adj.get(tgt).push({ neighbor: src, type: 'filiation', id: edge.id })
|
||||
})
|
||||
}
|
||||
adjacencyCache.set(layoutData, { meta: coupleBarMeta, edges: layoutData.edges, adj })
|
||||
return adj
|
||||
}
|
||||
|
||||
// BFS
|
||||
// Index des personnes par identifiant du placement, une fois par tableau (le tableau change d'identité à chaque
|
||||
// modification, voir replaceGraph)
|
||||
const personIndexCache = new WeakMap()
|
||||
function personIndexOf(persons) {
|
||||
let index = personIndexCache.get(persons)
|
||||
if (!index) { index = new Map(persons.map((person) => [`p-${person.id}`, person])); personIndexCache.set(persons, index) }
|
||||
return index
|
||||
}
|
||||
|
||||
// persons : les personnes à jour (sexe modifié sans recalcul du placement) ; à défaut, celles du placement
|
||||
export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
|
||||
if (!layoutData || fromElkId === toElkId) return null
|
||||
|
||||
const adj = adjacencyOf(layoutData)
|
||||
|
||||
// BFS (file lue par index : shift() recopie la file à chaque pas, quadratique sur un gros arbre)
|
||||
const visited = new Set([fromElkId])
|
||||
const prev = new Map()
|
||||
const queue = [fromElkId]
|
||||
|
||||
while (queue.length > 0) {
|
||||
const cur = queue.shift()
|
||||
for (let head = 0; head < queue.length; head++) {
|
||||
const cur = queue[head]
|
||||
if (cur === toElkId) break
|
||||
for (const link of (adj.get(cur) || [])) {
|
||||
if (!visited.has(link.neighbor)) {
|
||||
@@ -64,9 +83,7 @@ export function findPathBFS(fromElkId, toElkId, layoutData, persons = null) {
|
||||
}
|
||||
nodePath.unshift(fromElkId)
|
||||
|
||||
const personById = persons
|
||||
? new Map(persons.map((person) => [`p-${person.id}`, person]))
|
||||
: new Map((layoutData.children || []).filter((node) => node._type === 'person').map((node) => [node.id, node._data]))
|
||||
const personById = persons ? personIndexOf(persons) : new Map((layoutData.children || []).filter((node) => node._type === 'person').map((node) => [node.id, node._data]))
|
||||
const kinship = kinshipPath(edgePath, nodePath, (id) => personById.get(id))
|
||||
return { nodePath, edgePath, kinship, label: nameKinship(kinship) }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getEntranceProgress, drawPartialBezier } from './utils'
|
||||
import { fitNickname } from '../../utils/nickname.js'
|
||||
import { firstNameLabel, isFirstNameUnknown } from '../../utils/personName.js'
|
||||
import { initialsOf } from '../../utils/initial.js'
|
||||
import { blurhashCanvas } from '../../utils/blurhash.js'
|
||||
|
||||
// Largeur maximale du surnom sur le nœud (px canevas) : au-delà, coupé avec …
|
||||
const NICKNAME_MAX_W = 150
|
||||
@@ -471,7 +472,7 @@ function measureTextCached(ctx, text, fontKey) {
|
||||
// ============================================================
|
||||
function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
nodeRandomData, imageCache, hoverScales, selectedGlowScales, searchGlowScales,
|
||||
mediaHoverState, entrance, entranceActive, elapsed, view = null, liftScales = null,
|
||||
mediaHoverState, entrance, entranceActive, elapsed, view = null, liftScales = null, fullNames = false,
|
||||
}) {
|
||||
const { cx, cy } = pos
|
||||
const person = getPersonById(node._data.id) || node._data
|
||||
@@ -547,7 +548,8 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
const hs = hoverScales.get(node.id) || 0
|
||||
// Personne soulevée pour changer sa place dans la fratrie : un peu plus grande, ombre plus portée
|
||||
const lift = liftScales?.get(node.id) || 0
|
||||
const entranceScale = 0.3 + nodeProgress * 0.7
|
||||
// Un portrait qui entre part de 92 % de sa taille, en fondu : rien n'apparaît de rien
|
||||
const entranceScale = 0.92 + nodeProgress * 0.08
|
||||
const personScale = entranceScale * (1 + Math.max(hs, lift) * 0.1 + lift * 0.05) * k
|
||||
|
||||
// --- Glow sélection ---
|
||||
@@ -631,15 +633,20 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
ctx.font = `${nameUnknown ? 'italic ' : ''}400 13px "Newsreader", Georgia, serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
// Réglage « nom de famille sous le prénom » (#168) : celui de naissance s'il est connu, coupé comme le surnom
|
||||
const familyName = fullNames && withDetail ? String(person.birthName || person.lastName || '').trim() : ''
|
||||
const familyLabel = familyName ? fitNickname(ctx, familyName, NICKNAME_MAX_W) : ''
|
||||
const lineShift = familyLabel ? 15 : 0
|
||||
if (nameLabel) {
|
||||
if (withDetail) {
|
||||
const nameMetrics = measureTextCached(ctx, nameLabel, nameUnknown ? 'name-unknown' : 'name')
|
||||
const nameBlockH = Math.max(
|
||||
18,
|
||||
(nameMetrics.actualBoundingBoxAscent || 7) + (nameMetrics.actualBoundingBoxDescent || 4) + 8,
|
||||
)
|
||||
const labelGlowY = 6
|
||||
const labelGlowRx = Math.max(26, Math.round(nameMetrics.width * 0.62))
|
||||
) + lineShift
|
||||
const labelGlowY = 6 + lineShift / 2
|
||||
const labelW = familyLabel ? Math.max(nameMetrics.width, ctx.measureText(familyLabel).width) : nameMetrics.width
|
||||
const labelGlowRx = Math.max(26, Math.round(labelW * 0.62))
|
||||
const labelGlowRy = Math.max(16, Math.round(nameBlockH * 1.15))
|
||||
const labelGlow = ctx.createRadialGradient(0, labelGlowY, 0, 0, labelGlowY, labelGlowRx)
|
||||
labelGlow.addColorStop(0, 'rgba(251, 250, 247, 0.98)')
|
||||
@@ -659,13 +666,18 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.fillText(nameLabel, 0, 0)
|
||||
}
|
||||
if (familyLabel) {
|
||||
ctx.font = '400 13px "Newsreader", Georgia, serif'
|
||||
ctx.fillStyle = COLORS.text
|
||||
ctx.fillText(familyLabel, 0, 15)
|
||||
}
|
||||
|
||||
// Surnom sous le nom, en italique : les dates descendent pour lui faire place
|
||||
const hasNickname = withDetail && Boolean(person.nickname)
|
||||
if (hasNickname) {
|
||||
ctx.font = 'italic 300 13px "Newsreader", Georgia, serif'
|
||||
ctx.fillStyle = COLORS.textLight
|
||||
ctx.fillText(fitNickname(ctx, person.nickname, NICKNAME_MAX_W), 0, 15)
|
||||
ctx.fillText(fitNickname(ctx, person.nickname, NICKNAME_MAX_W), 0, 15 + lineShift)
|
||||
}
|
||||
|
||||
if (withDetail) {
|
||||
@@ -675,7 +687,7 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
const years = person.deathYear
|
||||
? `${birthYearLabel}–${person.deathYear}`
|
||||
: person.isAlive === false ? `${birthYearLabel}–?` : `${birthYearLabel}`
|
||||
ctx.fillText(years, 0, hasNickname ? 31 : 15)
|
||||
ctx.fillText(years, 0, (hasNickname ? 31 : 15) + lineShift)
|
||||
}
|
||||
|
||||
ctx.restore()
|
||||
@@ -685,7 +697,10 @@ function drawPersonNode(ctx, node, pos, nodeProgress, {
|
||||
// la charnière peut avoir des enfants dessous, jamais rien au-dessus que le fil de ses parents.
|
||||
if (withDetail && node._familyTab) {
|
||||
const tab = node._familyTab
|
||||
const text = `${tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')} · ${tab.count} ›`
|
||||
// Retour vers la famille d'où l'on vient : même place que la pastille de l'aller (la charnière ne bouge pas),
|
||||
// flèche retournée (Patch, 28/10, planche D)
|
||||
const name = tab.label ? t('Famille {name}', { name: tab.label }) : t('Famille')
|
||||
const text = tab.back ? `‹ ${name} · ${tab.count}` : `${name} · ${tab.count} ›`
|
||||
const py = cy - PERSON_R * personScale - RENVOI_CHILD_GAP
|
||||
familyPills.push({ text, cx, cy: py, anchorId: tab.anchorId, tabKey: tab.tabKey, alpha: nodeProgress * labelAlpha })
|
||||
}
|
||||
@@ -829,7 +844,8 @@ function drawGpxThumbnail(ctx, ms) {
|
||||
}
|
||||
|
||||
function drawMediaContent(ctx, media, ms, imageCache) {
|
||||
const mImg = media.url ? imageCache.get(media.url) : null
|
||||
// Photo pas encore chargée : son aperçu flou
|
||||
const mImg = media.url ? (imageCache.get(media.url) || (media.type === 'photo' ? blurhashCanvas(media.blurhash) : null)) : null
|
||||
|
||||
if (media.type === 'photo' && mImg) {
|
||||
ctx.save()
|
||||
@@ -1047,8 +1063,10 @@ function drawFrame(ctx, frameType, person, img) {
|
||||
// Dessiner l'image ou le placeholder initiales
|
||||
// ============================================================
|
||||
function drawPersonImage(ctx, img, person, x, y, w, h) {
|
||||
if (img) {
|
||||
drawImageCover(ctx, img, x, y, w, h, person.avatarCrop)
|
||||
// Portrait pas encore chargé : son aperçu flou, cadré pareil
|
||||
const placeholder = !img && person.photo ? blurhashCanvas(person.avatarBlurhash) : null
|
||||
if (img || placeholder) {
|
||||
drawImageCover(ctx, img || placeholder, x, y, w, h, person.avatarCrop)
|
||||
if (!person.isAlive) {
|
||||
ctx.fillStyle = 'rgba(235, 230, 223, 0.25)'
|
||||
ctx.fillRect(x, y, w, h)
|
||||
@@ -1109,7 +1127,7 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = 1
|
||||
} else if (node._type === 'unknown') {
|
||||
const unknownScale = 0.3 + nodeProgress * 0.7
|
||||
const unknownScale = 0.92 + nodeProgress * 0.08
|
||||
ctx.save()
|
||||
ctx.globalAlpha = nodeProgress
|
||||
ctx.translate(cx, cy)
|
||||
|
||||
@@ -55,6 +55,9 @@
|
||||
@media (hover: hover) and (pointer: fine) { .nav-add:hover { background: #3a3530; } }
|
||||
.nav-add.is-open,
|
||||
.nav-add.is-editing { box-shadow: 0 0 0 4px var(--pz-a100); }
|
||||
/* « Modifier » en clair dans la démo, quand « Créer mon arbre » prend le noir à côté */
|
||||
.nav-add--soft { background: var(--pz-line-soft); color: var(--pz-ink); }
|
||||
@media (hover: hover) and (pointer: fine) { .nav-add--soft:hover { background: #e9e3da; } }
|
||||
|
||||
/* Rond du compte */
|
||||
.nav-dropdown-wrapper { position: static; }
|
||||
@@ -180,29 +183,31 @@
|
||||
}
|
||||
|
||||
/* --- Exit edit mode button --- */
|
||||
/* Même hauteur que le « + » à côté (46 px), zone de tap comprise (Patch, 27/10) */
|
||||
.nav-annot-exit {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
height: 46px;
|
||||
padding: 0 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
border-radius: 999px;
|
||||
background: rgba(42, 38, 34, 0.08);
|
||||
color: #6B645D;
|
||||
color: #2A2622;
|
||||
font: 500 15px/1 var(--pz-sans);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition: background-color 160ms ease, transform 140ms var(--pz-ease);
|
||||
flex-shrink: 0;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
.nav-annot-exit:hover {
|
||||
background: rgba(42, 38, 34, 0.16);
|
||||
color: #2A2622;
|
||||
transform: rotate(90deg);
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.nav-annot-exit:hover { background: rgba(42, 38, 34, 0.14); }
|
||||
}
|
||||
|
||||
.nav-annot-exit:active {
|
||||
transform: rotate(90deg) scale(0.88);
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* --- Separator --- */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import {
|
||||
Brush, ImagePlus, Plus, UserPlus, User, X, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search,
|
||||
Brush, ImagePlus, Plus, UserPlus, User, MousePointer2, Pencil, Type, Star, Trash2, Image, ArrowLeft, Search, TreeDeciduous,
|
||||
} from 'lucide-react'
|
||||
import { NavMenu, NavMenuGroup, NavMenuItem } from './NavMenu'
|
||||
import AvatarMenu from './AvatarMenu'
|
||||
@@ -12,6 +12,8 @@ import { t } from '../../i18n/index.js'
|
||||
import { PHONE_QUERY } from '../../utils/sheetDrag'
|
||||
import './ContextualNavbar.css'
|
||||
import { initialOf } from '../../utils/initial.js'
|
||||
import Kbd from '../Kbd.jsx'
|
||||
import PortraitThumb from '../PortraitThumb.jsx'
|
||||
|
||||
const ANNOT_TOOLS = [
|
||||
{ id: 'pointer', label: t('Sélection'), Icon: MousePointer2 },
|
||||
@@ -41,6 +43,7 @@ function ContextualNavbar({
|
||||
userDisplayName = '',
|
||||
userEmail = '',
|
||||
userAvatarPhoto = '',
|
||||
userAvatarCrop = null,
|
||||
linkedPersonName = '',
|
||||
activeTreeName = '',
|
||||
onEditClick,
|
||||
@@ -53,6 +56,7 @@ function ContextualNavbar({
|
||||
onCreateTree,
|
||||
onProfileClick,
|
||||
canRegister = true,
|
||||
signupPrompts = false,
|
||||
onAccountClick,
|
||||
onSettingsClick,
|
||||
onLogout,
|
||||
@@ -147,9 +151,10 @@ function ContextualNavbar({
|
||||
}
|
||||
|
||||
// Création de compte dans l'app
|
||||
const handleCreateAccountClick = () => {
|
||||
trackAppEvent('create_account_clicked', accountClickProps)
|
||||
onProfileClick?.('register')
|
||||
// via : « ajouter » (menu Ajouter de la famille) ou « compte » (menu du compte)
|
||||
const handleCreateAccountClick = (via = 'compte') => {
|
||||
trackAppEvent('create_account_clicked', { ...accountClickProps, via })
|
||||
onProfileClick?.('register', via)
|
||||
}
|
||||
|
||||
const handleAnnotToolClick = (toolId) => {
|
||||
@@ -211,9 +216,9 @@ function ContextualNavbar({
|
||||
}
|
||||
const fixItems = []
|
||||
if (isFamily) {
|
||||
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, sub: editModeActive ? null : t('dates, noms, liens, le propriétaire relit'), onSelect: onEditClick })
|
||||
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, sub: editModeActive ? null : t('dates, noms, liens, le propriétaire relit'), onSelect: onEditClick, shortcut: 'e' })
|
||||
} else if (isOwner || isDemo) {
|
||||
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, onSelect: onEditClick })
|
||||
fixItems.push({ key: 'edit', icon: <Pencil {...ICON} />, label: editLabel, onSelect: onEditClick, shortcut: 'e' })
|
||||
}
|
||||
if (addItems.length) addGroups.push({ label: t('Ajouter'), items: addItems })
|
||||
if (fixItems.length) addGroups.push({ label: t('Corriger'), items: fixItems })
|
||||
@@ -222,6 +227,17 @@ function ContextualNavbar({
|
||||
addGroups.push({ label: t('Chercher'), items: [{ key: 'search', icon: <Search {...ICON} />, label: t("Quelqu'un ailleurs"), sub: t('décès depuis 1970, archives, presse'), onSelect: onSearchClick }] })
|
||||
}
|
||||
}
|
||||
// La famille sans compte trouve « Faire le mien » en bas du menu qu'elle ouvre, le menu Ajouter, et la ligne quitte
|
||||
// le menu du compte. Seulement si l'instance invite à créer son arbre (SIGNUP_PROMPTS).
|
||||
const ownTreeInAddMenu = signupPrompts && canRegister && isFamily && !authenticated && !showMobileAnnot
|
||||
// Démo sans compte : « Créer mon arbre » devient le bouton noir de la barre, « Modifier » passe en clair à côté
|
||||
// (planche du 03/11, variante C). Il quitte alors le menu du rond, pour ne pas être proposé deux fois.
|
||||
// Pas dans la démo intégrée à la landing (?embed) : l'inscription s'ouvrirait dans le cadre, la landing a ses boutons.
|
||||
const isEmbed = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('embed')
|
||||
const demoSignupInBar = signupPrompts && canRegister && isDemo && !isEmbed && !authenticated && !showMobileAnnot
|
||||
if (ownTreeInAddMenu) {
|
||||
addGroups.push({ label: t('Votre famille'), items: [{ key: 'own-tree', icon: <TreeDeciduous {...ICON} />, label: t('Faire le mien'), sub: t('un arbre pour votre famille, gratuit'), onSelect: () => handleCreateAccountClick('ajouter') }] })
|
||||
}
|
||||
const hasAddMenu = addGroups.length > 0
|
||||
|
||||
const renderAddMenu = () => (
|
||||
@@ -235,6 +251,7 @@ function ContextualNavbar({
|
||||
label={item.label}
|
||||
sub={item.sub}
|
||||
onSelect={run(item.onSelect)}
|
||||
right={item.shortcut ? <Kbd mod="ctrl" k={item.shortcut} /> : null}
|
||||
/>
|
||||
))}
|
||||
</NavMenuGroup>
|
||||
@@ -292,13 +309,14 @@ function ContextualNavbar({
|
||||
<div className={`navbar-content${showMobileAnnot ? ' navbar-content--annot' : ''}`}>
|
||||
{showMobileAnnot && !annotMode ? (
|
||||
<>
|
||||
{/* Sortie de l'édition en toutes lettres : une croix seule à côté du « + » ne disait pas ce qu'elle faisait (Patch, 27/10) */}
|
||||
<button
|
||||
type="button"
|
||||
className="nav-annot-exit"
|
||||
onClick={onEditClick}
|
||||
aria-label={t("Quitter l'édition")}
|
||||
title={t("Quitter l'édition")}
|
||||
>
|
||||
<X size={18} strokeWidth={2.5} />
|
||||
{t('Terminé')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -358,7 +376,7 @@ function ContextualNavbar({
|
||||
<button
|
||||
type="button"
|
||||
ref={addBtnRef}
|
||||
className={`nav-add${isPhone ? '' : ' nav-add--label'}${openMenu === 'add' ? ' is-open' : ''}${editModeActive ? ' is-editing' : ''}`}
|
||||
className={`nav-add${isPhone ? '' : ' nav-add--label'}${demoSignupInBar ? ' nav-add--soft' : ''}${openMenu === 'add' ? ' is-open' : ''}${editModeActive ? ' is-editing' : ''}`}
|
||||
onClick={() => setOpenMenu((v) => (v === 'add' ? null : 'add'))}
|
||||
aria-expanded={openMenu === 'add'}
|
||||
aria-haspopup="menu"
|
||||
@@ -373,6 +391,17 @@ function ContextualNavbar({
|
||||
)}
|
||||
{hasAddMenu && openMenu === 'add' && renderAddMenu()}
|
||||
|
||||
{demoSignupInBar && (
|
||||
<button
|
||||
type="button"
|
||||
className="nav-add nav-add--label"
|
||||
onClick={() => { closeMenus(); handleCreateAccountClick('demo-barre') }}
|
||||
>
|
||||
<TreeDeciduous size={18} strokeWidth={2.2} aria-hidden="true" />
|
||||
<span className="nav-add-label">{t('Créer mon arbre')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Rond du compte : icône de profil hors connexion, initiale ou photo connecté */}
|
||||
<div className="nav-dropdown-wrapper">
|
||||
<button
|
||||
@@ -388,7 +417,7 @@ function ContextualNavbar({
|
||||
{!authenticated ? (
|
||||
<User size={20} strokeWidth={2} aria-hidden="true" />
|
||||
) : userAvatarPhoto ? (
|
||||
<img className="nav-avatar-photo" src={userAvatarPhoto} alt="" />
|
||||
<PortraitThumb className="nav-avatar-photo" src={userAvatarPhoto} crop={userAvatarCrop} />
|
||||
) : (
|
||||
<span className="nav-avatar-initial" aria-hidden="true">{avatarInitial}</span>
|
||||
)}
|
||||
@@ -417,8 +446,8 @@ function ContextualNavbar({
|
||||
onSelectTree={onTreeSelect}
|
||||
onCreateTree={onCreateTree}
|
||||
onSettings={onSettingsClick}
|
||||
onLogin={() => onProfileClick?.('login')}
|
||||
onRegister={canRegister ? handleCreateAccountClick : undefined}
|
||||
onLogin={() => onProfileClick?.('login', 'compte')}
|
||||
onRegister={canRegister && !ownTreeInAddMenu && !demoSignupInBar ? () => handleCreateAccountClick('compte') : undefined}
|
||||
onLogout={onLogout}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -41,7 +41,8 @@
|
||||
padding: 0 14px calc(16px + env(safe-area-inset-bottom, 0px));
|
||||
background: var(--pz-surface);
|
||||
border-radius: 24px 24px 0 0;
|
||||
box-shadow: 0 -12px 40px rgba(42, 38, 34, 0.16);
|
||||
/* Ombre pleine sous la feuille, en premier pour passer au-dessus de l'ombre floue : tirée vers le haut (40 px au plus, sheetOffset), elle ne laisse pas voir la page dessous */
|
||||
box-shadow: 0 40px 0 var(--pz-surface), 0 -12px 40px rgba(42, 38, 34, 0.16);
|
||||
/* Pas de fill-mode : une fois l'entrée finie, le glisser (style inline) reprend la main */
|
||||
animation: navmSheetIn 320ms var(--pz-ease-drawer);
|
||||
transition: transform 240ms var(--pz-ease-drawer);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Search, X } from 'lucide-react'
|
||||
import SearchResultsDropdown from './SearchResultsDropdown'
|
||||
import './ExpandableSearch.css'
|
||||
import { t } from '../../i18n/index.js'
|
||||
import { isCommandK, withShortcut } from '../../utils/shortcuts.js'
|
||||
|
||||
/**
|
||||
* Recherche expandable haut-droite
|
||||
@@ -80,6 +81,22 @@ function ExpandableSearch({
|
||||
setExpanded(true)
|
||||
}
|
||||
|
||||
// Ctrl+K (⌘K sur Mac), de n'importe où : ouvre la recherche, ou y remet le curseur si elle est déjà ouverte
|
||||
useEffect(() => {
|
||||
function handleCommandK(event) {
|
||||
if (!isCommandK(event)) return
|
||||
event.preventDefault()
|
||||
if (!expanded) setExpanded(true)
|
||||
else {
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.select()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleCommandK)
|
||||
return () => document.removeEventListener('keydown', handleCommandK)
|
||||
}, [expanded])
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const value = e.target.value
|
||||
setQuery(value)
|
||||
@@ -106,6 +123,7 @@ function ExpandableSearch({
|
||||
className="search-icon-button"
|
||||
onClick={handleExpand}
|
||||
aria-label={t('Rechercher une personne')}
|
||||
title={withShortcut(t('Rechercher une personne'), 'cmd', 'k')}
|
||||
>
|
||||
<span className="search-icon"><Search size={22} strokeWidth={2} /></span>
|
||||
</button>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import './SearchResultsDropdown.css'
|
||||
import { t } from '../../i18n/index.js'
|
||||
import { firstNameLabel } from '../../utils/personName.js'
|
||||
import PortraitThumb from '../PortraitThumb.jsx'
|
||||
|
||||
// Raison du match renvoyée par usePersonSearch (valeur française), traduite à l'affichage
|
||||
const REASON_LABELS = {
|
||||
@@ -58,7 +59,7 @@ function SearchResultsDropdown({
|
||||
{/* Photo miniature */}
|
||||
<div className="search-result-thumb">
|
||||
{person.photo ? (
|
||||
<img src={person.photo} alt="" />
|
||||
<PortraitThumb src={person.photo} crop={person.avatarCrop} />
|
||||
) : (
|
||||
<div className="search-result-initials">
|
||||
{person.firstName?.[0] || '?'}
|
||||
|
||||
@@ -127,6 +127,8 @@ function mapRemotePersonToLocal(person, current = null) {
|
||||
photo: current?.photo || null,
|
||||
avatarFromImmich: person?.avatarFromImmich ?? false,
|
||||
avatarCrop: person ? (person.avatarCrop ?? null) : (current?.avatarCrop ?? null),
|
||||
// Aperçu flou du portrait pendant son chargement (utils/blurhash.js)
|
||||
avatarBlurhash: person ? (person.avatarBlurhash ?? null) : (current?.avatarBlurhash ?? null),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +156,15 @@ export function getDemoLife(personId) {
|
||||
return DEMO_LIFE[personId] || null
|
||||
}
|
||||
|
||||
// Remplace des tableaux du graphe. Ne jamais les muter en place (push, splice) : les index par personne
|
||||
// ci-dessous ne se reconstruisent que quand le tableau change d'identité
|
||||
export function replaceGraph(next) {
|
||||
if (next.persons) persons = next.persons
|
||||
if (next.unions) unions = next.unions
|
||||
if (next.filiations) filiations = next.filiations
|
||||
if (next.medias) medias = next.medias
|
||||
}
|
||||
|
||||
export function resetToDemoData() {
|
||||
persons = JSON.parse(JSON.stringify(INITIAL_PERSONS))
|
||||
medias = JSON.parse(JSON.stringify(INITIAL_MEDIAS))
|
||||
@@ -255,6 +266,7 @@ export function loadGraphData(graph, options = {}) {
|
||||
displayOrder: Number.isFinite(media.displayOrder) ? media.displayOrder : index + 1,
|
||||
isFeatured: Boolean(media.isFeatured),
|
||||
mimeType: media.mimeType || null,
|
||||
blurhash: media.blurhash || null,
|
||||
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
|
||||
people: (Array.isArray(media.people) ? media.people : [])
|
||||
.filter((tag) => personIdSet.has(tag.personId))
|
||||
|
||||
@@ -148,6 +148,13 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
|
||||
|
||||
try {
|
||||
const result = await submitContributionSession(treeContext.treeId, payload, treeContext.accessToken)
|
||||
// Ici et pas dans l'appelant : toutes les propositions passent par cette fonction (fenêtre d'envoi
|
||||
// comme raccourcis de la fiche). « approved » : un co-gestionnaire, appliqué sans relecture.
|
||||
trackAppEvent('proposal_sent', {
|
||||
changes: Array.isArray(payload.changes) ? payload.changes.length : 0,
|
||||
status: result?.session?.status || 'pending',
|
||||
tree_id: toOpaqueTreeId(treeContext.treeId),
|
||||
})
|
||||
|
||||
if (result?.session?.status === 'approved') {
|
||||
await refreshCurrentTreeGraph()
|
||||
|
||||
@@ -261,7 +261,8 @@ function getMatchDetail(query, person) {
|
||||
* @param {Array} persons - Liste de toutes les personnes
|
||||
* @returns {Object} API de recherche
|
||||
*/
|
||||
export function usePersonSearch(persons = []) {
|
||||
// revision : le tableau des personnes est modifié sur place (ajout, suppression) ; sa révision relance le calcul
|
||||
export function usePersonSearch(persons = [], revision = 0) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
|
||||
@@ -291,7 +292,7 @@ export function usePersonSearch(persons = []) {
|
||||
.slice(0, 50) // Limiter à 50 résultats max
|
||||
|
||||
return scored
|
||||
}, [query, persons])
|
||||
}, [query, persons, revision]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Liste des IDs de personnes matchées (pour highlight galaxie)
|
||||
const matchedPersonIds = useMemo(() => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
canStartSheetDrag,
|
||||
shouldCloseSheet,
|
||||
sheetDragIntent,
|
||||
sheetOffset,
|
||||
velocityBetween,
|
||||
} from '../utils/sheetDrag'
|
||||
|
||||
@@ -35,10 +36,11 @@ export function useSheetMode() {
|
||||
/**
|
||||
* Glisser pour fermer une feuille du bas.
|
||||
* - Le geste ne part que des éléments de `zone` (sélecteur CSS) et jamais d'un champ de saisie ni d'une sélection de texte.
|
||||
* - La feuille suit le doigt (transform inline) et ne monte jamais au-delà de sa position de repos.
|
||||
* - La feuille suit le doigt (transform inline) ; au-dessus de sa position de repos elle résiste (`sheetOffset`) et revient au lâcher.
|
||||
* - Au lâcher : `closes` décide (par défaut, un tiers de la hauteur ou un lâcher rapide). Fermer attend la fin de la sortie,
|
||||
* puis appelle `onClose`. Si `onClose` renvoie `false` (enregistrement refusé), la feuille revient.
|
||||
* - Après un vrai glisser, le clic qui suit est avalé (pas de bouton déclenché au lâcher).
|
||||
* - `canStart` (facultatif) peut refuser le geste au moment de l'appui (feuille qui a défilé, par exemple).
|
||||
*
|
||||
* Usage : `<div {...drag.bind} style={drag.style} data-dragging={drag.dragging || undefined}>`
|
||||
*/
|
||||
@@ -48,6 +50,7 @@ export function useSheetDrag({
|
||||
onClose,
|
||||
closes = shouldCloseSheet,
|
||||
closeDelayMs = 180,
|
||||
canStart = null,
|
||||
}) {
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
@@ -76,6 +79,7 @@ export function useSheetDrag({
|
||||
if (!enabled || closingRef.current || event.button > 0) return
|
||||
const selection = typeof window !== 'undefined' ? window.getSelection?.()?.toString() ?? '' : ''
|
||||
if (!canStartSheetDrag(event.target, zone, selection)) return
|
||||
if (canStart && !canStart(event)) return
|
||||
gesture.current = {
|
||||
id: event.pointerId,
|
||||
x: event.clientX,
|
||||
@@ -107,7 +111,7 @@ export function useSheetDrag({
|
||||
}
|
||||
g.prev = g.last
|
||||
g.last = { y: event.clientY, t: event.timeStamp }
|
||||
setOffset(Math.max(0, dy))
|
||||
setOffset(sheetOffset(dy))
|
||||
}
|
||||
|
||||
const onPointerUp = (event) => {
|
||||
@@ -142,6 +146,6 @@ export function useSheetDrag({
|
||||
return {
|
||||
dragging,
|
||||
bind: { onPointerDown, onPointerMove, onPointerUp, onPointerCancel, onClickCapture },
|
||||
style: offset > 0 ? { transform: `translateY(${offset}px)` } : undefined,
|
||||
style: offset !== 0 ? { transform: `translateY(${offset}px)` } : undefined,
|
||||
}
|
||||
}
|
||||
@@ -82,9 +82,11 @@ export function useTreeAccess() {
|
||||
const rootPerson = preferredRootPersonId
|
||||
? graphPersons.find((person) => String(person.id) === String(preferredRootPersonId)) || null
|
||||
: null
|
||||
// Par le lien de partage, la famille arrive sur l'arbre et sa carte d'accueil, pas sur une fiche ouverte
|
||||
// Par le lien de partage, la famille arrive sur l'arbre et sa carte d'accueil, pas sur une fiche ouverte.
|
||||
// Sur téléphone non plus : la fiche y couvre le bas de l'écran, et la personne de départ avec (#188).
|
||||
const openedByShareLink = (options.accessMode ?? '') === 'share'
|
||||
setSelectedPerson(openedByShareLink ? null : rootPerson)
|
||||
const phone = typeof window !== 'undefined' && window.matchMedia?.('(max-width: 768px)').matches
|
||||
setSelectedPerson(openedByShareLink || phone ? null : rootPerson)
|
||||
setSelectedMedia(null)
|
||||
|
||||
setTreeContext((current) => ({
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
// Délai pendant lequel une suppression reste annulable avant de partir au serveur
|
||||
export const UNDO_DELAY_MS = 6000
|
||||
|
||||
// Une suppression annulable à la fois. L'écran change tout de suite ; la requête ne part qu'à la fin du délai,
|
||||
// à la suppression suivante, ou à la fermeture de la page (fetch keepalive).
|
||||
// Une action : { message, commit({ keepalive }), undo(), reapply?() } ; reapply refait le retrait local
|
||||
// après un rechargement de l'arbre pendant le délai, et renvoie true s'il a changé quelque chose.
|
||||
export function useUndoable() {
|
||||
const pendingRef = useRef(null)
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
const commit = useCallback(async (action, options = {}) => {
|
||||
if (pendingRef.current !== action) return
|
||||
pendingRef.current = null
|
||||
clearTimeout(action.timer)
|
||||
setMessage('')
|
||||
await action.commit(options)
|
||||
}, [])
|
||||
|
||||
const start = useCallback((action) => {
|
||||
// La précédente part maintenant
|
||||
if (pendingRef.current) commit(pendingRef.current)
|
||||
action.timer = setTimeout(() => commit(action), UNDO_DELAY_MS)
|
||||
pendingRef.current = action
|
||||
setMessage(action.message)
|
||||
}, [commit])
|
||||
|
||||
const undo = useCallback(async () => {
|
||||
const action = pendingRef.current
|
||||
if (!action) return
|
||||
pendingRef.current = null
|
||||
clearTimeout(action.timer)
|
||||
setMessage('')
|
||||
await action.undo()
|
||||
}, [])
|
||||
|
||||
const reapply = useCallback(() => Boolean(pendingRef.current?.reapply?.()), [])
|
||||
|
||||
// Page fermée pendant le délai : la suppression part quand même
|
||||
useEffect(() => {
|
||||
const flush = () => {
|
||||
if (pendingRef.current) commit(pendingRef.current, { keepalive: true })
|
||||
}
|
||||
window.addEventListener('pagehide', flush)
|
||||
return () => window.removeEventListener('pagehide', flush)
|
||||
}, [commit])
|
||||
|
||||
// Ctrl+Z (Cmd+Z) annule, hors des champs de saisie
|
||||
useEffect(() => {
|
||||
if (!message) return undefined
|
||||
const onKeyDown = (event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.shiftKey || event.key.toLowerCase() !== 'z') return
|
||||
if (event.target.closest?.('input, textarea, select, [contenteditable="true"]')) return
|
||||
event.preventDefault()
|
||||
undo()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [message, undo])
|
||||
|
||||
return { message, start, undo, reapply }
|
||||
}
|
||||
@@ -34,6 +34,19 @@ export default {
|
||||
// App.jsx : liens familiaux
|
||||
'Lien déjà présent': 'This link already exists',
|
||||
'Lien familial ajouté (démo locale)': 'Family link added (local demo)',
|
||||
// Invitations à créer son arbre (SIGNUP_PROMPTS)
|
||||
'Lien ajouté à la démo.': 'Link added to the demo.',
|
||||
'Lien modifié dans la démo.': 'Link changed in the demo.',
|
||||
'Lien retiré de la démo.': 'Link removed from the demo.',
|
||||
'Votre essai reste dans la démo.': 'Your changes stay in the demo.',
|
||||
'Pour votre famille,': 'For your own family,',
|
||||
'créez votre arbre': 'create your tree',
|
||||
'Votre famille': 'Your family',
|
||||
'Faire le mien': 'Make my own',
|
||||
'un arbre pour votre famille, gratuit': 'a tree for your family, free',
|
||||
// Plusieurs prénoms dans le champ Prénom
|
||||
'et': 'and',
|
||||
"{names} sont sur l'arbre, en brouillon. Envoyez quand vous avez fini.": '{names} are on the tree, as a draft. Send when you are done.',
|
||||
'Lien ajouté à la contribution': 'Link added to your contribution',
|
||||
'Lien familial ajouté': 'Family link added',
|
||||
'Lien modifié (démo locale)': 'Link changed (local demo)',
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
// Traductions anglaises, clé = texte français exact passé à t().
|
||||
export default {
|
||||
'Famille {name}': 'The {name} family',
|
||||
'{n} personnes': '{n} people',
|
||||
'Famille': 'Family',
|
||||
'Familles': 'Families',
|
||||
'Affichage': 'Display',
|
||||
'Les grandes familles par alliance dans des onglets': 'Large in-law families in tabs',
|
||||
"Une belle-famille plus large qu'un écran s'ouvre dans son propre onglet au lieu d'être dessinée dans l'arbre. Réglage de ce navigateur.": 'An in-law family wider than a screen opens in its own tab instead of being drawn in the tree. Saved in this browser.',
|
||||
'Réglages de ce navigateur.': 'Saved in this browser.',
|
||||
'Séparer les belles-familles en onglets': 'Put in-law families in tabs',
|
||||
"Quand la famille d'un conjoint est trop large pour l'écran, elle s'ouvre dans un onglet en haut de l'arbre.": "When a spouse's family is too wide for the screen, it opens in a tab at the top of the tree.",
|
||||
"Dans un onglet, montrer aussi les proches de l'autre côté": 'In a tab, also show relatives from the other side',
|
||||
'Ma ligne seulement': 'Only my line',
|
||||
'1 lien': '1 link',
|
||||
'2 liens': '2 links',
|
||||
'3 liens': '3 links',
|
||||
'Un lien : un parent, un enfant ou un conjoint. Ils apparaissent en plus clair ; votre propre ligne reste toujours visible.': 'A link is a parent, a child or a spouse. They appear lighter; your own line always stays visible.',
|
||||
'Afficher le nom de famille': 'Show the last name',
|
||||
"Sous chaque prénom. Le nom de naissance s'il est connu.": 'Under each first name. The maiden name when it is known.',
|
||||
// Arbre sur canvas (components/galaxy/renderers.js)
|
||||
'Voir les parents': 'See parents',
|
||||
'Voir {firstName}': 'See {firstName}',
|
||||
@@ -15,12 +25,12 @@ export default {
|
||||
// Le lien de parenté affiché au survol se nomme langue par langue dans utils/kinship/
|
||||
|
||||
// Glisser pour ajouter une personne (components/ProximityFeedback.jsx)
|
||||
'Conjoint': 'Spouse',
|
||||
'Conjoint': 'Partner',
|
||||
'Frère·Sœur': 'Sibling',
|
||||
'Lien': 'Link',
|
||||
|
||||
// Contrôles
|
||||
'Contrôle du zoom': 'Zoom control',
|
||||
'Zoomer': 'Zoom in',
|
||||
'En cours': 'Loading',
|
||||
|
||||
// Annotations
|
||||
|
||||
@@ -41,6 +41,8 @@ export default {
|
||||
|
||||
// ─── Message d'invitation ───
|
||||
'Bonjour à tous,': 'Hi everyone,',
|
||||
"Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon et apportez vos vieilles photos, on le complétera ensemble à table. Et si quelqu'un raconte une histoire de famille, enregistrez-la sur votre téléphone, sa voix aura sa place dans l'arbre.":
|
||||
"For Christmas, I put our family tree online. Have a look before Christmas Eve and bring your old photos, we'll fill it in together at the table. And if someone tells a family story, record it on your phone, their voice belongs in the tree.",
|
||||
"Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter.":
|
||||
"I'm putting our family's history together in an online tree, and I'd love your help filling it in.",
|
||||
"L'arbre est ici {url}": 'The tree is here {url}',
|
||||
@@ -171,6 +173,8 @@ export default {
|
||||
'Modifications en cours': 'Unsaved changes',
|
||||
'Quitter {em} ?': 'Leave {em}?',
|
||||
"l'édition": 'edit mode',
|
||||
'Vos dernières modifications restent dans la démo tant que cette page est ouverte, puis elles disparaissent.':
|
||||
'Your latest changes stay in the demo while this page is open, then they are gone.',
|
||||
'Vos dernières modifications ne sont pas encore enregistrées. Elles restent sur cet appareil tant que vous ne les abandonnez pas.':
|
||||
"Your latest changes aren't saved yet. They stay on this device until you discard them.",
|
||||
'Abandonner': 'Discard',
|
||||
|
||||
@@ -85,14 +85,23 @@ export default {
|
||||
"Aucun parent pour l'instant": 'No parents yet',
|
||||
'Ajouter un parent': 'Add a parent',
|
||||
'Rechercher un parent': 'Search for a parent',
|
||||
'Conjoint·e·s': 'Spouses',
|
||||
'Aucun·e conjoint·e': 'No spouse',
|
||||
'Ajouter un·e conjoint·e': 'Add a spouse',
|
||||
'Rechercher un·e conjoint·e': 'Search for a spouse',
|
||||
'Conjoint·e·s': 'Partners',
|
||||
'Aucun·e conjoint·e': 'No partner',
|
||||
'Ajouter un·e conjoint·e': 'Add a partner',
|
||||
'Rechercher un·e conjoint·e': 'Search for a partner',
|
||||
'Aucun enfant': 'No children',
|
||||
'Ajouter un enfant': 'Add a child',
|
||||
'Rechercher un enfant': 'Search for a child',
|
||||
"Supprimer {name} de l'arbre ?": 'Delete {name} from the tree?',
|
||||
"{name} est supprimé·e de l'arbre.": '{name} was deleted from the tree.',
|
||||
"{name} est supprimée de l'arbre.": '{name} was deleted from the tree.',
|
||||
"{name} est supprimé de l'arbre.": '{name} was deleted from the tree.',
|
||||
"La personne est supprimée de l'arbre.": 'The person was deleted from the tree.',
|
||||
"{name} n'a pas pu être supprimé·e. Réessayez.": '{name} could not be deleted. Try again.',
|
||||
"{name} n'a pas pu être supprimée. Réessayez.": '{name} could not be deleted. Try again.',
|
||||
"{name} n'a pas pu être supprimé. Réessayez.": '{name} could not be deleted. Try again.',
|
||||
'Annuler (Ctrl+Z)': 'Undo (Ctrl+Z)',
|
||||
'Le souvenir est supprimé.': 'The memory was deleted.',
|
||||
'Le moment est supprimé.': 'The moment was deleted.',
|
||||
'Annuler': 'Cancel',
|
||||
'Supprimer': 'Delete',
|
||||
'Enregistré': 'Saved',
|
||||
@@ -169,20 +178,20 @@ export default {
|
||||
|
||||
// Ajout d'une personne (AddPersonButton, AddPersonPanel, InlinePersonForm)
|
||||
'Glisser pour ajouter une personne': 'Drag to add a person',
|
||||
'Ajouter un conjoint': 'Add a spouse',
|
||||
'Ajouter un conjoint': 'Add a partner',
|
||||
'Ajouter un frère/sœur': 'Add a sibling',
|
||||
'Relâcher pour créer': 'Release to create',
|
||||
'Ajouter une personne': 'Add a person',
|
||||
'Enfant': 'Child',
|
||||
'Conjoint': 'Spouse',
|
||||
'Conjoint': 'Partner',
|
||||
'Frère·Sœur': 'Sibling',
|
||||
'Positionnez la personne': 'Place the person',
|
||||
'Parent de': 'Parent of',
|
||||
'Enfant de': 'Child of',
|
||||
'Conjoint·e de': 'Spouse of',
|
||||
'Conjoint·e de': 'Partner of',
|
||||
'Frère·Sœur de': 'Sibling of',
|
||||
'Parent': 'Parent',
|
||||
'Conjoint·e': 'Spouse',
|
||||
'Conjoint·e': 'Partner',
|
||||
'Nouvelle fiche': 'New profile',
|
||||
"Ajouter <em>quelqu'un</em>": 'Add <em>someone</em>',
|
||||
'Marie': 'Mary',
|
||||
@@ -197,10 +206,10 @@ export default {
|
||||
'Ajout en cours': 'Adding',
|
||||
'Ajouter cette personne': 'Add this person',
|
||||
'Enfant de {name}': 'Child of {name}',
|
||||
'Conjoint de {name}': 'Spouse of {name}',
|
||||
'Conjoint de {name}': 'Partner of {name}',
|
||||
'Frère·Sœur de {name}': 'Sibling of {name}',
|
||||
'Nouvel enfant': 'New child',
|
||||
'Nouveau conjoint': 'New spouse',
|
||||
'Nouveau conjoint': 'New partner',
|
||||
'Nouveau frère/sœur': 'New sibling',
|
||||
'Nouvelle personne': 'New person',
|
||||
'Jean': 'John',
|
||||
@@ -419,14 +428,16 @@ export default {
|
||||
'Naissance de {name}, sa fratrie': 'Birth of a sibling, {name}',
|
||||
'Naissance à {place}': 'Born in {place}',
|
||||
'Conjoint inconnu': 'Unknown partner',
|
||||
'Union avec {name}': 'Partnership with {name}',
|
||||
'Épouse {name}': 'Marries {name}',
|
||||
'Mariage avec {name}': 'Married {name}',
|
||||
'En couple avec {name}': 'Together with {name}',
|
||||
'C’était un mariage': 'They were married',
|
||||
'Fin de l’union avec {name}': 'End of the relationship with {name}',
|
||||
'Décès de {name}': 'Death of {name}',
|
||||
'Événement': 'Event',
|
||||
'Naissance de {name}': 'Birth of {name}',
|
||||
'Décès à {place}': 'Died in {place}',
|
||||
'Ajouter un moment': 'Add a moment',
|
||||
'Chargement de la frise': 'Loading the timeline',
|
||||
'Modifier « {title} »': 'Edit “{title}”',
|
||||
'Déménagement': 'Move',
|
||||
'L’enregistrement n’a pas abouti. Réessayez dans un instant.': 'Saving did not go through. Try again in a moment.',
|
||||
|
||||
@@ -83,6 +83,7 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
|
||||
.pz-btn--danger-ghost:hover:not(:disabled) { background: var(--pz-danger-bg); }
|
||||
}
|
||||
.pz-link { background: none; border: 0; padding: 0; cursor: pointer; font: 500 14px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 3px; }
|
||||
.pz-inline-link { color: inherit; font-weight: 500; text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
/* ── Champs ───────────────────────────────────────────────────── */
|
||||
.pz-field { display: grid; gap: 7px; }
|
||||
@@ -265,3 +266,20 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
|
||||
/* Dans la barre d'actions, séparé des boutons par un filet */
|
||||
/* Sous les écrans d'accès et de connexion */
|
||||
.pz-lang--screen { justify-self: center; }
|
||||
|
||||
/* Zones de tap : sur écran tactile, chaque petit bouton se touche sur au moins 44 × 44 px (recommandation
|
||||
d'Apple) sans grossir à l'œil : une zone invisible déborde autour de lui. :where() garde la position que le
|
||||
bouton a déjà (une croix posée en absolu le reste) ; un bouton de 44 px ou plus ne déborde pas. */
|
||||
@media (pointer: coarse) {
|
||||
:where(.pz-btn--icon, .pz-btn--sm, .pz-tab, .search-close-button, .pc-rel-remove, .pc-rel-add, .pc-fill-chip) { position: relative; }
|
||||
:is(.pz-btn--icon, .pz-btn--sm, .pz-tab, .search-close-button, .pc-rel-remove, .pc-rel-add, .pc-fill-chip)::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: min(0px, calc((100% - 44px) / 2)) min(0px, calc((100% - 44px) / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/* Raccourci clavier à droite d'une entrée de menu (components/Kbd.jsx) ; rien sur écran tactile */
|
||||
.pz-kbd { display: inline-flex; gap: 3px; margin-left: auto; padding-left: 12px; }
|
||||
.pz-kbd kbd { min-width: 20px; padding: 1px 5px; border: 1px solid var(--pz-line); border-radius: 5px; background: var(--pz-bg); font: 500 11px/16px var(--pz-sans); color: var(--pz-muted); text-align: center; }
|
||||
@media (pointer: coarse) { .pz-kbd { display: none; } }
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isYearOnly } from './dateInput.js'
|
||||
|
||||
// Âge en années révolues (#198) : l'anniversaire compte une fois passé, pas dès le 1er janvier.
|
||||
// Une année seule (rangée au 1er janvier) n'a pas de jour : on garde la différence des années.
|
||||
const partsOf = (value) => {
|
||||
if (value === null || value === undefined || value === '') return null
|
||||
if (Number.isFinite(value)) return { year: Number(value), md: null }
|
||||
const text = String(value).slice(0, 10)
|
||||
const m = text.match(/^(\d{4})(?:-(\d{2})-(\d{2}))?/)
|
||||
if (!m) return null
|
||||
return { year: Number(m[1]), md: m[2] && !isYearOnly(text) ? `${m[2]}-${m[3]}` : null }
|
||||
}
|
||||
|
||||
const today = () => {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
// end absent = aujourd'hui ; null si la naissance (ou la fin donnée) manque
|
||||
export function ageInYears(birth, end = today()) {
|
||||
const from = partsOf(birth)
|
||||
const to = partsOf(end)
|
||||
if (!from || !to) return null
|
||||
const age = to.year - from.year
|
||||
return from.md && to.md && to.md < from.md ? age - 1 : age
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ageInYears } from './age.js'
|
||||
|
||||
describe('ageInYears', () => {
|
||||
it("ne compte l'anniversaire qu'une fois passé (#198)", () => {
|
||||
expect(ageInYears('1980-12-15', '2026-10-29')).toBe(45)
|
||||
expect(ageInYears('1980-12-15', '2026-12-15')).toBe(46)
|
||||
expect(ageInYears('1980-03-02T00:00:00.000Z', '2026-10-29')).toBe(46)
|
||||
})
|
||||
|
||||
it('garde la différence des années pour une année seule', () => {
|
||||
expect(ageInYears('1980-01-01', '2026-10-29')).toBe(46)
|
||||
expect(ageInYears(1980, '2026-10-29')).toBe(46)
|
||||
expect(ageInYears('1920-05-10', 1995)).toBe(75)
|
||||
})
|
||||
|
||||
it('calcule un âge au décès au jour près', () => {
|
||||
expect(ageInYears('1920-05-10', '1995-05-09T00:00:00.000Z')).toBe(74)
|
||||
})
|
||||
|
||||
it('rend null sans naissance', () => {
|
||||
expect(ageInYears(null)).toBe(null)
|
||||
expect(ageInYears('', '2026-10-29')).toBe(null)
|
||||
})
|
||||
})
|
||||
@@ -57,9 +57,10 @@ export function scrubAnalyticsEvent(event) {
|
||||
// La première source connue gagne, « direct » ne remplace jamais une vraie source.
|
||||
const SOURCE_KEY = 'parenthese_source'
|
||||
|
||||
export function rememberSource() {
|
||||
// fallback : source donnée par l'app quand l'adresse n'en porte pas (arbre-partage, demo), au même rang
|
||||
export function rememberSource(fallback = '') {
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('source')
|
||||
const fromUrl = new URLSearchParams(window.location.search).get('source') || fallback
|
||||
const clean = String(fromUrl || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, '').slice(0, 40)
|
||||
if (clean && clean !== 'direct' && !localStorage.getItem(SOURCE_KEY)) {
|
||||
localStorage.setItem(SOURCE_KEY, clean)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { decode, isBlurhashValid } from 'blurhash'
|
||||
|
||||
// Aperçus flous (BlurHash, calculés par l'API, backend/src/lib/blurhash.ts) : une image floue aux couleurs de la
|
||||
// photo, montrée pendant que la vraie se charge. Pas de fondu : la photo remplace l'aperçu d'un coup.
|
||||
|
||||
const SIZE = 32
|
||||
const canvases = new Map()
|
||||
|
||||
// Petit canvas de l'aperçu, gardé par chaîne ; null si la chaîne est absente ou invalide
|
||||
export function blurhashCanvas(hash) {
|
||||
if (!hash || typeof document === 'undefined') return null
|
||||
if (canvases.has(hash)) return canvases.get(hash)
|
||||
let canvas = null
|
||||
if (isBlurhashValid(hash).result) {
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.width = SIZE
|
||||
canvas.height = SIZE
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (ctx) {
|
||||
const image = ctx.createImageData(SIZE, SIZE)
|
||||
image.data.set(decode(hash, SIZE, SIZE))
|
||||
ctx.putImageData(image, 0, 0)
|
||||
} else {
|
||||
canvas = null
|
||||
}
|
||||
}
|
||||
if (canvases.size > 500) canvases.delete(canvases.keys().next().value)
|
||||
canvases.set(hash, canvas)
|
||||
return canvas
|
||||
}
|
||||
|
||||
const urls = new Map()
|
||||
|
||||
// Props d'une balise <img> : l'aperçu en fond tant que la photo n'est pas là. fit suit l'object-fit de l'image
|
||||
// (cover pour une vignette, contain pour la visionneuse). Le fond est retiré au chargement, pour une photo
|
||||
// transparente.
|
||||
export function blurPlaceholder(hash, fit = 'cover') {
|
||||
const canvas = blurhashCanvas(hash)
|
||||
if (!canvas) return {}
|
||||
if (!urls.has(hash)) urls.set(hash, canvas.toDataURL())
|
||||
return {
|
||||
style: { backgroundImage: `url(${urls.get(hash)})`, backgroundSize: fit === 'contain' ? 'contain' : 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat' },
|
||||
onLoad: (event) => { event.currentTarget.style.backgroundImage = 'none' },
|
||||
}
|
||||
}
|
||||
@@ -36,3 +36,20 @@ export function getSiblingParentLinks(personId, filiations, unions) {
|
||||
|
||||
return links
|
||||
}
|
||||
|
||||
/**
|
||||
* Parents à rattacher à un nouvel enfant de `parentId` : quand cette personne n'a qu'une union,
|
||||
* l'enfant est celui du couple (les deux partenaires, avec l'identifiant de l'union).
|
||||
* Aucune union, ou plusieurs (on ne devine pas l'autre parent) : liste vide, l'enfant reste au seul parent.
|
||||
*
|
||||
* @param {string} parentId - Personne à qui l'on ajoute un enfant
|
||||
* @param {Array} unions - Unions du graphe courant ({ id, partner1Id, partner2Id })
|
||||
* @returns {Array<{ parentId: string, unionId: string }>}
|
||||
*/
|
||||
export function getCoupleParentLinks(parentId, unions) {
|
||||
const key = String(parentId)
|
||||
const own = (unions || []).filter((union) => String(union.partner1Id) === key || String(union.partner2Id) === key)
|
||||
if (own.length !== 1 || !own[0].partner1Id || !own[0].partner2Id) return []
|
||||
const unionId = String(own[0].id)
|
||||
return [{ parentId: String(own[0].partner1Id), unionId }, { parentId: String(own[0].partner2Id), unionId }]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { getSiblingParentLinks } from './familyLinks'
|
||||
import { getCoupleParentLinks, getSiblingParentLinks } from './familyLinks'
|
||||
|
||||
describe('getSiblingParentLinks', () => {
|
||||
const unions = [{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' }]
|
||||
@@ -31,3 +31,23 @@ describe('getSiblingParentLinks', () => {
|
||||
expect(getSiblingParentLinks('c1', undefined, undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('getCoupleParentLinks', () => {
|
||||
it('gives both partners when the parent has a single union', () => {
|
||||
const unions = [{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' }]
|
||||
expect(getCoupleParentLinks('p2', unions)).toEqual([
|
||||
{ parentId: 'p1', unionId: 'u1' },
|
||||
{ parentId: 'p2', unionId: 'u1' },
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the child with the parent alone when there is no union or several', () => {
|
||||
expect(getCoupleParentLinks('p1', [])).toEqual([])
|
||||
expect(getCoupleParentLinks('p1', undefined)).toEqual([])
|
||||
const unions = [
|
||||
{ id: 'u1', partner1Id: 'p1', partner2Id: 'p2' },
|
||||
{ id: 'u2', partner1Id: 'p3', partner2Id: 'p1' },
|
||||
]
|
||||
expect(getCoupleParentLinks('p1', unions)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
// « Paul, Marie, Jean » dans le champ Prénom d'un enfant ou d'un frère : une personne par prénom.
|
||||
// Sans virgule, ou pour un parent ou un conjoint, le champ reste un seul prénom.
|
||||
const MAX_NAMES = 20
|
||||
|
||||
export function splitFirstNames(value, relations = []) {
|
||||
const text = String(value || '')
|
||||
if (!text.includes(',')) return [text]
|
||||
if (!relations.some((rel) => rel.type === 'child' || rel.type === 'sibling')) return [text]
|
||||
const names = []
|
||||
for (const part of text.split(',')) {
|
||||
const name = part.trim()
|
||||
if (name && !names.includes(name)) names.push(name)
|
||||
}
|
||||
return names.length > 1 ? names.slice(0, MAX_NAMES) : [names[0] || text]
|
||||
}
|
||||
|
||||
// « Paul, Marie et Jean »
|
||||
export function joinFirstNames(names, and) {
|
||||
if (names.length < 2) return names[0] || ''
|
||||
return `${names.slice(0, -1).join(', ')} ${and} ${names[names.length - 1]}`
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { joinFirstNames, splitFirstNames } from './firstNames.js'
|
||||
|
||||
const child = [{ type: 'child', personId: 1 }]
|
||||
|
||||
describe('plusieurs prénoms dans le champ Prénom', () => {
|
||||
it("découpe pour un enfant ou un frère", () => {
|
||||
expect(splitFirstNames('Paul, Marie, Jean', child)).toEqual(['Paul', 'Marie', 'Jean'])
|
||||
expect(splitFirstNames('Paul,Marie', [{ type: 'sibling', personId: 2 }])).toEqual(['Paul', 'Marie'])
|
||||
})
|
||||
|
||||
it('laisse un seul prénom sans virgule, ou pour un parent ou un conjoint', () => {
|
||||
expect(splitFirstNames('Jean-Pierre', child)).toEqual(['Jean-Pierre'])
|
||||
expect(splitFirstNames('Paul, Marie', [{ type: 'parent', personId: 1 }])).toEqual(['Paul, Marie'])
|
||||
expect(splitFirstNames('Paul, Marie', [])).toEqual(['Paul, Marie'])
|
||||
})
|
||||
|
||||
it('ignore les vides et les doublons', () => {
|
||||
expect(splitFirstNames(' Paul , , Marie, Paul ', child)).toEqual(['Paul', 'Marie'])
|
||||
expect(splitFirstNames('Paul,', child)).toEqual(['Paul'])
|
||||
})
|
||||
|
||||
it('les relie pour le message', () => {
|
||||
expect(joinFirstNames(['Paul', 'Marie', 'Jean'], 'et')).toBe('Paul, Marie et Jean')
|
||||
expect(joinFirstNames(['Paul'], 'et')).toBe('Paul')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from '../i18n/index.js'
|
||||
import { HELP_URL } from './siteLinks.js'
|
||||
|
||||
// Du 1er décembre au 6 janvier, la famille se retrouve : le message d'invitation parle de Noël
|
||||
export function isChristmasSeason(now = new Date()) {
|
||||
return now.getMonth() === 11 || (now.getMonth() === 0 && now.getDate() <= 6)
|
||||
}
|
||||
|
||||
// Message d'invitation prêt à envoyer par mail, SMS ou WhatsApp. seasonal : l'instance l'autorise (SIGNUP_PROMPTS)
|
||||
export function buildInviteText(url, password, { seasonal = false, now = new Date() } = {}) {
|
||||
return [
|
||||
t('Bonjour à tous,'),
|
||||
'',
|
||||
seasonal && isChristmasSeason(now)
|
||||
? t("Pour Noël, j'ai mis l'arbre de notre famille en ligne. Regardez-le avant le réveillon et apportez vos vieilles photos, on le complétera ensemble à table. Et si quelqu'un raconte une histoire de famille, enregistrez-la sur votre téléphone, sa voix aura sa place dans l'arbre.")
|
||||
: t("Je rassemble l'histoire de notre famille dans un arbre en ligne, et j'aimerais que vous m'aidiez à le compléter."),
|
||||
'',
|
||||
t("L'arbre est ici {url}", { url }),
|
||||
t("Le mot de passe pour l'ouvrir est {password}", { password }),
|
||||
'',
|
||||
t("En bas de l'écran, vous pouvez ajouter une photo, un souvenir ou une personne qui manque."),
|
||||
t("Le mode d'emploi, si besoin {url}", { url: HELP_URL }),
|
||||
].join('\n')
|
||||
}
|
||||
Loaded 100 of 145 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user