mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 16:16:09 +00:00
Merge origin/main dans feat/immich-memories-retouches
This commit is contained in:
commit
0ff143f2c1
199 files changed
+10164
-4288
No files matched your search
@@ -34,6 +34,26 @@ CORS_ORIGIN=http://localhost
|
||||
# MEDIA_QUOTA_PER_TREE_MB=5120
|
||||
# MEDIA_PENDING_QUOTA_PER_TREE_MB=500
|
||||
|
||||
# ── Comptes ───────────────────────────────────────────────────────────────────
|
||||
# À false, plus personne ne peut créer de compte : les comptes existants se connectent et
|
||||
# créent leurs arbres, la famille ouvre toujours les arbres partagés avec le lien et le mot
|
||||
# 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
|
||||
|
||||
# ── Connexion par un fournisseur d'identité (OIDC : Authentik, Authelia, Keycloak…) ──
|
||||
# Éteinte sans OIDC_ISSUER. Chez le fournisseur, adresse de retour = adresse publique + /auth/oidc/callback.
|
||||
# Détails dans le README, section « Connexion par votre fournisseur d'identité ».
|
||||
# OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/
|
||||
# OIDC_CLIENT_ID=parenthese
|
||||
# OIDC_CLIENT_SECRET=
|
||||
# OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback
|
||||
# OIDC_PROVIDER_NAME=Authentik
|
||||
# Email accepté seulement s'il est vérifié chez le fournisseur (email_verified).
|
||||
# OIDC_REQUIRE_VERIFIED_EMAIL=true
|
||||
# À false, plus de mot de passe Parenthèse : connexion par le fournisseur seulement.
|
||||
# PASSWORD_LOGIN=true
|
||||
|
||||
# ── Immich (reconnaissance des visages, désactivé par défaut) ────────────────
|
||||
# Instances Immich autorisées sur ce serveur, séparées par des virgules. Chaque entrée
|
||||
# peut recevoir un libellé, « Libellé=https://adresse » ; sans libellé, c'est le nom
|
||||
|
||||
@@ -2,3 +2,5 @@
|
||||
* text=auto
|
||||
# Scripts lancés sous Linux (serveur, conteneurs, CI) : fins de ligne LF même sur un clone Windows
|
||||
*.sh text eol=lf
|
||||
# Témoins texte du placement : comparés octet pour octet par vitest, toujours en LF
|
||||
frontend/src/components/galaxy/__snapshots__/** text eol=lf
|
||||
@@ -182,6 +182,13 @@ jobs:
|
||||
- name: Test de fumée par le port web
|
||||
run: bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
# Les commandes de sauvegarde et de restauration du README, jouées pour de vrai
|
||||
- name: Sauvegarde puis restauration
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash tools/docker-restore-check.sh http://localhost:8080
|
||||
bash tools/docker-smoke.sh http://localhost:8080
|
||||
|
||||
# Un second front qui joint l'API par le nom de son conteneur (API_UPSTREAM), comme sur Umbrel.
|
||||
- name: Front avec une autre adresse d'API
|
||||
run: |
|
||||
@@ -233,6 +240,9 @@ jobs:
|
||||
docker restart aio
|
||||
bash tools/docker-smoke.sh http://localhost:8080
|
||||
docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1
|
||||
# Base à jour au redémarrage : pas de sauvegarde avant migration, mais pg_dump marche dans l'image
|
||||
[ -z "$(docker exec aio ls -A /data/backups 2>/dev/null)" ]
|
||||
[ "$(docker exec aio runuser -u postgres -- pg_dump -Fc parenthese | wc -c)" -gt 1000 ]
|
||||
|
||||
- name: Journaux en cas d'échec
|
||||
if: failure()
|
||||
|
||||
@@ -2,6 +2,52 @@
|
||||
|
||||
Notable changes to Parenthèse. Dates are in YYYY-MM-DD format.
|
||||
|
||||
## 1.2.1 (2026-10-21)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Clicking a person without a photo opens their profile again. The file picker only opens from "+ photo" in edit mode (#124, reported by @coutadeurf, #125).
|
||||
- GEDCOM export stays valid 5.5.1. Sex "other" is written as `SEX U`, and `SEX X` is still read on import (#126).
|
||||
|
||||
### Self-hosting
|
||||
|
||||
- With `PASSWORD_LOGIN=false`, an account that had a password and was linked to OIDC can delete itself by retyping its email after a recent sign-in (#126).
|
||||
- An email already linked to an OIDC identity can't be claimed by a different subject at the provider (#126).
|
||||
- HEIC and AVIF files are recognised as images instead of video, and are refused cleanly until they can be converted (#126).
|
||||
|
||||
## 1.2.0 (2026-10-20)
|
||||
|
||||
### Tree layout
|
||||
|
||||
- Rows follow the kinship generation from the tree's starting person: spouses on the same row, parents one row up, children one row down. A shorter branch married into a deeper one no longer sits several rows too high (#67, reported by @SpaceOptimist, #111).
|
||||
- One tree, never islands. An in-law family attaches to its child at the end of the sibling row, never between two siblings, and each union of a remarriage sits above its own children (#105, #116).
|
||||
- In-law families wider than a screen open in their own tab, the same tree drawn from the spouse. A pill above the spouse leads there and back, and tabs with the same name show the couple's first names. Search opens the tab holding the person. Tree settings can turn tabs off (#116, #81 reported by @coutadeurf).
|
||||
- Lines between families are full lines when they cross no other family, with one trunk per sibling row (#83, #90).
|
||||
|
||||
### People
|
||||
|
||||
- Sex can be set to female, male or other, and is unspecified by default. Relationship names follow it in French and English, with neutral or double forms when unknown. GEDCOM `SEX X` is read and written (#114, suggested by @Arc676, #118).
|
||||
- Nicknames show under the name in the tree and the profile (#85). The last name is optional and an unknown first name can be ticked (#87, #89).
|
||||
- Profiles: pills for facts, place of death, adoption, autosave when leaving, a cleared field stays cleared, close by swiping down on phones (#91, #93, #98, #101, #106).
|
||||
- Portraits can be cropped with the full photo kept, in a round or square frame, and "+ photo" opens the picker right away (#95, #108, #109). Admins can edit a memory (#94).
|
||||
|
||||
### Sharing and family
|
||||
|
||||
- Guests can propose changes with the pencil, and the owner sees pending proposals on their avatar as soon as the tree opens (#96, #121).
|
||||
- A simpler start page with two choices, one door for shared trees, and a clearer login (#86, #88, #92).
|
||||
- The bottom bar is one pill with Add or correct and the account menu (#100, #104, #107). Search results can be tapped in edit mode on phones (#110).
|
||||
|
||||
### Self-hosting
|
||||
|
||||
- `ALLOW_REGISTRATION=false` closes sign-up. Existing accounts log in and the first account of an empty instance is still allowed (#113, suggested by @milan616, #117).
|
||||
- OIDC login (experimental) with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_REDIRECT_URI`. An existing account is linked by verified email only, and never an account that has a password (sign-up does not verify the address). `PASSWORD_LOGIN=false` turns passwords off (#115, suggested by @Arc676, #122).
|
||||
|
||||
### Fixes and maintenance
|
||||
|
||||
- GEDCOM import in transactions and much faster on large files, backups restore tested in CI, and other points from the 18/10 audit (#97). A union without a date sits at its logical place in the Life tab (#102).
|
||||
- Mobile: separated parents drawn as a dotted line, lighter edit bar, dates on the number pad (#82).
|
||||
- Eleven unused interface modules removed (#119).
|
||||
|
||||
## 1.1.0 (2026-10-17)
|
||||
|
||||
### Life tab
|
||||
|
||||
+24
-4
@@ -57,6 +57,26 @@ Trois conteneurs tournent : `db` (PostgreSQL 16), `api` (l'API Node) et `web` (n
|
||||
|
||||
Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HTTPS devant le port `web` (Caddy, Traefik, nginx) et mettez l'adresse publique dans `CORS_ORIGIN`, par exemple `https://arbre.votre-domaine.fr`.
|
||||
|
||||
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.
|
||||
|
||||
#### Connexion par votre fournisseur d'identité (OIDC)
|
||||
|
||||
*Expérimental dans la 1.2.0 : testé contre un fournisseur OpenID certifié, vos retours sur de vraies installations sont les bienvenus dans les issues.*
|
||||
|
||||
Parenthèse peut confier la connexion à un fournisseur OpenID Connect que vous faites déjà tourner (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…) : la famille garde un seul compte et sa double authentification. Créez chez lui une application OIDC (client confidentiel, flux « authorization code ») dont l'adresse de retour est l'adresse publique de Parenthèse suivie de `/auth/oidc/callback`, puis renseignez dans `.env` :
|
||||
|
||||
```bash
|
||||
OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/ # exactement comme le fournisseur l'annonce
|
||||
OIDC_CLIENT_ID=parenthese
|
||||
OIDC_CLIENT_SECRET=... # vide pour un client public (PKCE seul)
|
||||
OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback
|
||||
OIDC_PROVIDER_NAME=Authentik # nom affiché sur le bouton
|
||||
```
|
||||
|
||||
L'écran de connexion affiche alors « Se connecter avec Authentik ». À la première connexion, Parenthèse relie le compte du fournisseur au compte Parenthèse qui a le même email, ou en crée un si `ALLOW_REGISTRATION` le permet. Inscription fermée, le fournisseur ne connecte que les personnes qui ont déjà un compte (le premier compte d'une instance vide reste accepté). Un email n'est pris en compte que si le fournisseur le dit vérifié (`email_verified`) ; `OIDC_REQUIRE_VERIFIED_EMAIL=false` lève ce contrôle, pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse. Une fois relié, un compte suit l'identifiant de l'utilisateur chez le fournisseur, pas son email.
|
||||
|
||||
`PASSWORD_LOGIN=false` retire les mots de passe Parenthèse : la connexion passe par le fournisseur seulement (l'API refuse de démarrer sans `OIDC_ISSUER`). Un compte créé par le fournisseur n'a pas de mot de passe ; pour le supprimer, on retape son email. `OIDC_SCOPES` (par défaut `openid email profile`) et `OIDC_APP_URL` (où l'app s'ouvre après la connexion, par défaut l'origine de `OIDC_REDIRECT_URI`) se changent rarement.
|
||||
|
||||
Une instance auto-hébergée n'envoie aucune mesure d'audience : le suivi d'usage de parenthese.io ne s'active que si une clé PostHog est fournie au build, ce que cette configuration ne fait pas.
|
||||
|
||||
Les liens d'aide et de vie privée de l'application mènent à parenthese.io. Pour pointer vers vos propres pages, renseignez `VITE_HELP_URL` et `VITE_PRIVACY_URL` quand vous construisez vous-même le front (voir `frontend/.env.example`).
|
||||
@@ -69,7 +89,7 @@ docker compose ps # état des conteneurs
|
||||
docker compose down # arrêt, les données sont conservées
|
||||
```
|
||||
|
||||
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base.
|
||||
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base. Avec `ALLOW_REGISTRATION=false`, le script s'arrête à la création du compte, c'est le refus attendu.
|
||||
|
||||
### Un seul conteneur (Unraid, NAS)
|
||||
|
||||
@@ -79,7 +99,7 @@ Pour les systèmes qui installent un conteneur par application, `ghcr.io/iliesal
|
||||
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /chemin/vers/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
|
||||
```
|
||||
|
||||
Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16.
|
||||
Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Pour fermer la création de compte, ajoutez `-e ALLOW_REGISTRATION=false` (sur Unraid, « Allow registration » à `false`). Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16.
|
||||
|
||||
### Où sont les données
|
||||
|
||||
@@ -99,7 +119,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac
|
||||
|
||||
La première commande sauvegarde la base. Elle contient les personnes, leurs liens, les comptes et les contributions, et elle sait à qui appartient chaque photo, vidéo ou enregistrement. La seconde sauvegarde les fichiers eux-mêmes. Il faut les deux. Restaurez la base seule et les souvenirs pointent vers des fichiers disparus. Restaurez l'archive seule et vous avez un dossier de fichiers rattachés à personne. L'export GEDCOM n'est pas une sauvegarde non plus. Il sert à emmener l'arbre vers un autre logiciel de généalogie et laisse de côté les médias et les annotations.
|
||||
|
||||
Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`.
|
||||
Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`.
|
||||
|
||||
Le script `deploy/backup.sh` fait la même chose (dump PostgreSQL, archive des médias, sommes de contrôle, rotation, envoi S3 optionnel) pour une installation sans Docker : il lit `backend/.env` et a besoin de `pg_dump` sur la machine. Ses réglages passent par les variables `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` et `BACKUP_S3_BUCKET`.
|
||||
|
||||
@@ -111,7 +131,7 @@ docker compose pull && docker compose up -d
|
||||
|
||||
`latest` suit la branche main. Pour rester sur une version, renseignez `PARENTHESE_VERSION` dans `.env` (par exemple `1.0`). Depuis un clone, `git pull && docker compose up -d --build` reconstruit depuis les sources.
|
||||
|
||||
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude.
|
||||
Les migrations de base de données s'appliquent toutes seules au démarrage de l'API, et une migration ne se défait pas. Lancez les deux commandes de sauvegarde ci-dessus avant chaque mise à jour. L'image tout-en-un le fait seule : avant d'appliquer une migration, elle copie la base dans `/data/backups` et garde les cinq dernières copies.
|
||||
|
||||
## Développer
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
A family tree you explore like a map, with the photos, videos and voices of each person. Share it with one link and a password: relatives look around without an account and add what they know.
|
||||
|
||||
Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account needed: [app.parenthese.io](https://app.parenthese.io/?lang=en) · [Version française](README.fr.md)
|
||||
Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account needed: [app.parenthese.io](https://app.parenthese.io/?demo&lang=en) · [Version française](README.fr.md)
|
||||
|
||||

|
||||
|
||||
@@ -63,6 +63,26 @@ Three containers run: `db` (PostgreSQL 16), `api` (the Node API) and `web` (ngin
|
||||
|
||||
To open the instance to your family over the Internet, put an HTTPS reverse proxy in front of the `web` port (Caddy, Traefik, nginx) and set the public address in `CORS_ORIGIN`, for example `https://tree.your-domain.com`.
|
||||
|
||||
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.
|
||||
|
||||
#### 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.*
|
||||
|
||||
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`:
|
||||
|
||||
```bash
|
||||
OIDC_ISSUER=https://auth.your-domain.com/application/o/parenthese/ # exactly as the provider announces it
|
||||
OIDC_CLIENT_ID=parenthese
|
||||
OIDC_CLIENT_SECRET=... # empty for a public client (PKCE only)
|
||||
OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback
|
||||
OIDC_PROVIDER_NAME=Authentik # shown on the button
|
||||
```
|
||||
|
||||
The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email.
|
||||
|
||||
`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing.
|
||||
|
||||
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
|
||||
|
||||
The help and privacy links in the app point to parenthese.io. To use your own pages, set `VITE_HELP_URL` and `VITE_PRIVACY_URL` when you build the front end yourself (see `frontend/.env.example`).
|
||||
@@ -75,7 +95,7 @@ docker compose ps # container status
|
||||
docker compose down # stop, data is kept
|
||||
```
|
||||
|
||||
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database.
|
||||
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database. With `ALLOW_REGISTRATION=false` the script stops at account creation, which is the expected refusal.
|
||||
|
||||
### Single container (Unraid, NAS)
|
||||
|
||||
@@ -85,7 +105,7 @@ For systems that install one container per app, `ghcr.io/iliesallali/parenthese-
|
||||
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /path/to/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
|
||||
```
|
||||
|
||||
There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16.
|
||||
There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. To close account creation, add `-e ALLOW_REGISTRATION=false` (on Unraid, set "Allow registration" to `false`). Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16.
|
||||
|
||||
### Where the data lives
|
||||
|
||||
@@ -105,7 +125,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac
|
||||
|
||||
The first command saves the database. It holds the people, their links, the accounts and the contributions, plus the record of which photo, video or recording belongs to whom. The second saves the files themselves. You need both. Restore only the database and the memories point to files that are gone. Restore only the archive and you get a folder of files with no names attached. The GEDCOM export is not a backup either. It moves the tree to another genealogy program and leaves the media and the annotations behind.
|
||||
|
||||
Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`.
|
||||
Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`.
|
||||
|
||||
The `deploy/backup.sh` script does the same (PostgreSQL dump, media archive, checksums, rotation, optional S3 upload) for an installation without Docker. It reads `backend/.env` and needs `pg_dump` on the machine. It is configured through the `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` and `BACKUP_S3_BUCKET` variables.
|
||||
|
||||
@@ -117,7 +137,7 @@ docker compose pull && docker compose up -d
|
||||
|
||||
`latest` follows the main branch. To stay on a version, set `PARENTHESE_VERSION` in `.env` (for example `1.0`). From a clone, `git pull && docker compose up -d --build` builds from the source instead.
|
||||
|
||||
Database migrations apply on their own when the API starts. Make a backup first, as a habit.
|
||||
Database migrations apply on their own when the API starts, and a migration cannot be undone. Run the two backup commands above before each update. The all-in-one image does it by itself: before applying a migration, it copies the database to `/data/backups` and keeps the last five copies.
|
||||
|
||||
## Development
|
||||
|
||||
|
||||
@@ -11,3 +11,15 @@ MEDIA_STORAGE_PATH="./storage/media"
|
||||
# donc un libellé si deux instances partagent un nom). Les administrateurs de chaque arbre choisissent
|
||||
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
|
||||
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
|
||||
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
|
||||
# les mots de passe Parenthèse.
|
||||
OIDC_ISSUER=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_REDIRECT_URI=""
|
||||
OIDC_PROVIDER_NAME="SSO"
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL=true
|
||||
PASSWORD_LOGIN=true
|
||||
@@ -29,3 +29,16 @@ MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
|
||||
# donc un libellé si deux instances partagent un nom). Les administrateurs de chaque arbre choisissent
|
||||
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
|
||||
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
|
||||
# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire
|
||||
# les mots de passe Parenthèse.
|
||||
OIDC_ISSUER=""
|
||||
OIDC_CLIENT_ID=""
|
||||
OIDC_CLIENT_SECRET=""
|
||||
OIDC_REDIRECT_URI=""
|
||||
OIDC_PROVIDER_NAME="SSO"
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL=true
|
||||
PASSWORD_LOGIN=true
|
||||
@@ -538,6 +538,86 @@ components:
|
||||
required: [decision]
|
||||
|
||||
paths:
|
||||
/auth/options:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: What the sign-in screen can offer on this instance
|
||||
responses:
|
||||
'200':
|
||||
description: Instance options
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
registration:
|
||||
type: boolean
|
||||
description: false when ALLOW_REGISTRATION=false and the instance already has an account
|
||||
passwordLogin:
|
||||
type: boolean
|
||||
description: false when PASSWORD_LOGIN=false (sign-in through the OIDC provider only)
|
||||
oidc:
|
||||
type: object
|
||||
nullable: true
|
||||
description: OIDC provider shown on the sign-in screen, null when OIDC_ISSUER is not set
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
required: [registration, passwordLogin, oidc]
|
||||
|
||||
/auth/oidc/start:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Start an OIDC sign-in (browser navigation)
|
||||
description: Sets a signed, httpOnly cookie holding state, nonce and PKCE verifier, then redirects to the provider. Only exists when OIDC_ISSUER is set.
|
||||
parameters:
|
||||
- name: returnTo
|
||||
in: query
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
description: Either / or /arbre/<slug>, anything else falls back to /
|
||||
responses:
|
||||
'302':
|
||||
description: Redirect to the provider, or back to the app with "#oidc_error=oidc_unavailable"
|
||||
|
||||
/auth/oidc/callback:
|
||||
get:
|
||||
tags: [Auth]
|
||||
summary: Provider redirect target (browser navigation)
|
||||
description: Checks the cookie, then lets openid-client exchange the code and verify the ID token (signature, issuer, audience, expiry, nonce, state, PKCE). Finds the account by provider subject, links a verified email, or creates an account when registration allows it.
|
||||
responses:
|
||||
'302':
|
||||
description: Back to the app with "#oidc=<one-time handoff>" or "#oidc_error=oidc_failed|oidc_email_unverified|oidc_email_missing|registration_closed"
|
||||
|
||||
/auth/oidc/exchange:
|
||||
post:
|
||||
tags: [Auth]
|
||||
summary: Trade the one-time handoff for a session
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
required: [code]
|
||||
responses:
|
||||
'200':
|
||||
description: Session
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthResponse'
|
||||
'401':
|
||||
description: Handoff invalid, expired or already used (invalid_handoff)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
|
||||
/auth/register:
|
||||
post:
|
||||
tags: [Auth]
|
||||
@@ -561,6 +641,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: Registration closed on this instance (registration_closed), or PASSWORD_LOGIN=false (password_login_disabled)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'409':
|
||||
description: Email already exists
|
||||
content:
|
||||
@@ -597,6 +683,12 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'403':
|
||||
description: PASSWORD_LOGIN=false (password_login_disabled)
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ErrorResponse'
|
||||
'401':
|
||||
description: Invalid credentials
|
||||
content:
|
||||
|
||||
Generated
+66
-30
@@ -8,6 +8,7 @@
|
||||
"name": "backend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
@@ -15,6 +16,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^18.0.7",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
@@ -499,6 +501,39 @@
|
||||
"fast-uri": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^2.0.0",
|
||||
"fastify-plugin": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cookie/node_modules/cookie": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz",
|
||||
"integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/cors": {
|
||||
"version": "11.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.1.tgz",
|
||||
@@ -1409,9 +1444,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1430,9 +1462,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1451,9 +1480,6 @@
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1472,9 +1498,6 @@
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1493,9 +1516,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -1514,9 +1534,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2670,6 +2687,15 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/jose": {
|
||||
"version": "6.2.13",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz",
|
||||
"integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-ref-resolver": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
|
||||
@@ -2881,9 +2907,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2906,9 +2929,6 @@
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2931,9 +2951,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -2956,9 +2973,6 @@
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -3094,6 +3108,15 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oauth4webapi": {
|
||||
"version": "3.8.8",
|
||||
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz",
|
||||
"integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/obliterator": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
|
||||
@@ -3130,6 +3153,19 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/openid-client": {
|
||||
"version": "6.8.8",
|
||||
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz",
|
||||
"integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jose": "^6.2.12",
|
||||
"oauth4webapi": "^3.8.8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"prisma:deploy": "prisma migrate deploy"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.3",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
@@ -24,6 +25,7 @@
|
||||
"bcryptjs": "^3.0.2",
|
||||
"dotenv": "^18.0.7",
|
||||
"fastify": "^5.12.7",
|
||||
"openid-client": "^6.8.8",
|
||||
"sharp": "^0.35.5",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Prénom du compte, facultatif (comptes existants : vide). Migration additive.
|
||||
ALTER TABLE "users" ADD COLUMN "firstName" TEXT;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Cadrage du portrait (feat/recadrage-portrait). Migration additive : colonne nullable, sans défaut.
|
||||
-- AlterTable
|
||||
ALTER TABLE "persons" ADD COLUMN "avatarCrop" JSONB;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Connexion OIDC (#115) : un compte peut naître sans mot de passe, et ses identités chez un fournisseur sont gardées
|
||||
-- à part (émetteur + sub). Migration additive : les comptes existants gardent leur mot de passe.
|
||||
-- AlterTable
|
||||
ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP NOT NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_identities" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"issuer" TEXT NOT NULL,
|
||||
"subject" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "user_identities_userId_idx" ON "user_identities"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_identities_issuer_subject_key" ON "user_identities"("issuer", "subject");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
@@ -81,7 +81,9 @@ enum SharedAccessRole {
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
passwordHash String
|
||||
firstName String?
|
||||
// Vide pour un compte né d'une connexion OIDC : il n'a pas de mot de passe Parenthèse
|
||||
passwordHash String?
|
||||
lastOpenedTreeId String?
|
||||
lastOpenedAccessMode String?
|
||||
lastOpenedRole String?
|
||||
@@ -93,12 +95,28 @@ model User {
|
||||
submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy")
|
||||
reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy")
|
||||
treeVisits TreeVisit[]
|
||||
identities UserIdentity[]
|
||||
lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([lastOpenedAt])
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
// Compte d'un fournisseur OIDC relié à un compte Parenthèse : l'émetteur et le « sub » de l'ID token, jamais l'email,
|
||||
// qui peut changer chez le fournisseur.
|
||||
model UserIdentity {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
issuer String
|
||||
subject String
|
||||
createdAt DateTime @default(now())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([issuer, subject])
|
||||
@@index([userId])
|
||||
@@map("user_identities")
|
||||
}
|
||||
|
||||
model Tree {
|
||||
id String @id @default(cuid())
|
||||
ownerUserId String
|
||||
@@ -229,6 +247,10 @@ model Person {
|
||||
nationality String?
|
||||
avatarPath String?
|
||||
avatarMimeType String?
|
||||
// Cadrage du portrait, appliqué à l'affichage sur l'original (jamais de fichier dérivé) :
|
||||
// { "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?
|
||||
sex String?
|
||||
notes String?
|
||||
createdBy String?
|
||||
|
||||
+12
-2
@@ -1,4 +1,6 @@
|
||||
import { createHmac } from 'node:crypto'
|
||||
import Fastify, { type FastifyError } from 'fastify'
|
||||
import cookie from '@fastify/cookie'
|
||||
import cors from '@fastify/cors'
|
||||
import jwt from '@fastify/jwt'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
@@ -12,6 +14,7 @@ import { contributionRoutes } from './routes/contributions.js'
|
||||
import { graphRoutes } from './routes/graph.js'
|
||||
import { immichRoutes } from './routes/immich.js'
|
||||
import { mediaRoutes } from './routes/media.js'
|
||||
import { oidcRoutes } from './routes/oidc.js'
|
||||
import { publicRoutes } from './routes/public.js'
|
||||
import { annotationRoutes } from './routes/annotations.js'
|
||||
import { gedcomRoutes } from './routes/gedcom.js'
|
||||
@@ -19,9 +22,10 @@ import { treeRoutes } from './routes/trees.js'
|
||||
import { visitRoutes } from './routes/visits.js'
|
||||
import type { AnyJwtPayload } from './types/auth.js'
|
||||
|
||||
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête) : jamais dans les journaux
|
||||
// Les adresses des médias portent un jeton (<img src> n'envoie pas d'en-tête), le retour OIDC un code
|
||||
// d'autorisation : jamais dans les journaux
|
||||
export function redactUrl(url: string | undefined): string | undefined {
|
||||
return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]')
|
||||
return url?.replace(/([?&](?:token|code)=)[^&#]*/g, '$1[masqué]')
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
@@ -68,6 +72,11 @@ export function createApp() {
|
||||
secret: env.JWT_SECRET,
|
||||
})
|
||||
|
||||
// Cookie signé du parcours OIDC seulement (state, nonce, PKCE entre /auth/oidc/start et le retour) : les sessions
|
||||
// restent des jetons Bearer
|
||||
// Clé propre au cookie, dérivée de JWT_SECRET : une signature de cookie ne vaut jamais signature de jeton
|
||||
app.register(cookie, { secret: createHmac('sha256', env.JWT_SECRET).update('parenthese-oidc-cookie').digest('hex') })
|
||||
|
||||
app.addHook('preHandler', async (request) => {
|
||||
request.actor = null
|
||||
|
||||
@@ -140,6 +149,7 @@ export function createApp() {
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
app.register(authRoutes, { prefix: '/auth' })
|
||||
app.register(oidcRoutes, { prefix: '/auth/oidc' })
|
||||
app.register(publicRoutes)
|
||||
app.register(treeRoutes)
|
||||
app.register(mediaRoutes)
|
||||
|
||||
@@ -28,6 +28,16 @@ function parseTrustProxy(value: string): boolean | string {
|
||||
return value
|
||||
}
|
||||
|
||||
// « true » ou « false », sans tenir compte de la casse. z.coerce.boolean lirait « false » comme vrai.
|
||||
function parseBooleanFlag(name: string) {
|
||||
return (value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase()
|
||||
if (normalized === 'true') return true
|
||||
if (normalized === 'false') return false
|
||||
throw new Error(`${name} must be "true" or "false"`)
|
||||
}
|
||||
}
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(4000),
|
||||
@@ -54,10 +64,58 @@ const envSchema = z.object({
|
||||
// d'un arbre choisissent leur connexion parmi cette liste : sans elle, la reconnaissance
|
||||
// des visages reste éteinte et le serveur ne peut pas servir de proxy vers un service interne.
|
||||
IMMICH_ALLOWED_API_URLS: z.string().default(''),
|
||||
// Création de compte. À « false », seuls les comptes existants se connectent : une instance ouverte sur
|
||||
// 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')),
|
||||
// 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(''),
|
||||
OIDC_CLIENT_ID: z.string().trim().default(''),
|
||||
// Vide : client public, protégé par PKCE seul
|
||||
OIDC_CLIENT_SECRET: z.string().default(''),
|
||||
OIDC_REDIRECT_URI: z.string().trim().default(''),
|
||||
// Où l'app s'ouvre après la connexion. Par défaut l'origine de OIDC_REDIRECT_URI (Docker : front et API sur la
|
||||
// même adresse) ; à renseigner quand le front a sa propre adresse (développement).
|
||||
OIDC_APP_URL: z.string().trim().default(''),
|
||||
OIDC_SCOPES: z.string().default('openid email profile'),
|
||||
// Nom du bouton « Se connecter avec … »
|
||||
OIDC_PROVIDER_NAME: z.string().trim().max(40).default('SSO'),
|
||||
// Un compte n'est créé ou relié par son email que si le fournisseur le dit vérifié (email_verified). À false
|
||||
// seulement pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse.
|
||||
OIDC_REQUIRE_VERIFIED_EMAIL: z.string().default('true').transform(parseBooleanFlag('OIDC_REQUIRE_VERIFIED_EMAIL')),
|
||||
// À false, plus de mot de passe Parenthèse : connexion par le fournisseur OIDC seulement
|
||||
PASSWORD_LOGIN: z.string().default('true').transform(parseBooleanFlag('PASSWORD_LOGIN')),
|
||||
})
|
||||
|
||||
export const env = envSchema.parse(process.env)
|
||||
|
||||
function assertHttpUrl(name: string, value: string) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new Error(`${name} must be a full URL`)
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error(`${name} must be an http(s) URL`)
|
||||
}
|
||||
}
|
||||
|
||||
if (env.OIDC_ISSUER) {
|
||||
assertHttpUrl('OIDC_ISSUER', env.OIDC_ISSUER)
|
||||
if (!env.OIDC_CLIENT_ID) throw new Error('OIDC_CLIENT_ID is required with OIDC_ISSUER')
|
||||
if (!env.OIDC_REDIRECT_URI) throw new Error('OIDC_REDIRECT_URI is required with OIDC_ISSUER (public address of /auth/oidc/callback)')
|
||||
assertHttpUrl('OIDC_REDIRECT_URI', env.OIDC_REDIRECT_URI)
|
||||
if (env.OIDC_APP_URL) assertHttpUrl('OIDC_APP_URL', env.OIDC_APP_URL)
|
||||
if (!env.OIDC_SCOPES.split(/\s+/).includes('openid')) throw new Error('OIDC_SCOPES must include openid')
|
||||
}
|
||||
|
||||
// Sans mot de passe ni fournisseur, personne ne pourrait se connecter
|
||||
if (!env.PASSWORD_LOGIN && !env.OIDC_ISSUER) {
|
||||
throw new Error('PASSWORD_LOGIN=false needs OIDC_ISSUER: otherwise nobody can sign in')
|
||||
}
|
||||
|
||||
// Corps maximal des routes qui reçoivent un fichier en base64 ou un gros envoi (le reste de l'API : 1 Mo)
|
||||
export const UPLOAD_BODY_LIMIT = 12 * 1024 * 1024
|
||||
|
||||
|
||||
+10
-1
@@ -1,5 +1,7 @@
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
const HASH_ROUNDS = 12
|
||||
|
||||
export async function hashPassword(value: string): Promise<string> {
|
||||
@@ -8,4 +10,11 @@ export async function hashPassword(value: string): Promise<string> {
|
||||
|
||||
export async function verifyPassword(value: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(value, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte.
|
||||
// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le
|
||||
// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente.
|
||||
export function confirmsWithPassword(passwordHash: string | null | undefined): boolean {
|
||||
return Boolean(passwordHash) && env.PASSWORD_LOGIN
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Cadrage d'un portrait : centre (proportions de l'image), zoom, et forme du cadre si on l'a choisie.
|
||||
// Zoom 1 = l'image entière recouvre le cadre. Les bornes qui empêchent le vide sont appliquées à
|
||||
// l'affichage (frontend, portraitCrop.js), le serveur vérifie seulement la forme et les plages.
|
||||
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] as const
|
||||
export type AvatarCrop = { x: number; y: number; zoom: number; shape?: (typeof FRAME_SHAPES)[number] }
|
||||
|
||||
export const AVATAR_CROP_MAX_ZOOM = 4
|
||||
|
||||
export const avatarCropInputSchema = z.object({
|
||||
crop: z
|
||||
.object({
|
||||
x: z.number().min(0).max(1),
|
||||
y: z.number().min(0).max(1),
|
||||
zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM),
|
||||
shape: z.enum(FRAME_SHAPES).optional(),
|
||||
})
|
||||
.nullable(),
|
||||
})
|
||||
|
||||
const round4 = (value: number) => Math.round(value * 10000) / 10000
|
||||
|
||||
// Forme à écrire en base. Le cadrage par défaut, sans forme choisie, se stocke comme absence de cadrage (null).
|
||||
export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null {
|
||||
if (!crop) return null
|
||||
const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) }
|
||||
if (crop.shape) return { ...normalized, shape: crop.shape }
|
||||
if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null
|
||||
return normalized
|
||||
}
|
||||
|
||||
// Lecture de la colonne JSON : tout ce qui ne respecte pas la forme est ignoré (portrait affiché tel quel).
|
||||
export function readAvatarCrop(value: unknown): AvatarCrop | null {
|
||||
const parsed = avatarCropInputSchema.shape.crop.safeParse(value)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
import { Prisma, type PrismaClient } from '@prisma/client'
|
||||
|
||||
type Db = PrismaClient | Prisma.TransactionClient
|
||||
|
||||
@@ -32,7 +32,7 @@ export async function softDeletePersonCascade(
|
||||
|
||||
await db.person.updateMany({
|
||||
where: { id: personId, treeId, deletedAt: null },
|
||||
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, immichPersonId: null },
|
||||
data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null },
|
||||
})
|
||||
|
||||
const unions = await db.union.findMany({
|
||||
|
||||
@@ -148,10 +148,51 @@ export function plainNoteText(raw: string): string {
|
||||
return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim()
|
||||
}
|
||||
|
||||
// Une note ou une source partagée est citée par des milliers de fiches : son texte se calcule une fois
|
||||
// par import (les nœuds sont neufs à chaque import, la WeakMap se vide avec eux)
|
||||
const noteTextCache = new WeakMap<GedcomPhotoNode, string>()
|
||||
const recordTitleCache = new WeakMap<GedcomPhotoNode, string>()
|
||||
const hasMediaCache = new WeakMap<GedcomPhotoNode, boolean>()
|
||||
const MAX_RECORD_DEPTH = 200
|
||||
const MAX_WALKED_NODES_PER_PERSON = 5000
|
||||
|
||||
export function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string {
|
||||
if (!node) return ''
|
||||
const pointer = pointerOf(node.value)
|
||||
return plainNoteText(joinedText(pointer ? records.note.get(pointer) : node))
|
||||
const target = pointer ? records.note.get(pointer) : node
|
||||
if (!target) return ''
|
||||
let text = noteTextCache.get(target)
|
||||
if (text === undefined) {
|
||||
text = plainNoteText(joinedText(target))
|
||||
noteTextCache.set(target, text)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
function recordTitle(record: GedcomPhotoNode): string {
|
||||
let title = recordTitleCache.get(record)
|
||||
if (title === undefined) {
|
||||
title = joinedText(child(record, 'TITL')) || joinedText(child(record, 'ABBR'))
|
||||
recordTitleCache.set(record, title)
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
// Un nœud mène-t-il à un fichier (OBJE), directement ou par une source ou une union citée ?
|
||||
// Un renvoi en boucle compte pour non pendant le calcul.
|
||||
function hasMedia(node: GedcomPhotoNode, records: GedcomRecords, depth = 0): boolean {
|
||||
const known = hasMediaCache.get(node)
|
||||
if (known !== undefined) return known
|
||||
if (depth > MAX_RECORD_DEPTH) return false
|
||||
hasMediaCache.set(node, false)
|
||||
let found = false
|
||||
for (const c of node.children) {
|
||||
const pointer = pointerOf(c.value)
|
||||
const linked = !pointer ? undefined : c.tag === 'SOUR' ? records.sour.get(pointer) : c.tag === 'FAMS' ? records.fam.get(pointer) : undefined
|
||||
if (c.tag === 'OBJE' || hasMedia(c, records, depth + 1) || (linked && hasMedia(linked, records, depth + 1))) { found = true; break }
|
||||
}
|
||||
hasMediaCache.set(node, found)
|
||||
return found
|
||||
}
|
||||
|
||||
function clipText(value: string, max: number): string | null {
|
||||
@@ -170,7 +211,7 @@ function baseNameWithoutExtension(path: string): string {
|
||||
export function sourceLabel(citation: GedcomPhotoNode, records: GedcomRecords): string | null {
|
||||
const pointer = pointerOf(citation.value)
|
||||
const record = pointer ? records.sour.get(pointer) : null
|
||||
const title = joinedText(child(record ?? undefined, 'TITL')) || joinedText(child(record ?? undefined, 'ABBR')) || (pointer ? '' : citation.value)
|
||||
const title = (record ? recordTitle(record) : '') || (pointer ? '' : citation.value)
|
||||
const page = joinedText(child(citation, 'PAGE'))
|
||||
return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH)
|
||||
}
|
||||
@@ -203,32 +244,37 @@ export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords
|
||||
}
|
||||
}
|
||||
|
||||
const walk = (node: GedcomPhotoNode, source: string | null) => {
|
||||
let budget = MAX_WALKED_NODES_PER_PERSON
|
||||
const walk = (node: GedcomPhotoNode, source: string | null, depth: number) => {
|
||||
if (depth > MAX_RECORD_DEPTH) return
|
||||
for (const c of node.children) {
|
||||
if (--budget < 0 || refs.length >= MAX_MEDIA_PER_PERSON) return
|
||||
if (c.tag === 'OBJE') {
|
||||
addObje(c, source)
|
||||
} else if (c.tag === 'SOUR') {
|
||||
const label = sourceLabel(c, records) ?? source
|
||||
walk(c, label)
|
||||
const pointer = pointerOf(c.value)
|
||||
const record = pointer ? records.sour.get(pointer) : null
|
||||
if (record && !visitedRecords.has(record)) {
|
||||
visitedRecords.add(record)
|
||||
walk(record, label)
|
||||
const recordToWalk = record && !visitedRecords.has(record) && hasMedia(record, records) ? record : null
|
||||
if (!hasMedia(c, records) && !recordToWalk) continue
|
||||
const label = sourceLabel(c, records) ?? source
|
||||
walk(c, label, depth + 1)
|
||||
if (recordToWalk) {
|
||||
visitedRecords.add(recordToWalk)
|
||||
walk(recordToWalk, label, depth + 1)
|
||||
}
|
||||
} else if (c.tag === 'FAMS') {
|
||||
const pointer = pointerOf(c.value)
|
||||
const fam = pointer ? records.fam.get(pointer) : null
|
||||
if (fam && !visitedRecords.has(fam)) {
|
||||
if (fam && !visitedRecords.has(fam) && hasMedia(fam, records)) {
|
||||
visitedRecords.add(fam)
|
||||
walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source)
|
||||
walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source, depth + 1)
|
||||
}
|
||||
} else if (!pointerOf(c.value) && c.children.length > 0) {
|
||||
walk(c, source)
|
||||
} else if (!pointerOf(c.value) && c.children.length > 0 && hasMedia(c, records)) {
|
||||
walk(c, source, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(indi, null)
|
||||
walk(indi, null, 0)
|
||||
return refs
|
||||
}
|
||||
@@ -59,6 +59,9 @@ const EXTENSION_BY_MIME: Record<string, string> = {
|
||||
'application/gpx+xml': '.gpx',
|
||||
}
|
||||
|
||||
// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12)
|
||||
const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])
|
||||
|
||||
function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean {
|
||||
if (buf.length < offset + bytes.length) return false
|
||||
return bytes.every((byte, index) => buf[offset + index] === byte)
|
||||
@@ -89,6 +92,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa
|
||||
if (ascii(buf, 4, 8) === 'ftyp') {
|
||||
const brand = ascii(buf, 8, 12)
|
||||
if (brand === 'qt ') return 'video/quicktime'
|
||||
// HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode
|
||||
// pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo.
|
||||
if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic'
|
||||
if (brand === 'avif' || brand === 'avis') return 'image/avif'
|
||||
if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4'
|
||||
return 'video/mp4'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import * as client from 'openid-client'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
// Connexion par un fournisseur OIDC (#115). Tout le protocole passe par openid-client : découverte, PKCE (S256),
|
||||
// state et nonce, échange du code, puis vérification de l'ID token (signature par les clés publiées du
|
||||
// fournisseur, émetteur, audience = notre client, expiration, nonce). Aucun jeton n'est décodé à la main ici.
|
||||
|
||||
export type OidcPending = {
|
||||
state: string
|
||||
nonce: string
|
||||
codeVerifier: string
|
||||
returnTo: string
|
||||
}
|
||||
|
||||
export type OidcIdentity = {
|
||||
issuer: string
|
||||
subject: string
|
||||
email: string | null
|
||||
emailVerified: boolean
|
||||
firstName: string | null
|
||||
}
|
||||
|
||||
export function isOidcEnabled(): boolean {
|
||||
return Boolean(env.OIDC_ISSUER)
|
||||
}
|
||||
|
||||
let configuration: Promise<client.Configuration> | null = null
|
||||
|
||||
// Découverte faite au premier besoin puis gardée ; un échec (fournisseur injoignable) sera retenté à la connexion suivante
|
||||
export function getOidcConfiguration(): Promise<client.Configuration> {
|
||||
configuration ??= discover().catch((error: unknown) => {
|
||||
configuration = null
|
||||
throw error
|
||||
})
|
||||
return configuration
|
||||
}
|
||||
|
||||
function discover(): Promise<client.Configuration> {
|
||||
const issuer = new URL(env.OIDC_ISSUER)
|
||||
// Sans secret : client public, PKCE seul. Avec : client_secret_basic, la méthode par défaut d'OIDC.
|
||||
const authentication = env.OIDC_CLIENT_SECRET ? client.ClientSecretBasic(env.OIDC_CLIENT_SECRET) : client.None()
|
||||
// Un fournisseur en http (réseau local) doit être autorisé explicitement par openid-client
|
||||
const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined
|
||||
return client.discovery(issuer, env.OIDC_CLIENT_ID, undefined, authentication, options)
|
||||
}
|
||||
|
||||
// Adresse de l'app où revenir : OIDC_APP_URL, sinon l'origine de l'adresse de retour (front et API sur la même adresse)
|
||||
export function appBaseUrl(): string {
|
||||
return (env.OIDC_APP_URL || new URL(env.OIDC_REDIRECT_URI).origin).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
// Seules la racine et l'adresse d'un arbre partagé sont acceptées comme retour : pas de redirection ouverte
|
||||
export function safeReturnTo(value: unknown): string {
|
||||
return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64})?$/.test(value) ? value : '/'
|
||||
}
|
||||
|
||||
export async function startAuthorization(returnTo: string): Promise<{ url: URL; pending: OidcPending }> {
|
||||
const config = await getOidcConfiguration()
|
||||
const codeVerifier = client.randomPKCECodeVerifier()
|
||||
const pending: OidcPending = {
|
||||
state: client.randomState(),
|
||||
nonce: client.randomNonce(),
|
||||
codeVerifier,
|
||||
returnTo,
|
||||
}
|
||||
const url = client.buildAuthorizationUrl(config, {
|
||||
redirect_uri: env.OIDC_REDIRECT_URI,
|
||||
scope: env.OIDC_SCOPES,
|
||||
code_challenge: await client.calculatePKCECodeChallenge(codeVerifier),
|
||||
code_challenge_method: 'S256',
|
||||
state: pending.state,
|
||||
nonce: pending.nonce,
|
||||
})
|
||||
return { url, pending }
|
||||
}
|
||||
|
||||
function stringClaim(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : null
|
||||
}
|
||||
|
||||
// Retour du fournisseur. L'adresse vérifiée est OIDC_REDIRECT_URI plus les paramètres reçus, jamais l'en-tête Host.
|
||||
export async function finishAuthorization(callbackQuery: string, pending: OidcPending): Promise<OidcIdentity> {
|
||||
const config = await getOidcConfiguration()
|
||||
const currentUrl = new URL(env.OIDC_REDIRECT_URI)
|
||||
currentUrl.search = callbackQuery
|
||||
|
||||
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
|
||||
pkceCodeVerifier: pending.codeVerifier,
|
||||
expectedState: pending.state,
|
||||
expectedNonce: pending.nonce,
|
||||
idTokenExpected: true,
|
||||
})
|
||||
|
||||
const claims = tokens.claims()
|
||||
if (!claims) {
|
||||
throw new Error('oidc_missing_id_token')
|
||||
}
|
||||
|
||||
let email = stringClaim(claims.email)
|
||||
let emailVerified = claims.email_verified === true
|
||||
let firstName = stringClaim(claims.given_name) ?? stringClaim(claims.name)
|
||||
|
||||
// Certains fournisseurs (Authelia par défaut) ne mettent l'email que dans userinfo. Le sujet attendu est
|
||||
// celui de l'ID token : une réponse userinfo pour un autre compte est refusée par openid-client.
|
||||
if (!email && tokens.access_token) {
|
||||
const info = await client.fetchUserInfo(config, tokens.access_token, claims.sub)
|
||||
email = stringClaim(info.email)
|
||||
emailVerified = info.email_verified === true
|
||||
firstName ??= stringClaim(info.given_name) ?? stringClaim(info.name)
|
||||
}
|
||||
|
||||
return {
|
||||
issuer: claims.iss,
|
||||
subject: claims.sub,
|
||||
email: email?.toLowerCase() ?? null,
|
||||
emailVerified,
|
||||
firstName: firstName?.slice(0, 60) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// Pour les tests : oublie la configuration découverte
|
||||
export function resetOidcConfiguration() {
|
||||
configuration = null
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// Nom de famille facultatif, prénom facultatif (prénom inconnu = vide) : une personne garde au moins un des deux
|
||||
export function hasAnyName(firstName: string, lastName: string): boolean {
|
||||
return Boolean(firstName.trim() || lastName.trim())
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Sexe d'une personne : 'male', 'female', 'other' (autre), ou null (non précisé). L'import GEDCOM écrit
|
||||
// 'male'/'female'/'other' (SEX X de GEDCOM 7), d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est
|
||||
// ramené à ces valeurs, à l'écriture comme à la lecture, sans migration des lignes existantes.
|
||||
export type PersonSex = 'male' | 'female' | 'other'
|
||||
|
||||
const MALE = new Set(['male', 'm', 'homme', 'h'])
|
||||
const FEMALE = new Set(['female', 'f', 'femme'])
|
||||
const OTHER = new Set(['other', 'x', 'autre'])
|
||||
const UNSPECIFIED = new Set(['', 'u', 'unknown', 'unspecified'])
|
||||
|
||||
// undefined : valeur que l'on ne sait pas lire (refusée à l'écriture)
|
||||
export function parseSex(value: string | null): PersonSex | null | undefined {
|
||||
if (value === null) return null
|
||||
const key = value.trim().toLowerCase()
|
||||
if (MALE.has(key)) return 'male'
|
||||
if (FEMALE.has(key)) return 'female'
|
||||
if (OTHER.has(key)) return 'other'
|
||||
if (UNSPECIFIED.has(key)) return null
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Lecture d'une valeur déjà en base : ce qu'on ne sait pas lire vaut « non précisé »
|
||||
export function sexOrNull(value: string | null | undefined): PersonSex | null {
|
||||
return parseSex(value ?? null) ?? null
|
||||
}
|
||||
|
||||
// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female/other
|
||||
export const sexSchema = z
|
||||
.string()
|
||||
.max(20)
|
||||
.nullable()
|
||||
.refine((value) => parseSex(value) !== undefined, { message: 'invalid_sex' })
|
||||
.transform((value) => parseSex(value) ?? null)
|
||||
.optional()
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Prisma, PrismaClient } from '@prisma/client'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
// Inscription fermée (ALLOW_REGISTRATION=false) : une instance sans aucun compte accepte quand même le premier,
|
||||
// sinon personne ne pourrait jamais s'y connecter. Dès qu'un compte existe, plus aucun ne se crée.
|
||||
export async function isRegistrationOpen(prisma: PrismaClient): Promise<boolean> {
|
||||
if (env.ALLOW_REGISTRATION) return true
|
||||
return (await prisma.user.count()) === 0
|
||||
}
|
||||
|
||||
// Seul chemin de création d'un compte. Inscription fermée : le compte n'est créé que si la base n'en a aucun,
|
||||
// sous un verrou de transaction pour que deux inscriptions simultanées sur une instance neuve n'en créent pas
|
||||
// deux. Renvoie null quand la création est refusée.
|
||||
export async function createAccountIfAllowed<T>(
|
||||
prisma: PrismaClient,
|
||||
create: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
): Promise<T | null> {
|
||||
if (env.ALLOW_REGISTRATION) return create(prisma)
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
// Clé arbitraire, propre à la création de compte. pg_advisory_xact_lock renvoie void : $executeRaw, pas $queryRaw.
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(113001)`
|
||||
if ((await tx.user.count()) > 0) return null
|
||||
return create(tx)
|
||||
})
|
||||
}
|
||||
@@ -6,8 +6,11 @@ import { env } from '../config/env.js'
|
||||
// Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre
|
||||
// (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois.
|
||||
export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000
|
||||
// Au-delà, les visites sont effacées (lecture de l'onglet Visites)
|
||||
// Au-delà, les visites sont effacées : à l'enregistrement d'une visite (au plus une fois par heure,
|
||||
// pour tous les arbres) et à la lecture de l'onglet Visites
|
||||
export const VISIT_RETENTION_DAYS = 365
|
||||
const PURGE_INTERVAL_MS = 60 * 60 * 1000
|
||||
let lastPurgeAt = 0
|
||||
|
||||
export type VisitAccessKind = 'share' | 'member' | 'shared_account'
|
||||
|
||||
@@ -34,6 +37,18 @@ export function visitorKeyOf(treeId: string, ip: string, userAgent: string): str
|
||||
return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
// Compte supprimé : ses visites restent comptées, sans son identifiant
|
||||
export function deletedAccountVisitorKey(userId: string): string {
|
||||
return createHash('sha256').update(`${env.JWT_SECRET}|deleted|${userId}`).digest('hex').slice(0, 32)
|
||||
}
|
||||
|
||||
async function purgeExpiredVisits(app: FastifyInstance): Promise<void> {
|
||||
const now = Date.now()
|
||||
if (now - lastPurgeAt < PURGE_INTERVAL_MS) return
|
||||
lastPurgeAt = now
|
||||
await app.prisma.treeVisit.deleteMany({ where: { createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000) } } })
|
||||
}
|
||||
|
||||
// N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre
|
||||
export async function recordTreeVisit(
|
||||
app: FastifyInstance,
|
||||
@@ -69,6 +84,7 @@ export async function recordTreeVisit(
|
||||
device: deviceFromUserAgent(userAgent),
|
||||
},
|
||||
})
|
||||
await purgeExpiredVisits(app)
|
||||
} catch (error) {
|
||||
request.log.warn({ err: error, treeId }, 'tree_visit_record_failed')
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
|
||||
// Session d'un compte, la même quelle que soit la façon de se connecter (mot de passe ou fournisseur OIDC)
|
||||
export function signUserSession(app: FastifyInstance, user: { id: string; email: string }): string {
|
||||
return app.jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: user.id,
|
||||
jti: randomUUID(),
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{ expiresIn: env.JWT_EXPIRES_IN },
|
||||
)
|
||||
}
|
||||
@@ -306,64 +306,67 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_annotation_photo_path' })
|
||||
}
|
||||
|
||||
// Creates
|
||||
for (const [index, item] of payload.data.create.entries()) {
|
||||
await app.prisma.annotation.create({
|
||||
// Créations, modifications, suppressions et trace : tout ou rien, un nouvel essai ne double rien
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
// Creates
|
||||
for (const [index, item] of payload.data.create.entries()) {
|
||||
await tx.annotation.create({
|
||||
data: {
|
||||
treeId,
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
content: createContents[index] as string,
|
||||
style: (item.style ?? {}) as Prisma.InputJsonValue,
|
||||
zIndex: item.zIndex ?? 0,
|
||||
createdBy: user.userId,
|
||||
},
|
||||
})
|
||||
results.created++
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const [index, item] of payload.data.update.entries()) {
|
||||
const data: Record<string, unknown> = {}
|
||||
if (item.x !== undefined) data.x = item.x
|
||||
if (item.y !== undefined) data.y = item.y
|
||||
if (item.content !== undefined) data.content = updateContents[index]
|
||||
if (item.style !== undefined) data.style = item.style
|
||||
if (item.zIndex !== undefined) data.zIndex = item.zIndex
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
const updated = await tx.annotation.updateMany({
|
||||
where: { id: item.id, treeId, deletedAt: null },
|
||||
data,
|
||||
})
|
||||
if (updated.count > 0) results.updated++
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes
|
||||
if (payload.data.delete.length > 0) {
|
||||
const deleted = await tx.annotation.updateMany({
|
||||
where: {
|
||||
id: { in: payload.data.delete },
|
||||
treeId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
results.deleted = deleted.count
|
||||
}
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
type: item.type,
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
content: createContents[index] as string,
|
||||
style: (item.style ?? {}) as Prisma.InputJsonValue,
|
||||
zIndex: item.zIndex ?? 0,
|
||||
createdBy: user.userId,
|
||||
actorType: 'user',
|
||||
actorId: user.userId,
|
||||
action: 'annotations_batch',
|
||||
entityType: 'annotation',
|
||||
entityId: treeId,
|
||||
payloadJson: results,
|
||||
},
|
||||
})
|
||||
results.created++
|
||||
}
|
||||
|
||||
// Updates
|
||||
for (const [index, item] of payload.data.update.entries()) {
|
||||
const data: Record<string, unknown> = {}
|
||||
if (item.x !== undefined) data.x = item.x
|
||||
if (item.y !== undefined) data.y = item.y
|
||||
if (item.content !== undefined) data.content = updateContents[index]
|
||||
if (item.style !== undefined) data.style = item.style
|
||||
if (item.zIndex !== undefined) data.zIndex = item.zIndex
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
const updated = await app.prisma.annotation.updateMany({
|
||||
where: { id: item.id, treeId, deletedAt: null },
|
||||
data,
|
||||
})
|
||||
if (updated.count > 0) results.updated++
|
||||
}
|
||||
}
|
||||
|
||||
// Deletes
|
||||
if (payload.data.delete.length > 0) {
|
||||
const deleted = await app.prisma.annotation.updateMany({
|
||||
where: {
|
||||
id: { in: payload.data.delete },
|
||||
treeId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
results.deleted = deleted.count
|
||||
}
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId,
|
||||
actorType: 'user',
|
||||
actorId: user.userId,
|
||||
action: 'annotations_batch',
|
||||
entityType: 'annotation',
|
||||
entityId: treeId,
|
||||
payloadJson: results,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ ok: true, ...results })
|
||||
|
||||
+105
-34
@@ -3,8 +3,12 @@ import { randomUUID } from 'node:crypto'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import { hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js'
|
||||
import { isOidcEnabled } from '../lib/oidc.js'
|
||||
import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
import { revokeUserToken } from '../lib/token-revocation.js'
|
||||
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
|
||||
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
|
||||
import type { AnyJwtPayload } from '../types/auth.js'
|
||||
|
||||
@@ -20,10 +24,25 @@ const credentialsSchema = z.object({
|
||||
password: z.string().min(8).max(128),
|
||||
})
|
||||
|
||||
const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1).max(128),
|
||||
// Prénom du compte, facultatif : seulement à l'inscription (la connexion ne le demande pas)
|
||||
const registerSchema = credentialsSchema.extend({
|
||||
firstName: z.string().max(60).optional().nullable(),
|
||||
})
|
||||
|
||||
function trimmedFirstName(value: string | null | undefined): string | null {
|
||||
const trimmed = (value ?? '').trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
// Suppression d'un compte sans mot de passe : connexion chez le fournisseur de moins de 15 minutes
|
||||
const RECENT_SIGN_IN_MS = 15 * 60 * 1000
|
||||
|
||||
// Un compte avec mot de passe le redonne ; un compte né d'une connexion OIDC, sans mot de passe, retape son email
|
||||
const deleteAccountSchema = z.object({
|
||||
password: z.string().min(1).max(128).optional(),
|
||||
confirmEmail: z.string().min(1).max(320).optional(),
|
||||
}).refine((value) => value.password || value.confirmEmail, { message: 'password or confirmEmail required' })
|
||||
|
||||
const updateLastTreeSchema = z.object({
|
||||
treeId: z.string().min(1),
|
||||
accessMode: z.enum(['member', 'share']),
|
||||
@@ -83,6 +102,17 @@ async function resolveAccessibleLastTree(
|
||||
}
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Ce que l'écran d'accueil propose : l'app cache « Créer un compte » quand l'instance n'en accepte plus
|
||||
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,
|
||||
}),
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/register',
|
||||
{
|
||||
@@ -94,11 +124,21 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const parsed = credentialsSchema.safeParse(request.body)
|
||||
const parsed = registerSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
|
||||
}
|
||||
|
||||
// Connexion par le fournisseur seulement (PASSWORD_LOGIN=false) : pas de compte à mot de passe
|
||||
if (!env.PASSWORD_LOGIN) {
|
||||
return reply.code(403).send({ error: 'password_login_disabled' })
|
||||
}
|
||||
|
||||
// Inscription fermée : refusée avant toute lecture, la réponse ne dit pas si l'adresse a un compte
|
||||
if (!(await isRegistrationOpen(app.prisma))) {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
}
|
||||
|
||||
const email = parsed.data.email.trim().toLowerCase()
|
||||
const existing = await app.prisma.user.findUnique({ where: { email } })
|
||||
if (existing) {
|
||||
@@ -106,29 +146,24 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(parsed.data.password)
|
||||
const user = await app.prisma.user.create({
|
||||
const user = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
|
||||
data: {
|
||||
email,
|
||||
passwordHash,
|
||||
firstName: trimmedFirstName(parsed.data.firstName),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
},
|
||||
})
|
||||
}))
|
||||
// Un autre compte est né pendant le calcul du hash, sur une instance fermée
|
||||
if (!user) {
|
||||
return reply.code(403).send({ error: 'registration_closed' })
|
||||
}
|
||||
|
||||
const token = app.jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: user.id,
|
||||
jti: randomUUID(),
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{ expiresIn: env.JWT_EXPIRES_IN },
|
||||
)
|
||||
|
||||
return reply.code(201).send({ token, user })
|
||||
return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true } })
|
||||
},
|
||||
)
|
||||
|
||||
@@ -143,6 +178,10 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!env.PASSWORD_LOGIN) {
|
||||
return reply.code(403).send({ error: 'password_login_disabled' })
|
||||
}
|
||||
|
||||
const parsed = credentialsSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
|
||||
@@ -154,12 +193,14 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (!user) {
|
||||
// Même durée qu'un mauvais mot de passe : la réponse ne dit pas si l'adresse a un compte
|
||||
// Adresse inconnue, ou compte né d'une connexion OIDC (sans mot de passe) : même durée qu'un mauvais mot de
|
||||
// passe, la réponse ne dit pas si l'adresse a un compte
|
||||
if (!user?.passwordHash) {
|
||||
await verifyPassword(parsed.data.password, await getDummyHash())
|
||||
return reply.code(401).send({ error: 'invalid_credentials' })
|
||||
}
|
||||
@@ -169,27 +210,36 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(401).send({ error: 'invalid_credentials' })
|
||||
}
|
||||
|
||||
const token = app.jwt.sign(
|
||||
{
|
||||
kind: 'user',
|
||||
sub: user.id,
|
||||
jti: randomUUID(),
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
},
|
||||
{ expiresIn: env.JWT_EXPIRES_IN },
|
||||
)
|
||||
|
||||
return reply.send({
|
||||
token,
|
||||
token: signUserSession(app, user),
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
hasPassword: true,
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
// Profil du compte connecté (prénom compris) : la session restaurée sur un autre appareil le retrouve
|
||||
app.get('/me', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: request.actor.userId },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true },
|
||||
})
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
// hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe
|
||||
return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } })
|
||||
})
|
||||
|
||||
app.get('/me/last-tree', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
@@ -318,6 +368,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
passwordHash: true,
|
||||
},
|
||||
})
|
||||
@@ -326,12 +377,26 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const isValid = await verifyPassword(parsed.data.password, user.passwordHash)
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number; iat?: number }>()
|
||||
|
||||
const withPassword = confirmsWithPassword(user.passwordHash)
|
||||
if (!withPassword) {
|
||||
// Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à
|
||||
// redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur
|
||||
// (une session volée ou oubliée ouverte ne suffit pas).
|
||||
const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity
|
||||
if (sessionAgeMs > RECENT_SIGN_IN_MS) {
|
||||
return reply.code(403).send({ error: 'recent_sign_in_required' })
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = withPassword
|
||||
? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string)
|
||||
: (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email
|
||||
if (!isValid) {
|
||||
return reply.code(403).send({ error: 'invalid_password' })
|
||||
}
|
||||
|
||||
const payload = await request.jwtVerify<AnyJwtPayload & { exp?: number }>()
|
||||
|
||||
// Arbres possédés (y compris ceux marqués supprimés) puis compte, dans une seule transaction :
|
||||
// la relation propriétaire est en Restrict, les arbres doivent partir avant l'utilisateur.
|
||||
@@ -350,6 +415,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
data: { submittedByLabel: 'Contributeur' },
|
||||
})
|
||||
|
||||
// Ses visites des autres arbres restent comptées, sans son identifiant
|
||||
await tx.treeVisit.updateMany({
|
||||
where: { visitorKey: `user:${userId}` },
|
||||
data: { visitorKey: deletedAccountVisitorKey(userId) },
|
||||
})
|
||||
|
||||
await tx.user.delete({ where: { id: userId } })
|
||||
return treeIds
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import type { Actor, MembershipRole } from '../types/auth.js'
|
||||
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
|
||||
import { nextSiblingOrder } from '../lib/sibling-order.js'
|
||||
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
|
||||
import { hasAnyName } from '../lib/person-names.js'
|
||||
import { sexOrNull, sexSchema } from '../lib/person-sex.js'
|
||||
|
||||
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
@@ -68,33 +70,37 @@ const reviewSessionSchema = z.object({
|
||||
})
|
||||
|
||||
const personCreateSchema = z.object({
|
||||
firstName: z.string().min(1).max(120),
|
||||
lastName: z.string().min(1).max(120),
|
||||
firstName: z.string().trim().max(120),
|
||||
lastName: z.string().trim().max(120),
|
||||
birthName: z.string().max(120).optional().nullable(),
|
||||
nickname: z.string().max(120).optional().nullable(),
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
sex: z.string().max(20).optional().nullable(),
|
||||
sex: sexSchema,
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
})
|
||||
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
|
||||
|
||||
const personUpdateSchema = z
|
||||
.object({
|
||||
firstName: z.string().min(1).max(120).optional(),
|
||||
lastName: z.string().min(1).max(120).optional(),
|
||||
firstName: z.string().trim().max(120).optional(),
|
||||
lastName: z.string().trim().max(120).optional(),
|
||||
birthName: z.string().max(120).optional().nullable(),
|
||||
nickname: z.string().max(120).optional().nullable(),
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
sex: z.string().max(20).optional().nullable(),
|
||||
sex: sexSchema,
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
})
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
@@ -378,10 +384,12 @@ async function applyContributionChange(
|
||||
firstName: parsed.data.firstName.trim(),
|
||||
lastName: parsed.data.lastName.trim(),
|
||||
birthName: toTrimmedStringOrNull(parsed.data.birthName),
|
||||
nickname: toTrimmedStringOrNull(parsed.data.nickname),
|
||||
birthDate: toDateOrNull(parsed.data.birthDate),
|
||||
deathDate: toDateOrNull(parsed.data.deathDate),
|
||||
isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased),
|
||||
birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace),
|
||||
deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace),
|
||||
profession: toTrimmedStringOrNull(parsed.data.profession),
|
||||
region: toTrimmedStringOrNull(parsed.data.region),
|
||||
nationality: toTrimmedStringOrNull(parsed.data.nationality),
|
||||
@@ -424,6 +432,16 @@ async function applyContributionChange(
|
||||
})
|
||||
: null
|
||||
|
||||
if (parsed.data.firstName !== undefined || parsed.data.lastName !== undefined) {
|
||||
const before = await tx.person.findFirst({
|
||||
where: { id: change.entityId, treeId, deletedAt: null },
|
||||
select: { firstName: true, lastName: true },
|
||||
})
|
||||
if (before && !hasAnyName(parsed.data.firstName ?? before.firstName, parsed.data.lastName ?? before.lastName)) {
|
||||
throw new ContributionRouteError(400, 'invalid_change_after')
|
||||
}
|
||||
}
|
||||
|
||||
const result = await tx.person.updateMany({
|
||||
where: {
|
||||
id: change.entityId,
|
||||
@@ -435,10 +453,12 @@ async function applyContributionChange(
|
||||
firstName: parsed.data.firstName !== undefined ? parsed.data.firstName.trim() : undefined,
|
||||
lastName: parsed.data.lastName !== undefined ? parsed.data.lastName.trim() : undefined,
|
||||
birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined,
|
||||
nickname: parsed.data.nickname !== undefined ? toTrimmedStringOrNull(parsed.data.nickname) : undefined,
|
||||
birthDate: nextDates.birthDate,
|
||||
deathDate: nextDates.deathDate,
|
||||
isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased,
|
||||
birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined,
|
||||
deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined,
|
||||
profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined,
|
||||
region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined,
|
||||
nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined,
|
||||
@@ -832,10 +852,12 @@ async function snapshotEntity(
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
birthName: true,
|
||||
nickname: true,
|
||||
birthDate: true,
|
||||
deathDate: true,
|
||||
isDeceased: true,
|
||||
birthPlace: true,
|
||||
deathPlace: true,
|
||||
profession: true,
|
||||
region: true,
|
||||
nationality: true,
|
||||
@@ -848,6 +870,7 @@ async function snapshotEntity(
|
||||
...current,
|
||||
birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null,
|
||||
deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null,
|
||||
sex: sexOrNull(current.sex),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@ import { z } from 'zod'
|
||||
import { UPLOAD_BODY_LIMIT } from '../config/env.js'
|
||||
import { parseGedcomDate } from '../lib/gedcom-dates.js'
|
||||
import { adoptedFamilies, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.js'
|
||||
import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js'
|
||||
import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } from '../lib/gedcom-photos.js'
|
||||
import type { GedcomRecords } from '../lib/gedcom-photos.js'
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { nextSiblingOrder } from '../lib/sibling-order.js'
|
||||
import { sexOrNull, type PersonSex } from '../lib/person-sex.js'
|
||||
|
||||
const treeIdParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -20,6 +21,9 @@ const importBodySchema = z.object({
|
||||
// Un fichier GEDCOM vient de l'extérieur : longueur des lignes, nombre de fiches et taille des champs
|
||||
// plafonnés comme partout ailleurs (sinon un seul fichier bloque l'API ou remplit la base)
|
||||
const MAX_LINE_LENGTH = 4096
|
||||
const MAX_GEDCOM_LEVEL = 99
|
||||
// Remontées d'ascendance permises sur tout l'import : au-delà, les liens ne sont plus contrôlés (fichier hors norme)
|
||||
const MAX_ANCESTRY_STEPS = 5_000_000
|
||||
const MAX_IMPORTED_PERSONS = 20000
|
||||
const MAX_IMPORTED_FAMILIES = 20000
|
||||
// Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le
|
||||
@@ -94,6 +98,8 @@ export function parseGedcom(text: string): GedcomNode[] {
|
||||
if (!match) continue
|
||||
|
||||
const level = parseInt(match[1], 10)
|
||||
// La norme s'arrête à 99 niveaux : au-delà, la lecture récursive de l'import déborderait la pile
|
||||
if (level > MAX_GEDCOM_LEVEL) continue
|
||||
const xref = match[2] ? match[2].slice(1, -1) : undefined
|
||||
const tag = match[3]
|
||||
const rawValue = match[4] ?? ''
|
||||
@@ -137,19 +143,7 @@ function gedcomDateToIso(value: string): string | null {
|
||||
// Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC),
|
||||
// RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide.
|
||||
export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null {
|
||||
const texts: string[] = []
|
||||
for (const note of findAllChildren(indi, 'NOTE')) {
|
||||
const pointer = note.value.match(/^@([^@]+)@$/)?.[1]
|
||||
const source = pointer ? records.note.get(pointer) : note
|
||||
if (!source) continue
|
||||
let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value)
|
||||
for (const part of source.children) {
|
||||
if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}`
|
||||
else if (part.tag === 'CONC') text += part.rawValue ?? part.value
|
||||
}
|
||||
const clean = plainNoteText(text)
|
||||
if (clean) texts.push(clean)
|
||||
}
|
||||
const texts = findAllChildren(indi, 'NOTE').map((note) => noteText(note, records)).filter(Boolean)
|
||||
return texts.length ? texts.join('\n\n') : null
|
||||
}
|
||||
|
||||
@@ -258,8 +252,14 @@ function pushLifeEvent(lines: string[], event: ExportedEvent, tags: Record<strin
|
||||
|
||||
// ─── Name Parsing ────────────────────────────────────────────────────────────
|
||||
|
||||
// Valeur de « 1 NAME ». Prénom inconnu (chaîne vide) : « /Dupont/ », sans espace de tête
|
||||
export function gedcomNameValue(firstName: string, lastName: string): string {
|
||||
const given = firstName ? `${oneLine(firstName)} ` : ''
|
||||
return `${given}/${oneLine(lastName).replace(/\//g, '')}/`
|
||||
}
|
||||
|
||||
// « Jean /Dupont/ » : lu par indexOf, l'ancienne expression régulière était quadratique sur les espaces
|
||||
function parseGedcomName(value: string): { firstName: string; lastName: string } {
|
||||
export function parseGedcomName(value: string): { firstName: string; lastName: string } {
|
||||
const open = value.indexOf('/')
|
||||
const close = open >= 0 ? value.indexOf('/', open + 1) : -1
|
||||
if (open >= 0 && close > open) {
|
||||
@@ -285,6 +285,15 @@ function pairKey(a: string, b: string | null): string {
|
||||
return b ? [a, b].sort().join('|') : `${a}|`
|
||||
}
|
||||
|
||||
// GEDCOM 5.5.1 n'admet que M, F et U (inconnu, autre). « X » n'existe qu'en 7.0 : un sexe « autre » s'écrit U,
|
||||
// et l'import relit X comme avant.
|
||||
export function gedcomSexValue(sex: PersonSex | null): 'M' | 'F' | 'U' | null {
|
||||
if (sex === 'male') return 'M'
|
||||
if (sex === 'female') return 'F'
|
||||
if (sex === 'other') return 'U'
|
||||
return null
|
||||
}
|
||||
|
||||
function sanitizeXref(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_]/g, '_')
|
||||
}
|
||||
@@ -453,11 +462,11 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
for (const person of persons) {
|
||||
const xref = sanitizeXref(person.id)
|
||||
lines.push(`0 @I${xref}@ INDI`)
|
||||
lines.push(`1 NAME ${oneLine(person.firstName)} /${oneLine(person.lastName).replace(/\//g, '')}/`)
|
||||
lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`)
|
||||
if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`)
|
||||
|
||||
if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M')
|
||||
else if (person.sex === 'female' || person.sex === 'F') lines.push('1 SEX F')
|
||||
const sexValue = gedcomSexValue(sexOrNull(person.sex))
|
||||
if (sexValue) lines.push(`1 SEX ${sexValue}`)
|
||||
|
||||
const personEvents = eventsByPerson.get(person.id) ?? []
|
||||
const birthDetails = personEvents.find((event) => event.type === 'birth')
|
||||
@@ -519,9 +528,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
const p1 = union.partner1PersonId ? personById.get(union.partner1PersonId) : null
|
||||
const p2 = union.partner2PersonId ? personById.get(union.partner2PersonId) : null
|
||||
|
||||
const p1IsFemale = p1?.sex === 'female' || p1?.sex === 'F'
|
||||
const p1IsMale = p1?.sex === 'male' || p1?.sex === 'M'
|
||||
const p2IsMale = p2?.sex === 'male' || p2?.sex === 'M'
|
||||
const p1IsFemale = sexOrNull(p1?.sex) === 'female'
|
||||
const p1IsMale = sexOrNull(p1?.sex) === 'male'
|
||||
const p2IsMale = sexOrNull(p2?.sex) === 'male'
|
||||
|
||||
// Swap to HUSB=male if we know p1 is female and p2 is male (or unknown)
|
||||
const shouldSwap = p1IsFemale && !p2IsMale === false ? false : p1IsFemale && (p2IsMale || !p1IsMale)
|
||||
@@ -639,12 +648,30 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
}
|
||||
const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id]))
|
||||
const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`))
|
||||
// Parents de chaque personne, pour refuser un lien qui ferait de quelqu'un son propre ancêtre
|
||||
// (les validateurs de l'API relisent tout l'arbre à chaque lien : trop lent pour un import)
|
||||
const parentsOf = new Map<string, string[]>()
|
||||
for (const link of existingLinks) parentsOf.set(link.childPersonId, [...(parentsOf.get(link.childPersonId) ?? []), link.parentPersonId])
|
||||
let ancestryBudget = MAX_ANCESTRY_STEPS
|
||||
const isAncestorOf = (candidate: string, personId: string): boolean => {
|
||||
const seen = new Set<string>()
|
||||
const stack = [personId]
|
||||
while (stack.length) {
|
||||
const current = stack.pop()!
|
||||
if (current === candidate) return true
|
||||
if (seen.has(current) || --ancestryBudget < 0) continue
|
||||
seen.add(current)
|
||||
stack.push(...(parentsOf.get(current) ?? []))
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
let personsCreated = 0
|
||||
let personsReused = 0
|
||||
let unionsCreated = 0
|
||||
let linksCreated = 0
|
||||
let eventsCreated = 0
|
||||
let relationsSkipped = 0
|
||||
// Adoptions déclarées sous l'enfant (« 2 PEDI adopted ») : enfant → familles où il est adopté
|
||||
const adoptedByChild = new Map(indiNodes.map((indi) => [indi.xref!, adoptedFamilies(indi)]))
|
||||
|
||||
@@ -655,10 +682,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
const nameNode = findChild(indi, 'NAME')
|
||||
const { firstName, lastName } = nameNode
|
||||
? parseGedcomName(nameNode.value)
|
||||
: { firstName: '?', lastName: '' }
|
||||
: { firstName: '', lastName: '' }
|
||||
|
||||
const sexNode = findChild(indi, 'SEX')
|
||||
const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null
|
||||
const sex = sexOrNull(sexNode?.value)
|
||||
|
||||
const birtNode = findChild(indi, 'BIRT')
|
||||
const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '')
|
||||
@@ -669,9 +696,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200)
|
||||
|
||||
const notes = clip(personNotes(indi, records), 2000)
|
||||
|
||||
const personFirstName = (firstName || '?').slice(0, 120)
|
||||
// Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre
|
||||
const personFirstName = (firstName || '').slice(0, 120)
|
||||
const personLastName = (lastName || '').slice(0, 120)
|
||||
const birthDate = isoDateToDbDate(birthDateIso)
|
||||
const deathDate = isoDateToDbDate(deathDateIso)
|
||||
@@ -686,6 +712,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
continue
|
||||
}
|
||||
|
||||
const notes = clip(personNotes(indi, records), 2000)
|
||||
const { events, ...extras } = personExtras(indi, records)
|
||||
const person = await tx.person.create({
|
||||
data: {
|
||||
@@ -713,7 +740,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
xrefToPersonId.set(indi.xref!, person.id)
|
||||
personsCreated++
|
||||
if (photoPath) photoRefs.push({ personId: person.id, file: photoPath })
|
||||
for (const ref of mediaRefsForPerson(indi, records, photoPath)) {
|
||||
const personMedia = mediaRefs.length < MAX_IMPORTED_MEDIA_REFS ? mediaRefsForPerson(indi, records, photoPath) : []
|
||||
for (const ref of personMedia) {
|
||||
if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break
|
||||
mediaRefs.push({ personId: person.id, ...ref })
|
||||
}
|
||||
@@ -724,7 +752,12 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
const wifeXref = findChild(fam, 'WIFE')?.value?.replace(/@/g, '').trim()
|
||||
|
||||
const partner1Id = husbXref ? xrefToPersonId.get(husbXref) : undefined
|
||||
const partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
|
||||
let partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined
|
||||
// Une même personne des deux côtés d'une union : un seul partenaire
|
||||
if (partner2Id && partner2Id === partner1Id) {
|
||||
partner2Id = undefined
|
||||
relationsSkipped++
|
||||
}
|
||||
|
||||
if (!partner1Id && !partner2Id) continue
|
||||
|
||||
@@ -770,6 +803,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
for (const parentId of parentIds) {
|
||||
const linkKey = `${parentId}:${childId}`
|
||||
if (linkKeys.has(linkKey)) continue
|
||||
if (parentId === childId || isAncestorOf(childId, parentId)) {
|
||||
relationsSkipped++
|
||||
continue
|
||||
}
|
||||
await tx.parentChildLink.create({
|
||||
data: {
|
||||
treeId,
|
||||
@@ -781,6 +818,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
},
|
||||
})
|
||||
linkKeys.add(linkKey)
|
||||
parentsOf.set(childId, [...(parentsOf.get(childId) ?? []), parentId])
|
||||
linksCreated++
|
||||
}
|
||||
}
|
||||
@@ -794,7 +832,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
action: 'gedcom_imported',
|
||||
entityType: 'tree',
|
||||
entityId: treeId,
|
||||
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
|
||||
payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length },
|
||||
},
|
||||
})
|
||||
}, { timeout: 60000 })
|
||||
@@ -804,6 +842,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
// photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne ; mediaRefs : les
|
||||
// autres fichiers cités, avec leur titre et leur source (rien n'est gardé en base)
|
||||
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs, mediaRefs })
|
||||
return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs, mediaRefs })
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import path from 'node:path'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
type ImmichPersonSummary,
|
||||
} from '../lib/immich.js'
|
||||
import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js'
|
||||
import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js'
|
||||
import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js'
|
||||
|
||||
// Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la
|
||||
@@ -357,7 +358,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
// Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une
|
||||
// balise <img>. Vide, trop grande ou pas une image : rien à montrer.
|
||||
const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null
|
||||
if (!thumbnail || !mimeType?.startsWith('image/')) {
|
||||
if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) {
|
||||
request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image')
|
||||
return reply.code(404).send({ error: 'thumbnail_not_found' })
|
||||
}
|
||||
@@ -515,6 +516,8 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
data: {
|
||||
avatarPath: relativePath,
|
||||
avatarMimeType: prepared.mimeType,
|
||||
// Nouveau portrait, nouveau cadrage à faire
|
||||
avatarCrop: Prisma.DbNull,
|
||||
immichPersonId,
|
||||
updatedBy: request.actor.userId,
|
||||
},
|
||||
@@ -594,10 +597,12 @@ export const immichRoutes: FastifyPluginAsync = async (app) => {
|
||||
updatedBy: string
|
||||
avatarPath?: null
|
||||
avatarMimeType?: null
|
||||
avatarCrop?: typeof Prisma.DbNull
|
||||
} = { immichPersonId: null, updatedBy: request.actor.userId }
|
||||
if (avatarFromImmich) {
|
||||
updateData.avatarPath = null
|
||||
updateData.avatarMimeType = null
|
||||
updateData.avatarCrop = Prisma.DbNull
|
||||
}
|
||||
|
||||
// Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour.
|
||||
|
||||
+202
-15
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Prisma } from '@prisma/client'
|
||||
import type { FastifyPluginAsync, FastifyRequest } from 'fastify'
|
||||
import { z } from 'zod'
|
||||
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
applyMediaResponseHeaders,
|
||||
extensionForMime,
|
||||
InvalidMediaError,
|
||||
isImmichAvatarPath,
|
||||
isMimeAllowedForType,
|
||||
removeStoredFile,
|
||||
sanitizeImage,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
sniffMimeType,
|
||||
type BinaryMediaType,
|
||||
} from '../lib/media-files.js'
|
||||
import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js'
|
||||
import { syncPersonMediaOrder } from '../lib/media-order.js'
|
||||
import { resolveTreeMediaDirectory } from '../lib/tree-purge.js'
|
||||
import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js'
|
||||
@@ -94,6 +97,13 @@ const reorderMediaSchema = z.object({
|
||||
orderedMediaIds: z.array(z.string().min(1)).min(1),
|
||||
})
|
||||
|
||||
const MAX_CAPTION_LENGTH = 280
|
||||
const MAX_CITATION_LENGTH = 2000
|
||||
const updateMediaSchema = z.object({
|
||||
caption: z.string().trim().min(1).max(MAX_CITATION_LENGTH),
|
||||
lifeEventId: z.string().min(1).max(64).nullable().optional(),
|
||||
})
|
||||
|
||||
function hasAdminRight(role: MembershipRole): boolean {
|
||||
return role === 'owner' || role === 'admin'
|
||||
}
|
||||
@@ -273,6 +283,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
select: {
|
||||
id: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -362,7 +373,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
|
||||
mode: contribution.mode,
|
||||
status: contribution.autoStatus,
|
||||
title: `Souvenir pour ${person.firstName}`.slice(0, 120),
|
||||
title: `Souvenir pour ${person.firstName || person.lastName}`.slice(0, 120),
|
||||
reviewedAt: contribution.autoStatus === 'approved' ? now : undefined,
|
||||
reviewedBy: contribution.autoStatus === 'approved' ? contribution.submittedByUserId : undefined,
|
||||
},
|
||||
@@ -592,6 +603,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
data: {
|
||||
avatarPath: relativePath,
|
||||
avatarMimeType: prepared.mimeType,
|
||||
// Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci
|
||||
avatarCrop: Prisma.DbNull,
|
||||
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
|
||||
@@ -647,6 +660,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
data: {
|
||||
avatarPath: null,
|
||||
avatarMimeType: null,
|
||||
avatarCrop: Prisma.DbNull,
|
||||
// Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait.
|
||||
immichPersonId: null,
|
||||
updatedBy: request.actor.userId,
|
||||
@@ -673,6 +687,69 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
// Cadrage du portrait : l'original reste en place, seul le cadrage est enregistré. Même droit que changer la photo.
|
||||
app.put('/trees/:id/persons/:personId/avatar/crop', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
const params = treePersonParamsSchema.safeParse(request.params)
|
||||
if (!params.success) {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
const payload = avatarCropInputSchema.safeParse(request.body)
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() })
|
||||
}
|
||||
|
||||
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
|
||||
if (!membership) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
const person = await app.prisma.person.findFirst({
|
||||
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
|
||||
select: { avatarPath: true },
|
||||
})
|
||||
|
||||
if (!person) {
|
||||
return reply.code(404).send({ error: 'person_not_found' })
|
||||
}
|
||||
|
||||
if (!person.avatarPath) {
|
||||
return reply.code(404).send({ error: 'avatar_not_found' })
|
||||
}
|
||||
|
||||
// Hors périmètre pour l'instant : les portraits venus d'Immich ne se recadrent pas
|
||||
if (isImmichAvatarPath(person.avatarPath)) {
|
||||
return reply.code(409).send({ error: 'avatar_from_immich' })
|
||||
}
|
||||
|
||||
const avatarCrop = toStoredAvatarCrop(payload.data.crop)
|
||||
|
||||
await app.prisma.person.updateMany({
|
||||
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
|
||||
data: {
|
||||
avatarCrop: avatarCrop ?? Prisma.DbNull,
|
||||
updatedBy: request.actor.userId,
|
||||
},
|
||||
})
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId: request.actor.userId,
|
||||
action: 'person_avatar_cropped',
|
||||
entityType: 'person',
|
||||
entityId: params.data.personId,
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ ok: true, avatarCrop: readAvatarCrop(avatarCrop) })
|
||||
})
|
||||
|
||||
app.patch('/trees/:id/persons/:personId/media/reorder', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
@@ -718,38 +795,148 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_media_order_payload' })
|
||||
}
|
||||
|
||||
for (let index = 0; index < orderedIds.length; index += 1) {
|
||||
await app.prisma.mediaItem.updateMany({
|
||||
where: {
|
||||
id: orderedIds[index],
|
||||
treeId: params.data.id,
|
||||
personId: params.data.personId,
|
||||
deletedAt: null,
|
||||
},
|
||||
const actorId = request.actor.userId
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
for (let index = 0; index < orderedIds.length; index += 1) {
|
||||
await tx.mediaItem.updateMany({
|
||||
where: {
|
||||
id: orderedIds[index],
|
||||
treeId: params.data.id,
|
||||
personId: params.data.personId,
|
||||
deletedAt: null,
|
||||
},
|
||||
data: {
|
||||
displayOrder: index + 1,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
await syncPersonMediaOrder(tx, params.data.id, params.data.personId)
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: {
|
||||
displayOrder: index + 1,
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId,
|
||||
action: 'media_reordered',
|
||||
entityType: 'media_item',
|
||||
entityId: params.data.personId,
|
||||
payloadJson: {
|
||||
personId: params.data.personId,
|
||||
orderedMediaIds: payload.data.orderedMediaIds,
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
// Modifier un souvenir : titre (caption) et moment de vie. Pour une citation, caption est aussi le texte
|
||||
// du fichier citation.txt, réécrit ici pour que la lecture et le téléchargement restent identiques.
|
||||
app.patch('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
|
||||
if (!request.actor || request.actor.kind !== 'user') {
|
||||
return reply.code(401).send({ error: 'authentication_required' })
|
||||
}
|
||||
|
||||
await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId)
|
||||
const params = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
personId: z.string().min(1),
|
||||
mediaId: z.string().min(1),
|
||||
})
|
||||
.safeParse(request.params)
|
||||
|
||||
if (!params.success) {
|
||||
return reply.code(400).send({ error: 'invalid_params' })
|
||||
}
|
||||
|
||||
const membership = await requireAdminMembership(app, request.actor.userId, params.data.id)
|
||||
if (!membership) {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
const payload = updateMediaSchema.safeParse(request.body)
|
||||
if (!payload.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload' })
|
||||
}
|
||||
|
||||
const existingMedia = await app.prisma.mediaItem.findFirst({
|
||||
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
|
||||
select: { id: true, type: true, filePath: true },
|
||||
})
|
||||
if (!existingMedia) {
|
||||
return reply.code(404).send({ error: 'media_not_found' })
|
||||
}
|
||||
|
||||
const isCitation = existingMedia.type === 'citation'
|
||||
if (!isCitation && payload.data.caption.length > MAX_CAPTION_LENGTH) {
|
||||
return reply.code(400).send({ error: 'caption_too_long' })
|
||||
}
|
||||
|
||||
const lifeEventId = payload.data.lifeEventId
|
||||
if (lifeEventId) {
|
||||
const event = await app.prisma.lifeEvent.findFirst({
|
||||
where: { id: lifeEventId, treeId: params.data.id, deletedAt: null },
|
||||
select: { personId: true, unionId: true, status: true },
|
||||
})
|
||||
let ownsEvent = event?.personId === params.data.personId
|
||||
if (event && !ownsEvent && event.unionId) {
|
||||
const union = await app.prisma.union.findFirst({
|
||||
where: { id: event.unionId, treeId: params.data.id },
|
||||
select: { partner1PersonId: true, partner2PersonId: true },
|
||||
})
|
||||
ownsEvent = union?.partner1PersonId === params.data.personId || union?.partner2PersonId === params.data.personId
|
||||
}
|
||||
if (!event || !ownsEvent || event.status !== 'approved') {
|
||||
return reply.code(400).send({ error: 'invalid_life_event_reference' })
|
||||
}
|
||||
}
|
||||
|
||||
const data: { caption: string; lifeEventId?: string | null; sizeBytes?: number } = {
|
||||
caption: payload.data.caption,
|
||||
}
|
||||
if (lifeEventId !== undefined) data.lifeEventId = lifeEventId
|
||||
|
||||
if (isCitation) {
|
||||
const buffer = Buffer.from(payload.data.caption, 'utf8')
|
||||
const storageRoot = getStorageRoot()
|
||||
const absolutePath = path.resolve(storageRoot, existingMedia.filePath)
|
||||
if (!absolutePath.startsWith(storageRoot + path.sep)) {
|
||||
return reply.code(400).send({ error: 'invalid_media_path' })
|
||||
}
|
||||
await writeFile(absolutePath, buffer)
|
||||
data.sizeBytes = buffer.length
|
||||
}
|
||||
|
||||
await app.prisma.mediaItem.updateMany({
|
||||
where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null },
|
||||
data,
|
||||
})
|
||||
|
||||
await app.prisma.auditLog.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
actorType: 'user',
|
||||
actorId: request.actor.userId,
|
||||
action: 'media_reordered',
|
||||
action: 'media_updated',
|
||||
entityType: 'media_item',
|
||||
entityId: params.data.personId,
|
||||
entityId: params.data.mediaId,
|
||||
payloadJson: {
|
||||
personId: params.data.personId,
|
||||
orderedMediaIds: payload.data.orderedMediaIds,
|
||||
lifeEventChanged: lifeEventId !== undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return reply.send({ ok: true })
|
||||
return reply.send({
|
||||
ok: true,
|
||||
media: {
|
||||
id: params.data.mediaId,
|
||||
caption: payload.data.caption,
|
||||
lifeEventId,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => {
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { env } from '../config/env.js'
|
||||
import {
|
||||
appBaseUrl,
|
||||
finishAuthorization,
|
||||
isOidcEnabled,
|
||||
safeReturnTo,
|
||||
startAuthorization,
|
||||
type OidcIdentity,
|
||||
type OidcPending,
|
||||
} from '../lib/oidc.js'
|
||||
import { confirmsWithPassword } from '../lib/auth.js'
|
||||
import { createAccountIfAllowed } from '../lib/registration.js'
|
||||
import { signUserSession } from '../lib/user-session.js'
|
||||
|
||||
// Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ;
|
||||
// /auth/oidc/callback vérifie tout, trouve ou crée le compte, et renvoie vers l'app avec un jeton de passage dans le
|
||||
// fragment (#oidc=…, jamais envoyé à un serveur ni écrit dans un journal) ; l'app l'échange une seule fois contre
|
||||
// une session sur /auth/oidc/exchange. Le cookie lie le retour au navigateur qui a commencé la connexion.
|
||||
|
||||
const PENDING_COOKIE = 'parenthese_oidc'
|
||||
const PENDING_MAX_AGE_S = 10 * 60
|
||||
const HANDOFF_TTL_S = 60
|
||||
|
||||
type HandoffPayload = { kind: 'oidc_handoff'; sub: string; jti: string; exp?: number }
|
||||
|
||||
// Jetons de passage déjà échangés, jusqu'à leur expiration (une seule instance de l'API, comme les sessions révoquées)
|
||||
const usedHandoffs = new Map<string, number>()
|
||||
function consumeHandoff(jti: string, expSeconds: number): boolean {
|
||||
const now = Date.now()
|
||||
for (const [key, expiresAt] of usedHandoffs) {
|
||||
if (expiresAt < now) usedHandoffs.delete(key)
|
||||
}
|
||||
if (usedHandoffs.has(jti)) return false
|
||||
usedHandoffs.set(jti, expSeconds * 1000)
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingSchema = z.object({
|
||||
state: z.string().min(1),
|
||||
nonce: z.string().min(1),
|
||||
codeVerifier: z.string().min(43),
|
||||
returnTo: z.string(),
|
||||
})
|
||||
|
||||
const exchangeSchema = z.object({
|
||||
code: z.string().min(1).max(4096),
|
||||
})
|
||||
|
||||
type AccountResult = { userId: string } | { error: 'registration_closed' | 'oidc_email_missing' | 'oidc_email_unverified' | 'oidc_email_taken' }
|
||||
|
||||
// Compte relié à cette identité ; sinon le compte qui a cet email, si le fournisseur le garantit ; sinon un compte
|
||||
// neuf, seulement si l'instance en accepte (ALLOW_REGISTRATION, ou instance encore vide).
|
||||
async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Promise<AccountResult> {
|
||||
const linked = await app.prisma.userIdentity.findUnique({
|
||||
where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } },
|
||||
select: { userId: true },
|
||||
})
|
||||
if (linked) return { userId: linked.userId }
|
||||
|
||||
if (!identity.email) return { error: 'oidc_email_missing' }
|
||||
if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
|
||||
const existing = await app.prisma.user.findUnique({
|
||||
where: { email: identity.email },
|
||||
select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } },
|
||||
})
|
||||
if (existing) {
|
||||
// Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false :
|
||||
// sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte
|
||||
if (!identity.emailVerified) return { error: 'oidc_email_unverified' }
|
||||
// Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce
|
||||
// compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers
|
||||
if (existing.passwordHash) return { error: 'oidc_email_taken' }
|
||||
// Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui
|
||||
// présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié.
|
||||
if (existing.identities?.length) return { error: 'oidc_email_taken' }
|
||||
|
||||
await app.prisma.userIdentity.create({
|
||||
data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject },
|
||||
})
|
||||
return { userId: existing.id }
|
||||
}
|
||||
|
||||
const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
|
||||
data: {
|
||||
email: identity.email as string,
|
||||
firstName: identity.firstName,
|
||||
passwordHash: null,
|
||||
identities: { create: { issuer: identity.issuer, subject: identity.subject } },
|
||||
},
|
||||
select: { id: true },
|
||||
}))
|
||||
return created ? { userId: created.id } : { error: 'registration_closed' }
|
||||
}
|
||||
|
||||
function redirectToApp(reply: FastifyReply, returnTo: string, fragment: string) {
|
||||
reply.clearCookie(PENDING_COOKIE, { path: '/' })
|
||||
return reply.redirect(`${appBaseUrl()}${returnTo}#${fragment}`, 302)
|
||||
}
|
||||
|
||||
export const oidcRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!isOidcEnabled()) return
|
||||
|
||||
const secureCookie = new URL(env.OIDC_REDIRECT_URI).protocol === 'https:'
|
||||
|
||||
app.get(
|
||||
'/start',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const returnTo = safeReturnTo((request.query as Record<string, unknown> | undefined)?.returnTo)
|
||||
let start: Awaited<ReturnType<typeof startAuthorization>>
|
||||
try {
|
||||
start = await startAuthorization(returnTo)
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, 'OIDC discovery failed')
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable')
|
||||
}
|
||||
|
||||
reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau
|
||||
secure: secureCookie,
|
||||
signed: true,
|
||||
maxAge: PENDING_MAX_AGE_S,
|
||||
})
|
||||
return reply.redirect(start.url.href, 302)
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
'/callback',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const raw = request.cookies[PENDING_COOKIE]
|
||||
const unsigned = raw ? request.unsignCookie(raw) : null
|
||||
let pending: OidcPending | null = null
|
||||
if (unsigned?.valid && unsigned.value) {
|
||||
try {
|
||||
const parsed = pendingSchema.safeParse(JSON.parse(unsigned.value))
|
||||
pending = parsed.success ? parsed.data : null
|
||||
} catch {
|
||||
pending = null
|
||||
}
|
||||
}
|
||||
if (!pending) {
|
||||
// Pas de connexion commencée dans ce navigateur (cookie absent, expiré ou modifié)
|
||||
return redirectToApp(reply, '/', 'oidc_error=oidc_failed')
|
||||
}
|
||||
|
||||
const returnTo = safeReturnTo(pending.returnTo)
|
||||
const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : ''
|
||||
|
||||
let identity: OidcIdentity
|
||||
try {
|
||||
identity = await finishAuthorization(query, pending)
|
||||
} catch (error) {
|
||||
// Refus chez le fournisseur, state ou nonce faux, ID token invalide (signature, émetteur, audience, date)
|
||||
request.log.warn({ err: error }, 'OIDC callback rejected')
|
||||
return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed')
|
||||
}
|
||||
|
||||
let result: AccountResult
|
||||
try {
|
||||
result = await resolveAccount(app, identity)
|
||||
} catch (error) {
|
||||
// Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
result = await resolveAccount(app, identity)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
if ('error' in result) {
|
||||
return redirectToApp(reply, returnTo, `oidc_error=${result.error}`)
|
||||
}
|
||||
|
||||
const handoff = app.jwt.sign(
|
||||
{ kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() },
|
||||
{ expiresIn: `${HANDOFF_TTL_S}s` },
|
||||
)
|
||||
return redirectToApp(reply, returnTo, `oidc=${encodeURIComponent(handoff)}`)
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/exchange',
|
||||
{ config: { rateLimit: { max: 20, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const parsed = exchangeSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: 'invalid_payload' })
|
||||
}
|
||||
|
||||
let payload: HandoffPayload
|
||||
try {
|
||||
payload = app.jwt.verify<HandoffPayload>(parsed.data.code)
|
||||
} catch {
|
||||
return reply.code(401).send({ error: 'invalid_handoff' })
|
||||
}
|
||||
if (payload.kind !== 'oidc_handoff' || typeof payload.exp !== 'number' || !consumeHandoff(payload.jti, payload.exp)) {
|
||||
return reply.code(401).send({ error: 'invalid_handoff' })
|
||||
}
|
||||
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, email: true, firstName: true, passwordHash: true },
|
||||
})
|
||||
if (!user) {
|
||||
return reply.code(401).send({ error: 'invalid_handoff' })
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
token: signUserSession(app, user),
|
||||
user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) },
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
+103
-63
@@ -16,6 +16,8 @@ import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock-
|
||||
import type { MembershipRole } from '../types/auth.js'
|
||||
import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js'
|
||||
import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js'
|
||||
import { hasAnyName } from '../lib/person-names.js'
|
||||
import { sexSchema } from '../lib/person-sex.js'
|
||||
import { ContributionRouteError, resolveSubmissionContext } from './contributions.js'
|
||||
|
||||
const MAX_TREES_PER_ACCOUNT = 10
|
||||
@@ -84,33 +86,37 @@ const listAuditQuerySchema = z.object({
|
||||
const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
const createPersonSchema = z.object({
|
||||
firstName: z.string().min(1).max(120),
|
||||
lastName: z.string().min(1).max(120),
|
||||
firstName: z.string().trim().max(120),
|
||||
lastName: z.string().trim().max(120),
|
||||
birthName: z.string().max(120).optional().nullable(),
|
||||
nickname: z.string().max(120).optional().nullable(),
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
sex: z.string().max(20).optional().nullable(),
|
||||
sex: sexSchema,
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
})
|
||||
}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] })
|
||||
|
||||
const patchPersonSchema = z
|
||||
.object({
|
||||
firstName: z.string().min(1).max(120).optional(),
|
||||
lastName: z.string().min(1).max(120).optional(),
|
||||
firstName: z.string().trim().max(120).optional(),
|
||||
lastName: z.string().trim().max(120).optional(),
|
||||
birthName: z.string().max(120).optional().nullable(),
|
||||
nickname: z.string().max(120).optional().nullable(),
|
||||
birthDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
deathDate: z.string().regex(isoDateRegex).optional().nullable(),
|
||||
isDeceased: z.boolean().optional(),
|
||||
birthPlace: z.string().max(200).optional().nullable(),
|
||||
deathPlace: z.string().max(200).optional().nullable(),
|
||||
profession: z.string().max(200).optional().nullable(),
|
||||
region: z.string().max(120).optional().nullable(),
|
||||
nationality: z.string().max(120).optional().nullable(),
|
||||
sex: z.string().max(20).optional().nullable(),
|
||||
sex: sexSchema,
|
||||
notes: z.string().max(2000).optional().nullable(),
|
||||
})
|
||||
.refine((value) => Object.keys(value).length > 0, {
|
||||
@@ -1310,11 +1316,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
firstName: payload.data.firstName.trim(),
|
||||
lastName: payload.data.lastName.trim(),
|
||||
birthName: toTrimmedStringOrNull(payload.data.birthName),
|
||||
nickname: toTrimmedStringOrNull(payload.data.nickname),
|
||||
birthDate: toDateOrNull(payload.data.birthDate),
|
||||
deathDate: toDateOrNull(payload.data.deathDate),
|
||||
// Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date
|
||||
isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased),
|
||||
birthPlace: toTrimmedStringOrNull(payload.data.birthPlace),
|
||||
deathPlace: toTrimmedStringOrNull(payload.data.deathPlace),
|
||||
profession: toTrimmedStringOrNull(payload.data.profession),
|
||||
region: toTrimmedStringOrNull(payload.data.region),
|
||||
nationality: toTrimmedStringOrNull(payload.data.nationality),
|
||||
@@ -1364,14 +1372,27 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(403).send({ error: 'forbidden' })
|
||||
}
|
||||
|
||||
// Au moins un nom : on regarde le résultat de la modification, pas seulement ce qu'elle envoie
|
||||
if (payload.data.firstName !== undefined || payload.data.lastName !== undefined) {
|
||||
const before = await app.prisma.person.findFirst({
|
||||
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
|
||||
select: { firstName: true, lastName: true },
|
||||
})
|
||||
if (before && !hasAnyName(payload.data.firstName ?? before.firstName, payload.data.lastName ?? before.lastName)) {
|
||||
return reply.code(400).send({ error: 'invalid_payload', details: 'name_required' })
|
||||
}
|
||||
}
|
||||
|
||||
const data: {
|
||||
firstName?: string
|
||||
lastName?: string
|
||||
birthName?: string | null
|
||||
nickname?: string | null
|
||||
birthDate?: Date | null
|
||||
deathDate?: Date | null
|
||||
isDeceased?: boolean
|
||||
birthPlace?: string | null
|
||||
deathPlace?: string | null
|
||||
profession?: string | null
|
||||
region?: string | null
|
||||
nationality?: string | null
|
||||
@@ -1392,6 +1413,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
data.birthName = toTrimmedStringOrNull(payload.data.birthName)
|
||||
}
|
||||
|
||||
if (payload.data.nickname !== undefined) {
|
||||
data.nickname = toTrimmedStringOrNull(payload.data.nickname)
|
||||
}
|
||||
|
||||
if (payload.data.birthDate !== undefined) {
|
||||
data.birthDate = toDateOrNull(payload.data.birthDate)
|
||||
}
|
||||
@@ -1412,6 +1437,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace)
|
||||
}
|
||||
|
||||
if (payload.data.deathPlace !== undefined) {
|
||||
data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace)
|
||||
}
|
||||
|
||||
if (payload.data.profession !== undefined) {
|
||||
data.profession = toTrimmedStringOrNull(payload.data.profession)
|
||||
}
|
||||
@@ -2067,21 +2096,23 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.send({ link })
|
||||
})
|
||||
|
||||
// Réordonner une fratrie : displayOrder des liens parent-enfant réécrit en une transaction
|
||||
// Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre
|
||||
const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined) => {
|
||||
// Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre. La famille ne prend
|
||||
// que des souvenirs libres : elle ne détache pas un souvenir du moment d'un autre.
|
||||
const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined, onlyFree = false) => {
|
||||
if (!mediaIds?.length) return true
|
||||
const unique = [...new Set(mediaIds)]
|
||||
const count = await app.prisma.mediaItem.count({ where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null } })
|
||||
const count = await app.prisma.mediaItem.count({
|
||||
where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null, ...(onlyFree ? { lifeEventId: null } : {}) },
|
||||
})
|
||||
return count === unique.length
|
||||
}
|
||||
|
||||
// Rattache exactement ces souvenirs au moment (et détache ceux qui ne sont plus dans la liste)
|
||||
const setLifeEventMedia = async (treeId: string, eventId: string, mediaIds: string[] | undefined) => {
|
||||
const setLifeEventMedia = async (db: Prisma.TransactionClient, treeId: string, eventId: string, mediaIds: string[] | undefined) => {
|
||||
if (mediaIds === undefined) return
|
||||
const unique = [...new Set(mediaIds)]
|
||||
await app.prisma.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } })
|
||||
if (unique.length) await app.prisma.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } })
|
||||
await db.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } })
|
||||
if (unique.length) await db.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } })
|
||||
}
|
||||
|
||||
app.post('/trees/:id/persons/:personId/life/events', async (request, reply) => {
|
||||
@@ -2117,7 +2148,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
const person = await app.prisma.person.findFirst({
|
||||
where: { id: params.data.personId, treeId: params.data.id, deletedAt: null },
|
||||
select: { id: true, firstName: true },
|
||||
select: { id: true, firstName: true, lastName: true },
|
||||
})
|
||||
if (!person) return reply.code(404).send({ error: 'person_not_found' })
|
||||
|
||||
@@ -2142,55 +2173,59 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
if (existing) return reply.code(409).send({ error: 'event_exists', eventId: existing.id })
|
||||
}
|
||||
if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds))) {
|
||||
if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds, !isAdmin))) {
|
||||
return reply.code(400).send({ error: 'invalid_media_reference' })
|
||||
}
|
||||
|
||||
const isPending = contribution?.autoStatus === 'pending'
|
||||
const title = payload.data.label?.trim() || payload.data.description?.trim() || payload.data.type
|
||||
const session = contribution
|
||||
? await app.prisma.contributionSession.create({
|
||||
// Session, moment, souvenirs et trace de la contribution : tout ou rien
|
||||
const event = await app.prisma.$transaction(async (tx) => {
|
||||
const session = contribution
|
||||
? await tx.contributionSession.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
submittedByUserId: contribution.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
|
||||
mode: contribution.mode,
|
||||
status: contribution.autoStatus,
|
||||
title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120),
|
||||
reviewedAt: isPending ? undefined : new Date(),
|
||||
reviewedBy: isPending ? undefined : contribution.submittedByUserId,
|
||||
},
|
||||
})
|
||||
: null
|
||||
|
||||
const event = await tx.lifeEvent.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
submittedByUserId: contribution.submittedByUserId,
|
||||
submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel,
|
||||
mode: contribution.mode,
|
||||
status: contribution.autoStatus,
|
||||
title: `Moment pour ${person.firstName}`.slice(0, 120),
|
||||
reviewedAt: isPending ? undefined : new Date(),
|
||||
reviewedBy: isPending ? undefined : contribution.submittedByUserId,
|
||||
personId: unionId ? null : person.id,
|
||||
unionId,
|
||||
type: payload.data.type,
|
||||
...lifeEventData(payload.data, reserved),
|
||||
status: isPending ? 'pending' : 'approved',
|
||||
contributionSessionId: session?.id ?? null,
|
||||
},
|
||||
})
|
||||
: null
|
||||
await setLifeEventMedia(tx, params.data.id, event.id, payload.data.mediaIds)
|
||||
|
||||
const event = await app.prisma.lifeEvent.create({
|
||||
data: {
|
||||
treeId: params.data.id,
|
||||
personId: unionId ? null : person.id,
|
||||
unionId,
|
||||
type: payload.data.type,
|
||||
...lifeEventData(payload.data, reserved),
|
||||
status: isPending ? 'pending' : 'approved',
|
||||
contributionSessionId: session?.id ?? null,
|
||||
},
|
||||
})
|
||||
await setLifeEventMedia(params.data.id, event.id, payload.data.mediaIds)
|
||||
|
||||
if (session) {
|
||||
await app.prisma.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: 'life_event',
|
||||
entityId: event.id,
|
||||
action: 'create',
|
||||
afterJson: {
|
||||
personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null,
|
||||
datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources,
|
||||
if (session) {
|
||||
await tx.contributionChange.create({
|
||||
data: {
|
||||
sessionId: session.id,
|
||||
entityType: 'life_event',
|
||||
entityId: event.id,
|
||||
action: 'create',
|
||||
afterJson: {
|
||||
personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null,
|
||||
datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources,
|
||||
},
|
||||
decision: isPending ? undefined : 'approved',
|
||||
},
|
||||
decision: isPending ? undefined : 'approved',
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
return event
|
||||
})
|
||||
return reply.code(201).send({ event: { id: event.id, status: event.status } })
|
||||
})
|
||||
|
||||
@@ -2221,11 +2256,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
return reply.code(400).send({ error: 'invalid_media_reference' })
|
||||
}
|
||||
|
||||
await app.prisma.lifeEvent.update({
|
||||
where: { id: params.data.eventId },
|
||||
data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)),
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
await tx.lifeEvent.update({
|
||||
where: { id: params.data.eventId },
|
||||
data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)),
|
||||
})
|
||||
await setLifeEventMedia(tx, params.data.id, params.data.eventId, payload.data.mediaIds)
|
||||
})
|
||||
await setLifeEventMedia(params.data.id, params.data.eventId, payload.data.mediaIds)
|
||||
return reply.send({ ok: true })
|
||||
})
|
||||
|
||||
@@ -2238,13 +2275,16 @@ export const treeRoutes: FastifyPluginAsync = async (app) => {
|
||||
const membership = await getAdminMembership(app, user.userId, params.data.id)
|
||||
if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' })
|
||||
|
||||
const deleted = await app.prisma.lifeEvent.updateMany({
|
||||
where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } },
|
||||
data: { deletedAt: new Date() },
|
||||
const deletedCount = await app.prisma.$transaction(async (tx) => {
|
||||
const deleted = await tx.lifeEvent.updateMany({
|
||||
where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } },
|
||||
data: { deletedAt: new Date() },
|
||||
})
|
||||
// Les souvenirs restent dans la fiche, simplement détachés du moment
|
||||
if (deleted.count > 0) await tx.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } })
|
||||
return deleted.count
|
||||
})
|
||||
if (deleted.count === 0) return reply.code(404).send({ error: 'event_not_found' })
|
||||
// Les souvenirs restent dans la fiche, simplement détachés du moment
|
||||
await app.prisma.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } })
|
||||
if (deletedCount === 0) return reply.code(404).send({ error: 'event_not_found' })
|
||||
return reply.code(204).send()
|
||||
})
|
||||
|
||||
|
||||
@@ -28,7 +28,14 @@ export type MediaJwtPayload = {
|
||||
accessVersion?: number
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload
|
||||
// Passage d'une connexion OIDC à l'app (60 s, à usage unique, échangé sur /auth/oidc/exchange) : jamais une session
|
||||
export type OidcHandoffJwtPayload = {
|
||||
kind: 'oidc_handoff'
|
||||
sub: string
|
||||
jti: string
|
||||
}
|
||||
|
||||
export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload
|
||||
|
||||
export type UserActor = {
|
||||
kind: 'user'
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client'
|
||||
|
||||
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
|
||||
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
|
||||
import { isImmichAvatarPath } from '../lib/media-files.js'
|
||||
import { sexOrNull } from '../lib/person-sex.js'
|
||||
|
||||
export type GraphPayload = {
|
||||
persons: Array<{
|
||||
@@ -9,10 +11,12 @@ export type GraphPayload = {
|
||||
firstName: string
|
||||
lastName: string
|
||||
birthName: string | null
|
||||
nickname: string | null
|
||||
birthDate: string | null
|
||||
deathDate: string | null
|
||||
isDeceased: boolean
|
||||
birthPlace: string | null
|
||||
deathPlace: string | null
|
||||
profession: string | null
|
||||
region: string | null
|
||||
nationality: string | null
|
||||
@@ -20,6 +24,7 @@ export type GraphPayload = {
|
||||
sex: string | null
|
||||
notes: string | null
|
||||
avatarFromImmich: boolean
|
||||
avatarCrop: AvatarCrop | null
|
||||
}>
|
||||
unions: Array<{
|
||||
id: string
|
||||
@@ -84,17 +89,20 @@ export function buildGraphPayload(
|
||||
firstName: person.firstName,
|
||||
lastName: person.lastName,
|
||||
birthName: person.birthName,
|
||||
nickname: person.nickname,
|
||||
birthDate: formatDate(person.birthDate),
|
||||
deathDate: formatDate(person.deathDate),
|
||||
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
|
||||
birthPlace: person.birthPlace,
|
||||
deathPlace: person.deathPlace,
|
||||
profession: person.profession,
|
||||
region: person.region,
|
||||
nationality: person.nationality,
|
||||
avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null,
|
||||
sex: person.sex,
|
||||
sex: sexOrNull(person.sex),
|
||||
notes: person.notes,
|
||||
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
|
||||
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
|
||||
})),
|
||||
unions: unions.map((union) => ({
|
||||
id: union.id,
|
||||
|
||||
@@ -80,6 +80,9 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
},
|
||||
annotation: {
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(0),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
contributionSession: {
|
||||
create: vi.fn(),
|
||||
@@ -488,7 +491,7 @@ describe('Graph routes', () => {
|
||||
prismaMock.person.findMany.mockResolvedValue([
|
||||
{ ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' },
|
||||
{ ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true },
|
||||
{ ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'male', birthDate: null, birthDatePrecision: null, birthPlace: null },
|
||||
{ ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'M', birthDate: null, birthDatePrecision: null, birthPlace: null },
|
||||
])
|
||||
prismaMock.union.findMany.mockResolvedValue([
|
||||
{ id: 'u-1', partner1PersonId: 'p-1', partner2PersonId: 'p-2', startDate: new Date('1905-01-01T00:00:00Z'), startDatePrecision: 'estimated', startPlace: 'Limoges', endDate: null },
|
||||
@@ -515,6 +518,8 @@ describe('Graph routes', () => {
|
||||
expect(text).toContain('2 PEDI adopted')
|
||||
// Décédée sans date (issue #77) : la forme courte de la norme
|
||||
expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n')
|
||||
// Ancienne écriture « M » en base : exportée comme un homme (issue #114)
|
||||
expect(text).toContain('1 NAME Jean /Ragot/\n1 SEX M\n')
|
||||
|
||||
const roots = parseGedcom(text)
|
||||
const records = indexGedcomRecords(roots)
|
||||
@@ -753,6 +758,198 @@ describe('Moments de l’onglet Vie', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Écritures en plusieurs temps : tout passe par une transaction', () => {
|
||||
// Le client de la transaction est un autre objet que app.prisma : une écriture faite hors transaction se voit
|
||||
const txClient = () => ({
|
||||
annotation: { create: vi.fn().mockResolvedValue({ id: 'a' }), updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
|
||||
mediaItem: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findMany: vi.fn().mockResolvedValue([]), update: vi.fn() },
|
||||
lifeEvent: { create: vi.fn().mockResolvedValue({ id: 'e-1', status: 'approved' }), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }) },
|
||||
contributionSession: { create: vi.fn() },
|
||||
contributionChange: { create: vi.fn() },
|
||||
auditLog: { create: vi.fn() },
|
||||
})
|
||||
|
||||
it('lot d’annotations : créations, modifications, suppressions et trace dans la même transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth,
|
||||
payload: { create: [{ type: 'text', x: 0, y: 0, content: 'Été 1952' }, { type: 'text', x: 1, y: 1, content: 'Noël' }], update: [{ id: 'a-1', x: 5 }], delete: ['a-2'] },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ ok: true, created: 2, updated: 1, deleted: 1 })
|
||||
expect(tx.annotation.create).toHaveBeenCalledTimes(2)
|
||||
expect(tx.annotation.updateMany).toHaveBeenCalledTimes(2)
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.annotation.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.annotation.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('lot d’annotations : une erreur au milieu fait échouer toute la transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
tx.annotation.create.mockResolvedValueOnce({ id: 'a' }).mockRejectedValueOnce(new Error('base indisponible'))
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth,
|
||||
payload: { create: [{ type: 'text', x: 0, y: 0, content: 'a' }, { type: 'text', x: 1, y: 1, content: 'b' }] },
|
||||
})
|
||||
expect(response.statusCode).toBe(500)
|
||||
expect(tx.auditLog.create).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('réordonner les souvenirs : positions, renumérotation et trace dans la même transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
prismaMock.mediaItem.findMany.mockResolvedValueOnce([{ id: 'm-1' }, { id: 'm-2' }])
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PATCH', url: '/trees/tree-1/persons/person-1/media/reorder', headers: auth,
|
||||
payload: { orderedMediaIds: ['m-2', 'm-1'] },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(tx.mediaItem.updateMany).toHaveBeenCalledTimes(2)
|
||||
expect(tx.auditLog.create).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('moment : création et rattachement des souvenirs dans la même transaction', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const tx = txClient()
|
||||
prismaMock.mediaItem.count.mockResolvedValue(1)
|
||||
prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise<unknown>) => callback(tx))
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth,
|
||||
payload: { label: 'Départ pour Paris', mediaIds: ['m-1'] },
|
||||
})
|
||||
expect(response.statusCode).toBe(201)
|
||||
expect(tx.lifeEvent.create).toHaveBeenCalledTimes(1)
|
||||
expect(tx.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', id: { in: ['m-1'] } }, data: { lifeEventId: 'e-1' } })
|
||||
expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('la famille ne prend que des souvenirs libres, l’administrateur peut déplacer un souvenir d’un moment à l’autre', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
const post = () => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload: { label: 'Communion', mediaIds: ['m-1'] } })
|
||||
prismaMock.mediaItem.count.mockResolvedValue(1)
|
||||
prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1', status: 'approved' })
|
||||
|
||||
expect((await post()).statusCode).toBe(201)
|
||||
expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).not.toHaveProperty('lifeEventId')
|
||||
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null, settings: null } })
|
||||
prismaMock.contributionSession.create.mockResolvedValue({ id: 's-1' })
|
||||
expect((await post()).statusCode).toBe(201)
|
||||
expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).toMatchObject({ lifeEventId: null })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Import GEDCOM', () => {
|
||||
it('une personne des deux côtés d’une union garde un seul partenaire, une boucle parent-enfant est refusée', async () => {
|
||||
const app = createApp()
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
prismaMock.person.findMany.mockResolvedValue([])
|
||||
prismaMock.union.findMany.mockResolvedValue([])
|
||||
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
||||
prismaMock.parentChildLink.findFirst.mockResolvedValue(null)
|
||||
prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` }))
|
||||
let unionCount = 0
|
||||
prismaMock.union.create.mockImplementation(async () => ({ id: `u-${++unionCount}` }))
|
||||
|
||||
const content = [
|
||||
'0 HEAD',
|
||||
'0 @I1@ INDI', '1 NAME Jean /Martin/',
|
||||
'0 @I2@ INDI', '1 NAME Paul /Martin/',
|
||||
// Jean marié à lui-même (fichier abîmé)
|
||||
'0 @F1@ FAM', '1 HUSB @I1@', '1 WIFE @I1@',
|
||||
// Paul enfant de Jean, puis Jean enfant de Paul : la seconde famille ferait une boucle
|
||||
'0 @F2@ FAM', '1 HUSB @I1@', '1 CHIL @I2@',
|
||||
'0 @F3@ FAM', '1 HUSB @I2@', '1 CHIL @I1@',
|
||||
'0 TRLR',
|
||||
].join('\n')
|
||||
const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } })
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toMatchObject({ personsCreated: 2, linksCreated: 1, relationsSkipped: 2 })
|
||||
expect(prismaMock.union.create.mock.calls[0][0].data).toMatchObject({ partner1PersonId: 'p-Jean', partner2PersonId: null })
|
||||
expect(prismaMock.parentChildLink.create).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.parentChildLink.create.mock.calls[0][0].data).toMatchObject({ parentPersonId: 'p-Jean', childPersonId: 'p-Paul' })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sexe dans le GEDCOM (issue #114)', () => {
|
||||
const importer = async (app: FastifyInstance, content: string) => {
|
||||
const auth = { authorization: `Bearer ${await createUserToken(app)}` }
|
||||
prismaMock.person.findMany.mockResolvedValue([])
|
||||
prismaMock.union.findMany.mockResolvedValue([])
|
||||
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
||||
prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` }))
|
||||
prismaMock.person.create.mockClear()
|
||||
const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
return Object.fromEntries(prismaMock.person.create.mock.calls.map((call: [{ data: { firstName: string; sex: string | null } }]) => [call[0].data.firstName, call[0].data.sex]))
|
||||
}
|
||||
|
||||
it('import : M, F, X (GEDCOM 7), U et absent donnent male, female, other, null, null', async () => {
|
||||
const app = createApp()
|
||||
const content = [
|
||||
'0 HEAD',
|
||||
'0 @I1@ INDI', '1 NAME Jean /Martin/', '1 SEX M',
|
||||
'0 @I2@ INDI', '1 NAME Marie /Martin/', '1 SEX F',
|
||||
'0 @I3@ INDI', '1 NAME Camille /Martin/', '1 SEX X',
|
||||
'0 @I4@ INDI', '1 NAME Dominique /Martin/', '1 SEX U',
|
||||
'0 @I5@ INDI', '1 NAME Claude /Martin/',
|
||||
'0 TRLR',
|
||||
].join('\n')
|
||||
expect(await importer(app, content)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Dominique: null, Claude: null })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('aller-retour : other sort en SEX U (GEDCOM 5.5.1), non précisé sans ligne SEX, le reste se relit à l’identique', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() })
|
||||
const base = { lastName: 'Martin', birthDate: null, birthDatePrecision: null, birthPlace: null, birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null }
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Martin', rootPersonId: null })
|
||||
prismaMock.person.findMany.mockResolvedValue([
|
||||
{ ...base, id: 'p-1', firstName: 'Jean', sex: 'male' },
|
||||
{ ...base, id: 'p-2', firstName: 'Marie', sex: 'female' },
|
||||
{ ...base, id: 'p-3', firstName: 'Camille', sex: 'other' },
|
||||
{ ...base, id: 'p-4', firstName: 'Claude', sex: null },
|
||||
])
|
||||
prismaMock.union.findMany.mockResolvedValue([])
|
||||
prismaMock.parentChildLink.findMany.mockResolvedValue([])
|
||||
prismaMock.lifeEvent.findMany.mockResolvedValue([])
|
||||
|
||||
const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
const text = response.body.replace(/\r\n/g, '\n')
|
||||
expect(text).toContain('1 NAME Camille /Martin/\n1 SEX U\n')
|
||||
expect(text).not.toContain('SEX X')
|
||||
expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/)
|
||||
|
||||
// SEX U se relit comme non précisé : 5.5.1 n'a pas de valeur pour « autre ». SEX X reste lu à l'import (ligne 915).
|
||||
expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: null, Claude: null })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Visit log', () => {
|
||||
it('refuses the visit log to a non-admin member', async () => {
|
||||
const app = createApp()
|
||||
@@ -985,3 +1182,77 @@ describe('Relationship guardrails', () => {
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Surnom (nickname) en écriture directe', () => {
|
||||
it('PATCH accepte le surnom, le coupe, et vide = null', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
|
||||
|
||||
expect((await patch({ nickname: ' Loulou ' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' })
|
||||
|
||||
expect((await patch({ nickname: ' ' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: null })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un surnom de plus de 120 caractères', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload: { nickname: 'x'.repeat(121) } })
|
||||
expect(res.statusCode).toBe(400)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sexe d’une personne en écriture directe (issue #114)', () => {
|
||||
it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
|
||||
|
||||
expect((await patch({ sex: 'F' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' })
|
||||
|
||||
expect((await patch({ sex: 'male' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
|
||||
|
||||
expect((await patch({ sex: 'other' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'other' })
|
||||
|
||||
expect((await patch({ sex: null })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: null })
|
||||
|
||||
const calls = prismaMock.person.updateMany.mock.calls.length
|
||||
expect((await patch({ sex: 'robot' })).statusCode).toBe(400)
|
||||
expect(prismaMock.person.updateMany.mock.calls.length).toBe(calls)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Lieu de décès et filiation adoptive (écriture)', () => {
|
||||
it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload })
|
||||
|
||||
expect((await patch({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' })
|
||||
|
||||
expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' })
|
||||
prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 })
|
||||
const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' })
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -290,7 +290,8 @@ describe('API security and authz', () => {
|
||||
|
||||
expect(response.statusCode).toBe(429)
|
||||
await app.close()
|
||||
})
|
||||
// Onze connexions, chacune avec une vérification bcrypt : plus de 5 s sur les machines lentes de la CI
|
||||
}, 20_000)
|
||||
|
||||
it('rate-limits register endpoint', async () => {
|
||||
const app = createApp()
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me.
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let app: FastifyInstance
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PORT = '4000'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.JWT_EXPIRES_IN = '7d'
|
||||
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.user.count.mockResolvedValue(1)
|
||||
})
|
||||
|
||||
describe('prénom du compte', () => {
|
||||
it("l'inscription enregistre le prénom (sans espaces autour) et le renvoie", async () => {
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
payload: { firstName: ' Anaïs ', email: 'anais@example.com', password: 'securepass123' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ firstName: 'Anaïs' }),
|
||||
}))
|
||||
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
})
|
||||
|
||||
it("l'inscription sans prénom le garde vide, sans erreur", async () => {
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-2', email: 'sans@example.com', firstName: null })
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/register',
|
||||
payload: { email: 'sans@example.com', password: 'securepass123' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ firstName: null }),
|
||||
}))
|
||||
expect(res.json().user.firstName).toBeNull()
|
||||
})
|
||||
|
||||
it('la connexion renvoie le prénom du compte', async () => {
|
||||
const { hashPassword } = await import('../src/lib/auth.js')
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
email: 'anais@example.com',
|
||||
firstName: 'Anaïs',
|
||||
passwordHash: await hashPassword('securepass123'),
|
||||
})
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
payload: { email: 'anais@example.com', password: 'securepass123' },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
})
|
||||
|
||||
it('/me renvoie le profil du compte connecté, et refuse sans session', async () => {
|
||||
const { hashPassword } = await import('../src/lib/auth.js')
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-1',
|
||||
email: 'anais@example.com',
|
||||
firstName: 'Anaïs',
|
||||
passwordHash: await hashPassword('securepass123'),
|
||||
})
|
||||
const login = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
payload: { email: 'anais@example.com', password: 'securepass123' },
|
||||
})
|
||||
const token = login.json().token
|
||||
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' })
|
||||
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
|
||||
expect(me.statusCode).toBe(200)
|
||||
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true })
|
||||
|
||||
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
|
||||
expect(anonymous.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,230 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { Prisma } from '@prisma/client'
|
||||
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
count: vi.fn(),
|
||||
},
|
||||
person: {
|
||||
findFirst: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeMembership: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let createApp: () => FastifyInstance
|
||||
let storageRoot: string
|
||||
|
||||
const TREE = 'tree-1'
|
||||
const PERSON = 'person-1'
|
||||
const url = `/trees/${TREE}/persons/${PERSON}/avatar/crop`
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
}
|
||||
|
||||
function asAdmin(role: 'owner' | 'admin' | 'member' = 'owner') {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({ role, tree: { deletedAt: null } })
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PORT = '4000'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.JWT_EXPIRES_IN = '7d'
|
||||
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
|
||||
storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-crop-'))
|
||||
process.env.MEDIA_STORAGE_PATH = storageRoot
|
||||
|
||||
const module = await import('../src/app.js')
|
||||
createApp = module.createApp
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(storageRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.user.count.mockResolvedValue(1)
|
||||
prismaMock.person.updateMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.person.findFirst.mockResolvedValue({ avatarPath: `${TREE}/${PERSON}/avatar-a.jpg` })
|
||||
asAdmin('owner')
|
||||
})
|
||||
|
||||
describe('PUT /trees/:id/persons/:personId/avatar/crop', () => {
|
||||
it('refuse sans session', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({ method: 'PUT', url, payload: { crop: null } })
|
||||
expect(response.statusCode).toBe(401)
|
||||
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('réserve le cadrage aux administrateurs de l arbre, comme le changement de photo', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
asAdmin('member')
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuse un cadrage hors plage ou mal formé', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const headers = { authorization: `Bearer ${token}` }
|
||||
|
||||
const tooZoomed = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5, y: 0.5, zoom: 5 } } })
|
||||
const outside = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 1.2, y: 0.5, zoom: 1 } } })
|
||||
const missing = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5 } } })
|
||||
const noBody = await app.inject({ method: 'PUT', url, headers, payload: {} })
|
||||
|
||||
expect(tooZoomed.statusCode).toBe(400)
|
||||
expect(outside.statusCode).toBe(400)
|
||||
expect(missing.statusCode).toBe(400)
|
||||
expect(noBody.statusCode).toBe(400)
|
||||
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('enregistre le cadrage arrondi et audite le changement', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'PUT',
|
||||
url,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { crop: { x: 0.123456, y: 0.7, zoom: 1.8 } },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json()).toEqual({ ok: true, avatarCrop: { x: 0.1235, y: 0.7, zoom: 1.8 } })
|
||||
|
||||
const call = prismaMock.person.updateMany.mock.calls[0][0]
|
||||
expect(call.where).toEqual({ id: PERSON, treeId: TREE, deletedAt: null })
|
||||
expect(call.data.avatarCrop).toEqual({ x: 0.1235, y: 0.7, zoom: 1.8 })
|
||||
// La photo elle-même n'est jamais touchée
|
||||
expect(call.data).not.toHaveProperty('avatarPath')
|
||||
expect(prismaMock.auditLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ action: 'person_avatar_cropped', entityId: PERSON, treeId: TREE }),
|
||||
})
|
||||
})
|
||||
|
||||
it('remet le cadrage par défaut à zéro (null) plutôt que de le stocker', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const reset = await app.inject({
|
||||
method: 'PUT',
|
||||
url,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { crop: null },
|
||||
})
|
||||
expect(reset.statusCode).toBe(200)
|
||||
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
|
||||
|
||||
prismaMock.person.updateMany.mockClear()
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { crop: { x: 0.5, y: 0.5, zoom: 1 } },
|
||||
})
|
||||
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull)
|
||||
})
|
||||
|
||||
it('garde la forme choisie, même avec un cadrage centré, et refuse une forme inconnue', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const put = (crop: unknown) => app.inject({ method: 'PUT', url, headers: { authorization: `Bearer ${token}` }, payload: { crop } })
|
||||
|
||||
const saved = await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
|
||||
expect(saved.statusCode).toBe(200)
|
||||
expect(saved.json()).toEqual({ ok: true, avatarCrop: { x: 0.5, y: 0.5, zoom: 1, shape: 'round' } })
|
||||
expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' })
|
||||
|
||||
expect((await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'hexagone' })).statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const headers = { authorization: `Bearer ${token}` }
|
||||
|
||||
prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: null })
|
||||
const noAvatar = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } })
|
||||
expect(noAvatar.statusCode).toBe(404)
|
||||
|
||||
prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: `${TREE}/${PERSON}/immich-abc.jpg` })
|
||||
const fromImmich = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } })
|
||||
expect(fromImmich.statusCode).toBe(409)
|
||||
expect(fromImmich.json()).toEqual({ error: 'avatar_from_immich' })
|
||||
expect(prismaMock.person.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE avatar : le cadrage disparaît avec la photo', () => {
|
||||
it('remet avatarCrop à null en même temps que le portrait', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/trees/${TREE}/persons/${PERSON}/avatar`,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
})
|
||||
|
||||
expect(response.statusCode).toBe(200)
|
||||
const data = prismaMock.person.updateMany.mock.calls[0][0].data
|
||||
expect(data.avatarPath).toBeNull()
|
||||
expect(data.avatarCrop).toBe(Prisma.DbNull)
|
||||
})
|
||||
})
|
||||
|
||||
describe('lecture du cadrage stocké', () => {
|
||||
it('ignore toute valeur mal formée au lieu de déformer le portrait', async () => {
|
||||
const { readAvatarCrop, toStoredAvatarCrop } = await import('../src/lib/avatar-crop.js')
|
||||
|
||||
expect(readAvatarCrop(null)).toBeNull()
|
||||
expect(readAvatarCrop('oui')).toBeNull()
|
||||
expect(readAvatarCrop({ x: 0.2, y: 0.3 })).toBeNull()
|
||||
expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 9 })).toBeNull()
|
||||
expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 2 })).toEqual({ x: 0.2, y: 0.3, zoom: 2 })
|
||||
|
||||
expect(toStoredAvatarCrop({ x: 0.5, y: 0.5, zoom: 1 })).toBeNull()
|
||||
expect(toStoredAvatarCrop(null)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
@@ -28,6 +28,9 @@ const { prismaMock } = vi.hoisted(() => ({
|
||||
contributionSession: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeVisit: {
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
@@ -50,6 +53,7 @@ let storageRoot = ''
|
||||
let passwordHash = ''
|
||||
let createApp: () => FastifyInstance
|
||||
let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null
|
||||
let env: { PASSWORD_LOGIN: boolean }
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
@@ -94,6 +98,7 @@ beforeAll(async () => {
|
||||
|
||||
createApp = (await import('../src/app.js')).createApp
|
||||
resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -152,6 +157,138 @@ describe('DELETE /auth/me', () => {
|
||||
await app.close()
|
||||
})
|
||||
|
||||
describe("compte sans mot de passe (né d'une connexion OIDC)", () => {
|
||||
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign({
|
||||
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
|
||||
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
|
||||
}, { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash: null })
|
||||
})
|
||||
|
||||
it('se supprime avec son email retapé, juste après une connexion', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: ' User@Example.com ' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un autre email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'autre@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'invalid_password' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it("n'accepte pas un email à la place du mot de passe pour un compte qui en a un", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => {
|
||||
const signedAt = async (app: FastifyInstance, minutesAgo: number) => {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign({
|
||||
kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com',
|
||||
iat: Math.floor(Date.now() / 1000) - minutesAgo * 60,
|
||||
}, { expiresIn: '1h' })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
env.PASSWORD_LOGIN = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
env.PASSWORD_LOGIN = true
|
||||
})
|
||||
|
||||
it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse une session de plus de 15 minutes, même avec le bon email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 20)}` },
|
||||
payload: { confirmEmail: 'user@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(response.json()).toEqual({ error: 'recent_sign_in_required' })
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuse un autre email', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'DELETE', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
payload: { confirmEmail: 'autre@example.com' },
|
||||
})
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.user.delete).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => {
|
||||
const app = createApp()
|
||||
const response = await app.inject({
|
||||
method: 'GET', url: '/auth/me',
|
||||
headers: { authorization: `Bearer ${await signedAt(app, 2)}` },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(response.json().user.hasPassword).toBe(false)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects a wrong password without deleting anything', async () => {
|
||||
const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg')
|
||||
const app = createApp()
|
||||
@@ -214,6 +351,10 @@ describe('DELETE /auth/me', () => {
|
||||
where: { submittedByUserId: 'user-1' },
|
||||
data: { submittedByLabel: 'Contributeur' },
|
||||
})
|
||||
// Ses visites d'autres arbres restent comptées, sans son identifiant
|
||||
const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0]
|
||||
expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' })
|
||||
expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/)
|
||||
expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } })
|
||||
|
||||
// Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict).
|
||||
|
||||
@@ -278,6 +278,7 @@ describe('Journey 1 — Owner creates account, tree, and adds family', () => {
|
||||
},
|
||||
})
|
||||
expect(addSelfRes.statusCode).toBe(201)
|
||||
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
|
||||
|
||||
// Step 4: Add parent
|
||||
prismaMock.person.create.mockResolvedValue({ id: 'person-2', firstName: 'Jean', lastName: 'Martin' })
|
||||
@@ -734,3 +735,127 @@ describe('Journey 4 — Admin reviews a contribution session', () => {
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Surnom (nickname) proposé par un contributeur', () => {
|
||||
it('soumis en attente, puis appliqué par le propriétaire avec la valeur nettoyée', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
|
||||
const contributorToken = (app as any).jwt.sign({
|
||||
kind: 'tree_access',
|
||||
sub: 'tree:tree-1:contributor',
|
||||
treeId: 'tree-1',
|
||||
role: 'contributor',
|
||||
accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(),
|
||||
})
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', deletedAt: null, settings: { contributorPolicy: 'pending' } })
|
||||
|
||||
const submitRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions',
|
||||
headers: { authorization: `Bearer ${contributorToken}` },
|
||||
payload: {
|
||||
title: 'Surnom de Robert',
|
||||
submittedByLabel: 'Jeanette Martin',
|
||||
changes: [
|
||||
{
|
||||
entityType: 'person',
|
||||
action: 'update',
|
||||
entityId: 'person-1',
|
||||
before: { nickname: null },
|
||||
after: { nickname: ' Loulou ' },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
expect(submitRes.statusCode).toBe(201)
|
||||
expect(submitRes.json().session.status).toBe('pending')
|
||||
|
||||
const adminToken = (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||||
id: 'session-n',
|
||||
treeId: 'tree-1',
|
||||
status: 'pending',
|
||||
title: 'Surnom de Robert',
|
||||
changes: [
|
||||
{
|
||||
id: 'change-n',
|
||||
entityType: 'person',
|
||||
action: 'update',
|
||||
entityId: 'person-1',
|
||||
beforeJson: { nickname: null },
|
||||
afterJson: { nickname: ' Loulou ' },
|
||||
conflictState: 'none',
|
||||
},
|
||||
],
|
||||
})
|
||||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Robert', lastName: 'Dupont', nickname: null, deletedAt: null })
|
||||
|
||||
const reviewRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions/session-n/review',
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { decision: 'approved' },
|
||||
})
|
||||
expect(reviewRes.statusCode).toBe(200)
|
||||
expect(reviewRes.json().approvedCount).toBe(1)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' })
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Sexe proposé par un contributeur (issue #114)', () => {
|
||||
it('appliqué par le propriétaire avec la valeur normalisée', async () => {
|
||||
const app = createApp()
|
||||
await app.ready()
|
||||
|
||||
const adminToken = (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
prismaMock.contributionSession.findFirst.mockResolvedValue({
|
||||
id: 'session-s',
|
||||
treeId: 'tree-1',
|
||||
status: 'pending',
|
||||
title: 'Sexe de Camille',
|
||||
changes: [
|
||||
{
|
||||
id: 'change-s1',
|
||||
entityType: 'person',
|
||||
action: 'update',
|
||||
entityId: 'person-1',
|
||||
beforeJson: { sex: null },
|
||||
afterJson: { sex: 'F' },
|
||||
conflictState: 'none',
|
||||
},
|
||||
{
|
||||
id: 'change-s2',
|
||||
entityType: 'person',
|
||||
action: 'create',
|
||||
entityId: null,
|
||||
beforeJson: null,
|
||||
afterJson: { firstName: 'Léon', lastName: 'Martin', sex: 'male' },
|
||||
conflictState: 'none',
|
||||
},
|
||||
],
|
||||
})
|
||||
prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Camille', lastName: 'Martin', sex: null, deletedAt: null })
|
||||
|
||||
const reviewRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/trees/tree-1/contributions/sessions/session-s/review',
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
payload: { decision: 'approved' },
|
||||
})
|
||||
expect(reviewRes.statusCode).toBe(200)
|
||||
expect(reviewRes.json().approvedCount).toBe(2)
|
||||
expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' })
|
||||
expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' })
|
||||
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -220,4 +220,13 @@ describe('localMediaPath', () => {
|
||||
expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull()
|
||||
expect(localMediaPath('')).toBeNull()
|
||||
})
|
||||
|
||||
it('fichier piégé : sources en chaîne et niveaux trop profonds, sans débordement ni lenteur', () => {
|
||||
const chain = Array.from({ length: 20000 }, (_, i) => [`0 @S${i}@ SOUR`, `1 SOUR @S${i + 1}@`]).flat()
|
||||
const deep = Array.from({ length: 5000 }, (_, i) => `${i + 2} NOTE niveau`)
|
||||
const text = ['0 @I1@ INDI', '1 NAME A /B/', '1 SOUR @S0@', '1 BIRT', ...deep, ...chain, '0 @S20000@ SOUR', '1 OBJE', '2 FILE C:/fin.jpg'].join('\n')
|
||||
const started = Date.now()
|
||||
expect(() => mediaOf(text)).not.toThrow()
|
||||
expect(Date.now() - started).toBeLessThan(2000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseSex } from '../src/lib/person-sex'
|
||||
import { gedcomSexValue, parseGedcom } from '../src/routes/gedcom'
|
||||
|
||||
// Export en GEDCOM 5.5.1 : SEX ne vaut que M, F ou U (#118). « X » n'existe qu'en GEDCOM 7.
|
||||
describe('export GEDCOM 5.5.1 : valeur SEX', () => {
|
||||
it('écrit M, F, et U pour un sexe « autre »', () => {
|
||||
expect(gedcomSexValue('male')).toBe('M')
|
||||
expect(gedcomSexValue('female')).toBe('F')
|
||||
expect(gedcomSexValue('other')).toBe('U')
|
||||
})
|
||||
|
||||
it("n'écrit jamais X, et ne déclare rien pour un sexe non précisé", () => {
|
||||
for (const sex of ['male', 'female', 'other', null] as const) {
|
||||
expect(gedcomSexValue(sex)).not.toBe('X')
|
||||
}
|
||||
expect(gedcomSexValue(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('l’import relit toujours SEX X comme « autre », et U comme non précisé', () => {
|
||||
const sexes = parseGedcom(['0 @I1@ INDI', '1 SEX X', '0 @I2@ INDI', '1 SEX U'].join('\n'))
|
||||
.filter((node) => node.tag === 'INDI')
|
||||
.map((indi) => parseSex(indi.children.find((child) => child.tag === 'SEX')?.value ?? null))
|
||||
expect(sexes).toEqual(['other', null])
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ describe('buildGraphPayload', () => {
|
||||
firstName: 'Zoe',
|
||||
lastName: 'Martin',
|
||||
birthName: 'Durand',
|
||||
nickname: 'Zozo',
|
||||
birthDate: new Date('1994-06-02T00:00:00.000Z'),
|
||||
deathDate: null,
|
||||
sex: null,
|
||||
@@ -115,6 +116,7 @@ describe('buildGraphPayload', () => {
|
||||
expect(graph.persons[0].id).toBe('p-2')
|
||||
expect(graph.persons[0].birthDate).toBe('1994-06-02')
|
||||
expect(graph.persons[0].birthName).toBe('Durand')
|
||||
expect(graph.persons[0].nickname).toBe('Zozo')
|
||||
expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar')
|
||||
// L'identifiant Immich ne part jamais dans le graphe : il ne sert qu'aux administrateurs.
|
||||
// Seule l'origine du portrait (fichier préfixé « immich- ») est publiée.
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
count: vi.fn().mockResolvedValue(1),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
tree: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
treeMembership: {
|
||||
count: vi.fn(async () => 0),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
treeSettings: {
|
||||
create: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
},
|
||||
treeAccessPasswords: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
treeSharePassword: {
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
userTreeAccess: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
upsert: vi.fn(),
|
||||
updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
person: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
union: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
parentChildLink: {
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
mediaItem: {
|
||||
count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }),
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
lifeEvent: {
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn().mockResolvedValue([]),
|
||||
createMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
annotation: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
contributionSession: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||
count: vi.fn(),
|
||||
},
|
||||
contributionChange: {
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
auditLog: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let createApp: () => FastifyInstance
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>()
|
||||
return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) }
|
||||
})
|
||||
|
||||
function applyTestEnv(): void {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.PORT = '4000'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.JWT_EXPIRES_IN = '7d'
|
||||
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
}
|
||||
|
||||
async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise<string> {
|
||||
await app.ready()
|
||||
return (app as any).jwt.sign(
|
||||
{ kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' },
|
||||
{ expiresIn: '1h' },
|
||||
)
|
||||
}
|
||||
|
||||
const URL_MEDIA = '/trees/tree-1/persons/person-1/media/media-1'
|
||||
|
||||
beforeAll(async () => {
|
||||
applyTestEnv()
|
||||
const module = await import('../src/app.js')
|
||||
createApp = module.createApp
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null })
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValue({
|
||||
treeId: 'tree-1',
|
||||
userId: 'user-1',
|
||||
role: 'owner',
|
||||
tree: { deletedAt: null, rootPersonId: null },
|
||||
})
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', type: 'photo', filePath: 'tree-1/person-1/media-1.jpg' })
|
||||
prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 })
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, status: 'approved' })
|
||||
prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' })
|
||||
})
|
||||
|
||||
describe('PATCH media (modifier un souvenir)', () => {
|
||||
it('refuses a visitor', async () => {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'visitor', tree: { deletedAt: null } })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } })
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a contributor', async () => {
|
||||
prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'contributor', tree: { deletedAt: null } })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } })
|
||||
expect(response.statusCode).toBe(403)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts an admin and updates title and life event', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: URL_MEDIA,
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
payload: { caption: 'Mariage à Caen', lifeEventId: 'event-1' },
|
||||
})
|
||||
expect(response.statusCode).toBe(200)
|
||||
expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ id: 'media-1', treeId: 'tree-1', personId: 'person-1' }),
|
||||
data: { caption: 'Mariage à Caen', lifeEventId: 'event-1' },
|
||||
}))
|
||||
expect(response.json().media).toEqual({ id: 'media-1', caption: 'Mariage à Caen', lifeEventId: 'event-1' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('clears the life event with null and keeps it when the key is absent', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: null } })
|
||||
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo', lifeEventId: null })
|
||||
await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } })
|
||||
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo' })
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rejects an empty title and a photo title above 280 characters', async () => {
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const empty = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: ' ' } })
|
||||
expect(empty.statusCode).toBe(400)
|
||||
const long = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'a'.repeat(281) } })
|
||||
expect(long.statusCode).toBe(400)
|
||||
expect(long.json().error).toBe('caption_too_long')
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('refuses a life event from another tree or another person', async () => {
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce(null)
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const otherTree = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-other-tree' } })
|
||||
expect(otherTree.statusCode).toBe(400)
|
||||
expect(otherTree.json().error).toBe('invalid_life_event_reference')
|
||||
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: 'person-2', unionId: null, status: 'approved' })
|
||||
const otherPerson = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-2' } })
|
||||
expect(otherPerson.statusCode).toBe(400)
|
||||
expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled()
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts a life event of a union the person belongs to', async () => {
|
||||
prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: null, unionId: 'union-1', status: 'approved' })
|
||||
prismaMock.union.findFirst.mockResolvedValueOnce({ partner1PersonId: 'person-1', partner2PersonId: 'person-9' })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-union' } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('returns 404 for a media that does not belong to the person', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null)
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } })
|
||||
expect(response.statusCode).toBe(404)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('rewrites the citation file with the same text as caption', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' })
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const text = 'Il aimait la mer, « toujours »'
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: text } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
const writes = (writeFile as unknown as ReturnType<typeof vi.fn>).mock.calls
|
||||
expect(writes.length).toBe(1)
|
||||
expect(String(writes[0][0])).toMatch(/media-1\.txt$/)
|
||||
expect(Buffer.from(writes[0][1] as Buffer).toString('utf8')).toBe(text)
|
||||
expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({
|
||||
caption: text,
|
||||
sizeBytes: Buffer.byteLength(text, 'utf8'),
|
||||
})
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('accepts a citation longer than 280 characters', async () => {
|
||||
prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' })
|
||||
const app = createApp()
|
||||
const token = await createUserToken(app)
|
||||
const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'b'.repeat(500) } })
|
||||
expect(response.statusCode).toBe(200)
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,339 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Connexion OIDC (#115), routes et règles de compte. Le protocole lui-même (PKCE, state, nonce, signature, émetteur,
|
||||
// audience de l'ID token) est celui d'openid-client, simulé ici : il est testé de bout en bout contre un vrai
|
||||
// fournisseur à part. Ce fichier vérifie ce que Parenthèse décide avec ce que le fournisseur renvoie.
|
||||
const { prismaMock, oidcMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
userIdentity: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
oidcMock: {
|
||||
startAuthorization: vi.fn(),
|
||||
finishAuthorization: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({ prisma: prismaMock }))
|
||||
vi.mock('../src/lib/oidc.js', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../src/lib/oidc.js')>()),
|
||||
...oidcMock,
|
||||
}))
|
||||
|
||||
const ISSUER = 'https://id.example.org'
|
||||
const pending = { state: 'state-1', nonce: 'nonce-1', codeVerifier: 'v'.repeat(43), returnTo: '/' }
|
||||
const identity = { issuer: ISSUER, subject: 'sub-1', email: 'camille@example.com', emailVerified: true, firstName: 'Camille' }
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean; PASSWORD_LOGIN: boolean; OIDC_REQUIRE_VERIFIED_EMAIL: boolean }
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.OIDC_ISSUER = ISSUER
|
||||
process.env.OIDC_CLIENT_ID = 'parenthese'
|
||||
process.env.OIDC_CLIENT_SECRET = 'secret'
|
||||
process.env.OIDC_REDIRECT_URI = 'https://arbre.example.org/auth/oidc/callback'
|
||||
process.env.OIDC_PROVIDER_NAME = 'Authentik'
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
for (const key of ['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI', 'OIDC_PROVIDER_NAME']) delete process.env[key]
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
env.ALLOW_REGISTRATION = true
|
||||
env.PASSWORD_LOGIN = true
|
||||
env.OIDC_REQUIRE_VERIFIED_EMAIL = true
|
||||
oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({
|
||||
url: new URL(`${ISSUER}/authorize?state=state-1`),
|
||||
pending: { ...pending, returnTo },
|
||||
}))
|
||||
oidcMock.finishAuthorization.mockResolvedValue(identity)
|
||||
prismaMock.user.count.mockResolvedValue(1)
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue(null)
|
||||
prismaMock.userIdentity.create.mockResolvedValue({})
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-new' })
|
||||
prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock))
|
||||
prismaMock.$executeRaw.mockResolvedValue(1)
|
||||
})
|
||||
|
||||
// Chaque connexion simulée vient de sa propre adresse : les routes OIDC sont limitées à 20 appels par minute et
|
||||
// par adresse, et le fichier en fait plus
|
||||
let visitor = 0
|
||||
const nextAddress = () => `10.0.${Math.floor(++visitor / 250)}.${visitor % 250 + 1}`
|
||||
let currentAddress = nextAddress()
|
||||
|
||||
// Début de connexion : renvoie le cookie signé que le navigateur rapportera au retour
|
||||
async function start(returnTo = '/') {
|
||||
currentAddress = nextAddress()
|
||||
const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`, remoteAddress: currentAddress })
|
||||
const setCookie = String(res.headers['set-cookie'])
|
||||
return { res, cookie: setCookie.split(';')[0] }
|
||||
}
|
||||
|
||||
async function callback(cookie?: string, query = '?code=abc&state=state-1') {
|
||||
return app.inject({ method: 'GET', url: `/auth/oidc/callback${query}`, headers: cookie ? { cookie } : {}, remoteAddress: currentAddress })
|
||||
}
|
||||
|
||||
function fragmentOf(location: string | undefined) {
|
||||
return new URLSearchParams(String(location).split('#')[1] ?? '')
|
||||
}
|
||||
|
||||
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' } })
|
||||
})
|
||||
|
||||
it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => {
|
||||
const { res } = await start()
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(res.headers.location).toBe(`${ISSUER}/authorize?state=state-1`)
|
||||
const setCookie = String(res.headers['set-cookie'])
|
||||
expect(setCookie).toMatch(/^parenthese_oidc=/)
|
||||
expect(setCookie).toMatch(/HttpOnly/)
|
||||
expect(setCookie).toMatch(/SameSite=Lax/)
|
||||
expect(setCookie).toMatch(/Secure/)
|
||||
})
|
||||
|
||||
it("n'accepte comme retour que la racine ou un arbre partagé (pas de redirection ouverte)", async () => {
|
||||
await start('/arbre/famille-exemple')
|
||||
expect(oidcMock.startAuthorization).toHaveBeenLastCalledWith('/arbre/famille-exemple')
|
||||
for (const bad of ['https://evil.example', '//evil.example', '/arbre/../../x', '/\\evil.example']) {
|
||||
await start(bad)
|
||||
expect(oidcMock.startAuthorization).toHaveBeenLastCalledWith('/')
|
||||
}
|
||||
})
|
||||
|
||||
it('refuse un retour sans connexion commencée dans ce navigateur', async () => {
|
||||
const res = await callback()
|
||||
expect(res.statusCode).toBe(302)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(oidcMock.finishAuthorization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refuse un cookie modifié', async () => {
|
||||
const { cookie } = await start()
|
||||
const forged = cookie.replace(/state-1/, 'state-2').replace(/%22state%22%3A%22state-1/, '%22state%22%3A%22state-2')
|
||||
const tampered = forged === cookie ? `${cookie}x` : forged
|
||||
const res = await callback(tampered)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(oidcMock.finishAuthorization).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("passe à openid-client l'état gardé dans le cookie, et refuse quand la vérification échoue", async () => {
|
||||
const { cookie } = await start()
|
||||
oidcMock.finishAuthorization.mockRejectedValueOnce(new Error('unexpected ID Token "aud" (audience) claim value'))
|
||||
const res = await callback(cookie)
|
||||
expect(oidcMock.finishAuthorization).toHaveBeenCalledWith('?code=abc&state=state-1', expect.objectContaining({ state: 'state-1', nonce: 'nonce-1' }))
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed')
|
||||
expect(String(res.headers['set-cookie'])).toMatch(/parenthese_oidc=;/)
|
||||
})
|
||||
|
||||
it('revient sur l’arbre partagé demandé au départ', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
const { cookie } = await start('/arbre/famille-exemple')
|
||||
const res = await callback(cookie)
|
||||
expect(String(res.headers.location)).toMatch(/^https:\/\/arbre\.example\.org\/arbre\/famille-exemple#oidc=/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compte relié, créé ou refusé', () => {
|
||||
it('identité déjà reliée : connexion, même si le fournisseur ne dit plus l’email vérifié', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false, email: null })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('identité déjà reliée : retrouvée par émetteur et sujet, jamais par email (le fournisseur a pu le réattribuer)', async () => {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'reattribue@example.com' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.findUnique).toHaveBeenCalledWith({
|
||||
where: { issuer_subject: { issuer: ISSUER, subject: 'sub-1' } },
|
||||
select: { userId: true },
|
||||
})
|
||||
expect(prismaMock.user.findUnique).not.toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it("nouveau sujet avec un email vérifié déjà relié à un autre sujet : refusé, le compte ne change pas de mains", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null, identities: [{ id: 'identity-1' }] })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, subject: 'sub-2' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('email vérifié qui a déjà un compte sans mot de passe : relie le compte, ne le duplique pas', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-7', issuer: ISSUER, subject: 'sub-1' } })
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it("email non vérifié : ni relié ni créé (sinon n'importe qui prendrait le compte de cette adresse)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_unverified')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('OIDC_REQUIRE_VERIFIED_EMAIL=false accepte un email non vérifié', async () => {
|
||||
env.OIDC_REQUIRE_VERIFIED_EMAIL = false
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.user.create).toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('OIDC_REQUIRE_VERIFIED_EMAIL=false ne relie jamais un compte existant sur un email non vérifié', async () => {
|
||||
env.OIDC_REQUIRE_VERIFIED_EMAIL = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_unverified')
|
||||
expect(prismaMock.userIdentity.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sans email du tout : refus clair', async () => {
|
||||
oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: null })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_missing')
|
||||
})
|
||||
|
||||
it('compte neuf sans mot de passe, identité reliée, quand l’instance en accepte', async () => {
|
||||
const { cookie } = await start()
|
||||
await callback(cookie)
|
||||
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
email: 'camille@example.com',
|
||||
passwordHash: null,
|
||||
identities: { create: { issuer: ISSUER, subject: 'sub-1' } },
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('inscription fermée : le fournisseur ne crée pas de compte (#113)', async () => {
|
||||
env.ALLOW_REGISTRATION = false
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('registration_closed')
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('inscription fermée : un compte existant se connecte toujours par le fournisseur', async () => {
|
||||
env.ALLOW_REGISTRATION = false
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('inscription fermée sur une instance vide : le premier compte naît par le fournisseur', async () => {
|
||||
env.ALLOW_REGISTRATION = false
|
||||
prismaMock.user.count.mockResolvedValue(0)
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
expect(prismaMock.$executeRaw).toHaveBeenCalled()
|
||||
expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('échange du jeton de passage', () => {
|
||||
async function handoff() {
|
||||
prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' })
|
||||
const { cookie } = await start()
|
||||
const res = await callback(cookie)
|
||||
return fragmentOf(res.headers.location).get('oidc') as string
|
||||
}
|
||||
|
||||
it('donne une session une seule fois', async () => {
|
||||
const code = await handoff()
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: 'camille@example.com', firstName: 'Camille', passwordHash: null })
|
||||
const first = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code } })
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(first.json().user).toEqual({ id: 'user-9', email: 'camille@example.com', firstName: 'Camille', hasPassword: false })
|
||||
|
||||
const second = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code } })
|
||||
expect(second.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it("le jeton de passage n'ouvre aucune session, et une session n'est pas un jeton de passage", async () => {
|
||||
const code = await handoff()
|
||||
const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${code}` } })
|
||||
expect(asBearer.statusCode).toBe(401)
|
||||
|
||||
const session = app.jwt.sign({ kind: 'user', sub: 'user-9', jti: 'j', userId: 'user-9', email: 'camille@example.com' })
|
||||
const asCode = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code: session } })
|
||||
expect(asCode.statusCode).toBe(401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () => {
|
||||
it('refuse connexion et inscription par mot de passe, et le dit à l’écran', async () => {
|
||||
env.PASSWORD_LOGIN = false
|
||||
const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { email: 'a@example.com', password: 'motdepasse1' } })
|
||||
const register = await app.inject({ method: 'POST', url: '/auth/register', payload: { email: 'a@example.com', password: 'motdepasse1' } })
|
||||
expect(login.statusCode).toBe(403)
|
||||
expect(login.json()).toEqual({ error: 'password_login_disabled' })
|
||||
expect(register.statusCode).toBe(403)
|
||||
expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().passwordLogin).toBe(false)
|
||||
})
|
||||
|
||||
it('un compte sans mot de passe ne se connecte jamais par mot de passe', async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: 'camille@example.com', firstName: null, passwordHash: null })
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { email: 'camille@example.com', password: 'nimportequoi' } })
|
||||
expect(res.statusCode).toBe(401)
|
||||
}, 15000)
|
||||
})
|
||||
|
||||
describe('journaux', () => {
|
||||
it("le code d'autorisation du retour n'est pas journalisé", async () => {
|
||||
const { redactUrl } = await import('../src/app.js')
|
||||
expect(redactUrl('/auth/oidc/callback?code=secret-code&state=s1')).toBe('/auth/oidc/callback?code=[masqué]&state=s1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { hasAnyName } from '../src/lib/person-names'
|
||||
import { gedcomNameValue } from '../src/routes/gedcom'
|
||||
|
||||
// Nom de famille facultatif : une personne garde au moins un prénom ou un nom
|
||||
describe('au moins un nom', () => {
|
||||
it('un prénom ou un nom suffit', () => {
|
||||
expect(hasAnyName('Léa', '')).toBe(true)
|
||||
expect(hasAnyName('', 'Lambert')).toBe(true)
|
||||
expect(hasAnyName(' ', ' Lambert ')).toBe(true)
|
||||
})
|
||||
|
||||
it('ni prénom ni nom : refusé', () => {
|
||||
expect(hasAnyName('', '')).toBe(false)
|
||||
expect(hasAnyName(' ', ' ')).toBe(false)
|
||||
})
|
||||
|
||||
it('export GEDCOM sans nom de famille : « Léa // », valide', () => {
|
||||
expect(gedcomNameValue('Léa', '')).toBe('Léa //')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseSex, sexOrNull, sexSchema } from '../src/lib/person-sex'
|
||||
|
||||
// Sexe d'une personne (issue #114) : male, female, other ou null, anciennes valeurs M/F relues
|
||||
describe('sexe d’une personne', () => {
|
||||
it('ramène les écritures connues à male / female / null', () => {
|
||||
expect(parseSex('male')).toBe('male')
|
||||
expect(parseSex('M')).toBe('male')
|
||||
expect(parseSex(' homme ')).toBe('male')
|
||||
expect(parseSex('female')).toBe('female')
|
||||
expect(parseSex('F')).toBe('female')
|
||||
expect(parseSex('Femme')).toBe('female')
|
||||
expect(parseSex(null)).toBeNull()
|
||||
expect(parseSex('')).toBeNull()
|
||||
expect(parseSex('U')).toBeNull()
|
||||
expect(parseSex('other')).toBe('other')
|
||||
expect(parseSex('X')).toBe('other')
|
||||
expect(parseSex('Autre')).toBe('other')
|
||||
expect(parseSex('robot')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('une valeur illisible déjà en base se lit « non précisé »', () => {
|
||||
expect(sexOrNull('M')).toBe('male')
|
||||
expect(sexOrNull('robot')).toBeNull()
|
||||
expect(sexOrNull(undefined)).toBeNull()
|
||||
})
|
||||
|
||||
it('le schéma d’écriture normalise, garde l’absence et refuse l’inconnu', () => {
|
||||
expect(sexSchema.parse('F')).toBe('female')
|
||||
expect(sexSchema.parse(null)).toBeNull()
|
||||
expect(sexSchema.parse(undefined)).toBeUndefined()
|
||||
expect(sexSchema.safeParse('robot').success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Inscription fermée (ALLOW_REGISTRATION=false, issue #113) : plus aucun compte ne naît, les comptes existants
|
||||
// se connectent, et une instance neuve accepte quand même son premier compte.
|
||||
const { prismaMock } = vi.hoisted(() => ({
|
||||
prismaMock: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(),
|
||||
$executeRaw: vi.fn(),
|
||||
$disconnect: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../src/lib/prisma.js', () => ({
|
||||
prisma: prismaMock,
|
||||
}))
|
||||
|
||||
let app: FastifyInstance
|
||||
let env: { ALLOW_REGISTRATION: boolean }
|
||||
|
||||
const newAccount = { firstName: 'Camille', email: 'camille@example.com', password: 'securepass123' }
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
|
||||
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
|
||||
process.env.CORS_ORIGIN = 'http://localhost:5173'
|
||||
process.env.ALLOW_REGISTRATION = ' False '
|
||||
;({ env } = await import('../src/config/env.js'))
|
||||
const { createApp } = await import('../src/app.js')
|
||||
app = createApp()
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.ALLOW_REGISTRATION
|
||||
await app?.close()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
env.ALLOW_REGISTRATION = false
|
||||
prismaMock.user.findUnique.mockResolvedValue(null)
|
||||
prismaMock.user.count.mockResolvedValue(3)
|
||||
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: newAccount.email, firstName: 'Camille' })
|
||||
prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock))
|
||||
prismaMock.$executeRaw.mockResolvedValue(1)
|
||||
})
|
||||
|
||||
describe('inscription fermée', () => {
|
||||
it('lit la variable : « false » ferme, sans tenir compte de la casse', () => {
|
||||
expect(env.ALLOW_REGISTRATION).toBe(false)
|
||||
})
|
||||
|
||||
it("refuse l'inscription quand des comptes existent, sans rien créer", async () => {
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.json()).toEqual({ error: 'registration_closed' })
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("répond pareil pour une adresse qui a déjà un compte (pas d'énumération)", async () => {
|
||||
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: newAccount.email })
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.json()).toEqual({ error: 'registration_closed' })
|
||||
})
|
||||
|
||||
it('garde la connexion des comptes existants', async () => {
|
||||
const { hashPassword } = await import('../src/lib/auth.js')
|
||||
prismaMock.user.findUnique.mockResolvedValue({
|
||||
id: 'user-9',
|
||||
email: newAccount.email,
|
||||
firstName: 'Camille',
|
||||
passwordHash: await hashPassword(newAccount.password),
|
||||
})
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/auth/login',
|
||||
payload: { email: newAccount.email, password: newAccount.password },
|
||||
})
|
||||
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.json().token).toEqual(expect.any(String))
|
||||
}, 15000)
|
||||
|
||||
it("annonce l'inscription fermée à l'écran d'accueil", async () => {
|
||||
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 })
|
||||
})
|
||||
|
||||
it('instance neuve : le premier compte se crée, sous verrou', async () => {
|
||||
prismaMock.user.count.mockResolvedValue(0)
|
||||
|
||||
const options = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(options.json()).toMatchObject({ registration: true })
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1)
|
||||
expect(prismaMock.user.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it("instance neuve : une seconde inscription simultanée est refusée une fois le verrou pris", async () => {
|
||||
// Base vide au premier contrôle, puis le compte concurrent est là une fois le verrou obtenu
|
||||
prismaMock.user.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1)
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
expect(res.statusCode).toBe(403)
|
||||
expect(res.json()).toEqual({ error: 'registration_closed' })
|
||||
expect(prismaMock.user.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('inscription ouverte (par défaut)', () => {
|
||||
it('crée le compte sans transaction ni verrou, comme avant', async () => {
|
||||
env.ALLOW_REGISTRATION = true
|
||||
|
||||
const options = await app.inject({ method: 'GET', url: '/auth/options' })
|
||||
expect(options.json()).toMatchObject({ registration: true })
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
|
||||
|
||||
expect(res.statusCode).toBe(201)
|
||||
expect(prismaMock.$transaction).not.toHaveBeenCalled()
|
||||
expect(prismaMock.user.count).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -49,6 +49,45 @@ describe('fichiers envoyés : type lu dans les octets', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Boîte ftyp minimale : taille, « ftyp », marque principale
|
||||
function ftypBox(brand: string): Buffer {
|
||||
const box = Buffer.alloc(24)
|
||||
box.writeUInt32BE(24, 0)
|
||||
box.write('ftyp', 4, 'latin1')
|
||||
box.write(brand, 8, 'latin1')
|
||||
return box
|
||||
}
|
||||
|
||||
describe('HEIC et AVIF : reconnus, jamais pris pour une vidéo', () => {
|
||||
it.each(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])('marque %s : image/heic, refusée à l’envoi', (brand) => {
|
||||
expect(sniffMimeType(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBe('image/heic')
|
||||
expect(accepted(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBeNull()
|
||||
expect(accepted(ftypBox(brand), 'video', 'IMG_0001.MOV')).toBeNull()
|
||||
})
|
||||
|
||||
it('marque avif : image/avif, refusée à l’envoi (pas de conversion)', () => {
|
||||
expect(sniffMimeType(ftypBox('avif'), 'photo')).toBe('image/avif')
|
||||
expect(accepted(ftypBox('avif'), 'photo')).toBeNull()
|
||||
})
|
||||
|
||||
it('marque hevc : image/heic quand l’envoi se déclare photo, vidéo MP4 sinon (une vidéo HEVC reste acceptée)', () => {
|
||||
expect(sniffMimeType(ftypBox('hevc'), 'photo')).toBe('image/heic')
|
||||
expect(accepted(ftypBox('hevc'), 'video')).toBe('video/mp4')
|
||||
})
|
||||
|
||||
it('les vidéos MP4 et QuickTime ne changent pas', () => {
|
||||
expect(accepted(ftypBox('isom'), 'video')).toBe('video/mp4')
|
||||
expect(accepted(ftypBox('mp42'), 'video')).toBe('video/mp4')
|
||||
expect(accepted(ftypBox('qt '), 'video')).toBe('video/quicktime')
|
||||
expect(accepted(ftypBox('M4A '), 'audio')).toBe('audio/mp4')
|
||||
})
|
||||
|
||||
it('une image HEIC n’entre dans aucun type de média autorisé', () => {
|
||||
expect(isMimeAllowedForType('photo', 'image/heic')).toBe(false)
|
||||
expect(isMimeAllowedForType('photo', 'image/avif')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('photos : métadonnées retirées, rotation appliquée', () => {
|
||||
it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => {
|
||||
const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } })
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { recordTreeVisit, VISIT_RETENTION_DAYS } from '../src/lib/tree-visits'
|
||||
|
||||
function fakeApp() {
|
||||
return {
|
||||
prisma: {
|
||||
treeVisit: {
|
||||
findFirst: vi.fn().mockResolvedValue(null),
|
||||
create: vi.fn().mockResolvedValue({ id: 'v' }),
|
||||
update: vi.fn(),
|
||||
deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const request = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone)' }, ip: '203.0.113.10', log: { warn: vi.fn() } }
|
||||
|
||||
describe('journal des visites', () => {
|
||||
it('efface les visites de plus d’un an à l’enregistrement, au plus une fois par heure', async () => {
|
||||
const app = fakeApp()
|
||||
const before = Date.now()
|
||||
await recordTreeVisit(app as never, request as never, 'tree-1', 'share', null)
|
||||
await recordTreeVisit(app as never, request as never, 'tree-2', 'share', null)
|
||||
|
||||
expect(app.prisma.treeVisit.create).toHaveBeenCalledTimes(2)
|
||||
expect(app.prisma.treeVisit.deleteMany).toHaveBeenCalledTimes(1)
|
||||
const cutoff = app.prisma.treeVisit.deleteMany.mock.calls[0][0].where.createdAt.lt.getTime()
|
||||
// Tous les arbres, pas seulement celui qu'on ouvre : un arbre qu'on ne rouvre plus est purgé aussi
|
||||
expect(app.prisma.treeVisit.deleteMany.mock.calls[0][0].where).not.toHaveProperty('treeId')
|
||||
expect(before - cutoff).toBeGreaterThanOrEqual(VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000 - 1000)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { gedcomNameValue, parseGedcomName } from '../src/routes/gedcom'
|
||||
|
||||
// Prénom inconnu : enregistré vide. L'export écrit « /Nom/ », l'import relit un prénom vide (et non « ? »).
|
||||
describe('prénom inconnu, GEDCOM', () => {
|
||||
it('export : sans prénom, la valeur NAME commence par le nom', () => {
|
||||
expect(gedcomNameValue('', 'Dupont')).toBe('/Dupont/')
|
||||
expect(gedcomNameValue('Marie', 'Dupont')).toBe('Marie /Dupont/')
|
||||
expect(gedcomNameValue('Marie', 'Du/pont')).toBe('Marie /Dupont/')
|
||||
})
|
||||
|
||||
it('import : un NAME sans prénom donne un prénom vide', () => {
|
||||
expect(parseGedcomName('/Dupont/')).toEqual({ firstName: '', lastName: 'Dupont' })
|
||||
expect(parseGedcomName('Marie /Dupont/')).toEqual({ firstName: 'Marie', lastName: 'Dupont' })
|
||||
})
|
||||
|
||||
it('aller-retour export puis import garde le prénom vide', () => {
|
||||
expect(parseGedcomName(gedcomNameValue('', 'Martin'))).toEqual({ firstName: '', lastName: 'Martin' })
|
||||
})
|
||||
})
|
||||
@@ -78,6 +78,22 @@ stop_all() {
|
||||
trap 'stop_all; exit 0' TERM INT
|
||||
|
||||
cd /app
|
||||
# Migration en attente sur une base existante : copie de la base dans /data/backups avant de l'appliquer
|
||||
# (une migration ne se défait pas). Les 5 dernières sont gardées. Si la copie échoue, le conteneur s'arrête.
|
||||
if [ "$FIRST_START" = 0 ] && ! runuser -u node -- env PATH="$PATH" prisma migrate status >/dev/null 2>&1; then
|
||||
mkdir -p "$DATA_DIR/backups"
|
||||
chmod 700 "$DATA_DIR/backups"
|
||||
dump="$DATA_DIR/backups/parenthese-avant-migration-$(date -u +%Y%m%dT%H%M%SZ).dump"
|
||||
log "migration en attente : sauvegarde de la base dans $dump"
|
||||
if ! (umask 077 && runuser -u postgres -- pg_dump -Fc "$DB_NAME" > "$dump"); then
|
||||
rm -f "$dump"
|
||||
log "la sauvegarde a échoué, migration annulée (place sur le disque ?)"
|
||||
stop_all
|
||||
exit 1
|
||||
fi
|
||||
ls -1t "$DATA_DIR"/backups/parenthese-avant-migration-*.dump | tail -n +6 | xargs -r rm -f
|
||||
fi
|
||||
|
||||
log "migrations"
|
||||
runuser -u node -- env PATH="$PATH" prisma migrate deploy
|
||||
|
||||
|
||||
+3
-6
@@ -98,12 +98,9 @@ echo "[3/5] Generate checksums..."
|
||||
(
|
||||
cd "$BACKUP_ROOT"
|
||||
sha256sum "db/$(basename "$DB_BACKUP_FILE")"
|
||||
if [ "$MEDIA_IS_NEW" = "1" ]; then
|
||||
sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")"
|
||||
else
|
||||
# empreinte déjà calculée le jour où l'archive a été faite
|
||||
grep -h " media/$(basename "$MEDIA_BACKUP_FILE")" "$META_DIR"/genealogy-*.sha256 2>/dev/null | head -1 || true
|
||||
fi
|
||||
# Archive du jour ou archive gardée : son empreinte est recalculée, celle du jour où elle a été faite
|
||||
# disparaît avec la rotation des fichiers d'empreintes (14 jours) alors que l'archive reste
|
||||
sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")"
|
||||
) > "$CHECKSUM_FILE"
|
||||
|
||||
echo "[4/5] Optional offsite upload..."
|
||||
|
||||
+15
-18
@@ -544,31 +544,28 @@
|
||||
left: 20px;
|
||||
z-index: 1200;
|
||||
max-width: 340px;
|
||||
padding: 12px 14px;
|
||||
padding: 18px 20px 20px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(42, 38, 34, 0.14);
|
||||
background: rgba(242, 239, 234, 0.95);
|
||||
box-shadow: 0 6px 24px rgba(52, 48, 44, 0.18);
|
||||
color: #2a2622;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
gap: 10px;
|
||||
border-radius: var(--pz-r-l);
|
||||
border: 1px solid var(--pz-line);
|
||||
background: var(--pz-surface);
|
||||
box-shadow: var(--pz-shadow);
|
||||
color: var(--pz-ink-2);
|
||||
font: 400 14px/1.5 var(--pz-sans);
|
||||
}
|
||||
|
||||
.tree-created-prompt strong {
|
||||
color: #2A2622;
|
||||
font-size: 14px;
|
||||
color: var(--pz-ink);
|
||||
font: 400 21px/1.15 var(--pz-serif);
|
||||
letter-spacing: -.01em;
|
||||
}
|
||||
|
||||
.tree-created-prompt button {
|
||||
.tree-created-prompt .pz-btn {
|
||||
justify-self: start;
|
||||
border: 1px solid #efc7b8;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #2a2622;
|
||||
padding: 6px 10px;
|
||||
cursor: pointer;
|
||||
min-height: 38px;
|
||||
padding: 0 18px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
|
||||
+343
-39
@@ -4,11 +4,13 @@ import Galaxy from './components/Galaxy'
|
||||
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 { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome'
|
||||
import MediaViewer from './components/MediaViewer'
|
||||
import MediaManagerPanel from './components/MediaManagerPanel'
|
||||
import AdminPanel from './components/AdminPanel'
|
||||
import AccessGate from './components/AccessGate'
|
||||
import EntryChoice from './components/EntryChoice'
|
||||
import AccountDashboard from './components/AccountDashboard'
|
||||
import AccountPanel from './components/AccountPanel'
|
||||
import TreeCreationWizard from './components/TreeCreationWizard'
|
||||
@@ -25,6 +27,7 @@ import { ExpandableSearch } from './components/search'
|
||||
import { t, dateLocale } from './i18n/index.js'
|
||||
import {
|
||||
createParentChildLink,
|
||||
updateParentChildLink,
|
||||
deleteParentChildLink,
|
||||
deletePerson,
|
||||
deleteUnion,
|
||||
@@ -41,13 +44,15 @@ import {
|
||||
fetchSharePassword,
|
||||
rememberSharePassword,
|
||||
resolveTreeBySlug,
|
||||
saveAvatarCrop,
|
||||
uploadPersonAvatar,
|
||||
updatePerson,
|
||||
uploadAnnotationPhoto,
|
||||
reorderSiblings,
|
||||
uploadPersonMedia,
|
||||
} from './api/treeApi'
|
||||
import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
|
||||
import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
|
||||
import { fetchAuthOptions, oidcStartUrl } from './api/authApi'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
|
||||
import { useMediaManager } from './hooks/useMediaManager'
|
||||
@@ -226,7 +231,9 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
const addedPersons = Array.isArray(draft.addedPersons) ? draft.addedPersons : []
|
||||
for (const person of addedPersons) {
|
||||
if (!person || typeof person !== 'object') continue
|
||||
if (!person.firstName || !person.lastName) continue
|
||||
// Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant
|
||||
// Nom de famille facultatif : seule une personne sans aucun nom est ignorée
|
||||
if (!person.firstName && !person.lastName) continue
|
||||
|
||||
// Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens
|
||||
const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null
|
||||
@@ -239,6 +246,8 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
firstName: String(person.firstName).trim(),
|
||||
lastName: String(person.lastName).trim(),
|
||||
birthName: person.birthName ? String(person.birthName).trim() : null,
|
||||
nickname: person.nickname ? String(person.nickname).trim() : null,
|
||||
sex: person.sex || null,
|
||||
birthDate: person.birthDate || null,
|
||||
deathDate: person.deathDate || null,
|
||||
isDeceased: !person.deathDate && Boolean(person.isDeceased),
|
||||
@@ -253,9 +262,9 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
const relatedId = String(rel.personId)
|
||||
if (rel.type === 'child') {
|
||||
linkedParentIds.add(relatedId)
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } })
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
|
||||
} else if (rel.type === 'parent') {
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } })
|
||||
changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } })
|
||||
} else if (rel.type === 'spouse') {
|
||||
changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } })
|
||||
}
|
||||
@@ -272,6 +281,16 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// Lien avec ses parents basculé (naissance / adoption) sur un lien existant
|
||||
const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {}
|
||||
for (const [childId, parentageType] of Object.entries(parentageChanges)) {
|
||||
for (const filiation of filiations) {
|
||||
if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue
|
||||
if ((filiation.parentageType || 'biologique') === parentageType) continue
|
||||
changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } })
|
||||
}
|
||||
}
|
||||
|
||||
const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object'
|
||||
? draft.modifiedPersons
|
||||
: {}
|
||||
@@ -283,9 +302,12 @@ function buildContributionChangesFromDraft(draft = {}) {
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'firstName')) after.firstName = source.firstName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'nickname')) after.nickname = (source.nickname || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'sex')) after.sex = source.sex || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear)
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear)
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null
|
||||
@@ -452,6 +474,19 @@ function buildContribChangesList(draft = {}) {
|
||||
return items
|
||||
}
|
||||
|
||||
|
||||
// Retour du fournisseur OIDC : #oidc=<jeton de passage> ou #oidc_error=<code>. Lu une seule fois au chargement de
|
||||
// la page puis effacé de l'adresse (historique, partage d'écran).
|
||||
function readOidcReturn() {
|
||||
if (typeof window === 'undefined') return null
|
||||
const params = new URLSearchParams(window.location.hash.slice(1))
|
||||
if (!params.has('oidc') && !params.has('oidc_error')) return null
|
||||
window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`)
|
||||
return { code: params.get('oidc'), error: params.get('oidc_error') }
|
||||
}
|
||||
const OIDC_RETURN = readOidcReturn()
|
||||
let oidcSignIn = null
|
||||
|
||||
function App() {
|
||||
const auth = useAuth()
|
||||
const tree = useTreeAccess()
|
||||
@@ -489,6 +524,9 @@ function App() {
|
||||
})
|
||||
|
||||
const [adminPanelVisible, setAdminPanelVisible] = useState(false)
|
||||
// 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 */ } }, [])
|
||||
// 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)
|
||||
@@ -507,6 +545,10 @@ function App() {
|
||||
})
|
||||
const [treeNotFound, setTreeNotFound] = useState(false)
|
||||
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 registrationOpen = authOptions.registration !== false && authOptions.passwordLogin !== false
|
||||
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
|
||||
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
|
||||
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
|
||||
@@ -545,6 +587,10 @@ function App() {
|
||||
const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false)
|
||||
const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false)
|
||||
const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null)
|
||||
// Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries
|
||||
const [draftNotice, setDraftNotice] = useState('')
|
||||
// Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef)
|
||||
const personSaveOnLeaveRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) {
|
||||
@@ -793,25 +839,55 @@ function App() {
|
||||
updateDraft: editMode.updateDraft,
|
||||
})
|
||||
|
||||
const handlePersonSelect = useCallback((personLike) => {
|
||||
// Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un
|
||||
// souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte
|
||||
// avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas.
|
||||
const leaveSelectedPerson = useCallback(async () => {
|
||||
const current = tree.selectedPerson
|
||||
const saveOnLeave = personSaveOnLeaveRef.current
|
||||
if (!current || !saveOnLeave || isDemoMode) return true
|
||||
const outcome = await saveOnLeave()
|
||||
if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) }))
|
||||
return outcome !== 'blocked'
|
||||
}, [tree.selectedPerson, isDemoMode])
|
||||
|
||||
const chooseSelectedPerson = useCallback(async (next) => {
|
||||
if (!(await leaveSelectedPerson())) return false
|
||||
tree.setSelectedPerson(next)
|
||||
return true
|
||||
}, [leaveSelectedPerson, tree.setSelectedPerson])
|
||||
|
||||
const deactivateEditMode = useCallback(async () => {
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
editMode.deactivate()
|
||||
}, [leaveSelectedPerson, editMode.deactivate])
|
||||
|
||||
const handlePersonSelect = useCallback(async (personLike) => {
|
||||
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
|
||||
if (personId === null || personId === undefined) {
|
||||
tree.setSelectedPerson(null)
|
||||
await chooseSelectedPerson(null)
|
||||
return
|
||||
}
|
||||
|
||||
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
|
||||
// Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ;
|
||||
// sa saisie reste en mémoire et reprend quand on rouvre « Ajouter »
|
||||
setAddPersonPanelVisible(false)
|
||||
|
||||
tree.setSelectedPerson(freshPerson)
|
||||
trackTreeInteraction('text')
|
||||
trackDemoInteraction('person')
|
||||
}, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleMediaSelectFresh = useCallback((media, personLike) => {
|
||||
const handleMediaSelectFresh = useCallback(async (media, personLike) => {
|
||||
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
|
||||
const freshPerson = personId !== null && personId !== undefined
|
||||
? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
|
||||
: personLike || null
|
||||
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
tree.handleMediaSelect(media, freshPerson)
|
||||
trackTreeInteraction(toInteractionMediaType(media?.type))
|
||||
trackDemoInteraction('media')
|
||||
@@ -873,22 +949,26 @@ function App() {
|
||||
const handleEditToggle = useCallback(() => {
|
||||
if (editMode.isActive && editMode.hasDraft) {
|
||||
setShowConfirmModal(true)
|
||||
} else if (editMode.isActive) {
|
||||
deactivateEditMode()
|
||||
} else {
|
||||
if (!editMode.isActive) trackDemoInteraction('edit')
|
||||
trackDemoInteraction('edit')
|
||||
editMode.toggle()
|
||||
}
|
||||
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Actions de la modal de confirmation
|
||||
const handleConfirmContinue = useCallback(() => {
|
||||
setShowConfirmModal(false)
|
||||
}, [])
|
||||
|
||||
const handleConfirmSaveAndQuit = useCallback(() => {
|
||||
const handleConfirmSaveAndQuit = useCallback(async () => {
|
||||
const left = await leaveSelectedPerson()
|
||||
setShowConfirmModal(false)
|
||||
if (!left) return
|
||||
editMode.saveDraft()
|
||||
editMode.deactivate()
|
||||
setShowConfirmModal(false)
|
||||
}, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
editMode.clearDraft()
|
||||
@@ -944,11 +1024,11 @@ function App() {
|
||||
setSouvenirPickerVisible(true)
|
||||
}, [tree.treeContext.treeId])
|
||||
|
||||
const handleSouvenirPick = useCallback((person) => {
|
||||
const handleSouvenirPick = useCallback(async (person) => {
|
||||
if (!(await chooseSelectedPerson(person))) return
|
||||
setSouvenirPickerVisible(false)
|
||||
tree.setSelectedPerson(person)
|
||||
setPendingMediaPersonId(person.id)
|
||||
}, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
}, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSouvenirPickerAddPerson = useCallback(() => {
|
||||
setSouvenirPickerVisible(false)
|
||||
@@ -962,6 +1042,8 @@ function App() {
|
||||
|
||||
const handleAddPersonSubmit = useCallback(async (formData) => {
|
||||
if (!tree.treeContext.treeId && !isDemoMode) return
|
||||
// La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
|
||||
setAddPersonLoading(true)
|
||||
setAddPersonError('')
|
||||
@@ -996,6 +1078,8 @@ function App() {
|
||||
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,
|
||||
@@ -1030,7 +1114,7 @@ function App() {
|
||||
childId: rel.personId,
|
||||
parentId: personId,
|
||||
unionId: null,
|
||||
parentageType: 'biologique',
|
||||
parentageType: rel.adopted ? 'adoption' : 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
})
|
||||
} else if (rel.type === 'child') {
|
||||
@@ -1039,7 +1123,7 @@ function App() {
|
||||
childId: personId,
|
||||
parentId: rel.personId,
|
||||
unionId: null,
|
||||
parentageType: 'biologique',
|
||||
parentageType: rel.adopted ? 'adoption' : 'biologique',
|
||||
displayOrder: filiations.length + 1,
|
||||
})
|
||||
}
|
||||
@@ -1075,10 +1159,12 @@ function App() {
|
||||
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),
|
||||
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })),
|
||||
relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })),
|
||||
siblingParentLinks,
|
||||
},
|
||||
]
|
||||
@@ -1086,7 +1172,7 @@ function App() {
|
||||
// La personne n'apparaît dans l'arbre qu'après envoi : on passe tout de suite à l'envoi
|
||||
// (prénom, un mot), la famille n'a pas à chercher le bouton
|
||||
setAddPersonPanelVisible(false)
|
||||
setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: formData.firstName }))
|
||||
setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') }))
|
||||
setContribSubmitError('')
|
||||
setContribChanges(buildContribChangesList(editMode.restoreDraft()))
|
||||
setShowContribModal(true)
|
||||
@@ -1101,6 +1187,8 @@ function App() {
|
||||
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),
|
||||
@@ -1115,13 +1203,13 @@ function App() {
|
||||
linkedParentIds.add(String(rel.personId))
|
||||
await createParentChildLink(
|
||||
tree.treeContext.treeId,
|
||||
{ parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 },
|
||||
{ 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: 'biologique', displayOrder: 1 },
|
||||
{ parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 },
|
||||
auth.userAuth.token
|
||||
)
|
||||
} else if (rel.type === 'spouse') {
|
||||
@@ -1168,7 +1256,6 @@ function App() {
|
||||
const [contribSubmitLoading, setContribSubmitLoading] = useState(false)
|
||||
const [contribSubmitError, setContribSubmitError] = useState('')
|
||||
const [contribSuccess, setContribSuccess] = useState(false)
|
||||
const [draftNotice, setDraftNotice] = useState('')
|
||||
|
||||
// Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite.
|
||||
// L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit.
|
||||
@@ -1208,6 +1295,8 @@ function App() {
|
||||
|
||||
// Soumission des modifications
|
||||
const handleEditSubmit = useCallback(async () => {
|
||||
// Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
if (isDemoMode) {
|
||||
// Démo: édition locale uniquement, jamais de persistance serveur.
|
||||
editMode.saveDraft()
|
||||
@@ -1230,9 +1319,12 @@ function App() {
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'firstName')) payload.firstName = rawChanges.firstName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nickname')) payload.nickname = (rawChanges.nickname || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'sex')) payload.sex = rawChanges.sex || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear)
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear)
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null
|
||||
@@ -1504,6 +1596,8 @@ function App() {
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) localPerson.firstName = changes.firstName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) localPerson.nickname = changes.nickname || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'sex')) localPerson.sex = changes.sex || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) {
|
||||
const iso = yearToIsoDate(changes.birthYear)
|
||||
localPerson.birthDate = iso
|
||||
@@ -1515,6 +1609,7 @@ function App() {
|
||||
localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null
|
||||
@@ -1540,9 +1635,12 @@ function App() {
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) payload.firstName = changes.firstName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || ''
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) payload.nickname = (changes.nickname || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'sex')) payload.sex = changes.sex || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear)
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear)
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null
|
||||
if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null
|
||||
@@ -1587,11 +1685,81 @@ function App() {
|
||||
trackAppEvent('portrait_added', { via: 'card' })
|
||||
await refreshTreeAndKeepSelection(personId, { relayout: true })
|
||||
setAvatarActionState({ loading: false, error: '' })
|
||||
// Aperçu de la photo envoyée, pour la fenêtre de recadrage qui s'ouvre tout de suite
|
||||
return { previewUrl: URL.createObjectURL(prepared) }
|
||||
} catch (error) {
|
||||
setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) })
|
||||
return null
|
||||
}
|
||||
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
|
||||
|
||||
// Recadrage du portrait enregistré : true si c'est fait, la fenêtre se ferme alors
|
||||
const handlePersonAvatarCrop = useCallback(async (personId, crop) => {
|
||||
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!accessToken || !tree.treeContext.treeId) return false
|
||||
|
||||
setAvatarActionState({ loading: true, error: '' })
|
||||
try {
|
||||
await saveAvatarCrop(tree.treeContext.treeId, personId, crop, accessToken)
|
||||
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
|
||||
setAvatarActionState({ loading: false, error: '' })
|
||||
return true
|
||||
} catch (error) {
|
||||
setAvatarActionState({
|
||||
loading: false,
|
||||
error: error?.payload?.error === 'avatar_from_immich'
|
||||
? t('Les photos venues d’Immich ne se recadrent pas pour l’instant.')
|
||||
: getAccountErrorMessage(error),
|
||||
})
|
||||
return false
|
||||
}
|
||||
}, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection])
|
||||
|
||||
// « + photo » dans l'arbre : le sélecteur part dans le clic (Safari iOS refuse un input file ouvert hors geste),
|
||||
// puis la fenêtre de recadrage s'ouvre dans la fiche de la personne
|
||||
const portraitInputRef = useRef(null)
|
||||
const portraitPickerPersonIdRef = useRef(null)
|
||||
const [pendingPortraitCrop, setPendingPortraitCrop] = useState(null)
|
||||
const [photoSourceRequest, setPhotoSourceRequest] = useState(null)
|
||||
const [immichSourceReady, setImmichSourceReady] = useState(false)
|
||||
|
||||
// Administrateur : Immich proposé dans le menu des sources seulement s'il est configuré
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
const treeId = tree.treeContext.treeId
|
||||
const token = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!userRole.isAdmin || !tree.canEditCurrentTree || !treeId || !token) {
|
||||
setImmichSourceReady(false)
|
||||
return undefined
|
||||
}
|
||||
fetchImmichConfig(treeId, token)
|
||||
.then(({ config }) => { if (!cancelled) setImmichSourceReady(Boolean(config?.configured)) })
|
||||
.catch(() => { if (!cancelled) setImmichSourceReady(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [userRole.isAdmin, tree.canEditCurrentTree, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token])
|
||||
|
||||
const handlePhotoPlaceholderClick = useCallback((personId) => {
|
||||
// En modification avec Immich : le menu des sources s'ouvre dans la fiche (le badge en est l'ancre)
|
||||
if (editMode.isActive && immichSourceReady) {
|
||||
setPhotoSourceRequest({ personId })
|
||||
} else {
|
||||
// Sans menu : le sélecteur de fichiers part tout de suite, dans le clic
|
||||
portraitPickerPersonIdRef.current = personId
|
||||
portraitInputRef.current?.click()
|
||||
}
|
||||
handlePersonSelect(personId)
|
||||
}, [editMode.isActive, immichSourceReady, handlePersonSelect])
|
||||
|
||||
const handlePortraitPicked = useCallback(async (event) => {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
const personId = portraitPickerPersonIdRef.current
|
||||
portraitPickerPersonIdRef.current = null
|
||||
if (!file || personId === null) return
|
||||
const result = await handlePersonAvatarUpload(personId, file)
|
||||
if (result?.previewUrl) setPendingPortraitCrop({ personId, src: result.previewUrl })
|
||||
}, [handlePersonAvatarUpload])
|
||||
|
||||
// Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant)
|
||||
const handleImmichChange = useCallback(async (personId) => {
|
||||
await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() })
|
||||
@@ -1792,6 +1960,48 @@ function App() {
|
||||
}
|
||||
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi
|
||||
const [pendingParentage, setPendingParentage] = useState({})
|
||||
useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne
|
||||
const handleSetParentage = useCallback(async (childPersonId, parentageType) => {
|
||||
const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId))
|
||||
if (childLinks.length === 0) return
|
||||
|
||||
if (isDemoMode) {
|
||||
for (const link of childLinks) link.parentageType = parentageType
|
||||
tree.bumpGraphRevision()
|
||||
setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
if (!tree.canEditCurrentTree) {
|
||||
const draft = editMode.restoreDraft()
|
||||
const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType }
|
||||
editMode.updateDraft({ parentageChanges })
|
||||
setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType }))
|
||||
setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' })
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = tree.treeContext.accessToken || auth.userAuth.token
|
||||
if (!accessToken || !tree.treeContext.treeId) return
|
||||
|
||||
setRelationFeedback({ loading: true, success: '', error: '' })
|
||||
try {
|
||||
for (const link of childLinks) {
|
||||
if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue
|
||||
await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken)
|
||||
}
|
||||
await refreshTreeAndKeepSelection(childPersonId, { relayout: true })
|
||||
setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' })
|
||||
} catch (err) {
|
||||
setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) })
|
||||
console.error('Failed to update parentage:', err)
|
||||
}
|
||||
}, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Suppression d'une relation (mode édition)
|
||||
const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => {
|
||||
if (isDemoMode) {
|
||||
@@ -1994,6 +2204,31 @@ function App() {
|
||||
return
|
||||
}
|
||||
|
||||
// Lue en parallèle, attendue avant le premier écran sans session pour ne pas montrer un bouton qui disparaît
|
||||
let registrationAllowed = true
|
||||
const authOptionsReady = fetchAuthOptions()
|
||||
.then((options) => {
|
||||
registrationAllowed = options?.registration !== false && options?.passwordLogin !== false
|
||||
if (active && options) setAuthOptions(options)
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
// Retour du fournisseur OIDC (lu une fois au chargement, voir OIDC_RETURN). Le jeton de passage ne sert qu'une
|
||||
// fois : un second passage du démarrage (React en développement) attend le même échange.
|
||||
if (OIDC_RETURN) {
|
||||
oidcSignIn ??= OIDC_RETURN.code ? auth.handleOidcExchange(OIDC_RETURN.code) : Promise.resolve(false)
|
||||
const signedIn = await oidcSignIn
|
||||
if (!active) return
|
||||
if (!signedIn) {
|
||||
if (OIDC_RETURN.error) auth.setAccountError(getAccountErrorMessage({ payload: { error: OIDC_RETURN.error } }))
|
||||
await authOptionsReady
|
||||
if (!active) return
|
||||
setAccountEntryMode('login')
|
||||
tree.setBootState('account')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const slug = getSlugFromPathname()
|
||||
let resolvedTreeId = TREE_ID_FROM_ENV
|
||||
let resolvedTreeName = ''
|
||||
@@ -2002,12 +2237,14 @@ function App() {
|
||||
const resolvedTreeOwnerEmail = ''
|
||||
|
||||
// Root app:
|
||||
// - not connected => demo by default
|
||||
// - 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) {
|
||||
tree.handleUseDemo()
|
||||
await authOptionsReady
|
||||
if (!active) return
|
||||
tree.setBootState('welcome')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -2134,6 +2371,7 @@ function App() {
|
||||
}
|
||||
}
|
||||
|
||||
await authOptionsReady
|
||||
if (!active) return
|
||||
|
||||
if (resolvedTreeId) {
|
||||
@@ -2150,7 +2388,7 @@ function App() {
|
||||
}
|
||||
|
||||
// ?account=register (CTA de la landing) : écran compte directement en création
|
||||
if (query.get('account') === 'register') {
|
||||
if (query.get('account') === 'register' && registrationAllowed) {
|
||||
setAccountEntryMode('register')
|
||||
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: getSource() })
|
||||
}
|
||||
@@ -2211,8 +2449,9 @@ function App() {
|
||||
tree.setGateError('')
|
||||
auth.setAccountError('')
|
||||
setAccountPanelVisible(false)
|
||||
setAccountEntryMode(mode === 'register' ? 'register' : 'login')
|
||||
trackAppEvent('account_screen_viewed', { mode: mode === 'register' ? 'register' : 'login', from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
|
||||
const entryMode = mode === 'register' && registrationOpen ? 'register' : 'login'
|
||||
setAccountEntryMode(entryMode)
|
||||
trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
|
||||
media.resetMediaManager()
|
||||
setAdminPanelVisible(false)
|
||||
contrib.resetContribState()
|
||||
@@ -2517,9 +2756,24 @@ function App() {
|
||||
resetSignedOutState()
|
||||
}
|
||||
|
||||
// Accueil « J'ai reçu un lien de famille » : la porte demande le code de l'arbre et son mot de passe
|
||||
const handleFamilyLinkChoice = () => {
|
||||
tree.setTreeContext(buildEmptyTreeContext())
|
||||
tree.setGateError('')
|
||||
tree.setBootState('need-access')
|
||||
}
|
||||
|
||||
// « Ce n'est pas mon compte » : déconnexion, puis la porte du même arbre
|
||||
const handleNotMyAccount = async () => {
|
||||
setTreeNotFound(false)
|
||||
await auth.handleLogout()
|
||||
tree.setGateError('')
|
||||
tree.setBootState('need-access')
|
||||
}
|
||||
|
||||
// Suppression du compte : même remise à zéro que la déconnexion une fois la session effacée.
|
||||
const handleDeleteAccount = async (password) => {
|
||||
const errorMessage = await auth.handleDeleteAccount(password)
|
||||
const handleDeleteAccount = async (confirmation) => {
|
||||
const errorMessage = await auth.handleDeleteAccount(confirmation)
|
||||
if (errorMessage) {
|
||||
return errorMessage
|
||||
}
|
||||
@@ -2739,6 +2993,18 @@ function App() {
|
||||
)
|
||||
}
|
||||
|
||||
if (tree.bootState === 'welcome') {
|
||||
return (
|
||||
<div className="app">
|
||||
<EntryChoice
|
||||
onFamilyLink={handleFamilyLinkChoice}
|
||||
onLogin={() => handleUseAccount('login')}
|
||||
onRegister={registrationOpen ? () => handleUseAccount('register') : undefined}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (tree.bootState === 'need-access') {
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -2751,9 +3017,13 @@ function App() {
|
||||
treeNotFound={treeNotFound}
|
||||
loading={tree.gateLoading}
|
||||
errorMessage={tree.gateError}
|
||||
connected={Boolean(auth.userAuth.token)}
|
||||
connectedName={accountUser?.firstName || ''}
|
||||
onNotMyAccount={handleNotMyAccount}
|
||||
onSubmit={handleGateUnlock}
|
||||
onUseDemo={handleUseDemo}
|
||||
onUseAccount={handleUseAccount}
|
||||
canRegister={registrationOpen}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -2769,6 +3039,11 @@ function App() {
|
||||
trees={auth.accountTrees}
|
||||
activeTreeId={tree.treeContext.treeId}
|
||||
defaultMode={accountEntryMode}
|
||||
canRegister={registrationOpen}
|
||||
passwordLogin={authOptions.passwordLogin !== false}
|
||||
oidcName={authOptions.oidc?.name || ''}
|
||||
onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(getSlugFromPathname() ? window.location.pathname : '/')) : undefined}
|
||||
hasPassword={auth.userAuth.user?.hasPassword !== false}
|
||||
pendingTreeName={pendingSharedTreeName}
|
||||
onLogin={handleLogin}
|
||||
onRegister={handleRegister}
|
||||
@@ -2796,7 +3071,7 @@ function App() {
|
||||
<div className="tree-created-prompt" role="status">
|
||||
<strong>{t('Votre arbre est créé.')}</strong>
|
||||
{t("Ajoutez un proche pour commencer, comme votre mère, votre père ou un frère. Il suffit d'un prénom.")}
|
||||
<button type="button" onClick={() => setTreeWelcomeVisible(false)} aria-label={t('Fermer')}>
|
||||
<button type="button" className="pz-btn pz-btn--secondary" onClick={() => setTreeWelcomeVisible(false)} aria-label={t('Fermer')}>
|
||||
{t('Fermer')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -2806,7 +3081,7 @@ function App() {
|
||||
<strong>{t("Votre arbre est créé, mais l'import n'a pas abouti.")}</strong>
|
||||
{` ${treeImportError} `}
|
||||
{t("Vous pouvez réessayer depuis les paramètres de l'arbre.")}
|
||||
<button type="button" onClick={() => setTreeImportError('')} aria-label={t('Fermer')}>
|
||||
<button type="button" className="pz-btn pz-btn--secondary" onClick={() => setTreeImportError('')} aria-label={t('Fermer')}>
|
||||
{t('Fermer')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -2839,11 +3114,11 @@ function App() {
|
||||
hasResults={search.hasResults}
|
||||
onSearch={search.handleSearch}
|
||||
onClear={search.clearSearch}
|
||||
onResultClick={(person) => {
|
||||
onResultClick={async (person) => {
|
||||
const fullPerson = persons.find(p => p.id === person.id)
|
||||
if (!fullPerson) return
|
||||
trackDemoInteraction('search')
|
||||
tree.setSelectedPerson(fullPerson)
|
||||
if (!(await chooseSelectedPerson(fullPerson))) return
|
||||
galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id)
|
||||
}}
|
||||
/>
|
||||
@@ -2860,7 +3135,7 @@ function App() {
|
||||
hasDraft={editMode.hasDraft}
|
||||
draftTimestamp={editMode.draftTimestamp}
|
||||
role={userRole.isAdmin ? 'admin' : 'contributor'}
|
||||
onDeactivate={editMode.deactivate}
|
||||
onDeactivate={deactivateEditMode}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
{/* Confirmation Modal (quitter mode édition avec draft) */}
|
||||
@@ -2929,12 +3204,13 @@ function App() {
|
||||
activeTreeId={tree.treeContext.treeId}
|
||||
onEditClick={handleEditToggle}
|
||||
onAddPersonClick={handleOpenAddPerson}
|
||||
onAddSouvenirClick={isFamilyVisitor ? handleOpenSouvenirPicker : undefined}
|
||||
helpUrl={isFamilyVisitor ? HELP_URL : ''}
|
||||
onAddSouvenirClick={isFamilyVisitor || userRole.isAdmin ? handleOpenSouvenirPicker : undefined}
|
||||
helpUrl={HELP_URL}
|
||||
onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined}
|
||||
onTreeSelect={handleOpenTreeFromAccount}
|
||||
onCreateTree={handleOpenTreeWizard}
|
||||
onProfileClick={handleUseAccount}
|
||||
canRegister={registrationOpen}
|
||||
onAccountClick={() => setAccountPanelVisible(true)}
|
||||
onSettingsClick={() => setAdminPanelVisible(true)}
|
||||
onLogout={handleLogout}
|
||||
@@ -2968,21 +3244,33 @@ function App() {
|
||||
zoomApiRef={galaxyZoomApiRef}
|
||||
focusPersonId={tree.treeContext.rootPersonId}
|
||||
assetRevision={tree.assetRevision}
|
||||
photoHint={tree.canEditCurrentTree}
|
||||
// « + photo » seulement en modification : en lecture, un clic sur un portrait vide ouvre la fiche (issue #124)
|
||||
photoHint={tree.canEditCurrentTree && editMode.isActive}
|
||||
onPhotoPlaceholderClick={tree.canEditCurrentTree && editMode.isActive ? handlePhotoPlaceholderClick : null}
|
||||
reorderEnabled={canReorderSiblings}
|
||||
onReorderSiblings={handleReorderSiblings}
|
||||
familyTabs={familyTabs}
|
||||
/>
|
||||
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
|
||||
{tree.canEditCurrentTree && (
|
||||
<input ref={portraitInputRef} type="file" accept="image/*" className="pc-hidden-input" onChange={handlePortraitPicked} />
|
||||
)}
|
||||
{tree.selectedPerson && (
|
||||
<PersonCard
|
||||
person={tree.selectedPerson}
|
||||
onClose={() => tree.setSelectedPerson(null)}
|
||||
onClose={() => chooseSelectedPerson(null)}
|
||||
onMediaSelect={handleMediaSelectFresh}
|
||||
saveOnLeaveRef={personSaveOnLeaveRef}
|
||||
editMode={editMode.isActive}
|
||||
canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree}
|
||||
onManageMedia={media.handleOpenMediaManager}
|
||||
onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined}
|
||||
onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined}
|
||||
onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined}
|
||||
pendingPortraitCrop={pendingPortraitCrop}
|
||||
onPendingPortraitCropDone={() => setPendingPortraitCrop(null)}
|
||||
photoSourceRequest={photoSourceRequest}
|
||||
onPhotoSourceRequestDone={() => setPhotoSourceRequest(null)}
|
||||
treeId={tree.treeContext.treeId || null}
|
||||
immichToken={tree.treeContext.accessToken || auth.userAuth.token}
|
||||
onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined}
|
||||
@@ -2993,6 +3281,8 @@ function App() {
|
||||
onDelete={handlePersonDelete}
|
||||
onAddRelation={handleAddRelation}
|
||||
onRemoveRelation={handleRemoveRelation}
|
||||
onSetParentage={handleSetParentage}
|
||||
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
|
||||
relationActionLoading={relationFeedback.loading}
|
||||
relationActionSuccess={relationFeedback.success}
|
||||
relationActionError={relationFeedback.error}
|
||||
@@ -3002,11 +3292,23 @@ function App() {
|
||||
onMomentSaved={media.refreshMomentMedia}
|
||||
/>
|
||||
)}
|
||||
{/* Crayon de modification masqué en démo : rien n'y est enregistré */}
|
||||
{tree.selectedMedia && (
|
||||
<MediaViewer
|
||||
media={tree.selectedMedia.media}
|
||||
person={tree.selectedMedia.person}
|
||||
onClose={() => tree.setSelectedMedia(null)}
|
||||
canEdit={Boolean(tree.canEditCurrentTree && editMode.isActive && !isDemoMode)}
|
||||
onLoadLifeEvents={() => media.loadPersonLifeEvents(tree.selectedMedia.person.id)}
|
||||
onSave={async (payload) => {
|
||||
const { person, media: current } = tree.selectedMedia
|
||||
await media.handleUpdatePersonMedia(person.id, current.id, payload)
|
||||
// Le rafraîchissement de l'arbre referme la visionneuse : on la rouvre sur le souvenir modifié
|
||||
tree.setSelectedMedia({
|
||||
person,
|
||||
media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId },
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MediaManagerPanel
|
||||
@@ -3063,6 +3365,8 @@ function App() {
|
||||
onGedcomImported={handleGedcomImported}
|
||||
onOpenPhotoStart={() => { setAdminPanelVisible(false); openPhotoStart() }}
|
||||
onClose={() => setAdminPanelVisible(false)}
|
||||
familyTabs={familyTabs}
|
||||
onFamilyTabsChange={setFamilyTabs}
|
||||
/>
|
||||
<PhotoStartPanel
|
||||
visible={photoStart.visible}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { apiRequest } from './client'
|
||||
import { API_BASE_URL, apiRequest } from './client'
|
||||
|
||||
export async function loginUser(email, password) {
|
||||
const data = await apiRequest('/auth/login', {
|
||||
@@ -29,13 +29,26 @@ export async function logoutUser(token) {
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deleteAccount(token, password) {
|
||||
// confirmation : { password } pour un compte à mot de passe, { confirmEmail } pour un compte né d'une connexion OIDC
|
||||
export async function deleteAccount(token, confirmation) {
|
||||
const data = await apiRequest('/auth/me', {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ password }),
|
||||
body: JSON.stringify(confirmation),
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
// Profil du compte connecté (prénom compris), pour une session restaurée sur un autre appareil
|
||||
export async function fetchAccountProfile(token) {
|
||||
const data = await apiRequest('/auth/me', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
return data
|
||||
@@ -63,3 +76,21 @@ export async function updateLastOpenedTree(token, payload) {
|
||||
|
||||
return data?.lastTree || null
|
||||
}
|
||||
|
||||
// Options de l'instance : inscription ouverte ou fermée, connexion par mot de passe, fournisseur OIDC
|
||||
export async function fetchAuthOptions() {
|
||||
return apiRequest('/auth/options', { method: 'GET' })
|
||||
}
|
||||
|
||||
// Adresse qui part chez le fournisseur OIDC ; returnTo ramène sur l'arbre partagé ouvert (validé par l'API)
|
||||
export function oidcStartUrl(returnTo = '/') {
|
||||
return `${API_BASE_URL}/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`
|
||||
}
|
||||
|
||||
// Jeton de passage reçu au retour du fournisseur, échangé une seule fois contre une session
|
||||
export async function exchangeOidcCode(code) {
|
||||
return apiRequest('/auth/oidc/exchange', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ code }),
|
||||
})
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
const rawApiBaseUrl = import.meta.env.VITE_API_BASE_URL
|
||||
const defaultApiBaseUrl = import.meta.env.DEV ? 'http://localhost:4000' : ''
|
||||
const API_BASE_URL = (rawApiBaseUrl ?? defaultApiBaseUrl).replace(/\/$/, '')
|
||||
@@ -23,7 +25,21 @@ async function safeParseJson(text) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sans limite, un réseau mobile qui décroche laisse l'arbre « en chargement » pour toujours.
|
||||
// Un corps de plus de 100 Ko est un envoi de fichier : il a plus de temps sur une connexion lente.
|
||||
const DEFAULT_TIMEOUT_MS = 30_000
|
||||
const UPLOAD_TIMEOUT_MS = 5 * 60_000
|
||||
const LARGE_BODY_CHARS = 100_000
|
||||
|
||||
function timeoutSignal(options) {
|
||||
if (options.signal) return options.signal
|
||||
if (typeof AbortSignal === 'undefined' || typeof AbortSignal.timeout !== 'function') return undefined
|
||||
const isLargeBody = typeof options.body === 'string' && options.body.length > LARGE_BODY_CHARS
|
||||
return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS))
|
||||
}
|
||||
|
||||
async function apiRequest(path, options = {}) {
|
||||
const { timeoutMs: _timeoutMs, ...fetchOptions } = options
|
||||
const providedHeaders = options.headers || {}
|
||||
const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type')
|
||||
|
||||
@@ -32,12 +48,21 @@ async function apiRequest(path, options = {}) {
|
||||
...providedHeaders,
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...options,
|
||||
headers,
|
||||
})
|
||||
|
||||
const text = await response.text()
|
||||
let response
|
||||
let text
|
||||
try {
|
||||
response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
signal: timeoutSignal(options),
|
||||
})
|
||||
text = await response.text()
|
||||
} catch (error) {
|
||||
if (error?.name === 'TimeoutError') {
|
||||
throw new ApiError(t('Le serveur ne répond pas. Vérifiez la connexion et réessayez.'), 0, { error: 'request_timeout' })
|
||||
}
|
||||
throw error
|
||||
}
|
||||
const payload = await safeParseJson(text)
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { ApiError, apiRequest } from './client'
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('apiRequest', () => {
|
||||
it('une requête qui ne répond pas finit en erreur lisible', async () => {
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout')
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('trop long', 'TimeoutError')))
|
||||
await expect(apiRequest('/trees')).rejects.toMatchObject({
|
||||
name: 'ApiError', message: 'Le serveur ne répond pas. Vérifiez la connexion et réessayez.', status: 0, payload: { error: 'request_timeout' },
|
||||
})
|
||||
expect(timeout).toHaveBeenCalledWith(30_000)
|
||||
})
|
||||
|
||||
it('un envoi de fichier a plus de temps, et un délai demandé passe devant', async () => {
|
||||
const timeout = vi.spyOn(AbortSignal, 'timeout')
|
||||
vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('{"ok":true}', { status: 200 })))
|
||||
await apiRequest('/upload', { method: 'POST', body: 'x'.repeat(200_000) })
|
||||
expect(timeout).toHaveBeenLastCalledWith(5 * 60_000)
|
||||
await apiRequest('/import', { method: 'POST', body: '{}', timeoutMs: 1234 })
|
||||
expect(timeout).toHaveBeenLastCalledWith(1234)
|
||||
expect(fetch.mock.calls[1][1]).not.toHaveProperty('timeoutMs')
|
||||
})
|
||||
|
||||
it('une autre panne réseau remonte telle quelle', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch')))
|
||||
const error = await apiRequest('/trees').catch((e) => e)
|
||||
expect(error).toBeInstanceOf(TypeError)
|
||||
expect(error).not.toBeInstanceOf(ApiError)
|
||||
})
|
||||
})
|
||||
@@ -235,6 +235,19 @@ export async function reorderSiblings(treeId, order, token) {
|
||||
})
|
||||
}
|
||||
|
||||
// Lien parent-enfant modifié (type de filiation : naissance ou adoption)
|
||||
export async function updateParentChildLink(treeId, linkId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
return data.link
|
||||
}
|
||||
|
||||
export async function deleteParentChildLink(treeId, linkId, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
|
||||
method: 'DELETE',
|
||||
@@ -441,6 +454,22 @@ export async function deletePersonMedia(treeId, personId, mediaId, token) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Modifier un souvenir (titre, moment de vie, texte de citation) : administrateurs seulement
|
||||
export async function updatePersonMedia(treeId, personId, mediaId, payload, token) {
|
||||
const data = await apiRequest(
|
||||
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function uploadPersonAvatar(treeId, personId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
|
||||
method: 'POST',
|
||||
@@ -453,6 +482,19 @@ export async function uploadPersonAvatar(treeId, personId, payload, token) {
|
||||
return data
|
||||
}
|
||||
|
||||
// Cadrage du portrait ({ x, y, zoom }) ou null pour le cadrage par défaut
|
||||
export async function saveAvatarCrop(treeId, personId, crop, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar/crop`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({ crop }),
|
||||
})
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export async function deletePersonAvatar(treeId, personId, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
|
||||
method: 'DELETE',
|
||||
@@ -496,6 +538,8 @@ export async function importGedcom(treeId, fileContent, token) {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ content: fileContent }),
|
||||
// Un gros arbre se crée en une transaction d'une minute au plus côté serveur
|
||||
timeoutMs: 3 * 60_000,
|
||||
})
|
||||
return data
|
||||
}
|
||||
@@ -23,8 +23,12 @@ const AccessGate = ({
|
||||
treeNotFound = false,
|
||||
loading,
|
||||
errorMessage,
|
||||
connected = false,
|
||||
connectedName = '',
|
||||
onSubmit,
|
||||
onUseAccount,
|
||||
canRegister = true,
|
||||
onNotMyAccount,
|
||||
}) => {
|
||||
const [treeId, setTreeId] = useState(initialTreeId || '')
|
||||
const [password, setPassword] = useState('')
|
||||
@@ -55,7 +59,6 @@ const AccessGate = ({
|
||||
|
||||
<form className="pz-card pz-screen-card" onSubmit={handleSubmit}>
|
||||
<div className="pz-auth-head">
|
||||
<p className="pz-eyebrow">{t('Arbre partagé')}</p>
|
||||
{treeNotFound ? (
|
||||
<h1 className="pz-title">{richText(t('Cet arbre est *introuvable*'))}</h1>
|
||||
) : (
|
||||
@@ -67,7 +70,7 @@ const AccessGate = ({
|
||||
{treeNotFound
|
||||
? t("Le lien est peut-être incomplet, ou l'arbre a été supprimé. Demandez un nouveau lien à la personne qui vous l'a envoyé.")
|
||||
: treeName
|
||||
? t('Entrez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.')
|
||||
? t('Tapez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.')
|
||||
: t('Entrez le code et le mot de passe reçus avec votre lien de partage.')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -86,6 +89,21 @@ const AccessGate = ({
|
||||
|
||||
{!treeNotFound && (
|
||||
<div className="pz-auth-form">
|
||||
{connected && (
|
||||
<div className="access-gate-session" role="status">
|
||||
{connectedName ? (
|
||||
/^[aeiouyàâäéèêëîïôöùûüh]/i.test(connectedName)
|
||||
? t("Vous êtes sur le compte d'{name}", { name: connectedName })
|
||||
: t('Vous êtes sur le compte de {name}', { name: connectedName })
|
||||
) : (
|
||||
t('Vous êtes sur votre compte.')
|
||||
)}
|
||||
<span className="access-gate-session-step">
|
||||
{t("Tapez une fois le mot de passe reçu avec le lien. Ensuite, l'arbre s'ouvrira directement.")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!initialTreeId && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="treeId">{t("Code de l'arbre")}</label>
|
||||
@@ -103,21 +121,7 @@ const AccessGate = ({
|
||||
)}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="visitorFirstName">{t('Votre prénom')}</label>
|
||||
<input
|
||||
id="visitorFirstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="given-name"
|
||||
maxLength={60}
|
||||
/>
|
||||
<p className="pz-hint">{t("Facultatif. Il sert à savoir qui est venu voir l'arbre.")}</p>
|
||||
</div>
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="sharePassword">{t('Mot de passe de partage')}</label>
|
||||
<label htmlFor="sharePassword">{t('Mot de passe reçu avec le lien')}</label>
|
||||
<input
|
||||
id="sharePassword"
|
||||
type="password"
|
||||
@@ -128,6 +132,23 @@ const AccessGate = ({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Compte connecté : son prénom sert déjà, on ne le redemande pas */}
|
||||
{!connected && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="visitorFirstName">{t('Votre prénom (facultatif)')}</label>
|
||||
<input
|
||||
id="visitorFirstName"
|
||||
type="text"
|
||||
value={firstName}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
disabled={loading}
|
||||
autoComplete="given-name"
|
||||
maxLength={60}
|
||||
/>
|
||||
<p className="pz-hint">{t("Il sert à savoir qui est venu voir l'arbre.")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -135,25 +156,42 @@ const AccessGate = ({
|
||||
|
||||
{!treeNotFound && (
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={loading}>
|
||||
<PzBusy busy={loading} busyLabel={t("Ouverture de l'arbre")}>{t("Ouvrir l'arbre")}</PzBusy>
|
||||
<PzBusy busy={loading} busyLabel={t("Ouverture de l'arbre")}>{t("Voir l'arbre")}</PzBusy>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{treeNotFound && onUseAccount && (
|
||||
{/* Inscription fermée : un compte existant crée encore ses arbres, un visiteur ne peut que se connecter */}
|
||||
{treeNotFound && onUseAccount && (canRegister || connected) && (
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={() => onUseAccount('register')} disabled={loading}>
|
||||
{t('Créer mon arbre')}
|
||||
{t('Créer mon propre arbre')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{treeNotFound && onUseAccount && !canRegister && !connected && (
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={() => onUseAccount('login')} disabled={loading}>
|
||||
{t('Se connecter')}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{onUseAccount && !treeNotFound && (
|
||||
{connected && onNotMyAccount && !treeNotFound && (
|
||||
<div className="pz-account-foot">
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onNotMyAccount} disabled={loading}>
|
||||
{t("Ce n'est pas mon compte")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connected && onUseAccount && !treeNotFound && (
|
||||
<div className="pz-account-foot">
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('login')} disabled={loading}>
|
||||
{t('Se connecter avec un compte')}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('register')} disabled={loading}>
|
||||
{t('Créer un compte')}
|
||||
{t("J'ai déjà un compte")}
|
||||
</button>
|
||||
{canRegister && (
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('register')} disabled={loading}>
|
||||
{t("Je n'ai pas de compte")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Fragment, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronRight, KeyRound, LogOut, Plus, Sparkles, X } from 'lucide-react'
|
||||
import { ChevronRight, KeyRound, LogIn, LogOut, Plus, Sparkles, X } from 'lucide-react'
|
||||
import logoUrl from '../assets/parenthese-logo.svg?url'
|
||||
import './AccountScreens.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
@@ -23,6 +23,11 @@ const AccountDashboard = ({
|
||||
trees,
|
||||
activeTreeId,
|
||||
defaultMode = 'login',
|
||||
canRegister = true,
|
||||
passwordLogin = true,
|
||||
oidcName = '',
|
||||
onOidcLogin,
|
||||
hasPassword = true,
|
||||
pendingTreeName = '',
|
||||
onLogin,
|
||||
onRegister,
|
||||
@@ -62,7 +67,7 @@ const AccountDashboard = ({
|
||||
|
||||
setDeleting(true)
|
||||
setDeleteError('')
|
||||
const errorMessage = await onDeleteAccount(deletePassword)
|
||||
const errorMessage = await onDeleteAccount(hasPassword ? { password: deletePassword } : { confirmEmail: deletePassword })
|
||||
setDeleting(false)
|
||||
|
||||
if (errorMessage) {
|
||||
@@ -78,7 +83,8 @@ const AccountDashboard = ({
|
||||
setMode(defaultMode === 'register' ? 'register' : 'login')
|
||||
}, [defaultMode])
|
||||
|
||||
const isRegister = mode === 'register'
|
||||
// Inscription fermée sur l'instance : seul le formulaire de connexion reste
|
||||
const isRegister = mode === 'register' && canRegister
|
||||
|
||||
const canSubmitAuth = useMemo(() => {
|
||||
if (loading) return false
|
||||
@@ -132,67 +138,82 @@ const AccountDashboard = ({
|
||||
</p>
|
||||
)}
|
||||
|
||||
<form className="pz-auth-form" onSubmit={handleAuthSubmit}>
|
||||
{isRegister && (
|
||||
{/* Fournisseur d'identité de l'instance (OIDC, #115) : il connecte, et crée le compte si l'instance en accepte */}
|
||||
{onOidcLogin && (
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block account-oidc" onClick={onOidcLogin} disabled={loading}>
|
||||
<LogIn size={16} aria-hidden="true" />
|
||||
{t('Se connecter avec {name}', { name: oidcName })}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Sans formulaire à mot de passe, le refus du fournisseur s'affiche ici */}
|
||||
{!passwordLogin && errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
|
||||
{onOidcLogin && passwordLogin && <div className="pz-divider">{t('ou')}</div>}
|
||||
|
||||
{passwordLogin && (
|
||||
<form className="pz-auth-form" onSubmit={handleAuthSubmit}>
|
||||
{isRegister && (
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountFirstName">{t('Prénom')}</label>
|
||||
<input
|
||||
id="accountFirstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Camille"
|
||||
value={firstName}
|
||||
maxLength={100}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountFirstName">{t('Prénom')}</label>
|
||||
<label htmlFor="accountEmail">{t('Email')}</label>
|
||||
<input
|
||||
id="accountFirstName"
|
||||
type="text"
|
||||
autoComplete="given-name"
|
||||
placeholder="Camille"
|
||||
value={firstName}
|
||||
maxLength={100}
|
||||
onChange={(event) => setFirstName(event.target.value)}
|
||||
id="accountEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('camille@exemple.fr')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountEmail">{t('Email')}</label>
|
||||
<input
|
||||
id="accountEmail"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder={t('camille@exemple.fr')}
|
||||
value={email}
|
||||
onChange={(event) => setEmail(event.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountPassword">{t('Mot de passe')}</label>
|
||||
<input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
{isRegister && <p className="pz-hint">{t('8 caractères minimum.')}</p>}
|
||||
</div>
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountPassword">{t('Mot de passe')}</label>
|
||||
<input
|
||||
id="accountPassword"
|
||||
type="password"
|
||||
autoComplete={isRegister ? 'new-password' : 'current-password'}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
minLength={8}
|
||||
required
|
||||
/>
|
||||
{isRegister && <p className="pz-hint">{t('8 caractères minimum.')}</p>}
|
||||
</div>
|
||||
{errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
|
||||
{errorMessage && <div className="account-error pz-error" role="alert">{errorMessage}</div>}
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={!canSubmitAuth}>
|
||||
<PzBusy busy={loading} busyLabel={isRegister ? t('Création du compte') : t('Connexion')}>{isRegister ? t('Créer mon compte') : t('Se connecter')}</PzBusy>
|
||||
</button>
|
||||
|
||||
<button type="submit" className="pz-btn pz-btn--primary pz-btn--block" disabled={!canSubmitAuth}>
|
||||
<PzBusy busy={loading} busyLabel={isRegister ? t('Création du compte') : t('Connexion')}>{isRegister ? t('Créer mon compte') : t('Se connecter')}</PzBusy>
|
||||
</button>
|
||||
|
||||
{isRegister && (
|
||||
<p className="pz-small pz-auth-legal">
|
||||
{richText(
|
||||
t('En créant un compte, vous acceptez {slot}.'),
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
|
||||
{t('la façon dont Parenthèse traite vos données')}
|
||||
</a>,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
{isRegister && (
|
||||
<p className="pz-small pz-auth-legal">
|
||||
{richText(
|
||||
t('En créant un compte, vous acceptez {slot}.'),
|
||||
<a href={PRIVACY_URL} target="_blank" rel="noopener noreferrer">
|
||||
{t('la façon dont Parenthèse traite vos données')}
|
||||
</a>,
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="pz-divider">{t('ou')}</div>
|
||||
|
||||
@@ -203,21 +224,23 @@ const AccountDashboard = ({
|
||||
disabled={loading}
|
||||
>
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{t('Ouvrir un arbre partagé avec moi')}
|
||||
{t("J'ai reçu un lien de famille")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="pz-auth-switch pz-small">
|
||||
{isRegister ? t('Vous avez déjà un compte ?') : t('Pas encore de compte ?')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="pz-link account-signup-toggle"
|
||||
onClick={() => setMode(isRegister ? 'login' : 'register')}
|
||||
disabled={loading}
|
||||
>
|
||||
{isRegister ? t('Me connecter') : t('Créer un compte')}
|
||||
</button>
|
||||
</p>
|
||||
{canRegister && (
|
||||
<p className="pz-auth-switch pz-small">
|
||||
{isRegister ? t('Déjà inscrit ?') : t('Pas encore de compte ?')}{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="pz-link account-signup-toggle"
|
||||
onClick={() => setMode(isRegister ? 'login' : 'register')}
|
||||
disabled={loading}
|
||||
>
|
||||
{isRegister ? t('Se connecter') : t('Créer un compte')}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<LanguageSwitch className="pz-lang--screen" />
|
||||
</div>
|
||||
@@ -324,11 +347,12 @@ const AccountDashboard = ({
|
||||
|
||||
<form className="pz-auth-form" onSubmit={handleDeleteSubmit}>
|
||||
<div className="pz-field">
|
||||
<label htmlFor="accountDeletePassword">{t('Mot de passe actuel')}</label>
|
||||
{/* Compte né d'une connexion OIDC, sans mot de passe : il retape son adresse email */}
|
||||
<label htmlFor="accountDeletePassword">{hasPassword ? t('Mot de passe actuel') : t('Votre adresse email, pour confirmer')}</label>
|
||||
<input
|
||||
id="accountDeletePassword"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
type={hasPassword ? 'password' : 'email'}
|
||||
autoComplete={hasPassword ? 'current-password' : 'off'}
|
||||
value={deletePassword}
|
||||
onChange={(event) => setDeletePassword(event.target.value)}
|
||||
disabled={deleting}
|
||||
|
||||
@@ -41,6 +41,16 @@
|
||||
.pz-gate-owner-text { display: grid; gap: 2px; min-width: 0; }
|
||||
.pz-gate-owner-text strong { color: var(--pz-ink); font-weight: 500; }
|
||||
|
||||
/* Porte, personne déjà connectée : dit quel compte est ouvert et ce qu'il faut faire une fois */
|
||||
.access-gate-session { display: grid; gap: 4px; padding: 12px 14px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); color: var(--pz-ink); font: 400 14px/1.45 var(--pz-sans); }
|
||||
.access-gate-session-step { color: var(--pz-ink-2); }
|
||||
|
||||
/* Accueil sans session (variante B) */
|
||||
.pz-btn.pz-btn--line { background: transparent; border: 1px solid var(--pz-ink); color: var(--pz-ink); }
|
||||
.entry-choice-title { text-align: center; }
|
||||
.entry-choice-card { padding: 18px; display: grid; gap: 10px; }
|
||||
.entry-choice-name { font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink); margin: 0; }
|
||||
|
||||
/* Fenêtre « Mon compte » (personne liée) */
|
||||
.ap-linked { display: flex; gap: 12px; align-items: center; padding: 12px 14px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); }
|
||||
.ap-avatar { width: 44px; height: 44px; flex: none; display: grid; place-items: center; overflow: hidden; border-radius: 50%; background: var(--pz-a100); color: var(--pz-a700); font: 400 20px/1 var(--pz-serif); }
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
/* ============================================================
|
||||
Add Person Button - FAB draggable pour ajouter personne
|
||||
============================================================ */
|
||||
|
||||
.add-person-button {
|
||||
position: fixed;
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
|
||||
background: rgba(242, 239, 234, 0.95);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 2px solid rgba(147, 64, 42, 0.3);
|
||||
|
||||
box-shadow:
|
||||
0 8px 32px rgba(42, 38, 34, 0.25),
|
||||
0 2px 8px rgba(42, 38, 34, 0.12);
|
||||
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 900;
|
||||
|
||||
/* Position initiale : bas-gauche */
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
}
|
||||
|
||||
.add-person-button:hover {
|
||||
transform: scale(1.12);
|
||||
box-shadow:
|
||||
0 12px 40px rgba(147, 64, 42, 0.35),
|
||||
0 4px 12px rgba(42, 38, 34, 0.18);
|
||||
border-color: rgba(147, 64, 42, 0.5);
|
||||
}
|
||||
|
||||
.add-person-button.dragging {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.2) rotate(5deg);
|
||||
box-shadow:
|
||||
0 16px 48px rgba(147, 64, 42, 0.45),
|
||||
0 6px 16px rgba(42, 38, 34, 0.25);
|
||||
border-color: rgba(147, 64, 42, 0.7);
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
/* Icône + */
|
||||
.add-person-button-icon {
|
||||
font-size: 32px;
|
||||
font-weight: 300;
|
||||
color: #93402A;
|
||||
line-height: 1;
|
||||
filter: drop-shadow(0 1px 3px rgba(42, 38, 34, 0.15));
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.add-person-button:hover .add-person-button-icon {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.add-person-button.dragging .add-person-button-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Pulse animation sur apparition */
|
||||
@keyframes addPersonPulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.7);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.add-person-button.entering {
|
||||
animation: addPersonPulse 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
/* Tooltip */
|
||||
.add-person-button-tooltip {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: calc(100% + 12px);
|
||||
transform: translateY(-50%);
|
||||
|
||||
padding: 8px 14px;
|
||||
background: rgba(42, 38, 34, 0.95);
|
||||
color: #FFFFFF;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
border-radius: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
|
||||
box-shadow: 0 4px 12px rgba(42, 38, 34, 0.2);
|
||||
}
|
||||
|
||||
.add-person-button:hover .add-person-button-tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.add-person-button.dragging .add-person-button-tooltip {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* Instructions drag (affichées pendant drag) */
|
||||
.add-person-drag-hint {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1050;
|
||||
|
||||
padding: 10px 18px;
|
||||
background: rgba(242, 239, 234, 0.98);
|
||||
backdrop-filter: blur(24px);
|
||||
border-radius: 50px;
|
||||
border: 1px solid rgba(147, 64, 42, 0.2);
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 450;
|
||||
color: #2A2622;
|
||||
|
||||
box-shadow:
|
||||
0 6px 24px rgba(42, 38, 34, 0.18),
|
||||
0 2px 8px rgba(42, 38, 34, 0.08);
|
||||
|
||||
animation: hintSlideDown 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes hintSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.add-person-button {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.add-person-button-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.add-person-button-tooltip {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useRef, useEffect, useState } from 'react'
|
||||
import { Plus, Baby, Heart, Users, MapPin } from 'lucide-react'
|
||||
import './AddPersonButton.css'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
* Bouton FAB draggable pour ajouter une personne
|
||||
* Utilisé en mode édition pour drag-and-drop dans la galaxie
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.isDragging - État dragging (du hook)
|
||||
* @param {Object} props.dragPosition - Position drag {x, y}
|
||||
* @param {Function} props.onDragStart - Handler début drag
|
||||
* @param {string} props.linkType - Type de lien détecté ('child', 'spouse', 'sibling')
|
||||
*/
|
||||
function AddPersonButton({ isDragging, dragPosition, onDragStart, linkType }) {
|
||||
const buttonRef = useRef(null)
|
||||
const [entering, setEntering] = useState(true)
|
||||
|
||||
// Animation d'entrée
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setEntering(false), 500)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const handleMouseDown = (e) => {
|
||||
e.preventDefault()
|
||||
if (!buttonRef.current) return
|
||||
|
||||
const rect = buttonRef.current.getBoundingClientRect()
|
||||
const buttonPosition = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
}
|
||||
|
||||
onDragStart?.(e, buttonPosition)
|
||||
}
|
||||
|
||||
// Position inline si dragging
|
||||
const style = isDragging
|
||||
? {
|
||||
left: `${dragPosition.x - 32}px`,
|
||||
top: `${dragPosition.y - 32}px`,
|
||||
transition: 'none',
|
||||
}
|
||||
: {}
|
||||
|
||||
// Message tooltip selon linkType
|
||||
let tooltipText = t('Glisser pour ajouter une personne')
|
||||
if (isDragging) {
|
||||
if (linkType === 'child') tooltipText = t('Ajouter un enfant')
|
||||
else if (linkType === 'spouse') tooltipText = t('Ajouter un conjoint')
|
||||
else if (linkType === 'sibling') tooltipText = t('Ajouter un frère/sœur')
|
||||
else tooltipText = t('Relâcher pour créer')
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className={`add-person-button ${isDragging ? 'dragging' : ''} ${entering ? 'entering' : ''}`}
|
||||
style={style}
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label={t('Ajouter une personne')}
|
||||
>
|
||||
<span className="add-person-button-icon"><Plus size={28} strokeWidth={1.5} /></span>
|
||||
<span className="add-person-button-tooltip">{tooltipText}</span>
|
||||
</button>
|
||||
|
||||
{/* Hint pendant drag */}
|
||||
{isDragging && (
|
||||
<div className="add-person-drag-hint">
|
||||
{linkType === 'child' && <><Baby size={16} strokeWidth={2} /> {t('Enfant')}</>}
|
||||
{linkType === 'spouse' && <><Heart size={16} strokeWidth={2} /> {t('Conjoint')}</>}
|
||||
{linkType === 'sibling' && <><Users size={16} strokeWidth={2} /> {t('Frère·Sœur')}</>}
|
||||
{!linkType && <><MapPin size={16} strokeWidth={2} /> {t('Positionnez la personne')}</>}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddPersonButton
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
.apn-section { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--pz-line-soft); }
|
||||
.apn-types .pz-tab { font-size: 13px; padding: 0 6px; }
|
||||
/* Sexe : quatre choix, chacun sur une ligne même dans la colonne étroite du panneau */
|
||||
.apn-sex .pz-tab { white-space: nowrap; padding: 0 4px; }
|
||||
|
||||
.apn-relation { position: relative; }
|
||||
.apn-search { position: relative; display: flex; align-items: center; }
|
||||
@@ -108,6 +110,13 @@
|
||||
/* Décédé(e) sans date connue (issue #77) */
|
||||
.apn-deceased { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; }
|
||||
.apn-deceased input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
|
||||
/* Case sous une ligne de deux champs (« Je ne connais pas son prénom », « Décédé(e), date inconnue ») : sur une ligne,
|
||||
sous les deux champs sur ordinateur, sous son champ sur téléphone (ordre du DOM) */
|
||||
.apn-row > .apn-row-check { grid-column: 1 / -1; grid-row: 2; margin-top: -6px; white-space: nowrap; justify-self: start; }
|
||||
@media (max-width: 480px) { .apn-row > .apn-row-check { grid-row: auto; } }
|
||||
|
||||
/* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */
|
||||
.apn-row { align-items: start; }
|
||||
|
||||
/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */
|
||||
.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; }
|
||||
@@ -4,6 +4,8 @@ import { UserPlus, X, Search, ChevronDown } from 'lucide-react'
|
||||
import './AddPersonPanel.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { SEX_CHOICES } from '../utils/personSex.js'
|
||||
|
||||
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
|
||||
function withEmphasis(text) {
|
||||
@@ -32,12 +34,18 @@ function withEmphasis(text) {
|
||||
*/
|
||||
function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) {
|
||||
const [firstName, setFirstName] = useState('')
|
||||
// Prénom inconnu : enregistré vide, affiché « Prénom inconnu » dans l'arbre et la fiche
|
||||
const [unknownFirstName, setUnknownFirstName] = useState(false)
|
||||
const [lastName, setLastName] = useState('')
|
||||
const [birthName, setBirthName] = useState('')
|
||||
const [nickname, setNickname] = useState('')
|
||||
const [sex, setSex] = useState(null)
|
||||
const [birthDate, setBirthDate] = useState('')
|
||||
const [deathDate, setDeathDate] = useState('')
|
||||
// Décédé(e) sans date connue (issue #77)
|
||||
const [isDeceased, setIsDeceased] = useState(false)
|
||||
// Lien d'enfant ou de parent : la personne qui est l'enfant du lien a été adoptée
|
||||
const [adopted, setAdopted] = useState(false)
|
||||
// Les champs de date gardent leur texte : on les recrée après chaque ajout
|
||||
const [dateFieldsKey, setDateFieldsKey] = useState(0)
|
||||
const [relations, setRelations] = useState([])
|
||||
@@ -45,6 +53,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
const [relationDropdownOpen, setRelationDropdownOpen] = useState(false)
|
||||
const [selectedRelationType, setSelectedRelationType] = useState('parent')
|
||||
const [touched, setTouched] = useState({})
|
||||
// Prénom inconnu sans nom de famille : la personne n'aurait aucun nom
|
||||
const [lastNameMissing, setLastNameMissing] = useState(false)
|
||||
const [justAdded, setJustAdded] = useState(false)
|
||||
|
||||
const firstNameRef = useRef(null)
|
||||
@@ -89,14 +99,18 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
useEffect(() => {
|
||||
if (justAdded && !loading && !error) {
|
||||
setFirstName('')
|
||||
setUnknownFirstName(false)
|
||||
setLastName('')
|
||||
setBirthName('')
|
||||
setNickname('')
|
||||
setSex(null)
|
||||
setBirthDate('')
|
||||
setDeathDate('')
|
||||
setIsDeceased(false)
|
||||
setDateFieldsKey((k) => k + 1)
|
||||
setRelations([])
|
||||
setTouched({})
|
||||
setLastNameMissing(false)
|
||||
setJustAdded(false)
|
||||
firstNameRef.current?.focus()
|
||||
}
|
||||
@@ -114,7 +128,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
|
||||
setRelations((prev) => [
|
||||
...prev,
|
||||
{ personId: person.id, personName: `${person.firstName} ${person.lastName || ''}`.trim(), type: selectedRelationType },
|
||||
{ personId: person.id, personName: `${firstNameLabel(person)} ${person.lastName || ''}`.trim(), type: selectedRelationType },
|
||||
])
|
||||
setRelationSearch('')
|
||||
setRelationDropdownOpen(false)
|
||||
@@ -127,27 +141,33 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (!firstName.trim()) {
|
||||
if (!unknownFirstName && !firstName.trim()) {
|
||||
setTouched({ firstName: true })
|
||||
firstNameRef.current?.focus()
|
||||
return
|
||||
}
|
||||
|
||||
if (unknownFirstName && !lastName.trim()) {
|
||||
setLastNameMissing(true)
|
||||
return
|
||||
}
|
||||
|
||||
setJustAdded(true)
|
||||
|
||||
onSubmit({
|
||||
firstName: firstName.trim(),
|
||||
firstName: unknownFirstName ? '' : firstName.trim(),
|
||||
lastName: lastName.trim(),
|
||||
birthName: birthName.trim() || null,
|
||||
nickname: nickname.trim() || null,
|
||||
sex,
|
||||
birthDate: birthDate || null,
|
||||
deathDate: deathDate || null,
|
||||
isDeceased: !deathDate && isDeceased,
|
||||
relations,
|
||||
// Adopté(e) : s'applique aux liens d'enfant ou de parent de cette fiche
|
||||
relations: relations.map((rel) => ({ ...rel, adopted: adopted && (rel.type === 'child' || rel.type === 'parent') })),
|
||||
})
|
||||
}
|
||||
|
||||
const isValid = firstName.trim().length > 0
|
||||
|
||||
const relationTypeLabels = {
|
||||
parent: t('Parent de'),
|
||||
child: t('Enfant de'),
|
||||
@@ -171,7 +191,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
>
|
||||
<div className="apn-head">
|
||||
<div className="pz-modal-head">
|
||||
<p className="pz-eyebrow">{t('Nouvelle fiche')}</p>
|
||||
<h2 id="addPersonTitle" className="pz-title">{withEmphasis(t("Ajouter <em>quelqu'un</em>"))}</h2>
|
||||
</div>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon apn-close" onClick={onClose} aria-label={t('Fermer')}>
|
||||
@@ -188,30 +207,49 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
ref={firstNameRef}
|
||||
id="ap-firstName"
|
||||
type="text"
|
||||
className={touched.firstName && !firstName.trim() ? 'apn-input--error' : ''}
|
||||
className={touched.firstName && !unknownFirstName && !firstName.trim() ? 'apn-input--error' : ''}
|
||||
value={firstName}
|
||||
onChange={(e) => setFirstName(e.target.value)}
|
||||
onBlur={() => setTouched((prev) => ({ ...prev, firstName: true }))}
|
||||
placeholder={t('Marie')}
|
||||
autoComplete="off"
|
||||
aria-invalid={touched.firstName && !firstName.trim()}
|
||||
disabled={unknownFirstName}
|
||||
aria-invalid={touched.firstName && !unknownFirstName && !firstName.trim()}
|
||||
/>
|
||||
</div>
|
||||
{/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher).
|
||||
Hors de la colonne Prénom pour tenir sur une ligne : sous les deux champs sur ordinateur, sous Prénom sur téléphone */}
|
||||
{(!firstName.trim() || unknownFirstName) && (
|
||||
<label className="apn-deceased apn-row-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unknownFirstName}
|
||||
onChange={(e) => {
|
||||
setUnknownFirstName(e.target.checked)
|
||||
setTouched({})
|
||||
}}
|
||||
/>
|
||||
{t('Je ne connais pas son prénom')}
|
||||
</label>
|
||||
)}
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ap-lastName">{t('Nom')}</label>
|
||||
<input
|
||||
id="ap-lastName"
|
||||
type="text"
|
||||
value={lastName}
|
||||
onChange={(e) => setLastName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLastName(e.target.value)
|
||||
setLastNameMissing(false)
|
||||
}}
|
||||
placeholder={t('Dupont')}
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{touched.firstName && !firstName.trim() && (
|
||||
{touched.firstName && !unknownFirstName && !firstName.trim() && (
|
||||
<p className="apn-error-text">{t('Il faut au moins un prénom.')}</p>
|
||||
)}
|
||||
{lastNameMissing && <p className="apn-error-text">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ap-birthName">{t('Nom de naissance')} <span className="apn-optional">{t('si différent')}</span></label>
|
||||
@@ -225,6 +263,36 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ap-nickname">{t('Surnom')} <span className="apn-optional">{t('facultatif')}</span></label>
|
||||
<input
|
||||
id="ap-nickname"
|
||||
type="text"
|
||||
value={nickname}
|
||||
onChange={(e) => setNickname(e.target.value)}
|
||||
placeholder={t('Loulou')}
|
||||
autoComplete="off"
|
||||
maxLength={120}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pz-field">
|
||||
<span className="pz-label" id="ap-sex-label">{t('Sexe')} <span className="apn-optional">{t('facultatif')}</span></span>
|
||||
<div className="pz-tabs apn-types apn-sex" role="group" aria-labelledby="ap-sex-label">
|
||||
{SEX_CHOICES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={sex === value}
|
||||
className={`pz-tab ${sex === value ? 'is-active' : ''}`}
|
||||
onClick={() => setSex(sex === value ? null : value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pz-row2 apn-row">
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ap-birthDate">{t('Naissance')}</label>
|
||||
@@ -233,17 +301,18 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
<div className="pz-field">
|
||||
<label htmlFor="ap-deathDate">{t('Décès')}</label>
|
||||
<DateField key={`d-${dateFieldsKey}`} id="ap-deathDate" value={deathDate} onChange={setDeathDate} />
|
||||
{!deathDate && (
|
||||
<label className="apn-deceased">
|
||||
<input type="checkbox" checked={isDeceased} onChange={(e) => setIsDeceased(e.target.checked)} />
|
||||
{t('Décédé(e), date inconnue')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{/* Hors de la colonne Décès pour tenir sur une ligne, comme la case du prénom */}
|
||||
{!deathDate && (
|
||||
<label className="apn-deceased apn-row-check">
|
||||
<input type="checkbox" checked={isDeceased} onChange={(e) => setIsDeceased(e.target.checked)} />
|
||||
{sex === 'male' ? t('Décédé, date inconnue') : sex === 'female' ? t('Décédée, date inconnue') : t('Décédé(e), date inconnue')}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<section className="apn-section">
|
||||
<h3 className="pz-eyebrow">{t('Place dans la famille')}</h3>
|
||||
<h3 className="apn-section-title">{t('Place dans la famille')}</h3>
|
||||
|
||||
<div className="pz-tabs apn-types" role="radiogroup" aria-label={t('Type de lien')}>
|
||||
{Object.entries(relationTypeTabs).map(([type, label]) => (
|
||||
@@ -285,9 +354,9 @@ 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="" /> : (p.firstName || '?').charAt(0)}
|
||||
{p.photo ? <img src={p.photo} alt="" /> : (p.firstName || '').trim().charAt(0) || '?'}
|
||||
</span>
|
||||
<span className="apn-dropdown-name">{p.firstName} <em>{p.lastName || ''}</em></span>
|
||||
<span className="apn-dropdown-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
|
||||
{(p.birthYear || p.birthDate) && (
|
||||
<span className="apn-dropdown-date">{p.birthYear || String(p.birthDate).slice(0, 4)}</span>
|
||||
)}
|
||||
@@ -298,6 +367,13 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
)}
|
||||
</div>
|
||||
|
||||
{(selectedRelationType === 'child' || selectedRelationType === 'parent') && (
|
||||
<label className="apn-deceased">
|
||||
<input type="checkbox" checked={adopted} onChange={(e) => setAdopted(e.target.checked)} />
|
||||
{sex === 'male' ? t('Adopté') : sex === 'female' ? t('Adoptée') : t('Adopté(e)')}
|
||||
</label>
|
||||
)}
|
||||
|
||||
{relations.length > 0 && (
|
||||
<div className="apn-links">
|
||||
{relations.map((rel) => (
|
||||
@@ -325,7 +401,9 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose}>
|
||||
{t('Annuler')}
|
||||
</button>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={!isValid || loading}>
|
||||
{/* Toujours actif : sans prénom, l'appui le dit et ramène au champ (le dire dès qu'on quittait le prénom
|
||||
affichait une erreur avant même d'avoir rempli la fiche) */}
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={loading}>
|
||||
<UserPlus size={16} strokeWidth={2} />
|
||||
<PzBusy busy={loading} busyLabel={t('Ajout en cours')}>{t('Ajouter cette personne')}</PzBusy>
|
||||
</button>
|
||||
|
||||
@@ -33,6 +33,8 @@ function normalizeSlug(value) {
|
||||
}
|
||||
|
||||
const AdminPanel = ({
|
||||
familyTabs = true,
|
||||
onFamilyTabsChange = null,
|
||||
visible,
|
||||
treeId,
|
||||
authToken,
|
||||
@@ -475,6 +477,22 @@ const AdminPanel = ({
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<fieldset className="ts-policy">
|
||||
<legend className="pz-eyebrow">{t('Affichage')}</legend>
|
||||
<label className={`ts-choice ${familyTabs ? 'is-active' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="familyTabs"
|
||||
checked={familyTabs}
|
||||
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>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
</form>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import './SettingsPanels.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { HELP_URL } from '../utils/siteLinks.js'
|
||||
import { t, dateLocale } from '../i18n/index.js'
|
||||
import { sexLabel } from '../utils/personSex.js'
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -73,8 +74,11 @@ const PERSON_FIELD_LABELS = {
|
||||
firstName: t('Prénom'),
|
||||
lastName: t('Nom'),
|
||||
birthName: t('Nom de naissance'),
|
||||
nickname: t('Surnom'),
|
||||
sex: t('Sexe'),
|
||||
birthDate: t('Date de naissance'),
|
||||
deathDate: t('Date de décès'),
|
||||
deathPlace: t('Lieu de décès'),
|
||||
birthPlace: t('Lieu de naissance'),
|
||||
profession: t('Métier'),
|
||||
nationality: t('Nationalité'),
|
||||
@@ -82,7 +86,9 @@ const PERSON_FIELD_LABELS = {
|
||||
notes: t('Notes'),
|
||||
}
|
||||
|
||||
function displayVal(v) {
|
||||
function displayVal(v, key) {
|
||||
// Sexe : « Non précisé » plutôt que « vide »
|
||||
if (key === 'sex') return sexLabel(v)
|
||||
if (v === null || v === undefined || v === '') return null
|
||||
return String(v)
|
||||
}
|
||||
@@ -132,10 +138,11 @@ function getPersonDisplayName(change, sessionChanges = []) {
|
||||
const data = change.afterJson || {}
|
||||
return data.title || t('Moment')
|
||||
}
|
||||
const data = (change.afterJson && typeof change.afterJson === 'object')
|
||||
? change.afterJson
|
||||
: (change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null)
|
||||
if (!data) return null
|
||||
// Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base)
|
||||
const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null
|
||||
const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null
|
||||
if (!after && !before) return null
|
||||
const data = { ...before, ...after }
|
||||
const name = `${data.firstName || ''} ${data.lastName || ''}`.trim()
|
||||
return name || null
|
||||
}
|
||||
@@ -236,6 +243,8 @@ const ContributionPanel = ({
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [inviteLocalError, setInviteLocalError] = useState('')
|
||||
const [copyFeedback, setCopyFeedback] = useState('')
|
||||
// Le message d'invitation reste replié : une ligne, le texte ne s'ouvre que sur demande
|
||||
const [showInviteMessage, setShowInviteMessage] = useState(false)
|
||||
|
||||
// Load persisted decisions from localStorage
|
||||
useEffect(() => {
|
||||
@@ -364,7 +373,6 @@ const ContributionPanel = ({
|
||||
aria-labelledby="contribPanelTitle"
|
||||
>
|
||||
<div className="pz-modal-head">
|
||||
<p className="pz-eyebrow">{t('Partager et relire')}</p>
|
||||
<h1 id="contribPanelTitle" className="pz-title">{familyTitleBefore}<em>{t('participe')}</em>{familyTitleAfter}</h1>
|
||||
</div>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} aria-label={t('Fermer')}>
|
||||
@@ -410,11 +418,18 @@ const ContributionPanel = ({
|
||||
</div>
|
||||
{defaultInviteText && (
|
||||
<div className="contrib-invite-message">
|
||||
<label className="contrib-invite-title" htmlFor="contrib-invite-text">{t('Le message à envoyer')}</label>
|
||||
<textarea id="contrib-invite-text" className="contrib-invite-textarea" rows={9} value={inviteText} onChange={(e) => setInviteText(e.target.value)} />
|
||||
<div className="contrib-invite-message-actions">
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={() => handleCopy(inviteText, t('Message copié'))}><Copy size={14} strokeWidth={2} />{t('Copier le message')}</button>
|
||||
<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-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>
|
||||
</div>
|
||||
{showInviteMessage && (
|
||||
<>
|
||||
<label className="contrib-invite-message-label" htmlFor="contrib-invite-text">{t('Le message à envoyer')}</label>
|
||||
<textarea id="contrib-invite-text" className="contrib-invite-textarea" rows={6} value={inviteText} onChange={(e) => setInviteText(e.target.value)} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(inviteLocalError || inviteError) && <div className="contrib-error">{inviteLocalError || inviteError}</div>}
|
||||
@@ -424,7 +439,7 @@ const ContributionPanel = ({
|
||||
{/* ── En-tête sessions ── */}
|
||||
<div className="contrib-list-header">
|
||||
<div className="contrib-list-heading">
|
||||
<h2>{t('Propositions à relire')}</h2>
|
||||
<h2 id="contrib-propositions">{t('Propositions à relire')}</h2>
|
||||
<p>{t('Acceptez ou refusez chaque modification, puis appliquez vos choix.')}</p>
|
||||
</div>
|
||||
<div className="contrib-list-header-actions">
|
||||
@@ -604,12 +619,12 @@ const ContributionPanel = ({
|
||||
<span className="contrib-field-label">{label}</span>
|
||||
{oldVal !== undefined ? (
|
||||
<span className="contrib-field-diff">
|
||||
<span className="contrib-field-old">{displayVal(oldVal) ?? t('vide')}</span>
|
||||
<span className="contrib-field-old">{displayVal(oldVal, key) ?? t('vide')}</span>
|
||||
<span className="contrib-field-arrow">→</span>
|
||||
<span className="contrib-field-new">{displayVal(newVal) ?? t('vide')}</span>
|
||||
<span className="contrib-field-new">{displayVal(newVal, key) ?? t('vide')}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="contrib-field-new">{displayVal(newVal) ?? t('vide')}</span>
|
||||
<span className="contrib-field-new">{displayVal(newVal, key) ?? t('vide')}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,261 +0,0 @@
|
||||
/* ============================================================
|
||||
CreateMyTreeButton — Pill + Panel morph, bas-droite
|
||||
Même langage visuel que la ContextualNavbar
|
||||
============================================================ */
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Pill bouton (état fermé) — même aesthetic que la navbar
|
||||
---------------------------------------------------------- */
|
||||
.cmt-pill {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
z-index: 1001;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 13px 22px;
|
||||
border: 1px solid rgba(42, 38, 34, 0.1);
|
||||
border-radius: 50px;
|
||||
background: rgba(242, 239, 234, 0.97);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
box-shadow:
|
||||
0 4px 40px rgba(42, 38, 34, 0.22),
|
||||
0 1px 6px rgba(42, 38, 34, 0.1);
|
||||
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2A2622;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
animation: cmtPillIn 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition: box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes cmtPillIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.cmt-pill:hover {
|
||||
background: rgba(242, 239, 234, 1);
|
||||
box-shadow:
|
||||
0 8px 48px rgba(42, 38, 34, 0.28),
|
||||
0 2px 8px rgba(42, 38, 34, 0.12);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cmt-pill:active {
|
||||
transform: translateY(0) scale(0.97);
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Panel (état ouvert) — card qui morphe depuis la pill
|
||||
---------------------------------------------------------- */
|
||||
.cmt-panel {
|
||||
position: fixed;
|
||||
bottom: 100px;
|
||||
right: 20px;
|
||||
z-index: 1001;
|
||||
|
||||
width: 300px;
|
||||
background: rgba(255, 255, 255, 0.97);
|
||||
backdrop-filter: blur(28px);
|
||||
-webkit-backdrop-filter: blur(28px);
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(147, 64, 42, 0.18);
|
||||
box-shadow:
|
||||
0 12px 48px rgba(42, 38, 34, 0.22),
|
||||
0 2px 10px rgba(42, 38, 34, 0.1);
|
||||
|
||||
transform-origin: bottom right;
|
||||
animation: cmtMorphIn 0.38s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes cmtMorphIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.55) translateY(8px);
|
||||
border-radius: 50px;
|
||||
}
|
||||
60% {
|
||||
border-radius: 22px;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
border-radius: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.cmt-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px 12px;
|
||||
border-bottom: 1px solid rgba(147, 64, 42, 0.1);
|
||||
}
|
||||
|
||||
.cmt-panel-title {
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #2A2622;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.cmt-panel-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
background: rgba(42, 38, 34, 0.07);
|
||||
color: #6B645D;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.cmt-panel-close:hover {
|
||||
background: rgba(42, 38, 34, 0.13);
|
||||
color: #2A2622;
|
||||
}
|
||||
|
||||
/* Body */
|
||||
.cmt-panel-body {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.cmt-label {
|
||||
display: block;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #6B645D;
|
||||
margin-bottom: 7px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.cmt-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(147, 64, 42, 0.25);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
color: #2a2622;
|
||||
padding: 10px 13px;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.cmt-input:focus {
|
||||
border-color: #d2694a;
|
||||
box-shadow: 0 0 0 3px rgba(210, 105, 74, 0.12);
|
||||
}
|
||||
|
||||
.cmt-slug-preview {
|
||||
margin-top: 8px;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #93402A;
|
||||
}
|
||||
|
||||
.cmt-slug-preview code {
|
||||
background: rgba(147, 64, 42, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 1px 5px;
|
||||
color: #2a2622;
|
||||
}
|
||||
|
||||
.cmt-error {
|
||||
margin-top: 8px;
|
||||
background: rgba(171, 49, 49, 0.08);
|
||||
border: 1px solid rgba(171, 49, 49, 0.25);
|
||||
color: #862f2f;
|
||||
border-radius: 10px;
|
||||
padding: 9px 12px;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.cmt-btn-create {
|
||||
margin-top: 14px;
|
||||
width: 100%;
|
||||
padding: 11px 16px;
|
||||
border-radius: 12px;
|
||||
border: none;
|
||||
background: #2a2622;
|
||||
color: #ffffff;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
|
||||
}
|
||||
|
||||
.cmt-btn-create:hover:not(:disabled) {
|
||||
background: linear-gradient(135deg, #93402a, #2a2622);
|
||||
box-shadow: 0 4px 14px rgba(147, 64, 42, 0.32);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.cmt-btn-create:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.cmt-btn-create:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Mobile — bottom sheet
|
||||
---------------------------------------------------------- */
|
||||
@media (max-width: 768px) {
|
||||
.cmt-pill {
|
||||
bottom: 84px;
|
||||
right: 12px;
|
||||
font-size: 13px;
|
||||
padding: 11px 18px;
|
||||
}
|
||||
|
||||
.cmt-panel {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
border-radius: 20px 20px 0 0;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
|
||||
@keyframes cmtMorphIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.cmt-panel-body {
|
||||
padding: 16px 20px 32px;
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import './CreateMyTreeButton.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
function normalizeSlug(input) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
}
|
||||
|
||||
const CreateMyTreeButton = ({ onCreateTree, loading }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const slug = useMemo(() => normalizeSlug(name.trim()), [name])
|
||||
const canSubmit = name.trim().length >= 2 && slug.length >= 3 && !loading
|
||||
|
||||
const handleOpen = () => {
|
||||
setError('')
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
setName('')
|
||||
setError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault()
|
||||
if (!canSubmit) return
|
||||
setError('')
|
||||
const result = await onCreateTree(name.trim())
|
||||
if (result !== null && result !== undefined) {
|
||||
setError(typeof result === 'string' ? result : t('Erreur lors de la création. Essayez un autre nom.'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
<button className="cmt-pill" type="button" onClick={handleOpen}>
|
||||
{t('Créer mon arbre')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div className="cmt-panel">
|
||||
<div className="cmt-panel-header">
|
||||
<span className="cmt-panel-title">{t('Créer votre arbre dès maintenant')}</span>
|
||||
<button type="button" className="cmt-panel-close" onClick={handleClose} aria-label={t('Fermer')}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="cmt-panel-body">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<label className="cmt-label" htmlFor="firstTreeName">
|
||||
{t('Nom de la famille')}
|
||||
</label>
|
||||
<input
|
||||
id="firstTreeName"
|
||||
className="cmt-input"
|
||||
type="text"
|
||||
autoFocus
|
||||
maxLength={120}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t('Ex : Famille Martin')}
|
||||
required
|
||||
/>
|
||||
{slug && (
|
||||
<div className="cmt-slug-preview">
|
||||
{t('Adresse :')} <code>{slug}</code>
|
||||
</div>
|
||||
)}
|
||||
{error && <div className="cmt-error">{error}</div>}
|
||||
<button type="submit" className="cmt-btn-create" disabled={!canSubmit}>
|
||||
<PzBusy busy={loading} busyLabel={t("Création de l'arbre")}>{t("Créer l'arbre")}</PzBusy>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default CreateMyTreeButton
|
||||
@@ -1,11 +1,14 @@
|
||||
import { useState } from 'react'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { dateToInputText, parseDateInput } from '../utils/dateInput.js'
|
||||
import { dateToInputText, formatDigitsAsDate, parseDateInput } from '../utils/dateInput.js'
|
||||
|
||||
// Champ de date de la fiche : une année seule ou une date complète (issue #77). Le texte tapé reste tel
|
||||
// quel ; la valeur ne remonte qu'une fois comprise, sinon le champ le signale sans rien enregistrer.
|
||||
// Le clavier numérique du téléphone n'a pas de barre oblique : au-delà de 4 chiffres, les barres se posent
|
||||
// toutes seules (12031950 → 12/03/1950). Le message n'apparaît qu'en quittant le champ.
|
||||
export default function DateField({ id, value, onChange, placeholder, className = '' }) {
|
||||
const [text, setText] = useState(() => dateToInputText(value))
|
||||
const [touched, setTouched] = useState(false)
|
||||
const invalid = parseDateInput(text) === undefined
|
||||
|
||||
return (
|
||||
@@ -17,14 +20,23 @@ export default function DateField({ id, value, onChange, placeholder, className
|
||||
className={className}
|
||||
value={text}
|
||||
placeholder={placeholder || t('Année ou date')}
|
||||
aria-invalid={invalid || undefined}
|
||||
aria-invalid={(touched && invalid) || undefined}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value)
|
||||
const parsed = parseDateInput(e.target.value)
|
||||
if (parsed !== undefined) onChange(parsed)
|
||||
const next = formatDigitsAsDate(e.target.value)
|
||||
setText(next)
|
||||
const parsed = parseDateInput(next)
|
||||
if (parsed !== undefined) {
|
||||
setTouched(false)
|
||||
onChange(parsed)
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = parseDateInput(text)
|
||||
if (parsed === undefined) setTouched(true)
|
||||
else setText(dateToInputText(parsed))
|
||||
}}
|
||||
/>
|
||||
{invalid && <span className="pz-date-hint" role="status">{t('Une année (1950) ou une date (12/03/1950)')}</span>}
|
||||
{touched && invalid && <span className="pz-date-hint" role="status">{t('Une année (1950) ou une date (12/03/1950)')}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -48,9 +48,20 @@
|
||||
.edit-mode-submit { flex: none; }
|
||||
.edit-mode-toast-close { width: 36px; min-height: 36px; flex: none; }
|
||||
|
||||
/* Invité : la phrase complète sur ordinateur, « Vous proposez » sur téléphone, toujours sur une ligne */
|
||||
.edit-mode-toast-short { display: none; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.edit-mode-toast { top: 68px; max-width: calc(100vw - 24px); }
|
||||
.edit-mode-toast-text { font-size: 13px; }
|
||||
.edit-mode-toast--contrib .edit-mode-toast-long { display: none; }
|
||||
.edit-mode-toast--contrib .edit-mode-toast-short { display: inline; }
|
||||
}
|
||||
|
||||
/* 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) {
|
||||
.edit-mode-toast--with-submit:not(.edit-mode-toast--contrib) .edit-mode-toast-text { display: none; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMemo } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import './EditModeOverlay.css'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
function EditModeOverlay({ active, hasDraft, role = 'contributor', onDeactivate, onSubmit }) {
|
||||
const isAdmin = role === 'admin'
|
||||
|
||||
const borderClass = useMemo(() => (
|
||||
`edit-mode-border ${isAdmin ? 'edit-mode-border--admin' : 'edit-mode-border--contributor'}`
|
||||
), [isAdmin])
|
||||
|
||||
const toastLabel = isAdmin ? t('Mode édition actif') : t('Mode contribution actif')
|
||||
const submitLabel = isAdmin ? t("Mettre à jour l'arbre") : t('Envoyer les modifications')
|
||||
|
||||
if (!active) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={borderClass} aria-hidden="true" />
|
||||
|
||||
<div className={`edit-mode-toast ${isAdmin ? 'edit-mode-toast--admin' : ''}`} role="status" aria-live="polite">
|
||||
<span className="edit-mode-toast-text">{toastLabel}</span>
|
||||
|
||||
{onSubmit && (
|
||||
<button
|
||||
type="button"
|
||||
className={`edit-mode-submit ${isAdmin ? 'edit-mode-submit--admin' : ''}`}
|
||||
onClick={onSubmit}
|
||||
disabled={!hasDraft}
|
||||
title={hasDraft ? submitLabel : t('Aucune modification à soumettre')}
|
||||
>
|
||||
{submitLabel}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="edit-mode-toast-close"
|
||||
onClick={onDeactivate}
|
||||
aria-label={t('Quitter le mode édition')}
|
||||
>
|
||||
<X size={16} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditModeOverlay
|
||||
@@ -11,8 +11,9 @@ function EditSessionBar({ active, hasDraft, role = 'contributor', onDeactivate,
|
||||
), [isAdmin])
|
||||
|
||||
// Libellés courts : ils doivent tenir sur un téléphone à côté du bouton
|
||||
const toastLabel = isAdmin ? t("Vous modifiez l'arbre") : t('Vos propositions')
|
||||
const submitLabel = isAdmin ? t("Mettre à jour l'arbre") : t('Envoyer mes ajouts')
|
||||
// Invité : « Vous proposez » sur téléphone (une seule ligne), la phrase complète sur ordinateur
|
||||
const toastLabel = isAdmin ? t("Vous modifiez l'arbre") : null
|
||||
const submitLabel = isAdmin ? t("Mettre à jour l'arbre") : t('Envoyer')
|
||||
// La famille ne voit le bouton d'envoi que lorsqu'il y a quelque chose à envoyer
|
||||
const showSubmit = Boolean(onSubmit) && (isAdmin || hasDraft)
|
||||
|
||||
@@ -22,9 +23,16 @@ function EditSessionBar({ active, hasDraft, role = 'contributor', onDeactivate,
|
||||
<>
|
||||
<div className={borderClass} aria-hidden="true" />
|
||||
|
||||
<div className={`edit-mode-toast ${isAdmin ? 'edit-mode-toast--admin' : ''}`} role="status" aria-live="polite">
|
||||
<div className={`edit-mode-toast${isAdmin ? ' edit-mode-toast--admin' : ' edit-mode-toast--contrib'}${showSubmit ? ' edit-mode-toast--with-submit' : ''}`} role="status" aria-live="polite">
|
||||
<span className="edit-mode-toast-dot" aria-hidden="true" />
|
||||
<span className="edit-mode-toast-text">{toastLabel}</span>
|
||||
{isAdmin ? (
|
||||
<span className="edit-mode-toast-text">{toastLabel}</span>
|
||||
) : (
|
||||
<span className="edit-mode-toast-text">
|
||||
<span className="edit-mode-toast-long">{t('Vous proposez des changements')}</span>
|
||||
<span className="edit-mode-toast-short">{t('Vous proposez')}</span>
|
||||
</span>
|
||||
)}
|
||||
|
||||
{showSubmit && (
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import logoUrl from '../assets/parenthese-logo.svg?url'
|
||||
import './AccountScreens.css'
|
||||
import LanguageSwitch from './navbar/LanguageSwitch'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
// Accueil sans session (variante B validée le 18/10) : deux choix, rien d'autre au premier regard.
|
||||
const EntryChoice = ({ onFamilyLink, onLogin, onRegister }) => (
|
||||
<div className="entry-choice pz-screen">
|
||||
<div className="pz-screen-inner">
|
||||
<img src={logoUrl} alt="Parenthèse" className="pz-screen-logo" />
|
||||
|
||||
<h1 className="pz-title entry-choice-title">{t('Que voulez-vous faire ?')}</h1>
|
||||
|
||||
<div className="entry-choice-card pz-card">
|
||||
<h2 className="entry-choice-name">{t("J'ai reçu un lien")}</h2>
|
||||
<p className="pz-sub">{t("Pas besoin de compte pour regarder l'arbre.")}</p>
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={onFamilyLink}>
|
||||
{t("J'ai reçu un lien de famille")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="entry-choice-card pz-card">
|
||||
<h2 className="entry-choice-name">{t("J'ai déjà un compte")}</h2>
|
||||
<p className="pz-sub">{t('Pour retrouver vos arbres et ajouter des souvenirs.')}</p>
|
||||
<button type="button" className="pz-btn pz-btn--line pz-btn--block" onClick={onLogin}>
|
||||
{t('Se connecter')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Absent quand l'instance n'accepte plus de compte (ALLOW_REGISTRATION=false) */}
|
||||
{onRegister && (
|
||||
<div className="pz-account-foot">
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onRegister}>
|
||||
{t('Créer mon arbre')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LanguageSwitch className="pz-lang--screen" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
export default EntryChoice
|
||||
@@ -43,3 +43,71 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Onglets de familles : un onglet par famille, en haut à gauche
|
||||
============================================================ */
|
||||
|
||||
.family-tabs {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 500;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
max-width: calc(100vw - 120px);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: none;
|
||||
padding: 4px;
|
||||
}
|
||||
.family-tabs::-webkit-scrollbar { display: none; }
|
||||
/* Bords coupés : un fondu de chaque côté qui défile encore */
|
||||
.family-tabs.has-left { mask-image: linear-gradient(to right, transparent, #000 36px); -webkit-mask-image: linear-gradient(to right, transparent, #000 36px); }
|
||||
.family-tabs.has-right { mask-image: linear-gradient(to left, transparent, #000 36px); -webkit-mask-image: linear-gradient(to left, transparent, #000 36px); }
|
||||
.family-tabs.has-left.has-right { mask-image: linear-gradient(to right, transparent, #000 36px, #000 calc(100% - 36px), transparent); -webkit-mask-image: linear-gradient(to right, transparent, #000 36px, #000 calc(100% - 36px), transparent); }
|
||||
|
||||
.family-tab {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 7px;
|
||||
padding: 7px 14px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid #EBE6DF;
|
||||
background: #FFFFFF;
|
||||
color: #2A2622;
|
||||
font-family: "Newsreader", Georgia, serif;
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.family-tab:hover { border-color: #D9CFC6; }
|
||||
.family-tab:focus-visible { outline: 2px solid #C9553D; outline-offset: 2px; }
|
||||
.family-tab.is-active { background: #2A2622; border-color: #2A2622; color: #FFFFFF; }
|
||||
.family-tab-count {
|
||||
font-family: "DM Sans", system-ui, sans-serif;
|
||||
font-size: 11px;
|
||||
color: #93402A;
|
||||
}
|
||||
.family-tab.is-active .family-tab-count { color: #EDB6A4; }
|
||||
|
||||
/* En mode modification, le message « Vous proposez des changements » occupe le haut au centre : la barre
|
||||
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 */
|
||||
@media (min-width: 769px) {
|
||||
body:has(.person-card) .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); }
|
||||
}
|
||||
|
||||
@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); }
|
||||
/* 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; }
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as g
|
||||
import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_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 { easeOutCubic } from './galaxy/utils'
|
||||
import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout'
|
||||
import { computeBlockLayout } from './galaxy/blockLayout'
|
||||
import { computeBlockLayout, familyTabsOf, subgraphOf } from './galaxy/blockLayout'
|
||||
|
||||
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
|
||||
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
|
||||
@@ -13,6 +13,7 @@ import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSe
|
||||
import { annotations as annotationData } from '../data/mockData'
|
||||
import { useImageCache } from './galaxy/useImageCache'
|
||||
import { useGalaxyInteractions } from './galaxy/useGalaxyInteractions'
|
||||
import { t as tr } from '../i18n/index.js'
|
||||
import './Galaxy.css'
|
||||
|
||||
// Assez bas pour cadrer un grand arbre entier à l'ouverture
|
||||
@@ -26,6 +27,7 @@ function approach(prev, target, k, eps = 1e-3) {
|
||||
return Math.abs(target - next) < eps ? target : next
|
||||
}
|
||||
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
|
||||
const FOCUS_SCALE = 0.95
|
||||
@@ -40,16 +42,44 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
|
||||
const centerX = (node) => node.x + node.width / 2
|
||||
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, photoHint = false, reorderEnabled = false, onReorderSiblings = null }) => {
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, photoHint = false, onPhotoPlaceholderClick = null, reorderEnabled = false, onReorderSiblings = null, familyTabs = true }) => {
|
||||
const canvasRef = useRef(null)
|
||||
// Lu au calcul du cadrage seulement : changer de personne de départ ne recadre pas une vue en cours
|
||||
const layoutDataRef = useRef(null)
|
||||
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
|
||||
// version du graphe et par personne de départ, et l'onglet ouvert. Un onglet est le sous-arbre d'une
|
||||
// grande belle-famille, posé avec le conjoint charnière comme référence.
|
||||
const [tabs, setTabs] = useState([])
|
||||
const [activeKey, setActiveKey] = useState(focusPersonId != null ? String(focusPersonId) : null)
|
||||
const tabsCacheRef = useRef({ rev: -1, start: undefined, tabs: [] })
|
||||
const pendingFocusRef = useRef(null) // personne cherchée dans un autre onglet, visée après le changement
|
||||
const activeTabRef = useRef(null)
|
||||
const familyTabsRef = useRef(familyTabs)
|
||||
useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs])
|
||||
const startRefRef = useRef(focusPersonId)
|
||||
useEffect(() => { startRefRef.current = focusPersonId; setActiveKey(focusPersonId != null ? String(focusPersonId) : null) }, [focusPersonId])
|
||||
// Lu au calcul du cadrage et par le réordonnancement : la référence de l'arbre affiché
|
||||
const focusPersonIdRef = useRef(focusPersonId)
|
||||
// Mis à jour hors du rendu, avant l'effet de cadrage déclaré plus bas (même phase, ordre de déclaration)
|
||||
useEffect(() => { focusPersonIdRef.current = focusPersonId }, [focusPersonId])
|
||||
// La barre défile quand les onglets débordent : l'onglet ouvert se met en vue (sans animation) et les bords
|
||||
// coupés se signalent par un fondu
|
||||
const tabsBarRef = useRef(null)
|
||||
const [tabsEdges, setTabsEdges] = useState('')
|
||||
const updateTabsEdges = useCallback(() => {
|
||||
const el = tabsBarRef.current
|
||||
if (!el) return
|
||||
const left = el.scrollLeft > 12, right = el.scrollLeft + el.clientWidth < el.scrollWidth - 12
|
||||
setTabsEdges(`${left ? ' has-left' : ''}${right ? ' has-right' : ''}`)
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
document.querySelector('.family-tab.is-active')?.scrollIntoView({ behavior: 'auto', inline: 'nearest', block: 'nearest' })
|
||||
updateTabsEdges()
|
||||
window.addEventListener('resize', updateTabsEdges)
|
||||
return () => window.removeEventListener('resize', updateTabsEdges)
|
||||
}, [activeKey, tabs, updateTabsEdges])
|
||||
// Clic sur un badge : l'onglet de cette famille (ou, depuis un onglet, celui d'où l'on vient)
|
||||
const openFamily = useCallback((tabKey) => { if (tabKey != null) setActiveKey(String(tabKey)) }, [])
|
||||
// Propriétaire : un portrait vide survolé montre « + photo »
|
||||
const photoHintRef = useRef(photoHint)
|
||||
useEffect(() => { photoHintRef.current = photoHint }, [photoHint])
|
||||
const layoutDataRef = useRef(null)
|
||||
const [layoutReady, setLayoutReady] = useState(false)
|
||||
const transformRef = useRef({ x: 0, y: 0, scale: 1 })
|
||||
|
||||
@@ -70,7 +100,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const selectedPersonIdRef = useRef(null)
|
||||
const selectedGlowScales = useRef(new Map())
|
||||
const searchGlowScales = useRef(new Map())
|
||||
const searchHighlightIdsRef = useRef([])
|
||||
const searchHighlightSetRef = useRef(new Set())
|
||||
const immediateRelativesRef = useRef(new Set())
|
||||
const pathEdgesRef = useRef([])
|
||||
const pathNodesRef = useRef([])
|
||||
@@ -139,7 +169,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
canvasRef, transformRef, targetTransformRef,
|
||||
layoutDataRef, nodeRandomDataRef, entranceRef,
|
||||
hoveredNodeIdRef: hoveredNodeId, hoveredMediaKeyRef: hoveredMediaKey, mouseWorldPosRef: mouseWorldPos,
|
||||
getAnimatedPos, onPersonSelect, onMediaSelect,
|
||||
getAnimatedPos, onPersonSelect, onMediaSelect, onPhotoPlaceholderClick, onOpenFamily: openFamily,
|
||||
annotationHandlers,
|
||||
minScale: MIN_SCALE,
|
||||
maxScale: MAX_SCALE,
|
||||
@@ -184,12 +214,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
|
||||
// Placement complet pour un ordre de la fratrie, recalé sur la source (les parents ne bougent pas)
|
||||
const layoutForOrder = useCallback((group, order, base) => {
|
||||
const tab = activeTabRef.current
|
||||
const src = tab?.personIds ? subgraphOf(tab.personIds) : { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }
|
||||
const graph = {
|
||||
persons: graphPersons,
|
||||
unions: graphUnions,
|
||||
filiations: filiationsWithOrder(graphFiliations, graphUnions, group.source, order),
|
||||
persons: src.persons,
|
||||
unions: src.unions,
|
||||
filiations: filiationsWithOrder(src.filiations, src.unions, group.source, order),
|
||||
}
|
||||
const result = computeBlockLayout(graph)
|
||||
const result = computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs: familyTabsRef.current })
|
||||
const baseSource = base.children.find((n) => n.id === group.source)
|
||||
const source = result.children.find((n) => n.id === group.source)
|
||||
const shift = baseSource && source ? baseSource.x - source.x : 0
|
||||
@@ -375,6 +407,14 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
focusPerson: (personId) => {
|
||||
const canvas = canvasRef.current
|
||||
const node = layoutDataRef.current?.children.find(n => n.id === `p-${personId}`)
|
||||
// Rangée dans un autre onglet : on ouvre cet onglet, la personne est visée une fois l'arbre posé
|
||||
if (!node && familyTabsRef.current) {
|
||||
const id = String(personId)
|
||||
const all = tabsCacheRef.current.tabs
|
||||
const home = all.find((tb) => tb.personIds?.has(id) && !(tb.hinges || []).map(String).includes(id)) || all.find((tb) => tb.personIds?.has(id)) || all[0]
|
||||
if (home && home.key !== activeTabRef.current?.key) { pendingFocusRef.current = id; setActiveKey(home.key) }
|
||||
return
|
||||
}
|
||||
if (!canvas || !node) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const pos = getAnimatedPos(node, performance.now())
|
||||
@@ -428,7 +468,35 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
|
||||
// Layout
|
||||
useEffect(() => {
|
||||
const result = USE_BLOCK_LAYOUT ? computeBlockLayout() : computeLayout()
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
if (!familyTabs && tabsCacheRef.current.tabs.length) { tabsCacheRef.current = { rev: -1, start: undefined, tabs: [] }; setTabs([]) }
|
||||
const tab = familyTabs ? (tabsCacheRef.current.tabs.find((tb) => tb.key === activeKey) || tabsCacheRef.current.tabs[0] || null) : null
|
||||
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
|
||||
const graph = tab?.personIds ? subgraphOf(tab.personIds) : null
|
||||
const result = USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current)
|
||||
// Les pastilles portent le nom de l'onglet où elles mènent (prénoms ajoutés aux homonymes)
|
||||
for (const n of result.children) {
|
||||
const target = n._familyTab && tabsCacheRef.current.tabs.find((tb) => tb.key === n._familyTab.tabKey)
|
||||
if (target) n._familyTab = { ...n._familyTab, label: target.label, count: target.count }
|
||||
}
|
||||
// Dans l'onglet d'une belle-famille, chaque charnière (conjoint marié dans la famille d'où l'on vient)
|
||||
// porte le badge de retour
|
||||
if (tab?.parentKey) {
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
@@ -475,7 +543,8 @@ 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
|
||||
offsetY = cssH * 0.42 - (focusNode.y + focusNode.height / 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
|
||||
}
|
||||
|
||||
const init = { x: offsetX, y: offsetY, scale }
|
||||
@@ -558,7 +627,12 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
layoutDataRef.current = result
|
||||
needsRedrawRef.current = true
|
||||
setLayoutReady(true)
|
||||
}, [graphRevision])
|
||||
if (pendingFocusRef.current) {
|
||||
const id = pendingFocusRef.current
|
||||
pendingFocusRef.current = null
|
||||
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
|
||||
}
|
||||
}, [graphRevision, activeKey, familyTabs])
|
||||
|
||||
// Sync selectedPersonId
|
||||
useEffect(() => {
|
||||
@@ -567,7 +641,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
|
||||
// Sync searchHighlightIds
|
||||
useEffect(() => {
|
||||
searchHighlightIdsRef.current = searchHighlightIds
|
||||
searchHighlightSetRef.current = new Set(searchHighlightIds.map(id => `p-${id}`))
|
||||
}, [searchHighlightIds])
|
||||
|
||||
// Recalculer les proches immédiats
|
||||
@@ -646,15 +720,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (!layoutData || !canvas) { rafId = requestAnimationFrame(animate); return }
|
||||
const { x, y, scale } = transformRef.current
|
||||
|
||||
// Positions animées
|
||||
const posMap = new Map()
|
||||
layoutData.children.forEach(node => {
|
||||
posMap.set(node.id, getAnimatedPos(node, timestamp))
|
||||
})
|
||||
|
||||
const nodeMap = new Map()
|
||||
layoutData.children.forEach(n => nodeMap.set(n.id, n))
|
||||
|
||||
// Lissage hover states
|
||||
const hoverEasing = 0.12
|
||||
layoutData.children.forEach(node => {
|
||||
@@ -667,11 +732,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const pMedias = getPersonMedias(node._data.id)
|
||||
const orbitSlots = rd?.orbitSlots || []
|
||||
const orbitCount = Math.min(pMedias.length, MAX_ORBIT_MEDIAS)
|
||||
const pos = posMap.get(node.id)
|
||||
for (let i = 0; i < orbitCount; i++) {
|
||||
const key = `${node.id}:${i}`
|
||||
const slot = orbitSlots[i]
|
||||
if (!slot || !pos) continue
|
||||
if (!slot) continue
|
||||
const isHovered = hoveredMediaKey.current === key
|
||||
const prevMh = mediaHoverState.current.get(key) || { scale: 0, dx: 0, dy: 0 }
|
||||
const dirX = Math.cos(slot.angle)
|
||||
@@ -717,7 +781,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
|
||||
// Animation search highlight glow
|
||||
const searchEasing = 0.12
|
||||
const searchHighlightSet = new Set(searchHighlightIdsRef.current.map(id => `p-${id}`))
|
||||
const searchHighlightSet = searchHighlightSetRef.current
|
||||
layoutData.children.forEach(node => {
|
||||
if (node._type !== 'person') return
|
||||
const targetSearchGlow = searchHighlightSet.has(node.id) ? 1.2 : 0
|
||||
@@ -734,7 +798,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
dirty = true
|
||||
lastPathInputRef.current = { selected: selectedElkId, hovered: currentHovered }
|
||||
if (selectedElkId && currentHovered && currentHovered !== selectedElkId && currentHovered.startsWith('p-')) {
|
||||
const result = findPathBFS(selectedElkId, currentHovered, layoutData)
|
||||
const result = findPathBFS(selectedElkId, currentHovered, layoutData, graphPersons)
|
||||
pathNodesRef.current = result ? result.nodePath : []
|
||||
pathEdgesRef.current = result ? result.edgePath : []
|
||||
pathLabelRef.current = result ? result.label : ''
|
||||
@@ -752,8 +816,6 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const PATH_EDGE_DURATION = 300
|
||||
const pathElapsed = pathStartTime.current > 0 ? timestamp - pathStartTime.current : -1
|
||||
const pathEasing = 0.12
|
||||
const activePathEdges = new Set(pathEdgesRef.current.map(e => e.id))
|
||||
const activePathNodes = new Set(pathNodesRef.current)
|
||||
|
||||
for (let i = 0; i < pathEdgesRef.current.length; i++) {
|
||||
const e = pathEdgesRef.current[i]
|
||||
@@ -771,12 +833,17 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (pathProgressRef.current.get(n) !== staggeredTarget) { dirty = true; pathProgressRef.current.set(n, staggeredTarget) }
|
||||
}
|
||||
|
||||
for (const [key, val] of pathProgressRef.current) {
|
||||
if (!activePathEdges.has(key) && !activePathNodes.has(key)) {
|
||||
dirty = true
|
||||
const next = val + (0 - val) * pathEasing
|
||||
if (Math.abs(next) < 0.001) pathProgressRef.current.delete(key)
|
||||
else pathProgressRef.current.set(key, next)
|
||||
// Au repos, rien à éteindre : pas d'ensembles reconstruits à chaque frame
|
||||
if (pathProgressRef.current.size > 0) {
|
||||
const activePathEdges = new Set(pathEdgesRef.current.map(e => e.id))
|
||||
const activePathNodes = new Set(pathNodesRef.current)
|
||||
for (const [key, val] of pathProgressRef.current) {
|
||||
if (!activePathEdges.has(key) && !activePathNodes.has(key)) {
|
||||
dirty = true
|
||||
const next = val + (0 - val) * pathEasing
|
||||
if (Math.abs(next) < 0.001) pathProgressRef.current.delete(key)
|
||||
else pathProgressRef.current.set(key, next)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -796,6 +863,15 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
// Rien n'a changé : on ne redessine pas
|
||||
if (!dirty) { rafId = requestAnimationFrame(animate); return }
|
||||
|
||||
// Positions animées, calculées seulement quand on redessine
|
||||
const posMap = new Map()
|
||||
layoutData.children.forEach(node => {
|
||||
posMap.set(node.id, getAnimatedPos(node, timestamp))
|
||||
})
|
||||
|
||||
const nodeMap = new Map()
|
||||
layoutData.children.forEach(n => nodeMap.set(n.id, n))
|
||||
|
||||
// === RENDU ===
|
||||
const ctx = canvas.getContext('2d')
|
||||
ctx.setTransform(dprRef.current, 0, 0, dprRef.current, 0, 0)
|
||||
@@ -1032,6 +1108,25 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
{displayLabel && (
|
||||
<div className="path-label-overlay">{displayLabel}</div>
|
||||
)}
|
||||
{familyTabs && tabs.length > 1 && (
|
||||
<nav ref={tabsBarRef} onScroll={updateTabsEdges} className={`family-tabs${annotationHandlers ? ' family-tabs--lowered' : ''}${tabsEdges}`} aria-label={tr('Familles')}>
|
||||
{tabs.map((tb) => {
|
||||
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' : ''}`}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={() => setActiveKey(tb.key)}
|
||||
>
|
||||
<span className="family-tab-name">{tb.label ? tr('Famille {name}', { name: tb.label }) : tr('Famille')}</span>
|
||||
<span className="family-tab-count">{tb.count}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
const EMPTY_FORM = {
|
||||
selfFirstName: '',
|
||||
selfLastName: '',
|
||||
selfBirthYear: '',
|
||||
parent1FirstName: '',
|
||||
parent1LastName: '',
|
||||
parent2FirstName: '',
|
||||
parent2LastName: '',
|
||||
}
|
||||
|
||||
function toDateFromYear(yearText) {
|
||||
const year = Number.parseInt(yearText, 10)
|
||||
if (!Number.isFinite(year) || year < 1000 || year > 2999) {
|
||||
return null
|
||||
}
|
||||
|
||||
return `${year.toString().padStart(4, '0')}-01-01`
|
||||
}
|
||||
|
||||
const InitialTreeWizard = ({ loading, errorMessage, onSubmit, onSkip }) => {
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
|
||||
const canSubmit = useMemo(() => {
|
||||
return (
|
||||
form.selfFirstName.trim().length > 0 &&
|
||||
form.selfLastName.trim().length > 0 &&
|
||||
form.parent1FirstName.trim().length > 0 &&
|
||||
form.parent1LastName.trim().length > 0 &&
|
||||
form.parent2FirstName.trim().length > 0 &&
|
||||
form.parent2LastName.trim().length > 0 &&
|
||||
!loading
|
||||
)
|
||||
}, [form, loading])
|
||||
|
||||
const updateField = (key, value) => {
|
||||
setForm((current) => ({ ...current, [key]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
if (!canSubmit) return
|
||||
|
||||
await onSubmit({
|
||||
self: {
|
||||
firstName: form.selfFirstName.trim(),
|
||||
lastName: form.selfLastName.trim(),
|
||||
birthDate: toDateFromYear(form.selfBirthYear.trim()),
|
||||
},
|
||||
parent1: {
|
||||
firstName: form.parent1FirstName.trim(),
|
||||
lastName: form.parent1LastName.trim(),
|
||||
},
|
||||
parent2: {
|
||||
firstName: form.parent2FirstName.trim(),
|
||||
lastName: form.parent2LastName.trim(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="wizard-overlay">
|
||||
<form className="wizard-card" onSubmit={handleSubmit}>
|
||||
|
||||
<div className="wizard-persons">
|
||||
|
||||
<div className="wizard-person">
|
||||
<div className="wizard-person-label wizard-person-label--self">{t('Vous')}</div>
|
||||
<div className="wizard-person-fields">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Prénom')}
|
||||
value={form.selfFirstName}
|
||||
onChange={(e) => updateField('selfFirstName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Nom')}
|
||||
value={form.selfLastName}
|
||||
onChange={(e) => updateField('selfLastName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
className="wizard-year"
|
||||
type="number"
|
||||
placeholder={t('Année de naissance (optionnel)')}
|
||||
min="1000"
|
||||
max="2999"
|
||||
value={form.selfBirthYear}
|
||||
onChange={(e) => updateField('selfBirthYear', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wizard-person">
|
||||
<div className="wizard-person-label">{t('Parent 1')}</div>
|
||||
<div className="wizard-person-fields">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Prénom')}
|
||||
value={form.parent1FirstName}
|
||||
onChange={(e) => updateField('parent1FirstName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Nom')}
|
||||
value={form.parent1LastName}
|
||||
onChange={(e) => updateField('parent1LastName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-person">
|
||||
<div className="wizard-person-label">{t('Parent 2')}</div>
|
||||
<div className="wizard-person-fields">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Prénom')}
|
||||
value={form.parent2FirstName}
|
||||
onChange={(e) => updateField('parent2FirstName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('Nom')}
|
||||
value={form.parent2LastName}
|
||||
onChange={(e) => updateField('parent2LastName', e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{errorMessage && <div className="wizard-error">{errorMessage}</div>}
|
||||
|
||||
<button type="submit" disabled={!canSubmit}>
|
||||
<PzBusy busy={loading} busyLabel={t('Création en cours')}>{t('Créer les 3 personnes')}</PzBusy>
|
||||
</button>
|
||||
|
||||
<button type="button" className="ghost" onClick={onSkip} disabled={loading}>
|
||||
{t('Plus tard')}
|
||||
</button>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InitialTreeWizard
|
||||
@@ -1,262 +0,0 @@
|
||||
/* ============================================================
|
||||
Inline Person Form - Mini-formulaire après drop
|
||||
============================================================ */
|
||||
|
||||
.inline-person-form-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(42, 38, 34, 0.4);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1200;
|
||||
animation: overlayFadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
@keyframes overlayFadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-person-form {
|
||||
position: fixed;
|
||||
padding: 24px;
|
||||
background: rgba(242, 239, 234, 0.98);
|
||||
backdrop-filter: blur(32px);
|
||||
-webkit-backdrop-filter: blur(32px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(42, 38, 34, 0.12);
|
||||
|
||||
box-shadow:
|
||||
0 12px 48px rgba(42, 38, 34, 0.3),
|
||||
0 4px 16px rgba(42, 38, 34, 0.15);
|
||||
|
||||
min-width: 320px;
|
||||
max-width: 400px;
|
||||
|
||||
z-index: 1201;
|
||||
|
||||
animation: formPopIn 0.3s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes formPopIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -45%) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.inline-person-form-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid rgba(42, 38, 34, 0.1);
|
||||
}
|
||||
|
||||
.inline-person-form-title {
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: #2A2622;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.inline-person-form-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.inline-person-form-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: #6B645D;
|
||||
font-size: 20px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.inline-person-form-close:hover {
|
||||
background: rgba(42, 38, 34, 0.1);
|
||||
color: #2A2622;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* Badge lien créé */
|
||||
.inline-person-form-badge {
|
||||
display: inline-block;
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.inline-person-form-badge.child {
|
||||
background: rgba(76, 217, 100, 0.15);
|
||||
color: #4CD964;
|
||||
}
|
||||
|
||||
.inline-person-form-badge.spouse {
|
||||
background: rgba(255, 59, 48, 0.15);
|
||||
color: #FF3B30;
|
||||
}
|
||||
|
||||
.inline-person-form-badge.sibling {
|
||||
background: rgba(210, 105, 74, 0.15);
|
||||
color: #93402A;
|
||||
}
|
||||
|
||||
/* Fields */
|
||||
.inline-person-form-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.inline-person-form-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.inline-person-form-label {
|
||||
font-size: 14px;
|
||||
font-weight: 450;
|
||||
color: #2A2622;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.inline-person-form-label .required {
|
||||
color: #FF3B30;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.inline-person-form-input {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid rgba(42, 38, 34, 0.15);
|
||||
background: rgba(255, 255, 255, 0.6);
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 16px; /* ≥16px prevents iOS Safari from auto-zooming on focus */
|
||||
color: #2A2622;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.inline-person-form-input:focus {
|
||||
outline: none;
|
||||
border-color: #93402A;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
box-shadow: 0 0 0 3px rgba(147, 64, 42, 0.1);
|
||||
}
|
||||
|
||||
.inline-person-form-input::placeholder {
|
||||
color: #6B645D;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Row (2 champs côte-à-côte) */
|
||||
.inline-person-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.inline-person-form-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.inline-person-form-button {
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-size: 14px;
|
||||
font-weight: 450;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.inline-person-form-button.cancel {
|
||||
background: transparent;
|
||||
color: #6B645D;
|
||||
}
|
||||
|
||||
.inline-person-form-button.cancel:hover {
|
||||
background: rgba(42, 38, 34, 0.08);
|
||||
color: #2A2622;
|
||||
}
|
||||
|
||||
.inline-person-form-button.submit {
|
||||
background: #2a2622;
|
||||
color: #FFFFFF;
|
||||
box-shadow: 0 4px 12px rgba(147, 64, 42, 0.3);
|
||||
}
|
||||
|
||||
.inline-person-form-button.submit:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 16px rgba(147, 64, 42, 0.4);
|
||||
}
|
||||
|
||||
.inline-person-form-button.submit:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.inline-person-form-button.submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Loading state */
|
||||
.inline-person-form.loading {
|
||||
pointer-events: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* Responsive mobile */
|
||||
@media (max-width: 768px) {
|
||||
.inline-person-form {
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
min-width: auto;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
@keyframes formPopIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-45%) scale(0.9);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(-50%) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { UserPlus, X } from 'lucide-react'
|
||||
import './InlinePersonForm.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
/**
|
||||
* Mini-formulaire inline pour créer une personne après drop
|
||||
* Affiche le type de lien créé et demande les infos de base
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - Afficher le formulaire
|
||||
* @param {Object} props.position - Position d'affichage {x, y}
|
||||
* @param {string} props.linkType - Type de lien ('child', 'spouse', 'sibling', null)
|
||||
* @param {Object} props.linkedPerson - Personne liée (si linkType)
|
||||
* @param {Function} props.onSubmit - Callback soumission (formData)
|
||||
* @param {Function} props.onCancel - Callback annulation
|
||||
* @param {boolean} props.loading - État chargement
|
||||
*/
|
||||
function InlinePersonForm({
|
||||
visible,
|
||||
position,
|
||||
linkType,
|
||||
linkedPerson,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
loading = false,
|
||||
}) {
|
||||
const [formData, setFormData] = useState({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
birthDate: '',
|
||||
birthPlace: '',
|
||||
})
|
||||
|
||||
const firstInputRef = useRef(null)
|
||||
|
||||
// Reset form au show
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setFormData({
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
birthDate: '',
|
||||
birthPlace: '',
|
||||
})
|
||||
// Focus premier input
|
||||
setTimeout(() => firstInputRef.current?.focus(), 100)
|
||||
}
|
||||
}, [visible])
|
||||
|
||||
// Handle ESC
|
||||
useEffect(() => {
|
||||
if (!visible) return
|
||||
|
||||
const handleEscape = (e) => {
|
||||
if (e.key === 'Escape') onCancel?.()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
return () => document.removeEventListener('keydown', handleEscape)
|
||||
}, [visible, onCancel])
|
||||
|
||||
if (!visible) return null
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }))
|
||||
}
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
if (!formData.firstName.trim()) return
|
||||
|
||||
onSubmit?.({
|
||||
...formData,
|
||||
linkType,
|
||||
linkedPersonId: linkedPerson?.id,
|
||||
})
|
||||
}
|
||||
|
||||
const isValid = formData.firstName.trim().length > 0
|
||||
|
||||
// Labels type lien
|
||||
const linkLabels = {
|
||||
child: (name) => t('Enfant de {name}', { name }),
|
||||
spouse: (name) => t('Conjoint de {name}', { name }),
|
||||
sibling: (name) => t('Frère·Sœur de {name}', { name }),
|
||||
}
|
||||
|
||||
const linkTitle = {
|
||||
child: t('Nouvel enfant'),
|
||||
spouse: t('Nouveau conjoint'),
|
||||
sibling: t('Nouveau frère/sœur'),
|
||||
}
|
||||
|
||||
const title = linkType ? linkTitle[linkType] : t('Nouvelle personne')
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Overlay */}
|
||||
<div className="inline-person-form-overlay" onClick={onCancel} />
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
className={`inline-person-form ${loading ? 'loading' : ''}`}
|
||||
style={{
|
||||
left: `${position.x}px`,
|
||||
top: `${position.y}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="inline-person-form-header">
|
||||
<h3 className="inline-person-form-title">
|
||||
<span className="inline-person-form-icon"><UserPlus size={20} strokeWidth={2} /></span>
|
||||
{title}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-person-form-close"
|
||||
onClick={onCancel}
|
||||
aria-label={t('Fermer')}
|
||||
>
|
||||
<X size={18} strokeWidth={2} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Badge lien */}
|
||||
{linkType && linkedPerson && (
|
||||
<div className={`inline-person-form-badge ${linkType}`}>
|
||||
{linkLabels[linkType]?.([linkedPerson.person?.firstName, linkedPerson.person?.lastName].filter(Boolean).join(' '))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<div className="inline-person-form-fields">
|
||||
<div className="inline-person-form-field">
|
||||
<label className="inline-person-form-label">
|
||||
{t('Prénom')} <span className="required">*</span>
|
||||
</label>
|
||||
<input
|
||||
ref={firstInputRef}
|
||||
type="text"
|
||||
className="inline-person-form-input"
|
||||
placeholder={t('Jean')}
|
||||
value={formData.firstName}
|
||||
onChange={(e) => handleChange('firstName', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-person-form-field">
|
||||
<label className="inline-person-form-label">{t('Nom')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="inline-person-form-input"
|
||||
placeholder={t('Dupont')}
|
||||
value={formData.lastName}
|
||||
onChange={(e) => handleChange('lastName', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-person-form-row">
|
||||
<div className="inline-person-form-field">
|
||||
<label className="inline-person-form-label">{t('Date naissance')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="inline-person-form-input"
|
||||
placeholder="1990-05-15"
|
||||
value={formData.birthDate}
|
||||
onChange={(e) => handleChange('birthDate', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="inline-person-form-field">
|
||||
<label className="inline-person-form-label">{t('Lieu naissance')}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="inline-person-form-input"
|
||||
placeholder="Paris"
|
||||
value={formData.birthPlace}
|
||||
onChange={(e) => handleChange('birthPlace', e.target.value)}
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="inline-person-form-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-person-form-button cancel"
|
||||
onClick={onCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
{t('Annuler')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-person-form-button submit"
|
||||
disabled={!isValid || loading}
|
||||
>
|
||||
<PzBusy busy={loading} busyLabel={t('Création en cours')}>{t('Créer')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default InlinePersonForm
|
||||
@@ -450,3 +450,52 @@
|
||||
.mv-title { font-size: 22px; }
|
||||
.media-citation-text { font-size: 22px; }
|
||||
}
|
||||
|
||||
/* Modifier un souvenir : crayon discret, visible au survol sur ordinateur et au focus clavier,
|
||||
toujours visible sur écran tactile (pointeur grossier) */
|
||||
.mv-title-row { display: flex; align-items: flex-start; gap: 6px; min-width: 0; }
|
||||
.mv-title-row .mv-title { min-width: 0; }
|
||||
.mv-edit-btn {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
margin-top: 2px;
|
||||
padding: 0;
|
||||
border: 1px solid var(--pz-a100);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--pz-a700);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
.mv-caption:hover .mv-edit-btn,
|
||||
.mv-caption:focus-within .mv-edit-btn,
|
||||
.mv-edit-btn:focus-visible { opacity: 1; }
|
||||
.mv-edit-btn:hover { background: var(--pz-a100); }
|
||||
.mv-edit-btn:focus-visible { outline: 2px solid var(--pz-a700); outline-offset: 2px; }
|
||||
@media (hover: none) {
|
||||
.mv-edit-btn { opacity: 1; }
|
||||
}
|
||||
|
||||
.mv-edit-form { display: grid; gap: 12px; }
|
||||
.mv-edit-field { display: grid; gap: 4px; }
|
||||
.mv-edit-label { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); }
|
||||
.mv-edit-title,
|
||||
.mv-edit-quote,
|
||||
.mv-edit-select {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--pz-a100);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
color: var(--pz-ink);
|
||||
font: 400 16px/1.3 var(--pz-sans);
|
||||
}
|
||||
.mv-edit-title { font-family: var(--pz-serif); font-size: 22px; }
|
||||
.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; }
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { Pencil, X } from 'lucide-react'
|
||||
import {
|
||||
buildOpenStreetMapEmbedUrl,
|
||||
buildOpenStreetMapUrl,
|
||||
@@ -9,11 +9,27 @@ import {
|
||||
} from '../utils/geoMedia'
|
||||
import './MediaViewer.css'
|
||||
import { t } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import { eventTitle } from '../utils/lifeTimeline.js'
|
||||
import { getAccountErrorMessage } from '../utils/errorMessages.js'
|
||||
|
||||
const HTTP_URL_RE = /^https?:\/\/\S+$/i
|
||||
const MAX_TITLE_LENGTH = 280
|
||||
const MAX_CITATION_LENGTH = 2000
|
||||
|
||||
const MediaViewer = ({ media, person, onClose }) => {
|
||||
// Moment de vie qui porte ce souvenir (mediaIds des événements de la route /life)
|
||||
const findLinkedEventId = (events, mediaId) => {
|
||||
const id = String(mediaId)
|
||||
return events.find((event) => (event.mediaIds || []).map(String).includes(id))?.id ?? null
|
||||
}
|
||||
|
||||
const MediaViewer = ({ media, person, onClose, canEdit = false, onLoadLifeEvents, onSave }) => {
|
||||
const [mapState, setMapState] = useState({ loading: false, error: '', data: null })
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [lifeEvents, setLifeEvents] = useState([])
|
||||
const [draft, setDraft] = useState({ title: '', citationText: '', lifeEventId: null })
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveError, setSaveError] = useState('')
|
||||
const svgUid = useId().replace(/:/g, '')
|
||||
const mapGridId = `media-map-grid-${svgUid}`
|
||||
const mapGlowId = `media-map-glow-${svgUid}`
|
||||
@@ -22,6 +38,109 @@ const MediaViewer = ({ media, person, onClose }) => {
|
||||
if (event.target === event.currentTarget) onClose()
|
||||
}
|
||||
|
||||
const isCitation = media?.type === 'citation'
|
||||
|
||||
const startEditing = async () => {
|
||||
setSaveError('')
|
||||
const linkedInitially = 'lifeEventId' in (media || {}) ? media.lifeEventId ?? null : null
|
||||
setDraft({ title: media?.label || '', citationText: media?.label || '', lifeEventId: linkedInitially })
|
||||
setEditing(true)
|
||||
if (!onLoadLifeEvents) return
|
||||
try {
|
||||
const data = await onLoadLifeEvents()
|
||||
const events = Array.isArray(data?.events) ? data.events : []
|
||||
setLifeEvents(events)
|
||||
if (!('lifeEventId' in (media || {}))) {
|
||||
setDraft((current) => ({ ...current, lifeEventId: findLinkedEventId(events, media.id) }))
|
||||
}
|
||||
} catch {
|
||||
setSaveError(t('Impossible de charger les moments de vie.'))
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
setEditing(false)
|
||||
setSaveError('')
|
||||
}
|
||||
|
||||
const handleSubmit = async (event) => {
|
||||
event.preventDefault()
|
||||
const caption = (isCitation ? draft.citationText : draft.title).trim()
|
||||
if (!caption) {
|
||||
setSaveError(isCitation ? t('Saisissez le texte de la citation.') : t('Donnez un titre au souvenir.'))
|
||||
return
|
||||
}
|
||||
if (caption.length > (isCitation ? MAX_CITATION_LENGTH : MAX_TITLE_LENGTH)) {
|
||||
setSaveError(isCitation ? t('Le texte de la citation est trop long.') : t('Le titre est trop long.'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
await onSave({ caption, lifeEventId: draft.lifeEventId ?? null })
|
||||
setEditing(false)
|
||||
} catch (error) {
|
||||
setSaveError(getAccountErrorMessage(error))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const renderEditForm = () => (
|
||||
<form className="mv-edit-form" onSubmit={handleSubmit}>
|
||||
{isCitation ? (
|
||||
<label className="mv-edit-field">
|
||||
<span className="mv-edit-label">{t('Texte de la citation')}</span>
|
||||
<textarea
|
||||
className="mv-edit-quote"
|
||||
rows={3}
|
||||
maxLength={MAX_CITATION_LENGTH}
|
||||
value={draft.citationText}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, citationText: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<label className="mv-edit-field">
|
||||
<span className="mv-edit-label">{t('Titre')}</span>
|
||||
<input
|
||||
className="mv-edit-title"
|
||||
type="text"
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label className="mv-edit-field">
|
||||
<span className="mv-edit-label">{t('Moment de sa vie')}</span>
|
||||
<select
|
||||
className="mv-edit-select"
|
||||
value={draft.lifeEventId ?? ''}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, lifeEventId: event.target.value || null }))}
|
||||
>
|
||||
<option value="">{t('Aucun')}</option>
|
||||
{lifeEvents.map((lifeEvent) => {
|
||||
const year = lifeEvent.date ? String(lifeEvent.date).slice(0, 4) : ''
|
||||
return (
|
||||
<option key={lifeEvent.id} value={lifeEvent.id}>
|
||||
{year ? `${eventTitle(lifeEvent)} (${year})` : eventTitle(lifeEvent)}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
</label>
|
||||
{saveError && <p className="mv-edit-error" role="alert">{saveError}</p>}
|
||||
<div className="mv-edit-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={cancelEditing} disabled={saving}>
|
||||
{t('Annuler')}
|
||||
</button>
|
||||
<button type="submit" className="pz-btn pz-btn--primary" disabled={saving}>
|
||||
{t('Enregistrer')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
|
||||
const birthYearLabel = person?.birthYear ?? '-'
|
||||
const personYears = person
|
||||
? person.deathYear
|
||||
@@ -386,8 +505,22 @@ const MediaViewer = ({ media, person, onClose }) => {
|
||||
</div>
|
||||
|
||||
<div className="mv-caption">
|
||||
{mediaType !== 'citation' && media?.label && (
|
||||
<h2 className="mv-title">{media.label}</h2>
|
||||
{editing ? renderEditForm() : (
|
||||
<div className="mv-title-row">
|
||||
{mediaType !== 'citation' && media?.label && (
|
||||
<h2 className="mv-title">{media.label}</h2>
|
||||
)}
|
||||
{canEdit && onSave && (
|
||||
<button
|
||||
type="button"
|
||||
className="mv-edit-btn"
|
||||
onClick={startEditing}
|
||||
aria-label={t('Modifier le souvenir')}
|
||||
>
|
||||
<Pencil size={13} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mv-meta">
|
||||
<span className="mv-person">
|
||||
@@ -396,12 +529,12 @@ const MediaViewer = ({ media, person, onClose }) => {
|
||||
) : (
|
||||
<span className="mv-person-avatar mv-person-initial">{person?.firstName?.[0] || '?'}</span>
|
||||
)}
|
||||
<span className="mv-person-name">{person?.firstName} <em>{person?.lastName}</em></span>
|
||||
<span className="mv-person-name">{person ? firstNameLabel(person) : ''} <em>{person?.lastName}</em></span>
|
||||
{personYears && <span className="mv-person-years">{personYears}</span>}
|
||||
</span>
|
||||
<span className="pz-tag">{typeLabels[mediaType] || mediaType}</span>
|
||||
</div>
|
||||
{mediaSource && (
|
||||
{mediaSource && !editing && (
|
||||
<p className="mv-source">
|
||||
{t('Source')}{' '}
|
||||
{isMediaSourceUrl ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
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 { DESCRIBED_TYPES, eventTitle } from '../utils/lifeTimeline.js'
|
||||
import { inferMediaTypeFromMimeType } from '../utils/mediaUpload.js'
|
||||
@@ -188,7 +189,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
}
|
||||
}
|
||||
|
||||
const title = milestone ? entry.title : editing ? t('Modifier ce moment') : t('Ajouter à la vie de {name}', { name: person.firstName })
|
||||
const title = milestone ? entry.title : editing ? t('Modifier ce moment') : t('Ajouter à la vie de {name}', { name: firstNameLabel(person) })
|
||||
|
||||
return createPortal(
|
||||
<div className="pz-overlay ms-overlay" onMouseDown={(e) => { if (e.target === e.currentTarget && !busy) onClose() }}>
|
||||
@@ -272,7 +273,7 @@ export default function MomentSheet({ person, entry = null, treeId, token, media
|
||||
{pickingImmich && <ImmichAssetPicker treeId={treeId} token={token} onSelect={(file) => addFiles([file])} onClose={() => setPickingImmich(false)} />}
|
||||
<input ref={fileRef} type="file" accept={ACCEPTED_FILES} multiple hidden onChange={(e) => { addFiles(e.target.files || []); e.target.value = '' }} />
|
||||
{picking && (
|
||||
<div className="ms-pick" role="group" aria-label={t('Souvenirs de {name}', { name: person.firstName })}>
|
||||
<div className="ms-pick" role="group" aria-label={t('Souvenirs de {name}', { name: firstNameLabel(person) })}>
|
||||
{pickable.map((media) => {
|
||||
const on = linkedIds.includes(String(media.id))
|
||||
return (
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
.pc-hidden-input { display: none; }
|
||||
|
||||
/* ── En-tête ── */
|
||||
.pc-head { display: flex; gap: 16px; align-items: flex-start; padding-right: 36px; }
|
||||
.pc-photo-wrap { display: grid; gap: 8px; justify-items: center; flex: none; }
|
||||
.pc-head { --pc-name-h: 42px; --pc-name-gap: 6px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 12px 16px; align-items: start; padding-right: 36px; }
|
||||
.pc-photo-wrap { position: relative; display: grid; gap: 8px; justify-items: center; flex: none; }
|
||||
.pc-photo {
|
||||
width: 84px; height: 84px; border-radius: 24%; overflow: hidden;
|
||||
position: relative; width: 84px; height: 84px; border-radius: 24%; overflow: hidden;
|
||||
background: var(--pz-a100); box-shadow: 0 0 0 1px rgba(42, 38, 34, .06), var(--pz-shadow);
|
||||
display: grid; place-items: center;
|
||||
}
|
||||
@@ -54,21 +54,36 @@
|
||||
.pc-photo--add:active:not(:disabled) { transform: scale(.97); }
|
||||
.pc-photo--add:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 3px; }
|
||||
.pc-photo--add:disabled { opacity: .6; cursor: default; }
|
||||
.pc-photo-actions { display: grid; gap: 4px; justify-items: center; }
|
||||
/* Menu du choix de source du portrait : au-dessus de la fiche (z-index 1000), sous la modale Immich (2200) */
|
||||
.pc-source-backdrop { position: fixed; inset: 0; z-index: 1100; }
|
||||
.pc-source-menu { position: fixed; z-index: 1150; min-width: 220px; }
|
||||
.pc-photo-btn {
|
||||
border: 0; background: none; padding: 2px 4px; cursor: pointer;
|
||||
font: 500 12px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 2px;
|
||||
/* Mode modification : le portrait prend la hauteur des deux champs Prénom et Nom (écart compris) et reste carré */
|
||||
.person-card--edit-mode .pc-id { padding-top: 0; }
|
||||
.person-card--edit-mode .pc-photo-wrap { display: block; }
|
||||
/* Taille fixée par les champs, pas par le contenu : une vraie photo a une taille propre qui faisait grandir le portrait */
|
||||
.person-card--edit-mode .pc-photo-wrap > .pc-photo { --pc-photo-edit: calc(var(--pc-name-h) * 2 + var(--pc-name-gap)); width: var(--pc-photo-edit); height: var(--pc-photo-edit); }
|
||||
|
||||
.pc-photo-badge {
|
||||
position: absolute; right: -6px; bottom: -6px; z-index: 1;
|
||||
width: 30px; height: 30px; display: grid; place-items: center; padding: 0;
|
||||
border: 0; border-radius: 50%; background: #fff; color: var(--pz-ink-2); cursor: pointer;
|
||||
box-shadow: 0 0 0 1px rgba(42, 38, 34, .06), var(--pz-shadow);
|
||||
transition: transform 140ms var(--pz-ease), background-color 160ms ease;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.pc-photo-btn--danger { color: var(--pz-danger); }
|
||||
.pc-photo-btn:disabled { opacity: .5; cursor: default; }
|
||||
@media (hover: hover) and (pointer: fine) { .pc-photo-badge:hover:not(:disabled) { background: var(--pz-a50); color: var(--pz-a700); } }
|
||||
.pc-photo-badge:active:not(:disabled) { transform: scale(.94); }
|
||||
.pc-photo-badge:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
.pc-photo-badge:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.pc-id { display: grid; gap: 6px; min-width: 0; padding-top: 4px; }
|
||||
.pc-name { margin: 0; font: 400 28px/1.08 var(--pz-serif); letter-spacing: -.01em; color: var(--pz-ink); text-wrap: balance; }
|
||||
.pc-name em { font-style: italic; font-weight: 300; }
|
||||
.pc-life { margin: 0; font: 400 13.5px/1.4 var(--pz-sans); color: var(--pz-muted); }
|
||||
.pc-meta { margin: 0; font: 400 13.5px/1.5 var(--pz-sans); color: var(--pz-muted); text-wrap: pretty; }
|
||||
.pc-meta-item { display: inline; }
|
||||
.pc-meta-item--nowrap { white-space: nowrap; }
|
||||
.pc-nick { font: italic 300 15.5px/1 var(--pz-serif); color: var(--pz-ink-2); }
|
||||
.pc-meta-sep { margin: 0 .45em; color: var(--pz-faint); }
|
||||
.pc-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||
.pc-chips:empty { display: none; }
|
||||
/* Place dans la fratrie (mode édition) : ‹ 2 sur 3 › dans une pastille */
|
||||
@@ -87,7 +102,7 @@
|
||||
.pc-order-btn:hover:not(:disabled) { background: var(--pz-line-soft); color: var(--pz-ink); }
|
||||
}
|
||||
@media (pointer: coarse) { .pc-order-btn { width: 32px; height: 32px; } }
|
||||
.pc-name-edit { display: grid; gap: 6px; }
|
||||
.pc-name-edit { display: grid; gap: var(--pc-name-gap); }
|
||||
|
||||
/* ── Champs d'édition ── */
|
||||
.pc-input {
|
||||
@@ -98,12 +113,15 @@
|
||||
}
|
||||
.pc-input:focus { outline: none; border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
|
||||
.pc-input::placeholder { color: var(--pz-faint); }
|
||||
.pc-input--name { font: 400 20px/1.2 var(--pz-serif); min-height: 42px; }
|
||||
.pc-input--name { font: 400 20px/1.2 var(--pz-serif); min-height: var(--pc-name-h); }
|
||||
.pc-input--last { font-style: italic; }
|
||||
.pc-note-input { min-height: 88px; resize: vertical; }
|
||||
|
||||
/* ── Onglets et corps ── */
|
||||
.pc-tabs { flex: none; }
|
||||
/* Quatre onglets en modification (Liens en plus) : « Souvenirs · 3 » ne passe jamais à la ligne, il prend la place
|
||||
que les onglets courts lui laissent */
|
||||
.pc-tabs .pz-tab { flex: 1 1 auto; padding: 0 10px; white-space: nowrap; }
|
||||
.pc-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; margin: 0 -6px; padding: 0 6px 2px; overscroll-behavior: contain; scrollbar-width: thin; scrollbar-color: var(--pz-line) transparent; }
|
||||
.pc-inline-msg { font: 500 13px/1.45 var(--pz-sans); }
|
||||
.pc-inline-msg--loading { padding: 10px 12px; border-radius: var(--pz-r-s); background: var(--pz-bg); color: var(--pz-muted); }
|
||||
@@ -237,8 +255,15 @@
|
||||
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;
|
||||
animation: pc-sheet 400ms var(--pz-ease-drawer) both;
|
||||
/* 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;
|
||||
transition: transform 240ms var(--pz-ease-drawer);
|
||||
}
|
||||
/* Pendant le glisser, la feuille suit le doigt sans transition */
|
||||
.person-card[data-dragging] { transition: none; }
|
||||
/* La poignée et l'en-tête portent le geste : le navigateur ne doit pas le prendre pour un défilement (les champs gardent le leur) */
|
||||
.person-card .pc-grab, .person-card .pc-close, .person-card .pc-head { touch-action: none; }
|
||||
.person-card .pc-head :is(input, textarea, select, label, [contenteditable]) { touch-action: auto; }
|
||||
.pc-grab { display: block; width: 38px; height: 4px; margin: 0 auto 2px; border-radius: 4px; background: var(--pz-line); flex: none; }
|
||||
.pc-close { top: 10px; right: 10px; }
|
||||
.pc-photo { width: 68px; height: 68px; }
|
||||
@@ -248,4 +273,31 @@
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.person-card { animation: pz-fade 200ms ease both; }
|
||||
.person-card { transition-duration: 1ms; }
|
||||
}
|
||||
|
||||
/* 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; }
|
||||
.pc-unknown-first { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; margin: -4px 0 0; font: 400 12.5px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; white-space: nowrap; }
|
||||
.pc-unknown-first input { width: 15px; height: 15px; accent-color: var(--pz-a500); }
|
||||
.pc-name-error { margin: 6px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-danger); }
|
||||
.pc-save-error { margin: 0; font: 500 12.5px/1.4 var(--pz-sans); color: var(--pz-danger); }
|
||||
|
||||
/* Modification de l'onglet Infos : pastilles des champs vides (DA terracotta, sans nouvelle couleur) */
|
||||
.pc-fill-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
|
||||
.pc-fill-chip { height: 30px; padding: 0 12px; border-radius: 999px; border: 1px dashed var(--pz-a300); background: var(--pz-a50); color: var(--pz-a700); font: 500 13px var(--pz-sans); cursor: pointer; }
|
||||
@media (hover: hover) and (pointer: fine) { .pc-fill-chip:hover { background: var(--pz-a100); border-color: var(--pz-a500); } }
|
||||
.pc-fill-chip:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
|
||||
|
||||
/* Liens : parents adoptifs, réglage de la filiation */
|
||||
.pc-section-head { display: flex; align-items: baseline; justify-content: space-between; flex-wrap: wrap; gap: 6px; }
|
||||
.pc-adopt-tag { font: 500 12px var(--pz-sans); color: var(--pz-a700); background: var(--pz-a50); border: 1px solid var(--pz-a300); border-radius: 999px; padding: 1px 9px; }
|
||||
.pc-parentage { display: grid; gap: 6px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--pz-line-soft); }
|
||||
.pc-seg { width: 100%; }
|
||||
.pc-seg .pz-tab { flex: 1; }
|
||||
/* Sexe (issue #114) : étiquette au-dessus, trois choix sur toute la largeur, chacun sur une ligne */
|
||||
.pc-row--seg { flex-direction: column; align-items: stretch; gap: 6px; }
|
||||
.pc-row--seg .pz-tab { white-space: nowrap; padding: 0 8px; }
|
||||
|
||||
/* Titres de section de l'onglet Infos et Liens : casse normale, Newsreader 15 px, comme la maquette (.sec) */
|
||||
.pc-infos .pz-eyebrow { text-transform: none; letter-spacing: 0; font: 400 15px/1.3 var(--pz-serif); color: var(--pz-ink); }
|
||||
@@ -0,0 +1,55 @@
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import PersonCard from './PersonCard.jsx'
|
||||
|
||||
// Onglet Infos : en modification, seuls les champs remplis s'affichent ; les vides sont des pastilles
|
||||
const sparse = {
|
||||
id: 'p-sparse',
|
||||
firstName: 'Marie',
|
||||
lastName: 'Martin',
|
||||
nickname: 'Mamie Mimi',
|
||||
isAlive: false,
|
||||
birthDate: null,
|
||||
deathDate: null,
|
||||
}
|
||||
|
||||
const render = (person, editMode) => renderToStaticMarkup(
|
||||
<PersonCard person={person} editMode={editMode} onSave={() => {}} />,
|
||||
)
|
||||
|
||||
describe('PersonCard, onglet Infos', () => {
|
||||
it('en modification, une pastille par champ vide et le champ rempli reste affiché', () => {
|
||||
const html = render(sparse, true)
|
||||
expect(html).toContain('+ Date de naissance')
|
||||
expect(html).toContain('+ Lieu de naissance')
|
||||
expect(html).toContain('+ Nom de naissance')
|
||||
expect(html).toContain('+ Date de décès')
|
||||
expect(html).toContain('+ Lieu de décès')
|
||||
expect(html).toContain('+ Métier')
|
||||
expect(html).toContain('+ Région')
|
||||
expect(html).toContain('+ Nationalité')
|
||||
expect(html).toContain('+ Notes')
|
||||
// Le surnom est rempli : il n'a pas de pastille, il a son champ
|
||||
expect(html).not.toContain('+ Surnom')
|
||||
expect(html).toContain('Mamie Mimi')
|
||||
})
|
||||
|
||||
it('une personne vivante n’a ni pastille de décès ni champ de décès', () => {
|
||||
const html = render({ ...sparse, isAlive: true, nickname: null }, true)
|
||||
expect(html).not.toContain('+ Date de décès')
|
||||
expect(html).not.toContain('+ Lieu de décès')
|
||||
expect(html).toContain('+ Surnom')
|
||||
})
|
||||
|
||||
it('en lecture, pas de pastille, et le lieu de décès s’affiche quand il existe', () => {
|
||||
const html = render({ ...sparse, deathPlace: 'Nantes, France', deathDate: '1970-03-02' }, false)
|
||||
expect(html).not.toContain('pc-fill-chip')
|
||||
expect(html).toContain('Lieu de décès')
|
||||
expect(html).toContain('Nantes')
|
||||
})
|
||||
|
||||
it('en lecture, le lieu de décès absent n’a pas de ligne', () => {
|
||||
const html = render(sparse, false)
|
||||
expect(html).not.toContain('Lieu de décès')
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useRef, forwardRef } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, filiations, persons, unions } from '../data/mockData'
|
||||
import { Save, Trash2, Plus, X, Search, Check, Play, ImagePlus, Camera, Crop, Sparkles, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getParents, getChildren, getPersonUnions, getPersonMedias, getDemoLife, getPersonById, filiations, persons, unions } from '../data/mockData'
|
||||
import { fetchPersonLife } from '../api/treeApi.js'
|
||||
import LifeTimeline from './LifeTimeline.jsx'
|
||||
import MomentSheet from './MomentSheet.jsx'
|
||||
@@ -10,10 +10,19 @@ import GeoMediaThumbnail from './GeoMediaThumbnail'
|
||||
import ImmichFacePanel from './ImmichFacePanel.jsx'
|
||||
import './PersonCard.css'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import { useSheetDrag, useSheetMode } from '../hooks/useSheetDrag'
|
||||
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 DateField from './DateField.jsx'
|
||||
import { runOnce } from '../utils/saveOnLeave.js'
|
||||
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
|
||||
|
||||
// Aucune modification en cours (référence stable)
|
||||
const NO_EDITS = Object.freeze({})
|
||||
|
||||
const TYPE_LABELS = {
|
||||
photo: t('Photo'),
|
||||
@@ -44,6 +53,11 @@ const PersonCard = ({
|
||||
onManageMedia,
|
||||
onAvatarUpload,
|
||||
onAvatarDelete,
|
||||
onAvatarCrop,
|
||||
pendingPortraitCrop = null,
|
||||
onPendingPortraitCropDone = null,
|
||||
photoSourceRequest = null,
|
||||
onPhotoSourceRequestDone = null,
|
||||
treeId = null,
|
||||
immichToken = null,
|
||||
onImmichChange,
|
||||
@@ -54,6 +68,8 @@ const PersonCard = ({
|
||||
onDelete,
|
||||
onAddRelation,
|
||||
onRemoveRelation,
|
||||
onSetParentage,
|
||||
pendingParentage = null,
|
||||
relationActionLoading = false,
|
||||
relationActionSuccess = '',
|
||||
relationActionError = '',
|
||||
@@ -61,6 +77,7 @@ const PersonCard = ({
|
||||
onMoveSibling,
|
||||
onUploadMomentFile,
|
||||
onMomentSaved,
|
||||
saveOnLeaveRef = null,
|
||||
}) => {
|
||||
// Souvenirs d'abord quand il y en a, ou quand on peut en ajouter : c'est là qu'est le bouton
|
||||
const [tab, setTab] = useState(() => (getPersonMedias(person.id).length > 0 || canManageMedia ? 'medias' : 'infos'))
|
||||
@@ -92,11 +109,22 @@ const PersonCard = ({
|
||||
}
|
||||
}, [tab, person.id, treeId, immichToken, lifeRevision])
|
||||
|
||||
// État local pour les modifications en mode édition
|
||||
const [edits, setEdits] = useState({})
|
||||
const [hasChanges, setHasChanges] = useState(false)
|
||||
// Modifications en cours, rattachées à la personne qu'elles concernent. Au changement de fiche,
|
||||
// le premier rendu ne doit pas voir celles de la personne d'avant : un champ qui s'initialise à
|
||||
// ce moment-là (DateField) les garderait, et la date d'une personne passait sur une autre.
|
||||
const [editState, setEditState] = useState(() => ({ personId: person.id, values: {}, changed: false }))
|
||||
const ownEdits = editState.personId === person.id
|
||||
const edits = ownEdits ? editState.values : NO_EDITS
|
||||
const hasChanges = ownEdits && editState.changed
|
||||
// Prénom inconnu (case cochée) et saisie de prénom vide sans case : le prénom est demandé avant l'enregistrement
|
||||
const [unknownFirstName, setUnknownFirstName] = useState(() => isFirstNameUnknown(person))
|
||||
const [firstNameMissing, setFirstNameMissing] = useState(false)
|
||||
const [lastNameMissing, setLastNameMissing] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false)
|
||||
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
|
||||
@@ -106,39 +134,70 @@ const PersonCard = ({
|
||||
const [showSourceMenu, setShowSourceMenu] = useState(false)
|
||||
const [sourceMenuPos, setSourceMenuPos] = useState({ top: 0, left: 0 })
|
||||
const sourceBtnRef = useRef(null)
|
||||
// Menu du badge appareil photo (position, ou null fermé) et fenêtre de recadrage ({ src, mode, ownsUrl }, ou null)
|
||||
const [photoMenuPos, setPhotoMenuPos] = useState(null)
|
||||
const [cropState, setCropState] = useState(null)
|
||||
// Taille naturelle de la photo affichée dans la fiche, pour placer le portrait recadré
|
||||
const [photoSize, setPhotoSize] = useState(null)
|
||||
|
||||
// Inline relation search state
|
||||
const [activeRelationSearch, setActiveRelationSearch] = useState(null) // 'parent' | 'spouse' | 'child' | null
|
||||
const [relationSearchQuery, setRelationSearchQuery] = useState('')
|
||||
const relationSearchRef = useRef(null)
|
||||
const avatarInputRef = useRef(null)
|
||||
// Infos en modification : champs ouverts depuis une pastille (et leur focus)
|
||||
const [openedFields, setOpenedFields] = useState([])
|
||||
const [focusField, setFocusField] = useState(null)
|
||||
const bodyRef = useRef(null)
|
||||
// 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 })
|
||||
|
||||
// Reset edits quand la personne change
|
||||
useEffect(() => {
|
||||
setEdits({})
|
||||
setHasChanges(false)
|
||||
setEditState({ personId: person.id, values: {}, changed: false })
|
||||
setUnknownFirstName(isFirstNameUnknown(person))
|
||||
setFirstNameMissing(false)
|
||||
setLastNameMissing(false)
|
||||
setShowDeleteConfirm(false)
|
||||
setActiveRelationSearch(null)
|
||||
setRelationSearchQuery('')
|
||||
setSaveSuccess(false)
|
||||
setSaveError('')
|
||||
setShowSourceMenu(false)
|
||||
setMomentSheet(null)
|
||||
setLifeNotice('')
|
||||
setPhotoMenuPos(null)
|
||||
setCropState(null)
|
||||
setPhotoSize(null)
|
||||
setOpenedFields([])
|
||||
setFocusField(null)
|
||||
}, [person.id])
|
||||
|
||||
// Pastille cliquée : le champ s'ouvre à sa place et prend le focus
|
||||
useEffect(() => {
|
||||
if (!focusField) return
|
||||
const target = bodyRef.current?.querySelector(`[data-pc-field="${focusField}"] input, [data-pc-field="${focusField}"] textarea`)
|
||||
target?.focus()
|
||||
setFocusField(null)
|
||||
}, [focusField])
|
||||
|
||||
// Connexion Immich de l'arbre : métadonnées sans clé. Une erreur (rôle périmé, réseau) se
|
||||
// traduit par « non configuré » : la fiche reste utilisable, simplement sans choix de source.
|
||||
// Seul un administrateur de l'arbre voit l'option Immich, comme côté serveur.
|
||||
// Dépend de la présence de onImmichChange, pas de sa valeur : App le recrée à chaque rendu, ce qui
|
||||
// relançait la lecture de la config et refermait le menu des sources juste après son ouverture
|
||||
const canChangeImmich = Boolean(onImmichChange)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setImmichConfigured(null)
|
||||
setShowSourceMenu(false)
|
||||
if (!editMode || !isAdmin || !treeId || !onImmichChange || !immichToken) return undefined
|
||||
if (!editMode || !isAdmin || !treeId || !canChangeImmich || !immichToken) return undefined
|
||||
fetchImmichConfig(treeId, immichToken)
|
||||
.then(({ config }) => { if (!cancelled) setImmichConfigured(Boolean(config?.configured)) })
|
||||
.catch(() => { if (!cancelled) setImmichConfigured(false) })
|
||||
return () => { cancelled = true }
|
||||
}, [editMode, isAdmin, treeId, immichToken, onImmichChange])
|
||||
}, [editMode, isAdmin, treeId, immichToken, canChangeImmich])
|
||||
|
||||
useEffect(() => {
|
||||
if (!showSourceMenu) return undefined
|
||||
@@ -147,6 +206,39 @@ const PersonCard = ({
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [showSourceMenu])
|
||||
|
||||
// Escape ferme le menu du badge
|
||||
useEffect(() => {
|
||||
if (!photoMenuPos) return undefined
|
||||
const onKeyDown = (event) => { if (event.key === 'Escape') setPhotoMenuPos(null) }
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [photoMenuPos])
|
||||
|
||||
const openPhotoMenu = () => {
|
||||
if (avatarActionLoading) return
|
||||
const rect = sourceBtnRef.current?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
setPhotoMenuPos({
|
||||
top: Math.round(rect.bottom) + 6,
|
||||
left: Math.round(Math.min(rect.left, window.innerWidth - 232)),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fermer la fenêtre de recadrage ; l'aperçu envoyé est libéré
|
||||
const closeCrop = () => {
|
||||
if (cropState?.ownsUrl) URL.revokeObjectURL(cropState.src)
|
||||
setCropState(null)
|
||||
}
|
||||
|
||||
// Placement du portrait dans la fiche (carrée), comme le canevas
|
||||
const photoPlacement = photoSize ? imagePlacement(photoSize.w, photoSize.h, 1, person.avatarCrop) : null
|
||||
const photoStyle = photoPlacement
|
||||
? { position: 'absolute', maxWidth: 'none', left: `${photoPlacement.left}%`, top: `${photoPlacement.top}%`, width: `${photoPlacement.width}%`, height: `${photoPlacement.height}%` }
|
||||
: undefined
|
||||
// Même forme de cadre que dans l'arbre (renderers.js)
|
||||
const treeFrameType = getPersonById(person.id)?.frameType || person.frameType
|
||||
|
||||
const immichUsable = isAdmin && Boolean(immichConfigured || person.avatarFromImmich)
|
||||
const openAvatarPicker = () => {
|
||||
if (avatarActionLoading) return
|
||||
@@ -166,6 +258,21 @@ const PersonCard = ({
|
||||
const chooseFileSource = () => { setShowSourceMenu(false); avatarInputRef.current?.click() }
|
||||
const chooseImmichSource = () => { setShowSourceMenu(false); setShowImmichPanel(true) }
|
||||
|
||||
// Clic sur « + photo » dans l'arbre : la photo vient d'être envoyée, le recadrage s'ouvre tout de suite
|
||||
useEffect(() => {
|
||||
if (!pendingPortraitCrop || String(pendingPortraitCrop.personId) !== String(person.id)) return
|
||||
setCropState({ src: pendingPortraitCrop.src, mode: 'new', ownsUrl: true })
|
||||
onPendingPortraitCropDone?.()
|
||||
}, [pendingPortraitCrop, person.id, onPendingPortraitCropDone])
|
||||
|
||||
// Clic sur « + photo » dans l'arbre, en modification avec Immich : même menu que le badge, sans clic de plus
|
||||
useEffect(() => {
|
||||
if (!photoSourceRequest || String(photoSourceRequest.personId) !== String(person.id)) return
|
||||
if (immichConfigured === null) return
|
||||
onPhotoSourceRequestDone?.()
|
||||
if (immichUsable) openAvatarPicker()
|
||||
}, [photoSourceRequest, person.id, immichConfigured, immichUsable, onPhotoSourceRequestDone]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Focus search input when relation search opens
|
||||
useEffect(() => {
|
||||
if (activeRelationSearch) {
|
||||
@@ -193,22 +300,100 @@ const PersonCard = ({
|
||||
}
|
||||
|
||||
const handleFieldChange = (field, value) => {
|
||||
setEdits(prev => ({ ...prev, [field]: value }))
|
||||
setHasChanges(true)
|
||||
const personId = person.id
|
||||
setEditState(prev => ({
|
||||
personId,
|
||||
values: { ...(prev.personId === personId ? prev.values : {}), [field]: value },
|
||||
changed: true,
|
||||
}))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!hasChanges || !onSave) return
|
||||
// Onglet Infos en modification : seuls les champs remplis (ou ouverts) s'affichent, le reste est en pastilles
|
||||
const isFilled = (value) => value !== null && value !== undefined && String(value).trim() !== ''
|
||||
// Un champ ouvert par sa pastille, ou en cours de saisie, reste affiché jusqu'à l'enregistrement,
|
||||
// même vidé : sinon il disparaît sous les doigts et la frappe part dans le champ suivant
|
||||
const openedHas = (field) => openedFields.includes(field) || field in edits
|
||||
const openField = (field) => {
|
||||
setOpenedFields((prev) => (prev.includes(field) ? prev : [...prev, field]))
|
||||
setFocusField(field)
|
||||
}
|
||||
const birthFilled = isFilled(getEditValue('birthYear', person.birthDate || person.birthYear))
|
||||
const deathFilled = isFilled(getEditValue('deathYear', person.deathDate || person.deathYear))
|
||||
const deceasedNow = editMode ? !getEditValue('isAlive', person.isAlive) : !person.isAlive
|
||||
const editSex = normalizeSex(getEditValue('sex', person.sex))
|
||||
const notesShown = editMode && (isFilled(getEditValue('note', person.note)) || openedHas('note'))
|
||||
// En lecture : toujours visible si demandé (naissance, décès d'une personne décédée), sinon si rempli
|
||||
const infoRow = (field, label, filled, content, always = false) => {
|
||||
const shown = editMode ? (filled || openedHas(field)) : (always || filled)
|
||||
if (!shown) return null
|
||||
return (
|
||||
<div className="pc-row" data-pc-field={field}>
|
||||
<span className="pc-label">{label}</span>
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const chipCandidates = [
|
||||
{ field: 'nickname', label: t('Surnom'), filled: isFilled(getEditValue('nickname', person.nickname)), relevant: true },
|
||||
{ field: 'birthYear', label: t('Date de naissance'), filled: birthFilled, relevant: true },
|
||||
{ field: 'birthPlace', label: t('Lieu de naissance'), filled: isFilled(getEditValue('birthPlace', person.birthPlace)), relevant: true },
|
||||
{ field: 'deathYear', label: t('Date de décès'), filled: deathFilled, relevant: deceasedNow },
|
||||
{ field: 'deathPlace', label: t('Lieu de décès'), filled: isFilled(getEditValue('deathPlace', person.deathPlace)), relevant: deceasedNow },
|
||||
{ field: 'birthName', label: t('Nom de naissance'), filled: isFilled(getEditValue('birthName', person.birthName)), relevant: true },
|
||||
{ field: 'profession', label: t('Métier'), filled: isFilled(getEditValue('profession', person.profession)), relevant: true },
|
||||
{ field: 'region', label: t('Région'), filled: isFilled(getEditValue('region', person.region)), relevant: true },
|
||||
{ field: 'nationality', label: t('Nationalité'), filled: isFilled(getEditValue('nationality', person.nationality)), relevant: true },
|
||||
{ field: 'note', label: t('Notes'), filled: isFilled(getEditValue('note', person.note)), relevant: true },
|
||||
]
|
||||
const emptyChips = editMode ? chipCandidates.filter((c) => c.relevant && !c.filled && !openedHas(c.field)) : []
|
||||
|
||||
// Enregistre les modifications de cette fiche (bouton « Enregistrer » et départ de la fiche).
|
||||
// Résultat : 'saved', 'unchanged' (rien à enregistrer) ou 'blocked' (validation, ou échec réseau :
|
||||
// le message reste sur la fiche, la saisie n'est pas jetée).
|
||||
const persistEdits = () => runOnce(saveInFlight, async () => {
|
||||
if (!hasChanges || !onSave) return 'unchanged'
|
||||
if (!unknownFirstName && 'firstName' in edits && !String(edits.firstName).trim()) {
|
||||
setFirstNameMissing(true)
|
||||
return 'blocked'
|
||||
}
|
||||
// Prénom inconnu : il faut alors un nom de famille
|
||||
if (unknownFirstName && !String(getEditValue('lastName', person.lastName) ?? '').trim()) {
|
||||
setLastNameMissing(true)
|
||||
return 'blocked'
|
||||
}
|
||||
const savedFor = person.id
|
||||
setSaving(true)
|
||||
setSaveError('')
|
||||
try {
|
||||
await onSave(person.id, edits)
|
||||
setHasChanges(false)
|
||||
await onSave(savedFor, edits)
|
||||
setEditState(prev => (prev.personId === savedFor ? { ...prev, changed: false } : prev))
|
||||
setOpenedFields([])
|
||||
setSaveSuccess(true)
|
||||
setTimeout(() => setSaveSuccess(false), 2000)
|
||||
return 'saved'
|
||||
} catch (error) {
|
||||
// Serveur injoignable (status 0, message traduit par le client) ; sinon un texte clair, jamais un message technique
|
||||
setSaveError(error?.status === 0 ? error.message : t("L'enregistrement a échoué. Vos modifications sont gardées, réessayez."))
|
||||
return 'blocked'
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// App enregistre la fiche avant de la quitter (changement de personne, fermeture, sortie du mode
|
||||
// modification) : la référence suit toujours la dernière version de persistEdits
|
||||
useEffect(() => {
|
||||
if (!saveOnLeaveRef) return undefined
|
||||
saveOnLeaveRef.current = persistEdits
|
||||
return () => { if (saveOnLeaveRef.current === persistEdits) saveOnLeaveRef.current = null }
|
||||
})
|
||||
|
||||
// Sortie du mode modification : les modifications restantes (abandonnées) ne reviennent pas à la prochaine entrée
|
||||
useEffect(() => {
|
||||
if (!editMode) setEditState({ personId: person.id, values: {}, changed: false })
|
||||
}, [editMode]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleSave = () => { persistEdits() }
|
||||
|
||||
const handleDelete = () => {
|
||||
if (!onDelete) return
|
||||
@@ -220,7 +405,9 @@ const PersonCard = ({
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file || !onAvatarUpload) return
|
||||
await onAvatarUpload(person.id, file)
|
||||
const result = await onAvatarUpload(person.id, file)
|
||||
// Juste après l'envoi, la fenêtre de recadrage s'ouvre (« Garder tel quel » laisse la photo entière)
|
||||
if (result?.previewUrl) setCropState({ src: result.previewUrl, mode: 'new', ownsUrl: true })
|
||||
}
|
||||
|
||||
// Relation actions
|
||||
@@ -274,6 +461,18 @@ const PersonCard = ({
|
||||
return childFiliations[0]
|
||||
})()
|
||||
const personMedias = getPersonMedias(person.id)
|
||||
// Filiation d'adoption entre un enfant et un parent (lien direct ou via une union)
|
||||
const parentageAdopted = (childId, parentId) => filiations.some((f) => (
|
||||
String(f.childId) === String(childId)
|
||||
&& f.parentageType === 'adoption'
|
||||
&& (String(f.parentId) === String(parentId) || (f.unionId && personUnions.some((u) => String(u.id) === String(f.unionId))))
|
||||
))
|
||||
// Un contributeur voit son choix en attente avant l'envoi ; sinon le lien enregistré
|
||||
const parentsAdopted = pendingParentage ? pendingParentage === 'adoption' : filiations.some((f) => String(f.childId) === String(person.id) && f.parentageType === 'adoption')
|
||||
const adoptedChildLabel = (child) => {
|
||||
if (!parentageAdopted(child.id, person.id)) return null
|
||||
return child.sex === 'female' ? t('adoptée') : t('adopté')
|
||||
}
|
||||
|
||||
// Frise de vie : la famille vient de l'arbre déjà chargé, les événements de la route /life,
|
||||
// demandée seulement à l'ouverture de l'onglet (la démo a ses propres données)
|
||||
@@ -295,6 +494,8 @@ const PersonCard = ({
|
||||
unions: personUnions.map((union) => ({
|
||||
...union,
|
||||
partner: persons.find((p) => p.id === (union.partner1Id === person.id ? union.partner2Id : union.partner1Id)) || null,
|
||||
// Enfants du couple : placent dans la frise une union dont la date manque
|
||||
childIds: filiations.filter((f) => f.unionId && String(f.unionId) === String(union.id)).map((f) => f.childId),
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -333,16 +534,16 @@ const PersonCard = ({
|
||||
const p2 = persons.find(p => p.id === union.partner2Id)
|
||||
// Union sans type (fréquent après un import GEDCOM) : pas de « (null) » à côté des prénoms
|
||||
const kind = union.unionType ? ` (${unionTypeLabel(union.unionType)})` : ''
|
||||
return `${p1?.firstName || '?'} + ${p2?.firstName || '?'}${kind}`
|
||||
return `${p1 ? firstNameLabel(p1) : '?'} + ${p2 ? firstNameLabel(p2) : '?'}${kind}`
|
||||
}
|
||||
}
|
||||
|
||||
if (preferredFiliation.parentId && parentsList.length <= 1) {
|
||||
const parent = persons.find(p => p.id === preferredFiliation.parentId)
|
||||
if (preferredFiliation.parentageType === 'inconnu') {
|
||||
return t('{name} (parent unique, autre parent inconnu)', { name: parent?.firstName || '?' })
|
||||
return t('{name} (parent unique, autre parent inconnu)', { name: parent ? firstNameLabel(parent) : '?' })
|
||||
}
|
||||
return t('{name} (parent unique)', { name: parent?.firstName || '?' })
|
||||
return t('{name} (parent unique)', { name: parent ? firstNameLabel(parent) : '?' })
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -415,18 +616,24 @@ const PersonCard = ({
|
||||
return parts.join(' – ')
|
||||
})()
|
||||
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
|
||||
const metaItems = [
|
||||
person.nickname && { key: 'nick', node: <em className="pc-nick">{person.nickname}</em>, nowrap: true },
|
||||
lifeLine && { key: 'life', node: lifeLine },
|
||||
ageLabel && { key: 'age', node: ageLabel, nowrap: true },
|
||||
].filter(Boolean)
|
||||
|
||||
const renderRelationRow = (target, relationType, meta) => (
|
||||
<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>}
|
||||
</span>
|
||||
<span className="pc-rel-name">{target?.firstName} {target?.lastName || ''}</span>
|
||||
<span className="pc-rel-name">{target ? fullNameLabel(target) : ''}</span>
|
||||
{meta && <span className="pc-rel-meta">{meta}</span>}
|
||||
<button
|
||||
type="button"
|
||||
className="pz-btn pz-btn--ghost pz-btn--icon pc-rel-remove"
|
||||
aria-label={t('Retirer {name}', { name: target?.firstName })}
|
||||
aria-label={t('Retirer {name}', { name: target ? fullNameLabel(target) : '' })}
|
||||
onClick={() => handleRelationRemove(target.id, relationType)}
|
||||
disabled={relationActionLoading}
|
||||
>
|
||||
@@ -455,7 +662,14 @@ const PersonCard = ({
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`} role="dialog" aria-label={t('Fiche de {name}', { name: `${person.firstName} ${person.lastName || ''}` })}>
|
||||
<div
|
||||
className={`person-card ${editMode ? 'person-card--edit-mode' : ''}`}
|
||||
role="dialog"
|
||||
aria-label={t('Fiche de {name}', { name: fullNameLabel(person) })}
|
||||
style={drag.style}
|
||||
data-dragging={drag.dragging || undefined}
|
||||
{...drag.bind}
|
||||
>
|
||||
<span className="pc-grab" aria-hidden="true" />
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer la fiche')}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
@@ -481,41 +695,32 @@ const PersonCard = ({
|
||||
className="pc-photo pc-photo--add"
|
||||
onClick={() => avatarInputRef.current?.click()}
|
||||
disabled={avatarActionLoading}
|
||||
aria-label={t('Ajouter une photo')}
|
||||
title={t('Ajouter une photo')}
|
||||
>
|
||||
<ImagePlus size={22} strokeWidth={1.8} aria-hidden="true" />
|
||||
<span>{avatarActionLoading ? t('Envoi') : t('Ajouter une photo')}</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="pc-photo">
|
||||
{person.photo
|
||||
? <img src={person.photo} alt={`${person.firstName} ${person.lastName || ''}`} />
|
||||
? <img src={person.photo} alt={fullNameLabel(person)} onLoad={(event) => setPhotoSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })} style={photoStyle} />
|
||||
: <span className="pc-photo-initials">{initials}</span>}
|
||||
</div>
|
||||
)}
|
||||
{editMode && onAvatarUpload && (
|
||||
<div className="pc-photo-actions">
|
||||
<button
|
||||
ref={sourceBtnRef}
|
||||
type="button"
|
||||
className="pc-photo-btn"
|
||||
onClick={openAvatarPicker}
|
||||
disabled={avatarActionLoading}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSourceMenu}
|
||||
>
|
||||
{person.photo ? t('Changer') : t('Ajouter une photo')}
|
||||
</button>
|
||||
{person.photo && (
|
||||
<button
|
||||
type="button"
|
||||
className="pc-photo-btn pc-photo-btn--danger"
|
||||
onClick={() => onAvatarDelete?.(person.id, person.avatarFromImmich)}
|
||||
disabled={avatarActionLoading}
|
||||
>
|
||||
{t('Retirer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
ref={sourceBtnRef}
|
||||
type="button"
|
||||
className="pc-photo-badge"
|
||||
onClick={openPhotoMenu}
|
||||
disabled={avatarActionLoading}
|
||||
aria-label={t('Changer la photo')}
|
||||
title={t('Changer la photo')}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={showSourceMenu}
|
||||
>
|
||||
<Camera size={14} strokeWidth={2} aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -526,27 +731,46 @@ const PersonCard = ({
|
||||
type="text"
|
||||
className="pc-input pc-input--name"
|
||||
value={getEditValue('firstName', person.firstName)}
|
||||
onChange={(e) => handleFieldChange('firstName', e.target.value)}
|
||||
onChange={(e) => {
|
||||
setFirstNameMissing(false)
|
||||
handleFieldChange('firstName', e.target.value)
|
||||
}}
|
||||
placeholder={t('Prénom')}
|
||||
aria-label={t('Prénom')}
|
||||
disabled={unknownFirstName}
|
||||
aria-invalid={firstNameMissing}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
className="pc-input pc-input--name pc-input--last"
|
||||
value={getEditValue('lastName', person.lastName)}
|
||||
onChange={(e) => handleFieldChange('lastName', e.target.value)}
|
||||
onChange={(e) => {
|
||||
setLastNameMissing(false)
|
||||
handleFieldChange('lastName', e.target.value)
|
||||
}}
|
||||
placeholder={t('Nom')}
|
||||
aria-label={t('Nom')}
|
||||
/>
|
||||
{firstNameMissing && <p className="pc-name-error">{t('Il faut au moins un prénom.')}</p>}
|
||||
{lastNameMissing && <p className="pc-name-error">{t('Il faut au moins un nom si le prénom est inconnu.')}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<h2 className="pc-name">
|
||||
{person.firstName} <em>{person.lastName}</em>
|
||||
{isFirstNameUnknown(person) ? <span className="pc-name-unknown">{firstNameLabel(person)}</span> : person.firstName}
|
||||
{person.lastName && <>{' '}<em>{person.lastName}</em></>}
|
||||
</h2>
|
||||
)}
|
||||
{!editMode && lifeLine && <p className="pc-life">{lifeLine}</p>}
|
||||
{!editMode && metaItems.length > 0 && (
|
||||
<p className="pc-meta">
|
||||
{metaItems.map((item, i) => (
|
||||
<span key={item.key} className={`pc-meta-item${item.nowrap ? ' pc-meta-item--nowrap' : ''}`}>
|
||||
{item.node}
|
||||
{i < metaItems.length - 1 && <span className="pc-meta-sep" aria-hidden="true">·</span>}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
<div className="pc-chips">
|
||||
{editMode && <span className="pz-tag pz-tag--accent pz-tag--dot">{t('Édition')}</span>}
|
||||
{editMode && siblingPlace && (
|
||||
<span className="pz-tag pc-order" role="group" aria-label={t('Place {n} sur {total} parmi les frères et sœurs', { n: siblingPlace.index + 1, total: siblingPlace.count })}>
|
||||
<button type="button" className="pc-order-btn" onClick={() => onMoveSibling?.(-1)} disabled={siblingPlace.index === 0} aria-label={t('Déplacer à gauche')} title={t('Déplacer à gauche')}>
|
||||
@@ -558,10 +782,24 @@ const PersonCard = ({
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{!editMode && ageLabel && <span className="pz-tag">{ageLabel}</span>}
|
||||
{!editMode && person.profession && <span className="pz-tag">{person.profession}</span>}
|
||||
</div>
|
||||
</div>
|
||||
{editMode && ((unknownFirstName || !String(getEditValue('firstName', person.firstName) ?? '').trim())) && (
|
||||
<label className="pc-unknown-first">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unknownFirstName}
|
||||
onChange={(e) => {
|
||||
setUnknownFirstName(e.target.checked)
|
||||
setFirstNameMissing(false)
|
||||
setLastNameMissing(false)
|
||||
handleFieldChange('firstName', '')
|
||||
}}
|
||||
/>
|
||||
{t('Je ne connais pas son prénom')}
|
||||
</label>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{avatarActionError && <div className="pz-error pc-inline-msg">{avatarActionError}</div>}
|
||||
@@ -590,7 +828,7 @@ const PersonCard = ({
|
||||
<div className="pc-souvenirs">
|
||||
{personMedias.length === 0 && (
|
||||
<div className="pc-empty">
|
||||
<p className="pz-sub">{t('Pas encore de souvenir pour {name}.', { name: person.firstName })}</p>
|
||||
<p className="pz-sub">{t('Pas encore de souvenir pour {name}.', { name: firstNameLabel(person) })}</p>
|
||||
<p className="pz-small">{canManageMedia ? t('Une photo, une anecdote, sa voix. Vous pouvez ajouter le premier.') : t('Une photo, une anecdote, sa voix.')}</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -677,24 +915,30 @@ const PersonCard = ({
|
||||
|
||||
{/* ── Infos ── */}
|
||||
{tab === 'infos' && (
|
||||
<div className="pc-infos">
|
||||
<div className="pc-infos" ref={bodyRef}>
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('État civil')}</h3>
|
||||
<div className="pc-rows">
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Naissance')}</span>
|
||||
{renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' })}
|
||||
</div>
|
||||
{(person.birthPlace || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Lieu')}</span>
|
||||
{renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace })}
|
||||
</div>
|
||||
)}
|
||||
{(person.birthName || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Nom de naissance')}</span>
|
||||
{renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') })}
|
||||
{infoRow('birthYear', t('Naissance'), birthFilled, renderEditableValue({ field: 'birthYear', value: person.birthDate || person.birthYear, placeholder: t('Date de naissance'), type: 'date' }), true)}
|
||||
{infoRow('birthPlace', t('Lieu'), isFilled(getEditValue('birthPlace', person.birthPlace)), renderEditableValue({ field: 'birthPlace', value: person.birthPlace || '', placeholder: t('Lieu de naissance'), format: formatPlace }))}
|
||||
{infoRow('birthName', t('Nom de naissance'), isFilled(getEditValue('birthName', person.birthName)), renderEditableValue({ field: 'birthName', value: person.birthName || '', placeholder: t('Nom de naissance') }))}
|
||||
{infoRow('nickname', t('Surnom'), isFilled(getEditValue('nickname', person.nickname)), renderEditableValue({ field: 'nickname', value: person.nickname || '', placeholder: t('Surnom') }))}
|
||||
{editMode && (
|
||||
<div className="pc-row pc-row--seg" data-pc-field="sex">
|
||||
<span className="pc-label" id="pc-sex-label">{t('Sexe')}</span>
|
||||
<div className="pz-tabs pc-seg" role="group" aria-labelledby="pc-sex-label">
|
||||
{SEX_CHOICES.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
aria-pressed={editSex === value}
|
||||
className={`pz-tab ${editSex === value ? 'is-active' : ''}`}
|
||||
onClick={() => handleFieldChange('sex', editSex === value ? null : value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editMode && (
|
||||
@@ -706,41 +950,32 @@ const PersonCard = ({
|
||||
onClick={() => handleFieldChange('isAlive', !getEditValue('isAlive', person.isAlive))}
|
||||
>
|
||||
<span className="pc-alive-dot" />
|
||||
{getEditValue('isAlive', person.isAlive) ? t('En vie') : t('Décédé(e)')}
|
||||
{getEditValue('isAlive', person.isAlive) ? t('En vie') : editSex === 'male' ? t('Décédé') : editSex === 'female' ? t('Décédée') : t('Décédé(e)')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{(!getEditValue('isAlive', person.isAlive) || (!editMode && !person.isAlive)) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Décès')}</span>
|
||||
{renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') })}
|
||||
</div>
|
||||
)}
|
||||
{infoRow('deathYear', t('Décès'), deathFilled, renderEditableValue({ field: 'deathYear', value: person.deathDate || person.deathYear || '', placeholder: t('Date de décès'), type: 'date', emptyLabel: t('Date inconnue') }), deceasedNow)}
|
||||
{infoRow('deathPlace', t('Lieu de décès'), isFilled(getEditValue('deathPlace', person.deathPlace)), renderEditableValue({ field: 'deathPlace', value: person.deathPlace || '', placeholder: t('Lieu de décès'), format: formatPlace }))}
|
||||
{!editMode && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Âge')}</span>
|
||||
<span className="value">{ageLabel || '-'}</span>
|
||||
</div>
|
||||
)}
|
||||
{(person.region || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Région')}</span>
|
||||
{renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') })}
|
||||
</div>
|
||||
)}
|
||||
{(person.nationality || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Nationalité')}</span>
|
||||
{renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') })}
|
||||
</div>
|
||||
)}
|
||||
{(person.profession || editMode) && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Métier')}</span>
|
||||
{renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') })}
|
||||
</div>
|
||||
)}
|
||||
{infoRow('region', t('Région'), isFilled(getEditValue('region', person.region)), renderEditableValue({ field: 'region', value: person.region || '', placeholder: t('Région') }))}
|
||||
{infoRow('nationality', t('Nationalité'), isFilled(getEditValue('nationality', person.nationality)), renderEditableValue({ field: 'nationality', value: person.nationality || '', placeholder: t('Nationalité') }))}
|
||||
{infoRow('profession', t('Métier'), isFilled(getEditValue('profession', person.profession)), renderEditableValue({ field: 'profession', value: person.profession || '', placeholder: t('Métier') }))}
|
||||
</div>
|
||||
{/* Modification : une pastille par champ vide, qui ouvre le champ à sa place */}
|
||||
{editMode && emptyChips.length > 0 && (
|
||||
<div className="pc-fill-chips">
|
||||
{emptyChips.map((chip) => (
|
||||
<button key={chip.field} type="button" className="pc-fill-chip" onClick={() => openField(chip.field)}>
|
||||
+ {chip.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{!editMode && (parentsList.length > 0 || personUnions.length > 0 || childrenList.length > 0) && (
|
||||
@@ -750,7 +985,7 @@ const PersonCard = ({
|
||||
{parentsList.length > 0 && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Parents')}</span>
|
||||
<span className="value">{getParentageLabel() || parentsList.map(p => `${p.firstName} ${p.lastName}`).join(t(' et '))}</span>
|
||||
<span className="value">{getParentageLabel() || parentsList.map(p => fullNameLabel(p)).join(t(' et '))}</span>
|
||||
</div>
|
||||
)}
|
||||
{preferredFiliation && preferredFiliation.parentageType !== 'biologique' && parentsList.length <= 1 && (
|
||||
@@ -767,7 +1002,7 @@ const PersonCard = ({
|
||||
const partnerId = u.partner1Id === person.id ? u.partner2Id : u.partner1Id
|
||||
const partner = persons.find(p => p.id === partnerId)
|
||||
const yearRange = u.startYear ? (u.endYear ? `${u.startYear}–${u.endYear}` : t('depuis {year}', { year: u.startYear })) : ''
|
||||
return `${partner?.firstName || '?'} ${partner?.lastName || ''}${yearRange ? ` (${yearRange})` : ''}`
|
||||
return `${partner ? firstNameLabel(partner) : '?'} ${partner?.lastName || ''}${yearRange ? ` (${yearRange})` : ''}`
|
||||
}).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -775,15 +1010,15 @@ const PersonCard = ({
|
||||
{childrenList.length > 0 && (
|
||||
<div className="pc-row">
|
||||
<span className="pc-label">{t('Enfants')}</span>
|
||||
<span className="value">{childrenList.map(c => c.firstName).join(', ')}</span>
|
||||
<span className="value">{childrenList.map(c => firstNameLabel(c)).join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(person.note || editMode) && (
|
||||
<section className="pc-section">
|
||||
{(editMode ? notesShown : person.note) && (
|
||||
<section className="pc-section" data-pc-field="note">
|
||||
<h3 className="pz-eyebrow">{t('Notes')}</h3>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
@@ -811,9 +1046,21 @@ const PersonCard = ({
|
||||
)}
|
||||
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('Parents')}</h3>
|
||||
<div className="pc-section-head">
|
||||
<h3 className="pz-eyebrow">{t('Parents')}</h3>
|
||||
{parentsAdopted && parentsList.length > 0 && <span className="pc-adopt-tag">{t('Parents adoptifs')}</span>}
|
||||
</div>
|
||||
{parentsList.length > 0 ? parentsList.map(p => renderRelationRow(p, 'parent')) : <p className="pc-rel-empty">{t("Aucun parent pour l'instant")}</p>}
|
||||
{renderRelationAdd('parent', t('Ajouter un parent'), t('Rechercher un parent'))}
|
||||
{parentsList.length > 0 && (
|
||||
<div className="pc-parentage">
|
||||
<span className="pc-label">{t('Lien avec ses parents')}</span>
|
||||
<div className="pz-tabs pc-seg" role="radiogroup" aria-label={t('Lien avec ses parents')}>
|
||||
<button type="button" role="radio" aria-checked={!parentsAdopted} className={`pz-tab ${!parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'biologique')}>{t('Naissance')}</button>
|
||||
<button type="button" role="radio" aria-checked={parentsAdopted} className={`pz-tab ${parentsAdopted ? 'is-active' : ''}`} onClick={() => onSetParentage?.(person.id, 'adoption')}>{t('Adoption')}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="pc-section">
|
||||
@@ -828,7 +1075,7 @@ const PersonCard = ({
|
||||
|
||||
<section className="pc-section">
|
||||
<h3 className="pz-eyebrow">{t('Enfants')}</h3>
|
||||
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child')) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
|
||||
{childrenList.length > 0 ? childrenList.map(c => renderRelationRow(c, 'child', adoptedChildLabel(c))) : <p className="pc-rel-empty">{t('Aucun enfant')}</p>}
|
||||
{renderRelationAdd('child', t('Ajouter un enfant'), t('Rechercher un enfant'))}
|
||||
</section>
|
||||
</div>
|
||||
@@ -836,11 +1083,12 @@ const PersonCard = ({
|
||||
</div>
|
||||
|
||||
{/* ── Actions d'édition ── */}
|
||||
{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: person.firstName })}</span>
|
||||
<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>
|
||||
@@ -885,6 +1133,49 @@ const PersonCard = ({
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Menu du badge : changer, recadrer, retirer ── */}
|
||||
{photoMenuPos && (
|
||||
createPortal(
|
||||
<>
|
||||
<div className="pc-source-backdrop" onClick={() => setPhotoMenuPos(null)} />
|
||||
<div className="pc-source-menu pz-menu" role="menu" aria-label={t('Options du portrait')} style={{ top: `${photoMenuPos.top}px`, left: `${photoMenuPos.left}px` }}>
|
||||
<button type="button" role="menuitem" className="pz-menu-item" onClick={() => { setPhotoMenuPos(null); openAvatarPicker() }}>
|
||||
<ImagePlus size={16} aria-hidden="true" /> {t('Changer la photo')}
|
||||
</button>
|
||||
{person.photo && !person.avatarFromImmich && onAvatarCrop && (
|
||||
<button type="button" role="menuitem" className="pz-menu-item" onClick={() => { setPhotoMenuPos(null); setCropState({ src: person.photo, mode: 'recrop', ownsUrl: false }) }}>
|
||||
<Crop size={16} aria-hidden="true" /> {t('Recadrer')}
|
||||
</button>
|
||||
)}
|
||||
{person.photo && (
|
||||
<button type="button" role="menuitem" className="pz-menu-item pz-menu-item--danger" onClick={() => { setPhotoMenuPos(null); onAvatarDelete?.(person.id, person.avatarFromImmich) }} disabled={avatarActionLoading}>
|
||||
<Trash2 size={16} aria-hidden="true" /> {t('Retirer')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
)}
|
||||
|
||||
{/* ── Recadrage du portrait (fenêtre pz) ── */}
|
||||
{cropState && onAvatarCrop && (
|
||||
<PortraitCropModal
|
||||
src={cropState.src}
|
||||
frameType={treeFrameType}
|
||||
name={fullNameLabel(person)}
|
||||
mode={cropState.mode}
|
||||
initialCrop={cropState.mode === 'new' ? null : person.avatarCrop}
|
||||
saving={avatarActionLoading}
|
||||
error={avatarActionError}
|
||||
onSave={async (crop) => {
|
||||
const saved = await onAvatarCrop(person.id, crop)
|
||||
if (saved) closeCrop()
|
||||
}}
|
||||
onClose={closeCrop}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* ── Association d'un visage Immich (modale) ── */}
|
||||
{showImmichPanel && treeId && (
|
||||
<ImmichFacePanel
|
||||
@@ -960,7 +1251,7 @@ const RelationSearchInline = forwardRef(function RelationSearchInline(
|
||||
<span className="pc-rel-avatar" aria-hidden="true">
|
||||
{p.photo ? <img src={p.photo} alt="" /> : <span>{p.firstName[0]}</span>}
|
||||
</span>
|
||||
<span className="pc-rel-name">{p.firstName} <em>{p.lastName || ''}</em></span>
|
||||
<span className="pc-rel-name">{firstNameLabel(p)} <em>{p.lastName || ''}</em></span>
|
||||
{p.birthYear && <span className="pc-rel-meta">{p.birthYear}</span>}
|
||||
</button>
|
||||
</li>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/* ============================================================
|
||||
Recadrage du portrait (fenêtre pz)
|
||||
Le masque garde la forme du cadre de la personne dans l'arbre. La photo glisse dessous.
|
||||
Les variables --pz-* viennent de src/styles/pz.css.
|
||||
============================================================ */
|
||||
|
||||
.pcrop-modal { width: min(100%, 440px); }
|
||||
|
||||
.pcrop-body { display: grid; gap: 14px; min-width: 0; }
|
||||
|
||||
.pcrop-stage {
|
||||
position: relative; width: min(100%, 300px); aspect-ratio: 1 / 1; margin: 0 auto;
|
||||
overflow: hidden; border-radius: 18px; background: var(--pz-line-soft, #efe9e1);
|
||||
touch-action: none; cursor: grab; user-select: none; -webkit-user-select: none;
|
||||
outline: none;
|
||||
}
|
||||
.pcrop-stage.is-dragging { cursor: grabbing; }
|
||||
.pcrop-stage:focus-visible { box-shadow: 0 0 0 2px var(--pz-a500); }
|
||||
|
||||
/* La photo, placée en pourcentages du conteneur par portraitCrop.js */
|
||||
.pcrop-img {
|
||||
position: absolute; max-width: none; pointer-events: none;
|
||||
transition: left 220ms var(--pz-ease), top 220ms var(--pz-ease), width 220ms var(--pz-ease), height 220ms var(--pz-ease);
|
||||
}
|
||||
.pcrop-stage.is-dragging .pcrop-img { transition: none; }
|
||||
|
||||
/* Masque : l'ombre étendue assombrit tout ce qui est hors du cadre */
|
||||
.pcrop-window {
|
||||
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); pointer-events: none;
|
||||
box-shadow: 0 0 0 999px rgba(42, 38, 34, .55), inset 0 0 0 1px rgba(255, 255, 255, .75);
|
||||
}
|
||||
.pcrop-window--round { border-radius: 50%; }
|
||||
.pcrop-window--square { border-radius: 4px; }
|
||||
.pcrop-window--rect { border-radius: 6px; }
|
||||
/* Polaroid : une bordure blanche autour de la zone photo */
|
||||
.pcrop-window--polaroid { border-radius: 2px; box-shadow: 0 0 0 7px #fff, 0 0 0 999px rgba(42, 38, 34, .55); }
|
||||
|
||||
.pcrop-loading { position: absolute; inset: 0; display: grid; place-items: center; color: var(--pz-muted); }
|
||||
|
||||
.pcrop-hint { margin: 0; text-align: center; color: var(--pz-muted); }
|
||||
|
||||
.pcrop-zoom {
|
||||
display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 14px;
|
||||
min-height: 40px; max-width: 300px; width: 100%; margin: 0 auto;
|
||||
}
|
||||
.pcrop-zoom input[type="range"] { width: 100%; min-height: 40px; accent-color: var(--pz-a500); cursor: pointer; }
|
||||
.pcrop-zoom input[type="range"]:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
.pcrop-actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.pcrop-modal .pz-btn--ghost:hover:not(:disabled) { background: var(--pz-line-soft); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pcrop-img { transition: none; }
|
||||
}
|
||||
|
||||
/* Forme du cadre : chaque choix montre sa forme, son nom est dans aria-label et title */
|
||||
.pcrop-shapes { width: min(100%, 300px); margin: 0 auto; }
|
||||
.pcrop-shapes .pz-tab { display: grid; place-items: center; min-height: 40px; padding: 0; color: var(--pz-muted); }
|
||||
.pcrop-shapes .pz-tab.is-active { color: var(--pz-a700, #93402A); }
|
||||
.pcrop-shapes .pz-tab:disabled { cursor: default; opacity: .5; }
|
||||
.pcrop-ico { display: block; border: 1.75px solid currentColor; }
|
||||
.pcrop-ico--round { width: 20px; height: 20px; border-radius: 50%; }
|
||||
.pcrop-ico--square { width: 20px; height: 20px; border-radius: 3px; }
|
||||
/* Polaroïd : la marge du bas plus large que les côtés */
|
||||
.pcrop-ico--polaroid { width: 20px; height: 22px; border-radius: 2px; border-bottom-width: 5px; }
|
||||
.pcrop-ico--portrait { width: 16px; height: 22px; border-radius: 3px; }
|
||||
.pcrop-ico--landscape { width: 26px; height: 16px; border-radius: 3px; }
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Crosshair, X } from 'lucide-react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import { t } from '../i18n/index.js'
|
||||
import PzBusy from './PzBusy.jsx'
|
||||
import {
|
||||
DEFAULT_CROP,
|
||||
clampCrop,
|
||||
cropForSave,
|
||||
cropShape,
|
||||
cropViewport,
|
||||
frameAspect,
|
||||
imagePlacement,
|
||||
normalizeCrop,
|
||||
resolveFrameKind,
|
||||
shapeOfKind,
|
||||
} from '../utils/portraitCrop.js'
|
||||
import './PortraitCropModal.css'
|
||||
|
||||
// Taille du masque par rapport à la zone de recadrage (carrée)
|
||||
const WINDOW_SIZE = 0.8
|
||||
// Déplacement clavier : part de la zone visible (4 %, 16 % avec Maj)
|
||||
const KEY_STEP = 0.04
|
||||
const KEY_STEP_LARGE = 0.16
|
||||
const KEY_ZOOM_STEP = 0.2
|
||||
|
||||
const SHAPE_LABELS = { round: t('Rond'), square: t('Carré'), polaroid: t('Polaroïd'), portrait: t('Vertical'), landscape: t('Horizontal') }
|
||||
|
||||
// Recadrage d'un portrait. Le masque garde la forme du cadre de la personne dans l'arbre ; la photo
|
||||
// glisse dessous. La forme se choisit aussi ici : sans choix, elle suit la photo ou le tirage de la personne.
|
||||
// `mode` : 'recrop' (depuis le menu) ou 'new' (juste après le choix d'une photo).
|
||||
const PortraitCropModal = ({ src, frameType, name, mode = 'recrop', initialCrop = null, saving = false, error = '', onSave, onClose }) => {
|
||||
const stageRef = useRef(null)
|
||||
const dragRef = useRef(null)
|
||||
const pointersRef = useRef(new Map())
|
||||
const pinchRef = useRef(null)
|
||||
const [size, setSize] = useState(null)
|
||||
const [loadFailed, setLoadFailed] = useState(false)
|
||||
const [crop, setCrop] = useState(() => normalizeCrop(initialCrop))
|
||||
const [shape, setShape] = useState(() => cropShape(initialCrop))
|
||||
const [dragging, setDragging] = useState(false)
|
||||
|
||||
const imageRatio = size ? size.w / size.h : 1
|
||||
const frameKind = resolveFrameKind(frameType, imageRatio, shape)
|
||||
const aspect = frameAspect(frameKind, imageRatio, shape)
|
||||
const shownShape = shape ?? shapeOfKind(frameKind, imageRatio)
|
||||
const winW = aspect >= 1 ? WINDOW_SIZE : WINDOW_SIZE * aspect
|
||||
const winH = aspect >= 1 ? WINDOW_SIZE / aspect : WINDOW_SIZE
|
||||
|
||||
// Toute valeur passe par clampCrop : l'affichage et l'enregistrement restent identiques
|
||||
const apply = (next) => {
|
||||
if (!size) return
|
||||
setCrop(clampCrop(size.w, size.h, aspect, next))
|
||||
}
|
||||
|
||||
const current = size ? clampCrop(size.w, size.h, aspect, crop) : crop
|
||||
const placement = size ? imagePlacement(size.w, size.h, aspect, current, winW, winH) : null
|
||||
|
||||
// Molette : zoom autour du centre. Listener natif, pour pouvoir annuler le défilement de la page.
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (!stage) return undefined
|
||||
const onWheel = (event) => {
|
||||
event.preventDefault()
|
||||
setCrop((prev) => {
|
||||
const c = size ? clampCrop(size.w, size.h, aspect, prev) : normalizeCrop(prev)
|
||||
const zoom = Math.min(4, Math.max(1, c.zoom * Math.exp(-event.deltaY * 0.002)))
|
||||
return size ? clampCrop(size.w, size.h, aspect, { ...c, zoom }) : { ...c, zoom }
|
||||
})
|
||||
}
|
||||
stage.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => stage.removeEventListener('wheel', onWheel)
|
||||
}, [size, aspect])
|
||||
|
||||
// Escape ferme la fenêtre, sauf pendant l'enregistrement
|
||||
useEffect(() => {
|
||||
const onKeyDown = (event) => {
|
||||
if (event.key === 'Escape' && !saving) onClose?.()
|
||||
}
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [saving, onClose])
|
||||
|
||||
const startDrag = (pointer) => {
|
||||
dragRef.current = { x: pointer.x, y: pointer.y, crop: current }
|
||||
setDragging(true)
|
||||
}
|
||||
|
||||
const onPointerDown = (event) => {
|
||||
if (!size || saving) return
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId)
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
if (pointersRef.current.size === 1) {
|
||||
startDrag({ x: event.clientX, y: event.clientY })
|
||||
} else if (pointersRef.current.size === 2) {
|
||||
// Deux doigts : pincement, le glissement s'arrête
|
||||
const [a, b] = [...pointersRef.current.values()]
|
||||
pinchRef.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom: current.zoom }
|
||||
dragRef.current = null
|
||||
}
|
||||
}
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
if (!pointersRef.current.has(event.pointerId) || !size) return
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY })
|
||||
if (pointersRef.current.size === 2 && pinchRef.current) {
|
||||
const [a, b] = [...pointersRef.current.values()]
|
||||
const distance = Math.hypot(a.x - b.x, a.y - b.y)
|
||||
const zoom = pinchRef.current.zoom * (distance / (pinchRef.current.distance || distance))
|
||||
apply({ ...current, zoom: Math.min(4, Math.max(1, zoom)) })
|
||||
return
|
||||
}
|
||||
const drag = dragRef.current
|
||||
const rect = stageRef.current?.getBoundingClientRect()
|
||||
if (!drag || !rect || !rect.width) return
|
||||
// Le glissement déplace la photo : le centre visible va dans le sens inverse
|
||||
const viewport = cropViewport(size.w, size.h, aspect, drag.crop)
|
||||
const dxStage = (event.clientX - drag.x) / rect.width
|
||||
const dyStage = (event.clientY - drag.y) / rect.height
|
||||
const cx = drag.crop.x * size.w - (dxStage * viewport.sw) / winW
|
||||
const cy = drag.crop.y * size.h - (dyStage * viewport.sh) / winH
|
||||
apply({ x: cx / size.w, y: cy / size.h, zoom: drag.crop.zoom })
|
||||
}
|
||||
|
||||
const onPointerUp = (event) => {
|
||||
pointersRef.current.delete(event.pointerId)
|
||||
pinchRef.current = null
|
||||
if (pointersRef.current.size === 1) {
|
||||
const [remaining] = pointersRef.current.values()
|
||||
startDrag(remaining)
|
||||
} else {
|
||||
dragRef.current = null
|
||||
setDragging(false)
|
||||
}
|
||||
}
|
||||
|
||||
const onKeyDown = (event) => {
|
||||
if (!size) return
|
||||
const viewport = cropViewport(size.w, size.h, aspect, current)
|
||||
const step = event.shiftKey ? KEY_STEP_LARGE : KEY_STEP
|
||||
const moves = {
|
||||
ArrowLeft: [-1, 0],
|
||||
ArrowRight: [1, 0],
|
||||
ArrowUp: [0, -1],
|
||||
ArrowDown: [0, 1],
|
||||
}
|
||||
if (moves[event.key]) {
|
||||
event.preventDefault()
|
||||
const [dx, dy] = moves[event.key]
|
||||
apply({
|
||||
x: (current.x * size.w + dx * step * viewport.sw) / size.w,
|
||||
y: (current.y * size.h + dy * step * viewport.sh) / size.h,
|
||||
zoom: current.zoom,
|
||||
})
|
||||
} else if (event.key === '+' || event.key === '=') {
|
||||
event.preventDefault()
|
||||
apply({ ...current, zoom: current.zoom + KEY_ZOOM_STEP })
|
||||
} else if (event.key === '-' || event.key === '_') {
|
||||
event.preventDefault()
|
||||
apply({ ...current, zoom: current.zoom - KEY_ZOOM_STEP })
|
||||
}
|
||||
}
|
||||
|
||||
const recenter = () => apply(DEFAULT_CROP)
|
||||
const setZoom = (zoom) => apply({ ...current, zoom: Number(zoom) })
|
||||
const save = () => {
|
||||
if (!size || saving) return
|
||||
onSave?.(cropForSave(size.w, size.h, aspect, current, shape))
|
||||
}
|
||||
|
||||
const isNew = mode === 'new'
|
||||
|
||||
return createPortal(
|
||||
<div className="pz-overlay pcrop-overlay" onClick={(event) => { if (event.target === event.currentTarget && !saving) onClose?.() }}>
|
||||
<div className="pz-modal pcrop-modal" role="dialog" aria-modal="true" aria-labelledby="pcropTitle">
|
||||
<div className="pz-modal-head">
|
||||
<h1 id="pcropTitle" className="pz-title">{t('Recadrer la photo')}</h1>
|
||||
{name && <p className="pz-sub">{name}</p>}
|
||||
</div>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-modal-close" onClick={onClose} disabled={saving} aria-label={t('Fermer')}>
|
||||
<X size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<div className="pcrop-body">
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`pcrop-stage ${dragging ? 'is-dragging' : ''}`}
|
||||
tabIndex={0}
|
||||
role="group"
|
||||
aria-label={t('Zone de recadrage')}
|
||||
aria-describedby="pcropHint"
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{src && (
|
||||
<img
|
||||
className="pcrop-img"
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={(event) => setSize({ w: event.currentTarget.naturalWidth, h: event.currentTarget.naturalHeight })}
|
||||
onError={() => setLoadFailed(true)}
|
||||
style={placement ? { left: `${placement.left}%`, top: `${placement.top}%`, width: `${placement.width}%`, height: `${placement.height}%` } : { visibility: 'hidden' }}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`pcrop-window pcrop-window--${frameKind}`}
|
||||
style={{ width: `${winW * 100}%`, height: `${winH * 100}%` }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{!size && !loadFailed && <div className="pcrop-loading"><PzBusy busy busyLabel={t('Chargement de la photo')} /></div>}
|
||||
</div>
|
||||
|
||||
<div className="pz-tabs pcrop-shapes" role="radiogroup" aria-label={t('Forme du cadre')}>
|
||||
{Object.entries(SHAPE_LABELS).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={shownShape === value}
|
||||
className={`pz-tab ${shownShape === value ? 'is-active' : ''}`}
|
||||
onClick={() => setShape(value)}
|
||||
disabled={!size || saving}
|
||||
aria-label={label}
|
||||
title={label}
|
||||
>
|
||||
<span className={`pcrop-ico pcrop-ico--${value}`} aria-hidden="true" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p id="pcropHint" className="pz-small pcrop-hint">
|
||||
{t('Glissez la photo, ou utilisez les flèches. Molette, pincement ou curseur pour zoomer.')}
|
||||
</p>
|
||||
|
||||
<label className="pcrop-zoom">
|
||||
<span className="pz-small">{t('Zoom')}</span>
|
||||
<input
|
||||
type="range"
|
||||
min="1"
|
||||
max="4"
|
||||
step="0.01"
|
||||
value={current.zoom}
|
||||
onChange={(event) => setZoom(event.target.value)}
|
||||
disabled={!size || saving}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{loadFailed && <p className="pz-error">{t('Impossible d’afficher cette photo.')}</p>}
|
||||
{error && <p className="pz-error">{error}</p>}
|
||||
</div>
|
||||
|
||||
<div className="pz-modal-actions pz-modal-actions--split">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={recenter} disabled={!size || saving}>
|
||||
<Crosshair size={16} aria-hidden="true" />
|
||||
{t('Recentrer')}
|
||||
</button>
|
||||
<div className="pcrop-actions">
|
||||
<button type="button" className="pz-btn pz-btn--ghost" onClick={onClose} disabled={saving}>
|
||||
{isNew ? t('Garder tel quel') : t('Annuler')}
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--primary" onClick={save} disabled={!size || saving || loadFailed}>
|
||||
<PzBusy busy={saving} busyLabel={t('Enregistrement en cours')}>{t('Enregistrer')}</PzBusy>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
export default PortraitCropModal
|
||||
@@ -1,146 +0,0 @@
|
||||
/* ============================================================
|
||||
Proximity Feedback - Feedback visuel drag proximity
|
||||
============================================================ */
|
||||
|
||||
/* Ligne pointillée entre drag et personne proche */
|
||||
.proximity-line {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.proximity-line-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.proximity-line-path {
|
||||
fill: none;
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 6, 4;
|
||||
stroke-linecap: round;
|
||||
animation: proximityLineDash 1s linear infinite;
|
||||
}
|
||||
|
||||
.proximity-line-path.child {
|
||||
stroke: rgba(76, 217, 100, 0.8);
|
||||
}
|
||||
|
||||
.proximity-line-path.spouse {
|
||||
stroke: rgba(255, 59, 48, 0.7);
|
||||
}
|
||||
|
||||
.proximity-line-path.sibling {
|
||||
stroke: rgba(210, 105, 74, 0.7);
|
||||
}
|
||||
|
||||
.proximity-line-path.none {
|
||||
stroke: rgba(142, 142, 147, 0.5);
|
||||
}
|
||||
|
||||
@keyframes proximityLineDash {
|
||||
to {
|
||||
stroke-dashoffset: -10;
|
||||
}
|
||||
}
|
||||
|
||||
/* Badge type de lien sur la ligne */
|
||||
.proximity-badge {
|
||||
position: fixed;
|
||||
padding: 6px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
z-index: 1001;
|
||||
|
||||
box-shadow: 0 4px 12px rgba(42, 38, 34, 0.15);
|
||||
|
||||
animation: badgePulse 0.5s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes badgePulse {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.proximity-badge.child {
|
||||
background: rgba(76, 217, 100, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.proximity-badge.spouse {
|
||||
background: rgba(255, 59, 48, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.proximity-badge.sibling {
|
||||
background: rgba(210, 105, 74, 0.95);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Highlight glow sur la personne proche */
|
||||
.proximity-highlight {
|
||||
position: fixed;
|
||||
pointer-events: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.proximity-highlight-glow {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
animation: proximityGlow 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.proximity-highlight-glow.child {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(76, 217, 100, 0.4) 0%,
|
||||
rgba(76, 217, 100, 0.2) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.proximity-highlight-glow.spouse {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(255, 59, 48, 0.35) 0%,
|
||||
rgba(255, 59, 48, 0.15) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
.proximity-highlight-glow.sibling {
|
||||
background: radial-gradient(
|
||||
circle,
|
||||
rgba(210, 105, 74, 0.35) 0%,
|
||||
rgba(210, 105, 74, 0.15) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
|
||||
@keyframes proximityGlow {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.15);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { t } from '../i18n/index.js'
|
||||
import './ProximityFeedback.css'
|
||||
|
||||
/**
|
||||
* Feedback visuel de proximité pendant le drag
|
||||
* Affiche ligne pointillée + glow sur personne proche + badge type lien
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {boolean} props.visible - Afficher le feedback
|
||||
* @param {Object} props.dragPosition - Position drag {x, y}
|
||||
* @param {Object} props.closestPerson - Personne proche {id, x, y, person}
|
||||
* @param {string} props.linkType - Type de lien ('child', 'spouse', 'sibling')
|
||||
*/
|
||||
function ProximityFeedback({ visible, dragPosition, closestPerson, linkType }) {
|
||||
if (!visible || !closestPerson || !linkType) return null
|
||||
|
||||
const { x: personX, y: personY } = closestPerson
|
||||
const { x: dragX, y: dragY } = dragPosition
|
||||
|
||||
// Position badge au milieu de la ligne
|
||||
const badgeX = (dragX + personX) / 2
|
||||
const badgeY = (dragY + personY) / 2
|
||||
|
||||
// Labels
|
||||
const linkLabels = {
|
||||
child: t('Enfant'),
|
||||
spouse: t('Conjoint'),
|
||||
sibling: t('Frère·Sœur'),
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Ligne pointillée */}
|
||||
<div className="proximity-line">
|
||||
<svg className="proximity-line-svg">
|
||||
<line
|
||||
x1={dragX}
|
||||
y1={dragY}
|
||||
x2={personX}
|
||||
y2={personY}
|
||||
className={`proximity-line-path ${linkType}`}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Badge type lien */}
|
||||
<div
|
||||
className={`proximity-badge ${linkType}`}
|
||||
style={{
|
||||
left: `${badgeX}px`,
|
||||
top: `${badgeY}px`,
|
||||
transform: 'translate(-50%, -50%)',
|
||||
}}
|
||||
>
|
||||
{linkLabels[linkType] || t('Lien')}
|
||||
</div>
|
||||
|
||||
{/* Glow sur personne proche */}
|
||||
<div
|
||||
className="proximity-highlight"
|
||||
style={{
|
||||
left: `${personX - 50}px`,
|
||||
top: `${personY - 50}px`,
|
||||
}}
|
||||
>
|
||||
<div className={`proximity-highlight-glow ${linkType}`} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ProximityFeedback
|
||||
@@ -67,9 +67,12 @@
|
||||
.contrib-invite-passwords { display: grid; gap: 6px; }
|
||||
.contrib-pass-hint { margin: 2px 4px 0; font: 400 12.5px/1.45 var(--pz-sans); color: var(--pz-muted); }
|
||||
.contrib-invite-message { display: grid; gap: 8px; }
|
||||
.contrib-invite-message-row { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px 12px; }
|
||||
.contrib-invite-message-label { font: 500 12.5px var(--pz-sans); color: var(--pz-muted); }
|
||||
.contrib-link-btn { border: 0; padding: 4px 2px; background: none; cursor: pointer; font: 500 12.5px var(--pz-sans); color: var(--pz-a700); text-decoration: underline; text-underline-offset: 2px; }
|
||||
.contrib-link-btn:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; border-radius: 4px; }
|
||||
.contrib-invite-textarea { width: 100%; box-sizing: border-box; padding: 12px 14px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); font: 400 13.5px/1.5 var(--pz-sans); color: var(--pz-ink-2); resize: vertical; outline: none; field-sizing: content; min-height: 9.5lh; }
|
||||
.contrib-invite-textarea:focus { border-color: var(--pz-a500); box-shadow: 0 0 0 3px var(--pz-a100); }
|
||||
.contrib-invite-message-actions { display: flex; justify-content: flex-end; }
|
||||
.contrib-role-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 8px 8px 12px; background: var(--pz-surface); border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); }
|
||||
.contrib-role-badge { display: grid; gap: 1px; min-width: 130px; flex: 1; }
|
||||
.contrib-role-badge strong { font: 500 14px var(--pz-sans); color: var(--pz-ink); }
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
.drop-trash-zone {
|
||||
position: fixed;
|
||||
top: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 1060;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 32px 14px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(24px);
|
||||
-webkit-backdrop-filter: blur(24px);
|
||||
border: 2px dashed rgba(147, 64, 42, 0.3);
|
||||
box-shadow:
|
||||
0 8px 40px rgba(42, 38, 34, 0.2),
|
||||
0 2px 8px rgba(42, 38, 34, 0.08);
|
||||
animation: trashSlideDown 0.35s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes trashSlideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-50%) translateY(-20px) scale(0.85);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.drop-trash-zone__circle {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: rgba(147, 64, 42, 0.1);
|
||||
border: 2px solid rgba(147, 64, 42, 0.2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6B645D;
|
||||
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.drop-trash-zone__icon {
|
||||
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.drop-trash-zone__label {
|
||||
font-size: 14px;
|
||||
font-weight: 450;
|
||||
color: #2A2622;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Hover state: over trash --- */
|
||||
.drop-trash-zone--over {
|
||||
border-color: #C0392B;
|
||||
background: rgba(192, 57, 43, 0.08);
|
||||
transform: translateX(-50%) scale(1.08);
|
||||
box-shadow:
|
||||
0 12px 48px rgba(192, 57, 43, 0.25),
|
||||
0 4px 12px rgba(192, 57, 43, 0.12);
|
||||
}
|
||||
|
||||
.drop-trash-zone--over .drop-trash-zone__circle {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background: rgba(192, 57, 43, 0.12);
|
||||
border-color: #C0392B;
|
||||
color: #C0392B;
|
||||
animation: trashPulse 0.8s ease infinite;
|
||||
}
|
||||
|
||||
@keyframes trashPulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(192, 57, 43, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 10px rgba(192, 57, 43, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.drop-trash-zone--over .drop-trash-zone__label {
|
||||
color: #C0392B;
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { t } from '../../i18n/index.js'
|
||||
import './DropTrashZone.css'
|
||||
|
||||
export default function DropTrashZone({ visible, isOver }) {
|
||||
if (!visible) return null
|
||||
|
||||
return (
|
||||
<div className={`drop-trash-zone${isOver ? ' drop-trash-zone--over' : ''}`}>
|
||||
<div className="drop-trash-zone__circle">
|
||||
<Trash2
|
||||
size={isOver ? 28 : 22}
|
||||
strokeWidth={2}
|
||||
className="drop-trash-zone__icon"
|
||||
/>
|
||||
</div>
|
||||
<span className="drop-trash-zone__label">
|
||||
{isOver ? t('Relâcher pour supprimer') : t('Glisser ici pour supprimer')}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
Pa────•───Ma
|
||||
│
|
||||
┬───┴────┬
|
||||
│ │
|
||||
A B
|
||||
@@ -0,0 +1,13 @@
|
||||
SG────•───SGw
|
||||
│
|
||||
┬────┴
|
||||
│
|
||||
SP────•───SM Pa────•───Ma
|
||||
│ │
|
||||
┬────────┼────────┬ ┼────────┬
|
||||
│ │ │ │ │
|
||||
S2 S3 S────•────E E2
|
||||
│
|
||||
┬───┴────┬
|
||||
│ │
|
||||
K1 K2
|
||||
@@ -0,0 +1,21 @@
|
||||
A0────•───A0w
|
||||
│
|
||||
┬────┴
|
||||
│
|
||||
A1────•───A1w
|
||||
│
|
||||
┬────┴
|
||||
│
|
||||
A2────•───A2w
|
||||
│
|
||||
┬────┴
|
||||
│
|
||||
A3────•───A3w B0────•───B0w
|
||||
│ │
|
||||
┴─┬ ┬──────┴────┬
|
||||
│ │ │
|
||||
X────•────Y S
|
||||
│
|
||||
┼
|
||||
│
|
||||
K
|
||||
Loaded 100 of 199 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user