Merge pull request #230 from IliesAllali/chore/nettoyage-2026-11-12

Nettoyage : code mort, restes d'anciennes versions, doublons regroupés
This commit is contained in:
IliesAllali authored and GitHub committed 2026-11-12 13:57:01 +01:00
commit bbbb5eb3ce
63 files changed
+218 -1598

No files matched your search

+3 -10
View File
@@ -1,5 +1,3 @@
import path from 'node:path'
import { Prisma, type PrismaClient } from '../generated/prisma/client.js'
import { z } from 'zod'
@@ -15,9 +13,9 @@ type Db = PrismaClient | Tx
export const MAX_ANNOTATIONS_PER_TREE = 5000
// Un envoi de photo jamais proposé vit le temps d'un brouillon de la famille
export const PENDING_PHOTO_TTL_MS = 7 * 24 * 60 * 60 * 1000
const PENDING_PHOTO_TTL_MS = 7 * 24 * 60 * 60 * 1000
// Une famille pose quelques photos à la fois ; au-delà, c'est un envoi en boucle
export const MAX_PENDING_PHOTOS_PER_TREE = 50
const MAX_PENDING_PHOTOS_PER_TREE = 50
const MAX_STYLE_CHARS = 4000
const styleSchema = z.record(z.string(), z.unknown()).refine(
@@ -93,7 +91,7 @@ export function normalizedAnnotationAfter(change: AnnotationChange, treeId: stri
}
// Identifiant de la photo d'une annotation (contenu JSON { photoPath }), ou null
export function annotationPhotoId(after: unknown, treeId: string): string | null {
function annotationPhotoId(after: unknown, treeId: string): string | null {
if (!after || typeof after !== 'object' || Array.isArray(after)) return null
const content = (after as { content?: unknown }).content
if (typeof content !== 'string' || !content.startsWith('{')) return null
@@ -227,8 +225,3 @@ export async function reservePendingPhoto(prisma: PrismaClient, treeId: string,
export async function forgetPendingPhoto(db: Db, photoId: string): Promise<void> {
await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } })
}
// Chemin relatif sûr d'une photo d'annotation (sous <treeId>/annotation-photos/)
export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string {
return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import { z } from 'zod'
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
const AVATAR_CROP_MAX_ZOOM = 4
export const avatarCropInputSchema = z.object({
crop: z
+10 -7
View File
@@ -45,8 +45,11 @@ const clip = (value: string | null | undefined, max: number) => {
const child = (node: Node | undefined, tag: string) => node?.children.find((c) => c.tag === tag)
// Même règle que le lieu de naissance : noms seulement, sans case vide ni code INSEE ou postal
export function cleanPlace(value: string | null | undefined): string | null {
// Lieu d'un GEDCOM : les logiciels remplissent toutes les colonnes de leur format de lieu, même vides, et y
// glissent des codes. Heredis écrit « La Roche-sur-Yon,85191,Vendée,Pays de la Loire,France, » (ville, code
// INSEE, département, région, pays, subdivision). On garde les noms, sans case vide, sans code INSEE ni
// postal, sans répétition : « La Roche-sur-Yon, Vendée, Pays de la Loire, France ».
export function cleanGedcomPlace(value: string | null | undefined): string | null {
if (!value) return null
const parts: string[] = []
for (const raw of value.split(',')) {
@@ -58,7 +61,7 @@ export function cleanPlace(value: string | null | undefined): string | null {
return parts.length ? parts.join(', ') : null
}
export function isoToDbDate(iso: string | null | undefined): Date | null {
function isoToDbDate(iso: string | null | undefined): Date | null {
if (!iso) return null
if (/^\d{4}$/.test(iso)) return new Date(`${iso}-01-01T00:00:00.000Z`)
if (/^\d{4}-\d{2}$/.test(iso)) return new Date(`${iso}-01T00:00:00.000Z`)
@@ -76,7 +79,7 @@ function eventSources(node: Node, records: GedcomRecords): string | null {
}
// Ce qu'on lit d'un événement : date et précision, lieu (ou adresse), description, notes, sources
export function readEvent(node: Node, type: string, records: GedcomRecords): ImportedEvent {
function readEvent(node: Node, type: string, records: GedcomRecords): ImportedEvent {
const dateText = child(node, 'DATE')?.value ?? null
const parsed = parseGedcomDate(dateText)
const addr = child(node, 'ADDR')
@@ -84,7 +87,7 @@ export function readEvent(node: Node, type: string, records: GedcomRecords): Imp
.map((part) => part.trim())
.filter(Boolean)
.join(', ')
const place = cleanPlace(child(node, 'PLAC')?.value) ?? clip(address, 300)
const place = cleanGedcomPlace(child(node, 'PLAC')?.value) ?? clip(address, 300)
const typeLabel = joinedText(child(node, 'TYPE'))
const value = node.value && node.value.toUpperCase() !== 'Y' ? node.value : ''
return {
@@ -142,7 +145,7 @@ export function personExtras(indi: Node, records: GedcomRecords): PersonExtras {
birthDateEnd: isoToDbDate(birthDate?.endIso),
deathDatePrecision: deathDate && deathDate.precision !== 'exact' ? deathDate.precision : null,
deathDateEnd: isoToDbDate(deathDate?.endIso),
deathPlace: clip(cleanPlace(child(death, 'PLAC')?.value), 200),
deathPlace: clip(cleanGedcomPlace(child(death, 'PLAC')?.value), 200),
nickname: clip(child(nameNode, 'NICK')?.value ?? child(indi, 'NICK')?.value, 120),
events,
}
@@ -171,7 +174,7 @@ export function familyExtras(fam: Node, records: GedcomRecords): UnionExtras {
events.push(event)
}
return {
startPlace: clip(cleanPlace(child(marriage, 'PLAC')?.value), 300),
startPlace: clip(cleanGedcomPlace(child(marriage, 'PLAC')?.value), 300),
startDatePrecision: date && date.precision !== 'exact' ? date.precision : null,
events,
}
+4 -35
View File
@@ -1,37 +1,6 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
import { env } from '../config/env.js'
import { sealedSecret } from './sealed-secret.js'
// Clé d'API Immich d'un arbre : secrète au repos, jamais renvoyée au front ni écrite dans les
// journaux. Même mécanisme que le mot de passe de partage (share-password.ts) : AES-256-GCM,
// clé dérivée de JWT_SECRET. Changer ce secret rend les clés illisibles ; chaque arbre
// repasse alors en « à configurer », sans rien casser d'autre.
const VERSION = 'v1'
function getKey(): Buffer {
return Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'parenthese', 'immich-api-key-v1', 32))
}
export function encryptImmichApiKey(value: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${VERSION}:${Buffer.concat([iv, tag, encrypted]).toString('base64')}`
}
// null si la valeur stockée est absente, d'un autre format, ou illisible (JWT_SECRET changé).
export function decryptImmichApiKey(stored: string | null | undefined): string | null {
if (!stored || !stored.startsWith(`${VERSION}:`)) {
return null
}
try {
const raw = Buffer.from(stored.slice(VERSION.length + 1), 'base64')
const decipher = createDecipheriv('aes-256-gcm', getKey(), raw.subarray(0, 12))
decipher.setAuthTag(raw.subarray(12, 28))
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
} catch {
return null
}
}
// journaux. Changer JWT_SECRET rend les clés illisibles ; chaque arbre repasse alors en
// « à configurer », sans rien casser d'autre.
export const { encrypt: encryptImmichApiKey, decrypt: decryptImmichApiKey } = sealedSecret('immich-api-key-v1')
+2 -2
View File
@@ -61,7 +61,7 @@ type ImmichPeoplePage = {
hasNextPage?: unknown
}
export class ImmichClient {
class ImmichClient {
private readonly apiUrl: string
private readonly apiKey: string
@@ -274,7 +274,7 @@ export type ImmichInstance = {
let instancesCache: ImmichInstance[] | undefined
export function immichInstanceId(apiUrl: string): string {
function immichInstanceId(apiUrl: string): string {
return createHash('sha256').update(apiUrl).digest('hex').slice(0, 16)
}
+2 -11
View File
@@ -5,11 +5,11 @@ type Db = PrismaClient | Prisma.TransactionClient
// Personnes identifiées sur un souvenir. La liste envoyée remplace la précédente : retirer quelqu'un, c'est
// renvoyer la liste sans lui. Le point (x, y) est facultatif, en proportion de l'image (0 à 1), et va par paire.
export const MAX_PEOPLE_PER_MEDIA = 50
const MAX_PEOPLE_PER_MEDIA = 50
const coordinate = z.number().min(0).max(1).nullable().optional()
export const mediaPersonSchema = z
const mediaPersonSchema = z
.object({
personId: z.string().min(1).max(64),
x: coordinate,
@@ -28,15 +28,6 @@ export function normalizeMediaPeople(people: z.infer<typeof mediaPeopleSchema>):
return people.map((person) => ({ personId: person.personId, x: person.x ?? null, y: person.y ?? null }))
}
export async function readMediaPeople(db: Db, treeId: string, mediaId: string): Promise<MediaPerson[]> {
const tags = await db.mediaPersonTag.findMany({
where: { treeId, mediaId },
select: { personId: true, x: true, y: true },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
return tags
}
// Remplace les personnes d'un souvenir. Une personne supprimée entre l'envoi et la relecture est écartée
// sans faire échouer le reste. Renvoie la liste réellement enregistrée.
export async function replaceMediaPeople(
+1 -6
View File
@@ -31,7 +31,7 @@ export function isOidcEnabled(): boolean {
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> {
function getOidcConfiguration(): Promise<client.Configuration> {
configuration ??= discover().catch((error: unknown) => {
configuration = null
throw error
@@ -122,8 +122,3 @@ export async function finishAuthorization(callbackQuery: string, pending: OidcPe
firstName: firstName?.slice(0, 60) ?? null,
}
}
// Pour les tests : oublie la configuration découverte
export function resetOidcConfiguration() {
configuration = null
}
+37
View File
@@ -0,0 +1,37 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
import { env } from '../config/env.js'
// Secret gardé au repos et relisible par le serveur : AES-256-GCM, clé dérivée de JWT_SECRET et d'un
// libellé propre à chaque usage. Changer JWT_SECRET rend les valeurs illisibles : la lecture renvoie null.
const VERSION = 'v1'
export function sealedSecret(usage: string) {
const key = () => Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'parenthese', usage, 32))
function encrypt(value: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', key(), iv)
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${VERSION}:${Buffer.concat([iv, tag, encrypted]).toString('base64')}`
}
// null si la valeur stockée est absente, d'un autre format, ou illisible (JWT_SECRET changé).
function decrypt(stored: string | null | undefined): string | null {
if (!stored || !stored.startsWith(`${VERSION}:`)) {
return null
}
try {
const raw = Buffer.from(stored.slice(VERSION.length + 1), 'base64')
const decipher = createDecipheriv('aes-256-gcm', key(), raw.subarray(0, 12))
decipher.setAuthTag(raw.subarray(12, 28))
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
} catch {
return null
}
}
return { encrypt, decrypt }
}
+1 -1
View File
@@ -36,7 +36,7 @@ const TIMEOUT_MS = 10_000
const MAX_RESULTS = 10
const USER_AGENT = 'Parenthese (+https://parenthese.io)'
export function normalizeName(value: string): string {
function normalizeName(value: string): string {
return value.normalize('NFD').replace(/\p{Diacritic}/gu, '').toLowerCase().replace(/[^a-z]+/g, ' ').trim()
}
+4 -33
View File
@@ -1,35 +1,6 @@
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from 'node:crypto'
import { env } from '../config/env.js'
import { sealedSecret } from './sealed-secret.js'
// Le mot de passe de partage est fait pour être donné à la famille : le propriétaire doit pouvoir le relire.
// Chiffré en AES-256-GCM, clé dérivée de JWT_SECRET (changer ce secret rend les valeurs illisibles,
// le panneau repasse alors en « à saisir », sans rien casser pour la famille).
const VERSION = 'v1'
function getKey(): Buffer {
return Buffer.from(hkdfSync('sha256', env.JWT_SECRET, 'parenthese', 'share-password-v1', 32))
}
export function encryptSharePassword(value: string): string {
const iv = randomBytes(12)
const cipher = createCipheriv('aes-256-gcm', getKey(), iv)
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
const tag = cipher.getAuthTag()
return `${VERSION}:${Buffer.concat([iv, tag, encrypted]).toString('base64')}`
}
export function decryptSharePassword(stored: string | null | undefined): string | null {
if (!stored || !stored.startsWith(`${VERSION}:`)) {
return null
}
try {
const raw = Buffer.from(stored.slice(VERSION.length + 1), 'base64')
const decipher = createDecipheriv('aes-256-gcm', getKey(), raw.subarray(0, 12))
decipher.setAuthTag(raw.subarray(12, 28))
return Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8')
} catch {
return null
}
}
// Changer JWT_SECRET rend les valeurs illisibles : le panneau repasse alors en « à saisir », sans rien
// casser pour la famille.
export const { encrypt: encryptSharePassword, decrypt: decryptSharePassword } = sealedSecret('share-password-v1')
+1 -1
View File
@@ -36,7 +36,7 @@ export async function canReadTree(app: FastifyInstance, treeId: string, actor: A
// Les images sont chargées par <img src>, qui ne peut pas envoyer d'en-tête : leur adresse porte un jeton.
// Ce jeton ne sert qu'à lire les médias d'un arbre, jamais la session du compte (qui finissait dans les
// journaux, l'historique et les liens copiés).
export const MEDIA_TOKEN_TTL = '12h'
const MEDIA_TOKEN_TTL = '12h'
export function signMediaToken(app: FastifyInstance, treeId: string, actor: NonNullable<Actor>): string {
const payload: MediaJwtPayload = {
+1 -1
View File
@@ -10,7 +10,7 @@ export type PurgeLogger = {
error: (details: Record<string, unknown>, message: string) => void
}
export function getMediaStorageRoot(): string {
function getMediaStorageRoot(): string {
return path.resolve(env.MEDIA_STORAGE_PATH)
}
+4 -4
View File
@@ -5,7 +5,7 @@ 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
const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000
// 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
@@ -14,14 +14,14 @@ let lastPurgeAt = 0
export type VisitAccessKind = 'share' | 'member' | 'shared_account'
export function deviceFromUserAgent(userAgent: string): 'phone' | 'tablet' | 'desktop' {
function deviceFromUserAgent(userAgent: string): 'phone' | 'tablet' | 'desktop' {
if (/iPad|Tablet/i.test(userAgent)) return 'tablet'
if (/Mobi|Android|iPhone|iPod/i.test(userAgent)) return 'phone'
return 'desktop'
}
// Prénom envoyé par l'app en en-tête X-Visitor-Name (encodé en URI pour les accents)
export function visitorNameFromHeader(value: unknown): string | null {
function visitorNameFromHeader(value: unknown): string | null {
if (typeof value !== 'string' || !value) return null
let decoded = value
try {
@@ -33,7 +33,7 @@ export function visitorNameFromHeader(value: unknown): string | null {
return trimmed || null
}
export function visitorKeyOf(treeId: string, ip: string, userAgent: string): string {
function visitorKeyOf(treeId: string, ip: string, userAgent: string): string {
return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32)
}
-6
View File
@@ -40,12 +40,6 @@ export function reserveTreeUnlockAttempt(treeId: string, now = Date.now()): (()
}
}
export function recordTreeUnlockFailure(treeId: string, now = Date.now()): void {
const recent = recentAttempts(treeId, now)
recent.push(now)
attemptsByTree.set(treeId, recent)
}
export function resetTreeUnlockGuard(): void {
attemptsByTree.clear()
}
+1 -1
View File
@@ -1450,7 +1450,7 @@ export const contributionRoutes: FastifyPluginAsync = async (app) => {
const rejectedMediaIds: string[] = []
const filesToRemove: string[] = []
const { updatedSession, approvedCount, rejectedCount, conflictCount, status } = await app.prisma.$transaction(async (tx) => {
const { updatedSession, approvedCount, rejectedCount, conflictCount } = await app.prisma.$transaction(async (tx) => {
// Deux relectures simultanées : la seconde attend le verrou de la ligne, puis ne trouve plus
// la session en attente. Sans ce verrou, les deux appliquaient les mêmes ajouts.
const claimed = await tx.contributionSession.updateMany({
+1 -17
View File
@@ -3,7 +3,7 @@ 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 { adoptedFamilies, cleanGedcomPlace, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.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'
@@ -44,22 +44,6 @@ function clip(value: string | null, max: number): string | null {
return trimmed ? trimmed.slice(0, max) : null
}
// Lieu d'un GEDCOM : les logiciels remplissent toutes les colonnes de leur format de lieu, même vides, et y
// glissent des codes. Heredis écrit « La Roche-sur-Yon,85191,Vendée,Pays de la Loire,France, » (ville, code
// INSEE, département, région, pays, subdivision). On garde les noms, sans case vide, sans code INSEE ni
// postal, sans répétition : « La Roche-sur-Yon, Vendée, Pays de la Loire, France ».
export function cleanGedcomPlace(value: string | null | undefined): string | null {
if (!value) return null
const parts: string[] = []
for (const raw of value.split(',')) {
const part = raw.trim()
if (!part || /^(\d{4,5}|2[AB]\d{3})$/i.test(part)) continue
if (parts.length && parts[parts.length - 1].toLowerCase() === part.toLowerCase()) continue
parts.push(part)
}
return parts.length ? parts.join(', ') : null
}
// Export : un champ sur une ligne ne doit jamais en créer une autre (enregistrement injecté)
function oneLine(value: string | null | undefined): string {
return String(value ?? '').replace(/[\r\n]+/g, ' ')
+1 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { cleanGedcomPlace } from '../src/routes/gedcom'
import { cleanGedcomPlace } from '../src/lib/gedcom-events'
describe('lieu importé d’un GEDCOM', () => {
it('Heredis : colonnes vides et code INSEE retirés', () => {
+2 -3
View File
@@ -6,7 +6,6 @@ import { isMimeAllowedForType, sanitizeImage, scrubMp4Location, sniffMimeType }
import { matchSharePassword } from '../src/lib/password-variants.js'
import {
isTreeUnlockLocked,
recordTreeUnlockFailure,
reserveTreeUnlockAttempt,
resetTreeUnlockGuard,
serializePasswordCheck,
@@ -159,9 +158,9 @@ describe('mot de passe de partage', () => {
it('verrouille un arbre après 30 échecs dans la fenêtre, puis le libère', () => {
resetTreeUnlockGuard()
const now = Date.now()
for (let i = 0; i < 29; i += 1) recordTreeUnlockFailure('tree-x', now)
for (let i = 0; i < 29; i += 1) reserveTreeUnlockAttempt('tree-x', now)
expect(isTreeUnlockLocked('tree-x', now)).toBe(false)
recordTreeUnlockFailure('tree-x', now)
reserveTreeUnlockAttempt('tree-x', now)
expect(isTreeUnlockLocked('tree-x', now)).toBe(true)
expect(isTreeUnlockLocked('tree-autre', now)).toBe(false)
expect(isTreeUnlockLocked('tree-x', now + 16 * 60 * 1000)).toBe(false)
-587
View File
@@ -21,258 +21,10 @@
place-items: center;
}
.account-dashboard-card {
width: min(720px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
display: grid;
gap: 12px;
padding: 24px;
border-radius: 18px;
border: 1px solid #ebe6df;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 16px 38px rgba(42, 38, 34, 0.14);
}
.account-dashboard-card h1 {
font-size: 28px;
color: #2a2622;
}
.account-dashboard-card h2 {
font-size: 20px;
color: #4f4841;
}
.account-dashboard-card p {
color: #4f4841;
}
.account-form,
.create-tree-form {
display: grid;
gap: 8px;
background: rgba(255, 255, 255, 0.65);
border: 1px solid #ebe6df;
border-radius: 12px;
padding: 14px;
}
.account-form label,
.create-tree-form label {
font-size: 14px;
color: #4f4841;
}
.account-form input,
.create-tree-form input {
width: 100%;
border-radius: 10px;
border: 1px solid #e6e1da;
background: #ffffff;
color: #2a2622;
padding: 10px 12px;
outline: none;
}
.account-form input:focus,
.create-tree-form input:focus {
border-color: #d2694a;
box-shadow: 0 0 0 2px rgba(210, 105, 74, 0.15);
}
.account-form button,
.create-tree-form button {
border: 0;
border-radius: 10px;
padding: 11px 14px;
font-weight: 450;
color: #ffffff;
background: #2a2622;
cursor: pointer;
margin-top: 4px;
}
.account-form button:disabled,
.create-tree-form button:disabled {
opacity: 0.65;
cursor: not-allowed;
}
.tree-name {
font-weight: 700;
}
.tree-meta {
font-size: 14px;
color: #4f4841;
}
.wizard-overlay {
position: absolute;
inset: 0;
z-index: 30;
display: grid;
place-items: center;
padding: 20px;
background: rgba(34, 33, 31, 0.34);
}
.wizard-card {
width: min(460px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
display: grid;
gap: 10px;
border-radius: 18px;
border: 1px solid #f4d6c9;
background: #ffffff;
padding: 22px 20px 18px;
box-shadow: 0 24px 45px rgba(36, 33, 30, 0.34);
}
/* ── Sections personnes ── */
.wizard-persons {
display: grid;
gap: 8px;
}
.wizard-person {
display: grid;
gap: 7px;
border: 1px solid #edb6a4;
border-radius: 12px;
background: rgba(251, 250, 249, 0.9);
padding: 11px 12px;
}
.wizard-person-label {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #8a847d;
}
.wizard-person-label--self {
color: #2a2622;
}
.wizard-person-fields {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.wizard-card input {
width: 100%;
border-radius: 9px;
border: 1px solid #e6e1da;
background: #ffffff;
color: #2a2622;
padding: 9px 11px;
font-size: 14px;
outline: none;
box-sizing: border-box;
}
.wizard-card input::placeholder {
color: #a7a29c;
}
.wizard-card input:focus {
border-color: #d2694a;
box-shadow: 0 0 0 2px rgba(210, 105, 74, 0.15);
}
.wizard-year {
grid-column: 1 / -1;
font-size: 13px !important;
}
.wizard-card button {
border: 0;
border-radius: 10px;
padding: 11px 14px;
font-size: 14px;
font-weight: 500;
color: #ffffff;
background: #2a2622;
cursor: pointer;
margin-top: 4px;
transition: filter 0.15s;
}
.wizard-card button:not(:disabled):hover {
filter: brightness(1.08);
}
.wizard-card button:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.wizard-card .ghost {
margin-top: 0;
background: transparent;
color: #5f5a54;
border: 1px solid #efc7b8;
font-weight: 450;
}
.wizard-error {
background: rgba(171, 49, 49, 0.1);
border: 1px solid rgba(171, 49, 49, 0.32);
color: #862f2f;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
}
/* ============================================================
Bannière de bienvenue — onboarding premier utilisateur
============================================================ */
.demo-welcome {
position: fixed;
top: 20px;
left: 20px;
z-index: 800;
max-width: 260px;
padding: 14px 16px;
background: rgba(242, 239, 234, 0.93);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-radius: 16px;
border: 1px solid rgba(42, 38, 34, 0.1);
box-shadow:
0 4px 24px rgba(42, 38, 34, 0.14),
0 1px 4px rgba(42, 38, 34, 0.06);
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 13px;
color: #2A2622;
line-height: 1.55;
pointer-events: none;
animation: demoWelcomeIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.3s both;
}
@keyframes demoWelcomeIn {
from {
opacity: 0;
transform: translateY(-8px) scale(0.96);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.demo-welcome strong {
color: #2a2622;
display: block;
margin-bottom: 5px;
font-size: 14px;
}
/* ============================================================
Nœud vide arbre — premier arbre sans personnes
============================================================ */
@@ -349,190 +101,6 @@
margin-left: 2px;
}
.media-manager-overlay {
position: absolute;
inset: 0;
z-index: 32;
display: grid;
place-items: center;
padding: 20px;
background: rgba(34, 33, 31, 0.3);
}
.media-manager-card {
width: min(760px, 100%);
max-height: calc(100vh - 40px);
overflow: auto;
display: grid;
gap: 10px;
border-radius: 16px;
border: 1px solid #f4d6c9;
background: #ffffff;
padding: 20px;
box-shadow: 0 24px 45px rgba(36, 33, 30, 0.34);
}
.media-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.media-manager-header h1 {
font-size: 24px;
color: #2a2622;
}
.media-manager-form {
display: grid;
gap: 8px;
border: 1px solid #ebe6df;
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.7);
}
.media-manager-form label {
font-size: 14px;
color: #4f4841;
}
.media-manager-form input,
.media-manager-form select {
width: 100%;
border-radius: 10px;
border: 1px solid #e6e1da;
background: #ffffff;
color: #2a2622;
padding: 10px 12px;
outline: none;
}
.media-manager-form button,
.media-manager-actions button {
border: 0;
border-radius: 10px;
padding: 10px 12px;
font-weight: 450;
color: #ffffff;
background: #2a2622;
cursor: pointer;
}
.media-manager-card .ghost {
border: 1px solid #efc7b8;
border-radius: 10px;
padding: 10px 12px;
background: transparent;
color: #2a2622;
font-weight: 450;
cursor: pointer;
}
.media-manager-list {
display: grid;
gap: 8px;
}
.media-manager-item {
display: grid;
grid-template-columns: 74px 1fr auto;
align-items: center;
gap: 10px;
border: 1px solid #f4d6c9;
background: #ffffff;
border-radius: 10px;
padding: 8px;
}
.media-manager-preview {
width: 74px;
height: 74px;
border-radius: 8px;
background: #f4f2ee;
overflow: hidden;
display: grid;
place-items: center;
color: #4f4841;
font-size: 14px;
}
.media-manager-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.media-manager-meta {
display: grid;
gap: 2px;
color: #4f4841;
}
.media-manager-meta span {
color: #6b645d;
font-size: 14px;
}
.media-manager-actions {
display: flex;
gap: 6px;
}
.media-manager-empty {
color: #4f4841;
background: rgba(255, 255, 255, 0.72);
border: 1px dashed #f4d6c9;
border-radius: 10px;
padding: 12px;
}
.media-manager-error {
background: rgba(171, 49, 49, 0.1);
border: 1px solid rgba(171, 49, 49, 0.32);
color: #862f2f;
border-radius: 10px;
padding: 10px 12px;
font-size: 14px;
}
/* ── Contribution change cards ── */
/* champs modifiés */
/* Boutons décision dans le header de carte */
/* Aperçu annotations */
/* résumé décisions dans la meta */
/* footer session */
/* Groupement par type d'entité */
/* Confirmation "Tout approuver" inline */
@media (max-width: 700px) {
.media-manager-item {
grid-template-columns: 1fr;
}
.media-manager-actions {
width: 100%;
}
.media-manager-actions button {
flex: 1;
}
}
.account-dashboard-card.auth-only {
width: min(480px, 100%);
text-align: center;
}
.tree-created-prompt {
position: fixed;
top: 20px;
@@ -563,123 +131,6 @@
font-size: 14px;
}
.settings-card {
width: min(900px, 100%);
}
.settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.settings-tabs button {
border: 1px solid #efc7b8;
border-radius: 999px;
padding: 8px 12px;
background: transparent;
color: #2a2622;
cursor: pointer;
}
.settings-tabs button.active {
color: #ffffff;
border-color: #2a2622;
background: #2a2622;
}
.settings-share-card {
display: grid;
gap: 8px;
border: 1px solid #ebe6df;
border-radius: 12px;
padding: 12px;
background: rgba(255, 255, 255, 0.65);
}
.settings-share-card h3 {
color: #4f4841;
font-size: 16px;
}
.settings-share-link-row {
display: flex;
gap: 8px;
align-items: center;
}
.settings-share-link-row input {
flex: 1;
min-width: 0;
}
.settings-password-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.settings-password-row code {
border: 1px dashed #f4d6c9;
border-radius: 8px;
background: #ffffff;
padding: 6px 10px;
color: #4f4841;
}
.settings-hint {
color: #4f4841;
font-size: 14px;
border-left: 3px solid #f4d6c9;
padding-left: 10px;
}
.settings-data-row {
display: flex;
justify-content: space-between;
gap: 12px;
border: 1px solid #ebe6df;
border-radius: 10px;
padding: 12px;
align-items: center;
}
.settings-data-row p {
color: #6B645D;
font-size: 14px;
margin-top: 4px;
}
.settings-data-row--muted {
opacity: 0.72;
}
.settings-advanced-toggle {
justify-self: start;
}
.settings-danger-box {
display: grid;
gap: 8px;
border: 1px solid rgba(171, 49, 49, 0.34);
border-radius: 12px;
background: rgba(171, 49, 49, 0.06);
padding: 12px;
}
@media (max-width: 700px) {
.settings-share-link-row {
flex-direction: column;
align-items: stretch;
}
.settings-data-row {
flex-direction: column;
align-items: stretch;
}
}
.account-overlay--open {
visibility: visible;
pointer-events: auto;
@@ -697,23 +148,6 @@
color: var(--ink-2, #6B645D);
}
.account-hint {
margin: -6px 0 10px;
font-size: 12px;
color: var(--ink-2, #6B645D);
}
/* Paramètres : contributions de la famille */
/* Écran compte : suppression du compte */
.account-delete-zone {
display: flex;
justify-content: flex-end;
margin-top: 6px;
padding-top: 10px;
border-top: 1px solid #f8e4db;
}
.account-delete-link {
border: 0;
background: transparent;
@@ -733,27 +167,6 @@
cursor: not-allowed;
}
.account-delete-list {
display: grid;
gap: 6px;
margin: 0;
padding-left: 18px;
list-style: disc;
color: #4f4841;
font-size: 14px;
line-height: 1.45;
}
.account-delete-form {
display: grid;
gap: 8px;
}
.account-delete-form label {
font-size: 14px;
color: #4f4841;
}
.account-delete-modal .ghost {
border: 1px solid #efc7b8;
border-radius: 10px;
+2 -2
View File
@@ -24,9 +24,9 @@ import DraftRestoreBanner from './components/DraftRestoreBanner'
import AddPersonPanel from './components/AddPersonPanel'
import SearchSourcesPanel from './components/SearchSourcesPanel.jsx'
import ContributionSessionModal from './components/ContributionSessionModal'
import { ContextualNavbar } from './components/navbar'
import ContextualNavbar from './components/navbar/ContextualNavbar'
import ZoomControl from './components/ZoomControl'
import { ExpandableSearch } from './components/search'
import ExpandableSearch from './components/search/ExpandableSearch'
import { t, dateLocale } from './i18n/index.js'
import {
createParentChildLink,
+7 -19
View File
@@ -1,4 +1,4 @@
import { API_BASE_URL, apiRequest } from './client'
import { API_BASE_URL, apiRequest, bearer } from './client'
export async function loginUser(email, password) {
const data = await apiRequest('/auth/login', {
@@ -22,9 +22,7 @@ export async function registerUser(firstName, email, password, invitationToken)
export async function logoutUser(token) {
const data = await apiRequest('/auth/logout', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -34,9 +32,7 @@ export async function logoutUser(token) {
export async function deleteAccount(token, confirmation) {
const data = await apiRequest('/auth/me', {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(confirmation),
})
@@ -47,9 +43,7 @@ export async function deleteAccount(token, confirmation) {
export async function fetchAccountProfile(token) {
const data = await apiRequest('/auth/me', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -58,9 +52,7 @@ export async function fetchAccountProfile(token) {
export async function fetchLastOpenedTree(token) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.lastTree || null
@@ -69,9 +61,7 @@ export async function fetchLastOpenedTree(token) {
export async function updateLastOpenedTree(token, payload) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -92,9 +82,7 @@ export function oidcStartUrl(returnTo = '/') {
export async function requestOidcLinkTicket(token) {
const data = await apiRequest('/auth/oidc/link', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.ticket
+4 -1
View File
@@ -104,4 +104,7 @@ async function apiRequest(path, options = {}) {
return payload
}
export { ApiError, API_BASE_URL, apiRequest }
// En-tête d'un appel fait au nom d'un compte ou d'un accès partagé
const bearer = (token) => ({ Authorization: `Bearer ${token}` })
export { ApiError, API_BASE_URL, apiRequest, bearer }
+4 -4
View File
@@ -1,4 +1,4 @@
import { API_BASE_URL, apiRequest } from './client.js'
import { API_BASE_URL, apiRequest, bearer } from './client.js'
// Reconnaissance des visages : la connexion Immich est propre à chaque arbre, configurée par
// ses administrateurs. L'adresse est choisie parmi celles autorisées par l'opérateur du
@@ -7,7 +7,7 @@ import { API_BASE_URL, apiRequest } from './client.js'
function withAuth(options = {}, token) {
if (!token) return options
return { ...options, headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` } }
return { ...options, headers: { ...(options.headers || {}), ...bearer(token) } }
}
export function getImmichErrorMessage(error, t) {
@@ -38,7 +38,7 @@ export function listImmichAssets(treeId, token, page = 1, type = 'IMAGE', { quer
export async function fetchImmichAsset(treeId, assetId, token, variant = 'thumbnail', signal) {
const response = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/immich/assets/${encodeURIComponent(assetId)}/${variant}`, {
headers: { Authorization: `Bearer ${token}` }, signal,
headers: bearer(token), signal,
})
if (!response.ok) {
const error = new Error('immich_asset_failed')
@@ -106,7 +106,7 @@ export async function fetchImmichLink(treeId, personId, token) {
export async function fetchImmichThumbnail(treeId, immichPersonId, token) {
const path = `/trees/${encodeURIComponent(treeId)}/immich/thumbnail/${encodeURIComponent(immichPersonId)}`
const response = await fetch(`${API_BASE_URL}${path}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
headers: token ? bearer(token) : {},
})
if (!response.ok) {
throw new Error(`immich_thumbnail_failed_${response.status}`)
+7 -8
View File
@@ -1,29 +1,28 @@
import { apiRequest } from './client'
import { apiRequest, bearer } from './client'
// Co-gestion d'un arbre (issue #145) : gestionnaires, invitations, et la page du lien d'invitation
const auth = (token) => ({ Authorization: `Bearer ${token}` })
const tree = (treeId) => `/trees/${encodeURIComponent(treeId)}`
export function listManagers(treeId, token) {
return apiRequest(`${tree(treeId)}/managers`, { headers: auth(token) })
return apiRequest(`${tree(treeId)}/managers`, { headers: bearer(token) })
}
export function inviteManager(treeId, email, token) {
return apiRequest(`${tree(treeId)}/invitations`, { method: 'POST', headers: auth(token), body: JSON.stringify({ email }) })
return apiRequest(`${tree(treeId)}/invitations`, { method: 'POST', headers: bearer(token), body: JSON.stringify({ email }) })
}
export function renewInvitationLink(treeId, invitationId, token) {
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}/link`, { method: 'POST', headers: auth(token) })
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}/link`, { method: 'POST', headers: bearer(token) })
}
export function revokeInvitation(treeId, invitationId, token) {
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: auth(token) })
return apiRequest(`${tree(treeId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE', headers: bearer(token) })
}
// Retirer un co-gestionnaire (le propriétaire), ou quitter la gestion (son propre identifiant)
export function removeManager(treeId, userId, token) {
return apiRequest(`${tree(treeId)}/managers/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: auth(token) })
return apiRequest(`${tree(treeId)}/managers/${encodeURIComponent(userId)}`, { method: 'DELETE', headers: bearer(token) })
}
// Sous /api comme /api/arbre/:slug : nginx ne relaie à l'API que /api (préfixe retiré), /auth et /trees
@@ -32,7 +31,7 @@ export function fetchInvitation(invitationToken) {
}
export function acceptInvitation(invitationToken, token) {
return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}/accept`, { method: 'POST', headers: auth(token) })
return apiRequest(`/api/invitations/${encodeURIComponent(invitationToken)}/accept`, { method: 'POST', headers: bearer(token) })
}
export function invitationUrl(invitationToken) {
+3 -3
View File
@@ -1,9 +1,9 @@
import { apiRequest } from './client'
import { apiRequest, bearer } from './client'
// Sources que ce serveur interroge (SEARCH_SOURCES) : une liste d'identifiants, vide par défaut
export async function fetchSearchSources(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/search-sources`, {
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
return Array.isArray(data?.sources) ? data.sources : []
}
@@ -11,7 +11,7 @@ export async function fetchSearchSources(treeId, token) {
export async function searchSource(treeId, sourceId, query, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/search-sources/${encodeURIComponent(sourceId)}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify(query),
})
return Array.isArray(data?.results) ? data.results : []
+45 -163
View File
@@ -1,4 +1,4 @@
import { apiRequest } from './client'
import { API_BASE_URL, apiRequest, bearer } from './client'
import { readContributorName } from '../utils/contributorName.js'
export async function resolveTreeBySlug(slug) {
@@ -12,14 +12,9 @@ export async function resolveTreeBySlug(slug) {
}
export async function unlockTreeAccess(treeId, password, userToken = '') {
const headers = {}
if (userToken) {
headers.Authorization = `Bearer ${userToken}`
}
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/unlock`, {
method: 'POST',
headers,
headers: userToken ? bearer(userToken) : {},
body: JSON.stringify({ password }),
})
@@ -30,9 +25,7 @@ export async function unlockTreeAccess(treeId, password, userToken = '') {
export async function linkTreeAccess(treeId, accessToken, userToken) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/link`, {
method: 'POST',
headers: {
Authorization: `Bearer ${userToken}`,
},
headers: bearer(userToken),
body: JSON.stringify({ accessToken }),
})
@@ -46,7 +39,7 @@ export async function fetchTreeGraph(treeId, token) {
method: 'GET',
cache: 'no-store',
headers: {
Authorization: `Bearer ${token}`,
...bearer(token),
...(visitorName ? { 'X-Visitor-Name': encodeURIComponent(visitorName) } : {}),
},
})
@@ -63,7 +56,7 @@ export async function fetchTreeGraph(treeId, token) {
export async function fetchPersonLife(treeId, personId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life`, {
method: 'GET',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
}
@@ -72,7 +65,7 @@ export async function createLifeEvent(treeId, personId, payload, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/life/events`, {
method: 'POST',
idempotent: true,
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify(payload),
})
}
@@ -80,7 +73,7 @@ export async function createLifeEvent(treeId, personId, payload, token) {
export async function updateLifeEvent(treeId, eventId, payload, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, {
method: 'PATCH',
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
body: JSON.stringify(payload),
})
}
@@ -89,16 +82,14 @@ export async function deleteLifeEvent(treeId, eventId, token, { keepalive = fals
return apiRequest(`/trees/${encodeURIComponent(treeId)}/life/events/${encodeURIComponent(eventId)}`, {
method: 'DELETE',
keepalive,
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
}
export async function listTrees(token) {
const data = await apiRequest('/trees', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return {
@@ -110,9 +101,7 @@ export async function listTrees(token) {
export async function getTree(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return {
@@ -124,9 +113,7 @@ export async function getTree(treeId, token) {
export async function createTree(payload, token) {
const data = await apiRequest('/trees', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -136,9 +123,7 @@ export async function createTree(payload, token) {
export async function updateTree(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -148,9 +133,7 @@ export async function updateTree(treeId, payload, token) {
export async function deleteTree(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -159,9 +142,7 @@ export async function deleteTree(treeId, token) {
export async function createPerson(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -171,9 +152,7 @@ export async function createPerson(treeId, payload, token) {
export async function updatePerson(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -184,9 +163,7 @@ export async function deletePerson(treeId, personId, token, { keepalive = false
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}`, {
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -195,9 +172,7 @@ export async function deletePerson(treeId, personId, token, { keepalive = false
export async function createUnion(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -207,9 +182,7 @@ export async function createUnion(treeId, payload, token) {
export async function createParentChildLink(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -220,9 +193,7 @@ export async function createParentChildLink(treeId, payload, token) {
export async function updateUnion(treeId, unionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -232,9 +203,7 @@ export async function updateUnion(treeId, unionId, payload, token) {
export async function deleteUnion(treeId, unionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/unions/${encodeURIComponent(unionId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -244,9 +213,7 @@ export async function deleteUnion(treeId, unionId, token) {
export async function reorderSiblings(treeId, order, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/links/order`, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ order }),
})
}
@@ -255,9 +222,7 @@ export async function reorderSiblings(treeId, order, token) {
export async function updateParentChildLink(treeId, linkId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -267,9 +232,7 @@ export async function updateParentChildLink(treeId, linkId, payload, token) {
export async function deleteParentChildLink(treeId, linkId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -278,9 +241,7 @@ export async function deleteParentChildLink(treeId, linkId, token) {
export async function rotateTreePasswords(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -290,39 +251,22 @@ export async function rotateTreePasswords(treeId, payload, token) {
export async function fetchSharePassword(treeId, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
}
export async function rememberSharePassword(treeId, password, token) {
return apiRequest(`/trees/${encodeURIComponent(treeId)}/access/passwords/remember`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ password }),
})
}
export async function fetchTreeStats(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/stats`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data?.stats || null
}
export async function fetchTreeSettings(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/settings`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.settings || null
@@ -331,51 +275,18 @@ export async function fetchTreeSettings(treeId, token) {
export async function updateTreeSettings(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/settings`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
return data?.settings || null
}
export async function listTreeAuditLogs(treeId, token, options = {}) {
const queryParams = new URLSearchParams()
if (options.limit) {
queryParams.set('limit', String(options.limit))
}
if (options.offset !== undefined && options.offset !== null) {
queryParams.set('offset', String(options.offset))
}
if (options.action) {
queryParams.set('action', options.action)
}
const query = queryParams.toString()
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/audit${query ? `?${query}` : ''}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return {
logs: Array.isArray(data?.logs) ? data.logs : [],
nextOffset: typeof data?.nextOffset === 'number' ? data.nextOffset : null,
}
}
// Journal des visites (administrateurs de l'arbre)
export async function listTreeVisits(treeId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/visits`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return {
@@ -388,9 +299,7 @@ export async function submitContributionSession(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -401,9 +310,7 @@ export async function listContributionSessions(treeId, token, status = 'pending'
const query = status ? `?status=${encodeURIComponent(status)}` : ''
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions${query}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return Array.isArray(data?.sessions) ? data.sessions : []
@@ -412,9 +319,7 @@ export async function listContributionSessions(treeId, token, status = 'pending'
export async function reviewContributionSession(treeId, sessionId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/review`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -425,9 +330,7 @@ export async function reviewContributionSession(treeId, sessionId, payload, toke
export async function fetchContributionStatus(treeId, sessionId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/status`, {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data?.status || null
@@ -437,9 +340,7 @@ export async function uploadAnnotationPhoto(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -450,9 +351,7 @@ export async function uploadPersonMedia(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media`, {
method: 'POST',
idempotent: true,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -462,9 +361,7 @@ export async function uploadPersonMedia(treeId, personId, payload, token) {
export async function reorderPersonMedia(treeId, personId, orderedMediaIds, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/reorder`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify({ orderedMediaIds }),
})
@@ -477,9 +374,7 @@ export async function deletePersonMedia(treeId, personId, mediaId, token, { keep
{
method: 'DELETE',
keepalive,
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
},
)
@@ -492,9 +387,7 @@ export async function updatePersonMedia(treeId, personId, mediaId, payload, toke
`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
},
)
@@ -509,9 +402,7 @@ export async function updateMediaPeople(treeId, mediaId, payload, token) {
`/trees/${encodeURIComponent(treeId)}/media/${encodeURIComponent(mediaId)}/people`,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
},
)
@@ -520,9 +411,7 @@ export async function updateMediaPeople(treeId, mediaId, payload, token) {
export async function uploadPersonAvatar(treeId, personId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -533,9 +422,7 @@ export async function uploadPersonAvatar(treeId, personId, payload, token) {
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}`,
},
headers: bearer(token),
body: JSON.stringify({ crop }),
})
@@ -545,9 +432,7 @@ export async function saveAvatarCrop(treeId, personId, crop, token) {
export async function deletePersonAvatar(treeId, personId, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
})
return data
@@ -556,9 +441,7 @@ export async function deletePersonAvatar(treeId, personId, token) {
export async function batchAnnotations(treeId, payload, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotations/batch`, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
},
headers: bearer(token),
body: JSON.stringify(payload),
})
@@ -566,9 +449,8 @@ export async function batchAnnotations(treeId, payload, token) {
}
export async function exportGedcom(treeId, token) {
const { API_BASE_URL } = await import('./client')
const resp = await fetch(`${API_BASE_URL}/trees/${encodeURIComponent(treeId)}/export/gedcom`, {
headers: { Authorization: `Bearer ${token}` },
headers: bearer(token),
})
if (!resp.ok) {
throw new Error('export_failed')
@@ -583,7 +465,7 @@ export async function exportGedcom(treeId, token) {
export async function importGedcom(treeId, fileContent, token) {
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/import/gedcom`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
headers: 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,
-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

Before

Width:  |  Height:  |  Size: 4.0 KiB

+2 -10
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useState } from 'react'
import { useEffect, useState } from 'react'
import logoUrl from '../assets/parenthese-logo.svg?url'
import './AccountScreens.css'
import PzBusy from './PzBusy.jsx'
@@ -6,15 +6,7 @@ import LanguageSwitch from './navbar/LanguageSwitch'
import { t } from '../i18n/index.js'
import { readContributorName, saveContributorName } from '../utils/contributorName.js'
import { initialOf } from '../utils/initial.js'
// Phrase traduite d'un bloc : les passages entre astérisques passent en <em>, {slot} reçoit un nœud.
function richText(text, slotNode = null) {
return text.split(/(\{slot\})/).flatMap((chunk, chunkIndex) => (
chunk === '{slot}'
? [<Fragment key={`s${chunkIndex}`}>{slotNode}</Fragment>]
: chunk.split('*').map((part, index) => (index % 2 ? <em key={`${chunkIndex}-${index}`}>{part}</em> : part))
))
}
import { richText } from '../utils/richText.jsx'
const AccessGate = ({
initialTreeId,
+2 -10
View File
@@ -1,4 +1,4 @@
import { Fragment, useEffect, useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { ChevronRight, KeyRound, LogIn, LogOut, Plus, Sparkles, X } from 'lucide-react'
import logoUrl from '../assets/parenthese-logo.svg?url'
import './AccountScreens.css'
@@ -7,15 +7,7 @@ import LanguageSwitch from './navbar/LanguageSwitch'
import { t } from '../i18n/index.js'
import { PRIVACY_URL } from '../utils/siteLinks.js'
import { initialOf } from '../utils/initial.js'
// Phrase traduite d'un bloc : les passages entre astérisques passent en <em>, {slot} reçoit un nœud.
function richText(text, slotNode = null) {
return text.split(/(\{slot\})/).flatMap((chunk, chunkIndex) => (
chunk === '{slot}'
? [<Fragment key={`s${chunkIndex}`}>{slotNode}</Fragment>]
: chunk.split('*').map((part, index) => (index % 2 ? <em key={`${chunkIndex}-${index}`}>{part}</em> : part))
))
}
import { richText } from '../utils/richText.jsx'
const AccountDashboard = ({
authenticated,
+1 -6
View File
@@ -9,12 +9,7 @@ import { SEX_CHOICES } from '../utils/personSex.js'
import { initialOf } from '../utils/initial.js'
import { findLikelyDuplicates } from '../utils/duplicates.js'
import PortraitThumb from './PortraitThumb.jsx'
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
import { withEmphasis } from '../utils/richText.jsx'
/**
* Panneau slide-in gauche pour ajouter une personne
+7 -9
View File
@@ -2,12 +2,10 @@ import { memo, useEffect, useImperativeHandle, useRef, useState, useCallback } f
import { getPersonMedias, getParents, getChildren, getPersonUnions, persons as graphPersons, unions as graphUnions, filiations as graphFiliations } from '../data/mockData'
import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STRENGTH, MAX_ORBIT_MEDIAS, ENTRANCE_NODE_DURATION, ENTRANCE_CAMERA_DURATION, ENTRANCE_CAMERA_FROM_SCALE, ENTRANCE_REDUCED_DURATION, ENTRANCE_LINE_DELAY, ENTRANCE_LINE_DURATION, ENTRANCE_ORBIT_DELAY, ENTRANCE_ORBIT_DURATION, ENTRANCE_ORBIT_STAGGER } from './galaxy/constants'
import { easeOutCubic } from './galaxy/utils'
import { computeLayout, generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { generateNodeRandomData, coupleBarMeta, restoreCoupleBarMeta } from './galaxy/elkLayout'
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf, takeRootLayout } from './galaxy/blockLayout'
import { hasGhosts, withGhosts } from '../utils/ghosts'
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
import { findPathBFS } from './galaxy/pathfinding'
import { drawBackground, drawCoupleLinks, drawFiliations, drawAnnotations, drawPathHighlight, drawNodes, drawRenvoiPills, nodeRadius } from './galaxy/renderers'
import { buildSiblingGroups, moveInOrder, pickSlot, rubberBand, springStep, isSettled, buildOrderPayload, filiationsWithOrder } from './galaxy/siblingReorder'
@@ -277,7 +275,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [])
const canReorderNow = useCallback(() => (
reorderEnabledRef.current && USE_BLOCK_LAYOUT && Boolean(layoutDataRef.current) && Boolean(entranceRef.current?.finished)
reorderEnabledRef.current && Boolean(layoutDataRef.current) && Boolean(entranceRef.current?.finished)
), [])
const worldX = useCallback((clientX) => {
@@ -458,7 +456,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
}, [canReorderNow, getSiblingGroups, swapLayout, layoutForOrder, commitOrder])
const getSiblingPlace = useCallback((personId) => {
if (!USE_BLOCK_LAYOUT || !layoutDataRef.current) return null
if (!layoutDataRef.current) return null
const group = getSiblingGroups().get(`p-${personId}`)
return group ? { index: group.ids.indexOf(`p-${personId}`), count: group.ids.length } : null
}, [getSiblingGroups])
@@ -640,7 +638,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
let freshTabs = null
// Liste en attente du worker : l'arbre affiché est celui de la famille de départ
let tabsPending = false
if (USE_BLOCK_LAYOUT && familyTabs && (cache.rev !== graphRevision || cache.start !== start)) {
if (familyTabs && (cache.rev !== graphRevision || cache.start !== start)) {
const pending = tabsRequestRef.current
// Dans un onglet d'une autre famille, ou à l'arrivée par le lien d'un onglet, l'arbre affiché dépend de la
// liste : elle est calculée sur place
@@ -682,10 +680,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
const legacy = annotationData.some((a) => !readAnchor(a))
const departure = legacy && USE_BLOCK_LAYOUT && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
const departure = legacy && graph ? computeBlockLayout(null, startRefRef.current, { familyTabs }) : null
// Famille de départ sans propositions : son placement vient d'être calculé avec la liste des onglets
const kept = USE_BLOCK_LAYOUT && !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || (USE_BLOCK_LAYOUT ? computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs }) : computeLayout(null, focusPersonIdRef.current))
const kept = !graph && tab === freshTabs?.[0] ? takeRootLayout(freshTabs, focusPersonIdRef.current) : null
const result = kept || computeBlockLayout(graph, focusPersonIdRef.current, { familyTabs })
if (legacy) {
const anchored = anchorLegacy(annotationData, layoutCenters(departure || result))
for (const ann of anchored) updateAnnotationInStore(ann.id, { style: ann.style })
-50
View File
@@ -90,56 +90,6 @@
overflow: hidden;
}
.media-video-perfs {
position: absolute;
left: 8px;
top: 0;
bottom: 0;
display: flex;
flex-direction: column;
justify-content: space-around;
gap: 6px;
padding: 10px 0;
}
.media-video-perfs.right {
left: auto;
right: 8px;
}
.media-video-perf {
width: 8px;
height: 8px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.06);
}
.media-video-play {
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
}
.media-video-play:hover {
background: rgba(255, 255, 255, 0.18);
transform: scale(1.05);
}
.media-video-play-triangle {
width: 0;
height: 0;
border-left: 20px solid rgba(255, 255, 255, 0.7);
border-top: 12px solid transparent;
border-bottom: 12px solid transparent;
margin-left: 5px;
}
.media-video-text {
color: rgba(255, 255, 255, 0.35);
font-size: 14px;
+1 -5
View File
@@ -6,11 +6,7 @@ import { t } from '../i18n/index.js'
import { matchPhotoFiles } from '../utils/photoImport.js'
import { materializeFile } from '../utils/zipReader.js'
import { initialsOf } from '../utils/initial.js'
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
import { withEmphasis } from '../utils/richText.jsx'
function lifeYears(person) {
if (!person.birthYear) return ''
@@ -10,6 +10,7 @@ import { changesFromResult, personFromResult } from '../utils/searchResultChange
import { fullNameLabel } from '../utils/personName.js'
import Skeleton from './Skeleton.jsx'
import PortraitThumb from './PortraitThumb.jsx'
import { withEmphasis } from '../utils/richText.jsx'
// Ce que chaque source interrogée couvre et ce qui part vers elle : affiché avant qu'on coche
const SOURCE_INFO = {
@@ -86,12 +87,6 @@ function personYears(person) {
return [birth, death].filter(Boolean).join(' – ')
}
// Titre traduit : le passage entre <em> et </em> garde son italique (comme « Ajouter quelqu'un »)
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
// Mêmes libellés que « Ajouter quelqu'un » : la nouvelle personne est parent, enfant… de quelqu'un de l'arbre
const RELATION_TABS = {
parent: t('Parent'),
@@ -3,6 +3,7 @@ import { FileUp, ImagePlus } from 'lucide-react'
import './TreeCreationWizard.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
import { withEmphasis } from '../utils/richText.jsx'
const INITIAL_STATE = {
treeName: '',
@@ -15,12 +16,6 @@ const INITIAL_STATE = {
gedcomFile: null,
}
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
function TreeCreationWizard({
visible,
loading,
@@ -2,7 +2,6 @@ import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import PersonCard from './PersonCard.jsx'
import AdminPanel from './AdminPanel.jsx'
import TreeDropdown from './navbar/TreeDropdown.jsx'
// Co-gestion (issue #145) et « Modifier » de la fiche : ce que chaque rôle voit. Les droits eux-mêmes sont tenus
// par le serveur (backend/test/co-gestion.db.test.ts) ; ici, l'interface ne propose pas ce qui serait refusé.
@@ -21,12 +20,6 @@ describe('fiche, « Modifier » d’un clic', () => {
})
describe('co-gestionnaire dans les menus', () => {
it('le menu des arbres nomme le rôle « Co-gestionnaire », plus « Admin »', () => {
const html = renderToStaticMarkup(<TreeDropdown trees={[{ id: 't1', name: 'Arbre de démo', role: 'admin' }]} activeTreeId="t1" onSelect={noop} onCreate={noop} />)
expect(html).toContain('Co-gestionnaire')
expect(html).not.toContain('>Admin<')
})
it('le panneau a un onglet Gestion', () => {
const html = renderToStaticMarkup(<AdminPanel visible treeId="t1" authToken="x" isAdmin onClose={noop} />)
expect(html).toContain('Gestion')
+2 -40
View File
@@ -101,9 +101,9 @@ function computeCouplePoints(p1Pos, p2Pos, unionPos, p1R, p2R, nodeRandomData, s
// commune à la fratrie, chaque enfant s'en détache par une courbe.
// Coins arrondis de rayon fixe (accolade franche, forme A).
// ============================================================
export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
// Proposé, pas encore relu (utils/ghosts.js) : pointillés terracotta, portrait délavé, étiquette au-dessus
export const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 }
const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 }
const GHOST_LABELS = { draft: () => t('Brouillon'), sent: () => t('En attente'), pending: () => t('Proposé') }
// Ligne de la fratrie : sous les prénoms et années des parents, au-dessus des enfants
const BUS_DY = 100
@@ -1507,41 +1507,3 @@ function drawActiveDrawingPath(ctx, pathData) {
ctx.stroke()
ctx.restore()
}
// ============================================================
// Badge lien de parenté
// ============================================================
export function drawPathLabel(ctx, pathLabelRef, pathLabelAlpha, pathNodesRef, posMap) {
if (!pathLabelRef || pathLabelAlpha <= 0.02) return
const personNodesOnPath = pathNodesRef.filter(id => id.startsWith('p-'))
if (personNodesOnPath.length < 2) return
const midIdx = Math.floor(personNodesOnPath.length / 2)
const midPos1 = posMap.get(personNodesOnPath[midIdx - 1])
const midPos2 = posMap.get(personNodesOnPath[midIdx])
if (!midPos1 || !midPos2) return
const labelX = (midPos1.cx + midPos2.cx) / 2
const labelY = (midPos1.cy + midPos2.cy) / 2 - 20
ctx.save()
ctx.globalAlpha = pathLabelAlpha * 0.9
ctx.font = '500 11px "DM Sans", system-ui, sans-serif'
const metrics = ctx.measureText(pathLabelRef)
const pillW = metrics.width + 16
const pillH = 22
ctx.fillStyle = 'rgba(42, 38, 34, 0.85)'
ctx.beginPath()
ctx.roundRect(labelX - pillW / 2, labelY - pillH / 2, pillW, pillH, pillH / 2)
ctx.fill()
ctx.fillStyle = '#FFFFFF'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(pathLabelRef, labelX, labelY)
ctx.restore()
}
@@ -300,15 +300,6 @@
transition: all 0.15s cubic-bezier(0.22, 1, 0.36, 1);
}
/* --- Add person button --- */
.nav-annot-btn--add {
color: #93402A;
}
.nav-annot-btn--add:hover:not(:disabled) {
color: #3D3731;
}
/* --- Sub-panel (options) above navbar --- */
.navbar-annot-panel {
position: absolute;
@@ -1,41 +0,0 @@
/* Bouton rond de la barre d'actions (DA terracotta) */
.nav-button {
position: relative;
width: 44px;
height: 44px;
display: grid;
place-items: center;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--pz-ink);
cursor: pointer;
transition: background-color 160ms ease, color 160ms ease, transform 140ms var(--pz-ease);
-webkit-tap-highlight-color: transparent;
}
@media (hover: hover) and (pointer: fine) { .nav-button:hover:not(.disabled) { background: var(--pz-line-soft); } }
.nav-button:active:not(.disabled) { transform: scale(.94); }
.nav-button:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
.nav-button.active { background: var(--pz-a100); color: var(--pz-a700); }
.nav-button.disabled { opacity: .35; cursor: not-allowed; }
.nav-button-icon { display: grid; place-items: center; line-height: 1; }
.nav-button-icon svg { width: 20px; height: 20px; }
.nav-button-badge {
position: absolute;
top: 2px;
right: 2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
display: grid;
place-items: center;
border-radius: 999px;
border: 2px solid var(--pz-surface);
font: 600 10.5px/1 var(--pz-sans);
color: #fff;
box-sizing: content-box;
}
.nav-button-badge.orange { background: var(--pz-a500); }
.nav-button-badge.gray { background: var(--pz-faint); }
@@ -1,44 +0,0 @@
import './NavButton.css'
/**
* Bouton de navigation contextuel
* Style glassmorphism
*
* @param {Object} props
* @param {string} props.icon - Emoji ou texte de l'icône
* @param {string} props.label - Label au hover (tooltip)
* @param {Function} props.onClick - Handler du clic
* @param {boolean} props.active - État actif (pour toggle)
* @param {boolean} props.disabled - État désactivé
* @param {number} props.badge - Nombre à afficher dans le badge (optionnel)
* @param {string} props.badgeColor - Couleur du badge ('orange' | 'gray')
*/
function NavButton({
icon,
label,
onClick,
active = false,
disabled = false,
badge = null,
badgeColor = 'orange',
}) {
return (
<button
type="button"
className={`nav-button ${active ? 'active' : ''} ${disabled ? 'disabled' : ''}`}
onClick={onClick}
disabled={disabled}
aria-label={label}
title={label}
>
<span className="nav-button-icon">{icon}</span>
{badge !== null && badge > 0 && (
<span className={`nav-button-badge ${badgeColor}`}>
{badge > 99 ? '99+' : badge}
</span>
)}
</button>
)
}
export default NavButton
@@ -1,151 +0,0 @@
.tree-dropdown {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
min-width: 320px;
max-width: 400px;
max-height: 480px;
background: rgba(242, 239, 234, 0.98);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border-radius: 12px;
border: 1px solid rgba(42, 38, 34, 0.08);
box-shadow:
0 8px 32px rgba(42, 38, 34, 0.25),
0 2px 8px rgba(42, 38, 34, 0.08);
padding: 8px;
overflow-y: auto;
animation: dropdownSlideUp 0.25s cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: bottom right;
}
.tree-dropdown::-webkit-scrollbar {
width: 6px;
}
.tree-dropdown::-webkit-scrollbar-track {
background: transparent;
}
.tree-dropdown::-webkit-scrollbar-thumb {
background: rgba(147, 64, 42, 0.2);
border-radius: 3px;
}
.tree-dropdown-section {
margin-bottom: 8px;
}
.tree-dropdown-section:last-child {
margin-bottom: 0;
}
.tree-dropdown-section-title {
font-family: 'Newsreader', Georgia, 'Times New Roman', serif;
font-size: 14px;
font-weight: 450;
color: #93402A;
text-transform: uppercase;
letter-spacing: 0.8px;
padding: 8px 12px 6px;
}
.tree-dropdown-item {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: transparent;
border: none;
border-radius: 8px;
cursor: pointer;
text-align: left;
transition: all 0.2s ease;
}
.tree-dropdown-item:hover {
background: rgba(147, 64, 42, 0.08);
}
.tree-dropdown-item.active {
background: rgba(147, 64, 42, 0.15);
}
.tree-dropdown-item-thumb {
width: 40px;
height: 40px;
border-radius: 8px;
overflow: hidden;
background: #2a2622;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.tree-dropdown-item-thumb img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tree-dropdown-item-icon {
font-size: 20px;
}
.tree-dropdown-item-info {
flex: 1;
min-width: 0;
}
.tree-dropdown-item-name {
font-size: 14px;
font-weight: 500;
color: #2A2622;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tree-dropdown-item-role {
font-size: 14px;
color: #6B645D;
text-transform: uppercase;
letter-spacing: 0.3px;
margin-top: 2px;
}
/* Actions */
.tree-dropdown-actions {
border-top: 1px solid rgba(42, 38, 34, 0.08);
padding-top: 8px;
margin-top: 8px;
}
.tree-dropdown-create {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: rgba(147, 64, 42, 0.08);
border: 1px dashed rgba(147, 64, 42, 0.3);
border-radius: 8px;
cursor: pointer;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 14px;
font-weight: 500;
color: #93402A;
transition: all 0.2s ease;
}
.tree-dropdown-create:hover {
background: rgba(147, 64, 42, 0.15);
border-color: rgba(147, 64, 42, 0.5);
}
.tree-dropdown-create-icon {
font-size: 20px;
font-weight: 300;
}
@@ -1,116 +0,0 @@
import { Trees } from 'lucide-react'
import { t } from '../../i18n/index.js'
import './TreeDropdown.css'
/**
* Dropdown de sélection d'arbres
* Affiche la liste des arbres accessibles + option création
*
* @param {Object} props
* @param {Array} props.trees - Liste des arbres [{id, name, role, thumb}]
* @param {string} props.activeTreeId - ID de l'arbre actif
* @param {Function} props.onSelect - Handler sélection arbre (treeId)
* @param {Function} props.onCreate - Handler création nouvel arbre
*/
function TreeDropdown({ trees = [], activeTreeId, onSelect, onCreate }) {
// Séparer arbres par type
const myTrees = trees.filter((tree) => tree.role === 'owner' || tree.role === 'admin')
const sharedTrees = trees.filter((tree) => tree.role !== 'owner' && tree.role !== 'admin')
const mockTree = trees.find((tree) => tree.isMockTree)
return (
<div className="tree-dropdown">
{/* Mes arbres */}
{myTrees.length > 0 && (
<div className="tree-dropdown-section">
<div className="tree-dropdown-section-title">{t('Mes arbres')}</div>
{myTrees.map(tree => (
<button
key={tree.id}
type="button"
className={`tree-dropdown-item ${tree.id === activeTreeId ? 'active' : ''}`}
onClick={() => onSelect(tree.id)}
>
<div className="tree-dropdown-item-thumb">
{tree.thumb ? (
<img src={tree.thumb} alt="" />
) : (
<span className="tree-dropdown-item-icon"><Trees size={18} strokeWidth={2} /></span>
)}
</div>
<div className="tree-dropdown-item-info">
<div className="tree-dropdown-item-name">{tree.name}</div>
<div className="tree-dropdown-item-role">
{tree.role === 'owner' ? t('Propriétaire') : t('Co-gestionnaire')}
</div>
</div>
</button>
))}
</div>
)}
{/* Arbres partagés */}
{sharedTrees.length > 0 && (
<div className="tree-dropdown-section">
<div className="tree-dropdown-section-title">{t('Arbres partagés')}</div>
{sharedTrees.map(tree => (
<button
key={tree.id}
type="button"
className={`tree-dropdown-item ${tree.id === activeTreeId ? 'active' : ''}`}
onClick={() => onSelect(tree.id)}
>
<div className="tree-dropdown-item-thumb">
{tree.thumb ? (
<img src={tree.thumb} alt="" />
) : (
<span className="tree-dropdown-item-icon"><Trees size={18} strokeWidth={2} /></span>
)}
</div>
<div className="tree-dropdown-item-info">
<div className="tree-dropdown-item-name">{tree.name}</div>
<div className="tree-dropdown-item-role">
{tree.role === 'contributor' ? t('Contributeur') : t('Visiteur')}
</div>
</div>
</button>
))}
</div>
)}
{/* Arbre mock */}
{mockTree && (
<div className="tree-dropdown-section">
<div className="tree-dropdown-section-title">{t('Exemple')}</div>
<button
type="button"
className={`tree-dropdown-item ${mockTree.id === activeTreeId ? 'active' : ''}`}
onClick={() => onSelect(mockTree.id)}
>
<div className="tree-dropdown-item-thumb">
<span className="tree-dropdown-item-icon">✨</span>
</div>
<div className="tree-dropdown-item-info">
<div className="tree-dropdown-item-name">{mockTree.name}</div>
<div className="tree-dropdown-item-role">{t('Arbre exemple')}</div>
</div>
</button>
</div>
)}
{/* Actions */}
<div className="tree-dropdown-actions">
<button
type="button"
className="tree-dropdown-create"
onClick={onCreate}
>
<span className="tree-dropdown-create-icon">+</span>
<span>{t('Créer un nouvel arbre')}</span>
</button>
</div>
</div>
)
}
export default TreeDropdown
-5
View File
@@ -1,5 +0,0 @@
// Export central pour les composants navbar
export { default as ContextualNavbar } from './ContextualNavbar'
export { default as NavButton } from './NavButton'
export { default as AvatarMenu } from './AvatarMenu'
export { default as TreeDropdown } from './TreeDropdown'
-3
View File
@@ -1,3 +0,0 @@
// Export central pour les composants search
export { default as ExpandableSearch } from './ExpandableSearch'
export { default as SearchResultsDropdown } from './SearchResultsDropdown'
-34
View File
@@ -355,28 +355,6 @@ export function getPersonById(personId) {
return personsById.get(String(personId)) || null
}
export function upsertPersonFromGraphPayload(person) {
if (!person?.id) {
return null
}
const index = persons.findIndex((candidate) => String(candidate.id) === String(person.id))
const current = index >= 0 ? persons[index] : null
const localPerson = mapRemotePersonToLocal(person, current)
if (index >= 0) {
persons = [
...persons.slice(0, index),
localPerson,
...persons.slice(index + 1),
]
} else {
persons = [...persons, localPerson]
}
return localPerson
}
export function getParents(personId) {
const fil = filiations.filter((filiation) => filiation.childId === personId)
const result = []
@@ -438,18 +416,6 @@ export function getPersonUnions(personId) {
.sort((a, b) => a.displayOrder - b.displayOrder)
}
export function getUnionChildren(unionId) {
return filiations
.filter((filiation) => filiation.unionId === unionId)
.sort((a, b) => a.displayOrder - b.displayOrder)
.map((filiation) => persons.find((person) => person.id === filiation.childId))
.filter(Boolean)
}
export function getChildFiliation(childId) {
return filiations.find((filiation) => filiation.childId === childId) || null
}
// ---- Annotations helpers ----
let annotationIdCounter = 0
+15 -18
View File
@@ -73,6 +73,19 @@ export function useAuth() {
return { trees, lastOpenedTree: lastTree }
}
// Session ouverte : jeton et profil gardés sur l'appareil, puis les arbres du compte
const openSession = async (token, user) => {
localStorage.setItem(USER_TOKEN_STORAGE_KEY, token)
saveUserProfile(user || null)
setUserAuth({ token, user })
await Promise.all([
loadAccountTrees(token),
fetchLastOpenedTree(token).then((lastTree) => {
if (lastTree) setLastOpenedTree(lastTree)
}).catch(() => {}),
])
}
const handleLogin = (email, password) => signIn(() => loginUser(email, password))
// Retour d'une connexion OIDC : le jeton de passage devient une session, comme une connexion par mot de passe
@@ -89,15 +102,7 @@ export function useAuth() {
...(storedProfile && storedProfile.email === result.user?.email ? storedProfile : {}),
...(result.user || {}),
}
localStorage.setItem(USER_TOKEN_STORAGE_KEY, result.token)
saveUserProfile(mergedUser || null)
setUserAuth({ token: result.token, user: mergedUser })
await Promise.all([
loadAccountTrees(result.token),
fetchLastOpenedTree(result.token).then((lastTree) => {
if (lastTree) setLastOpenedTree(lastTree)
}).catch(() => {}),
])
await openSession(result.token, mergedUser)
return true
} catch (error) {
setAccountError(getAccountErrorMessage(error))
@@ -117,15 +122,7 @@ export function useAuth() {
...(result.user || {}),
firstName: firstName || result.user?.firstName || '',
}
localStorage.setItem(USER_TOKEN_STORAGE_KEY, result.token)
saveUserProfile(mergedUser || null)
setUserAuth({ token: result.token, user: mergedUser })
await Promise.all([
loadAccountTrees(result.token),
fetchLastOpenedTree(result.token).then((lastTree) => {
if (lastTree) setLastOpenedTree(lastTree)
}).catch(() => {}),
])
await openSession(result.token, mergedUser)
return true
} catch (error) {
setAccountError(getAccountErrorMessage(error))
+1 -1
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
// Délai pendant lequel une suppression reste annulable avant de partir au serveur
export const UNDO_DELAY_MS = 6000
const UNDO_DELAY_MS = 6000
// Une suppression annulable à la fois. L'écran change tout de suite ; la requête ne part qu'à la fin du délai,
// à la suppression suivante, ou à la fermeture de la page (fetch keepalive).
+1 -3
View File
@@ -17,7 +17,7 @@ export default {
// Le lien de parenté affiché au survol se nomme langue par langue dans utils/kinship/
// Glisser pour ajouter une personne (components/ProximityFeedback.jsx)
// Ajouter un proche
'Conjoint': 'Partner',
'Frère·Sœur': 'Sibling',
'Lien': 'Link',
@@ -38,8 +38,6 @@ export default {
'Épais': 'Thick',
'Couleur': 'Color',
'Épaisseur': 'Thickness',
'Relâcher pour supprimer': 'Release to delete',
'Glisser ici pour supprimer': 'Drag here to delete',
'Stickers': 'Stickers',
'Cliquez sur le canvas': 'Click on the canvas',
'Saisir du texte...': 'Type some text...',
-13
View File
@@ -8,18 +8,14 @@ export default {
"Ouvrir {slot}": "Open {slot}",
"Ouvrir un arbre *partagé*": "Open a *shared* tree",
"Le lien est peut-être incomplet, ou l'arbre a été supprimé. Demandez un nouveau lien à la personne qui vous l'a envoyé.": "The link may be incomplete, or the tree may have been deleted. Ask the person who sent it for a new link.",
"Entrez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.": "Enter the password that came with the link. You don't need an account to look around.",
"Entrez le code et le mot de passe reçus avec votre lien de partage.": "Enter the code and password that came with your share link.",
"Partagé par {slot}": "Shared by {slot}",
"Code de l'arbre": "Tree code",
"Il commence souvent par cm": "It often starts with cm",
"Votre prénom": "Your first name",
"Facultatif. Il sert à savoir qui est venu voir l'arbre.": "Optional. It shows who came to see the tree.",
"Mot de passe de partage": "Sharing password",
"Ouverture de l'arbre": "Opening the tree",
"Ouvrir l'arbre": "Open the tree",
"Créer mon arbre": "Create my tree",
"Se connecter avec un compte": "Sign in with an account",
"Créer un compte": "Create an account",
// Connexion, inscription, liste des arbres, suppression du compte (AccountDashboard)
"Revenir à l'arbre": "Back to the tree",
@@ -40,8 +36,6 @@ export default {
"En créant un compte, vous acceptez {slot}.": "By creating an account, you agree to {slot}.",
"la façon dont Parenthèse traite vos données": "how Parenthèse handles your data",
"ou": "or",
"Ouvrir un arbre partagé avec moi": "Open a tree shared with me",
"Vous avez déjà un compte ?": "Already have an account?",
"Pas encore de compte ?": "Don't have an account yet?",
"Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.": "This instance no longer accepts new accounts. Ask the person who hosts it.",
"Se connecter avec {name}": "Sign in with {name}",
@@ -54,7 +48,6 @@ export default {
"Votre fournisseur de connexion n'a pas donné d'adresse email. La personne qui héberge l'instance doit autoriser le partage de l'email.": "Your sign-in provider didn't share an email address. The person who hosts this instance needs to allow it.",
"Le fournisseur de connexion ne répond pas. Réessayez dans un moment.": "The sign-in provider isn't responding. Try again in a moment.",
"La connexion n'a pas abouti. Réessayez.": "Signing in didn't work. Try again.",
"Me connecter": "Sign in",
"Vos *arbres*": "Your *trees*",
"Ouvrez un arbre, ou commencez celui d'une autre branche de la famille.": "Open a tree, or start one for another branch of the family.",
"Aucun arbre pour le moment.": "No trees yet.",
@@ -185,7 +178,6 @@ export default {
// Barre d'actions, menu du compte, choix de la langue (navbar)
"Aucun arbre": "No tree",
"Vos arbres": "Your trees",
"Arbre ouvert": "Current tree",
"Langue": "Language",
"Sélection": "Select",
"Texte": "Text",
@@ -194,7 +186,6 @@ export default {
"Ajouter un souvenir": "Add a memory",
"Ajouter une personne": "Add a person",
"Plus": "More",
"Dessiner ou écrire sur l'arbre": "Draw or write on the tree",
"Dessiner": "Draw",
"Écrire un texte": "Write a text",
"Poser un sticker": "Add a sticker",
@@ -203,7 +194,6 @@ export default {
"Le mode d'emploi": "User guide",
"Modifier l'arbre": "Edit the tree",
"Contributions": "Contributions",
"Contribuer (mot de passe contribution requis)": "Contribute (contribution password required)",
"Modifier la démo": "Edit the demo",
"Quitter l'édition": "Exit edit mode",
"Supprimer": "Delete",
@@ -211,11 +201,8 @@ export default {
"Mes arbres": "My trees",
"Propriétaire": "Owner",
"Admin": "Admin",
"Arbres partagés": "Shared trees",
"Contributeur": "Contributor",
"Visiteur": "Visitor",
"Exemple": "Example",
"Arbre exemple": "Sample tree",
"Votre prénom (facultatif)": "Your first name (optional)",
"Il sert à savoir qui est venu voir l'arbre.": "It shows who came to see the tree.",
"Mot de passe reçu avec le lien": "Password received with the link",
-3
View File
@@ -179,9 +179,6 @@ export default {
"Your latest changes aren't saved yet. They stay on this device until you discard them.",
'Abandonner': 'Discard',
'Enregistrer et quitter': 'Save and exit',
'Mode édition actif': 'Edit mode on',
'Mode contribution actif': 'Contribution mode on',
'Envoyer les modifications': 'Send changes',
'Aucune modification à soumettre': 'No changes to submit',
"Vous modifiez l'arbre": "You're editing the tree",
'Vous proposez des changements': 'You are suggesting changes',
-32
View File
@@ -24,7 +24,6 @@ export default {
'Retirer': 'Remove',
'Prénom': 'First name',
'Nom': 'Last name',
'Édition': 'Editing',
'Souvenirs': 'Memories',
'Infos': 'Details',
'Liens': 'Relatives',
@@ -177,22 +176,16 @@ export default {
'Miniature cartographique': 'Map thumbnail',
// Ajout d'une personne (AddPersonButton, AddPersonPanel, InlinePersonForm)
'Glisser pour ajouter une personne': 'Drag to add a person',
'Ajouter un conjoint': 'Add a partner',
'Ajouter un frère/sœur': 'Add a sibling',
'Relâcher pour créer': 'Release to create',
'Ajouter une personne': 'Add a person',
'Enfant': 'Child',
'Conjoint': 'Partner',
'Frère·Sœur': 'Sibling',
'Positionnez la personne': 'Place the person',
'Parent de': 'Parent of',
'Enfant de': 'Child of',
'Conjoint·e de': 'Partner of',
'Frère·Sœur de': 'Sibling of',
'Parent': 'Parent',
'Conjoint·e': 'Partner',
'Nouvelle fiche': 'New profile',
"Ajouter <em>quelqu'un</em>": 'Add <em>someone</em>',
'Marie': 'Mary',
'Dupont': 'Smith',
@@ -205,26 +198,12 @@ export default {
"Chercher dans l'arbre": 'Search the tree',
'Ajout en cours': 'Adding',
'Ajouter cette personne': 'Add this person',
'Enfant de {name}': 'Child of {name}',
'Conjoint de {name}': 'Partner of {name}',
'Frère·Sœur de {name}': 'Sibling of {name}',
'Nouvel enfant': 'New child',
'Nouveau conjoint': 'New partner',
'Nouveau frère/sœur': 'New sibling',
'Nouvelle personne': 'New person',
'Jean': 'John',
'Date naissance': 'Date of birth',
'Lieu naissance': 'Birthplace',
'Création en cours': 'Creating',
'Créer': 'Create',
// Création d'un arbre (TreeCreationWizard)
'Vous': 'You',
'Année de naissance (optionnel)': 'Year of birth (optional)',
'Parent 1': 'Parent 1',
'Parent 2': 'Parent 2',
'Créer les 3 personnes': 'Create the 3 people',
'Plus tard': 'Later',
'Étape {step} sur 2': 'Step {step} of 2',
"Comment s'appelle <em>votre famille</em> ?": "What's <em>your family</em> called?",
"Ce nom apparaîtra en haut de l'arbre et dans le lien que vous partagerez.": 'This name will appear at the top of the tree and in the link you share.',
@@ -242,11 +221,6 @@ export default {
'Retour': 'Back',
"Création de l'arbre": 'Creating the tree',
'Créer mon arbre': 'Create my tree',
'Erreur lors de la création. Essayez un autre nom.': 'Something went wrong. Try another name.',
'Créer votre arbre dès maintenant': 'Create your tree now',
'Ex : Famille Martin': 'E.g. The Johnson family',
'Adresse :': 'Address:',
"Créer l'arbre": 'Create the tree',
// Recherche (search/*)
'Rechercher une personne': 'Search for a person',
@@ -278,7 +252,6 @@ export default {
'Enseignante': 'Teacher',
'Mariage 1918': 'Wedding, 1918',
'Atelier de menuiserie': 'Joinery workshop',
'Témoignage filmé 1960': 'Filmed interview, 1960',
'Jeunesse': 'Youth',
'Portrait de famille': 'Family portrait',
'Service militaire': 'Military service',
@@ -286,9 +259,7 @@ export default {
'Années 70': 'The 70s',
'Diplôme': 'Graduation',
'Plan du cabinet 1985': 'Office floor plan, 1985',
'Anniversaire 2020': 'Birthday, 2020',
'En rédaction': 'In the newsroom',
'Interview archive radio': 'Archived radio interview',
'Inconnu': 'Unknown',
// Place dans la fratrie (fiche, mode édition)
'Place {n} sur {total} parmi les frères et sœurs': 'Place {n} of {total} among siblings',
@@ -300,7 +271,6 @@ export default {
'Aperçu indisponible': 'Preview unavailable',
'Un souvenir à regarder de plus près': 'A memory to take a closer look at',
'Retrouver vos souvenirs': 'Find your memories',
'Choisissez une image pour l’agrandir, puis ajoutez-la aux souvenirs.': 'Open an image for a closer look, then add it to memories.',
'Retour aux photos': 'Back to photos',
'Aperçu de la vidéo': 'Video preview',
'Recherche dans Immich': 'Search Immich',
@@ -346,7 +316,6 @@ export default {
'Terminer la sélection': 'Finish selecting',
'Depuis un fichier': 'From a file',
'Source du portrait': 'Portrait source',
'Visage Immich': 'Immich face',
'Reconnaissance des visages': 'Face recognition',
'Immich n’est pas configuré pour cet arbre.': 'Immich is not configured for this tree.',
'Immich n’est pas configuré pour cet arbre. Ses administrateurs le règlent dans Paramètres de l’arbre.': 'Immich is not configured for this tree. Its administrators set it up in Tree settings.',
@@ -451,7 +420,6 @@ export default {
'environ': 'about',
'Où ?': 'Where?',
'Ville ou adresse': 'Town or address',
'Retirer la photo': 'Remove the photo',
'Une photo': 'A photo',
'Plus de détails': 'More details',
'Date exacte': 'Exact date',
-2
View File
@@ -66,14 +66,12 @@ export default {
"Heredis, Geneanet, Gramps, MyHeritage ou Ancestry savent exporter un fichier .ged. Parenthèse reprend les personnes, les liens et les dates, puis vous aide à retrouver les photos.": "Heredis, Geneanet, Gramps, MyHeritage and Ancestry can all export a .ged file. Parenthèse brings in the people, links and dates, then helps you find the photos.",
"Choisir le fichier .ged": "Choose the .ged file",
"Cliquez pour en choisir un autre": "Click to choose another one",
"Exporté depuis le logiciel où vous tenez votre arbre": "Exported from the software where you keep your tree",
"Importer mon arbre": "Import my tree",
"Votre arbre est créé, mais l'import n'a pas abouti.": "Your tree is created, but the import didn't go through.",
"Vous pouvez réessayer depuis les paramètres de l'arbre.": "You can try again from the tree settings.",
// Import d’un zip (GEDCOM et ses photos)
'Ajoute les personnes et les liens du fichier à cet arbre. Un fichier .ged, ou un zip qui contient le .ged et ses photos.': 'Adds the people and links from the file to this tree. A .ged file, or a zip with the .ged and its photos.',
'Aucun fichier du zip ne correspond à ceux que votre logiciel citait. Choisissez le dossier où il les range.': 'No file in the zip matches the ones your software listed. Choose the folder where it keeps them.',
'Votre zip contenait aussi les fichiers. Parenthèse a retrouvé ceux que votre logiciel citait.': 'Your zip also held the files. Parenthèse found the ones your software listed.',
'Choisir le fichier .ged ou le zip': 'Choose the .ged file or the zip',
'Exporté depuis votre logiciel. Un zip avec le .ged et ses photos fait tout en une fois.': 'Exported from your software. A zip with the .ged and its photos does it all at once.',
'Ce zip est protégé par un mot de passe. Refaites-le sans mot de passe, puis réessayez.': 'This zip is password protected. Make it again without a password, then try again.',
+3 -7
View File
@@ -39,14 +39,10 @@
/* DM Sans pour les petits éléments UI : labels, meta, nav, badges */
button, input, select, textarea,
label,
.tree-meta, .admin-meta, .admin-stat-card span,
.contrib-invite-header span, .contrib-item-meta span,
.contrib-item-meta span,
.contrib-session-date, .contrib-role-badge,
.contrib-link-url, .contrib-pass-value, .contrib-pass-reveal,
.access-gate-tree-meta, .media-manager-meta span,
.admin-audit-main span, .admin-audit-meta,
.admin-person-item span,
.account-help {
.contrib-link-url, .contrib-pass-reveal,
.access-gate-tree-meta {
font-family: 'DM Sans', system-ui, sans-serif;
}
}
+1 -21
View File
@@ -31,7 +31,6 @@
--pz-sans: 'DM Sans', system-ui, -apple-system, sans-serif;
--pz-ease: cubic-bezier(.23, 1, .32, 1);
--pz-ease-drawer: cubic-bezier(.32, .72, 0, 1);
--pz-ease-pop: cubic-bezier(.34, 1.4, .64, 1);
--pz-r-s: 10px;
--pz-r-m: 14px;
--pz-r-l: 22px;
@@ -46,8 +45,6 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-eyebrow { font: 500 12px/1.3 var(--pz-sans); letter-spacing: .1em; text-transform: uppercase; color: var(--pz-muted); margin: 0; }
.pz-title { font: 400 30px/1.1 var(--pz-serif); letter-spacing: -.01em; color: var(--pz-ink); margin: 0; text-wrap: balance; }
.pz-title em { font-style: italic; font-weight: 300; }
.pz-title-lg { font: 300 clamp(36px, 5vw, 48px)/1.04 var(--pz-serif); letter-spacing: -.02em; color: var(--pz-ink); margin: 0; text-wrap: balance; }
.pz-title-lg em { font-style: italic; }
.pz-sub { font: 400 15px/1.55 var(--pz-sans); color: var(--pz-ink-2); margin: 0; }
.pz-small { font: 400 13px/1.5 var(--pz-sans); color: var(--pz-muted); margin: 0; }
.pz-small a, .pz-sub a { color: var(--pz-a700); text-underline-offset: 3px; }
@@ -190,8 +187,6 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-menu-item--danger { color: var(--pz-danger); }
.pz-menu-item--danger svg { color: var(--pz-danger); }
@media (hover: hover) and (pointer: fine) { .pz-menu-item:hover { background: var(--pz-line-soft); } .pz-menu-item.is-active:hover { background: var(--pz-a100); } }
.pz-menu-sep { height: 1px; margin: 4px 6px; background: var(--pz-line); }
.pz-menu-label { padding: 8px 12px 4px; font: 500 11.5px var(--pz-sans); letter-spacing: .08em; text-transform: uppercase; color: var(--pz-faint); }
/* ── Le rond qui attend (motion validée le 25/09/2026) ────────── */
/* Le rond se divise en trois points qui respirent, puis se referme avec un léger rebond. */
@@ -200,13 +195,6 @@ p, li, blockquote, figcaption, dd, .pz-sub, .pz-small, .pz-hint, .pz-error, .pz-
.pz-mark-c { fill: var(--pz-a500); transform-box: fill-box; transform-origin: center; }
.pz-mark-p { transition: transform 420ms var(--pz-ease); }
.pz-mark-s { transform-box: fill-box; transform-origin: center; transition: transform 420ms var(--pz-ease); }
.pz-mark.is-loading .pz-mark-p0 { transform: translateX(-300px); }
.pz-mark.is-loading .pz-mark-p2 { transform: translateX(300px); }
.pz-mark.is-loading .pz-mark-s { transform: scale(.27); }
.pz-mark.is-loading .pz-mark-c { animation: pz-souffle 1200ms ease-in-out 420ms infinite; }
.pz-mark.is-loading .pz-mark-p1 .pz-mark-c { animation-delay: 580ms; }
.pz-mark.is-loading .pz-mark-p2 .pz-mark-c { animation-delay: 740ms; }
.pz-mark.is-merging .pz-mark-p, .pz-mark.is-merging .pz-mark-s { transition-duration: 560ms; transition-timing-function: var(--pz-ease-pop); }
@keyframes pz-souffle { 0%, 100% { opacity: 1; transform: none; } 50% { opacity: .32; transform: scale(.78); } }
.pz-boot {
@@ -238,7 +226,7 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
.pz-btn:active:not(:disabled) { transform: none; }
/* les points apparaissent sur place et clignotent en fondu, sans déplacement */
.pz-mark-p, .pz-mark-s, .pz-boot .pz-mark { transition-duration: 1ms !important; }
.pz-mark.is-loading .pz-mark-c, .pz-busy.is-busy .pz-dots i { animation-name: pz-fade-pulse !important; }
.pz-busy.is-busy .pz-dots i { animation-name: pz-fade-pulse !important; }
.pz-busy.is-busy .pz-busy-lbl { filter: none; transform: none; }
}
@keyframes pz-fade-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
@@ -256,14 +244,6 @@ button:disabled:has(.pz-busy.is-busy) { opacity: 1; cursor: progress; }
.pz-lang .pz-lang-opt:focus-visible { outline: 2px solid var(--pz-a500); outline-offset: 2px; }
@media (hover: hover) and (pointer: fine) { .pz-lang .pz-lang-opt:not(.is-active):hover { color: var(--pz-ink); background: var(--pz-line-soft); } }
.pz-lang-sep { font: 400 12px/1 var(--pz-sans); color: var(--pz-faint); user-select: none; }
/* Ligne de menu : libellé à gauche, choix à droite */
.pz-lang-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px; min-height: 40px; padding: 4px 6px 4px 12px;
font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink);
}
.pz-lang-row-label { display: inline-flex; align-items: center; gap: 10px; }
.pz-lang-row-label svg { color: var(--pz-muted); flex: none; }
/* Dans la barre d'actions, séparé des boutons par un filet */
/* Sous les écrans d'accès et de connexion */
.pz-lang--screen { justify-self: center; }
+4 -4
View File
@@ -1,8 +1,8 @@
export const DEFAULT_MAP_VIEWBOX_WIDTH = 420
export const DEFAULT_MAP_VIEWBOX_HEIGHT = 280
export const MAX_MERCATOR_LAT = 85.05112878
const DEFAULT_MAP_VIEWBOX_WIDTH = 420
const DEFAULT_MAP_VIEWBOX_HEIGHT = 280
const MAX_MERCATOR_LAT = 85.05112878
export function clamp(value, min, max) {
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value))
}
+1 -1
View File
@@ -118,7 +118,7 @@ export function ghostsFromChanges(changes = [], meta = {}, graph = {}) {
return { persons, unions, filiations, annotations, patches }
}
export function emptyGhosts() {
function emptyGhosts() {
return { persons: [], unions: [], filiations: [], annotations: [], patches: new Map() }
}
+1 -1
View File
@@ -25,7 +25,7 @@ export function kinshipFacts(path) {
}
// Suite des pas d'un chemin, pour reconnaître les liens que le compte des générations confond (#218)
export const movesOf = (path) => path.steps.map((step) => step.move).join(',')
const movesOf = (path) => path.steps.map((step) => step.move).join(',')
// Demi-frère ou demi-sœur : un seul parent en commun. Deux enfants du même couple passent par 'sibling'
// (le chemin le plus court) ; deux enfants d'un même parent avec deux conjoints passent par ce parent.
export const isHalfSibling = (path) => movesOf(path) === 'up,down'
-4
View File
@@ -7,10 +7,6 @@ export function setMediaToken(token) {
currentMediaToken = typeof token === 'string' ? token : ''
}
export function getMediaToken() {
return currentMediaToken
}
// Adresse d'un fichier servi par l'API. Une adresse externe (YouTube) ne reçoit jamais de jeton.
export function buildMediaUrl(pathOrUrl, apiBaseUrl = '', token = currentMediaToken) {
if (!pathOrUrl) {
+1 -1
View File
@@ -1,7 +1,7 @@
import { t } from '../i18n/index.js'
export const MAX_IMAGE_BYTES = 5 * 1024 * 1024
export const MAX_FILE_BYTES = 20 * 1024 * 1024
const MAX_FILE_BYTES = 20 * 1024 * 1024
export function inferMediaTypeFromMimeType(mimeType) {
const normalized = String(mimeType || '').toLowerCase()
+2 -2
View File
@@ -2,14 +2,14 @@
// (1 = l'image entière recouvre le cadre, 4 = gros plan). Null = cadrage par défaut (centré, zoom 1).
// Ce module ne fait que des calculs : l'éditeur, la fiche et le canevas s'y réfèrent tous.
export const CROP_MAX_ZOOM = 4
const CROP_MAX_ZOOM = 4
export const DEFAULT_CROP = { x: 0.5, y: 0.5, zoom: 1 }
const clamp = (value, min, max) => Math.min(max, Math.max(min, value))
// Formes qu'on choisit au recadrage, gardées dans le cadrage (crop.shape). Sans choix, la forme suit la photo
// (très large ou très haute) ou le cadre tiré au hasard pour la personne.
export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape']
const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape']
export const cropShape = (crop) => (FRAME_SHAPES.includes(crop?.shape) ? crop.shape : null)
// Forme du cadre dans le canevas : même règle que resolveFrameType et drawFrame (renderers.js).
+18
View File
@@ -0,0 +1,18 @@
import { Fragment } from 'react'
// Mise en forme des phrases traduites : le dictionnaire porte le texte, ces deux fonctions posent l'italique.
// Titre traduit d'un bloc : le passage entre <em> et </em> garde son italique
export function withEmphasis(text) {
const [before, emphasized = '', after = ''] = text.split(/<\/?em>/)
return <>{before}<em>{emphasized}</em>{after}</>
}
// Phrase traduite d'un bloc : les passages entre astérisques passent en <em>, {slot} reçoit un nœud.
export function richText(text, slotNode = null) {
return text.split(/(\{slot\})/).flatMap((chunk, chunkIndex) => (
chunk === '{slot}'
? [<Fragment key={`s${chunkIndex}`}>{slotNode}</Fragment>]
: chunk.split('*').map((part, index) => (index % 2 ? <em key={`${chunkIndex}-${index}`}>{part}</em> : part))
))
}
+3 -3
View File
@@ -7,10 +7,10 @@ export const PHONE_QUERY = '(max-width: 767px), (pointer: coarse)'
export const SHEET_WIDTH_QUERY = '(max-width: 768px)'
// Distance avant de trancher entre glisser vertical, horizontal et tap
export const DIRECTION_LOCK_PX = 8
const DIRECTION_LOCK_PX = 8
// Lâcher rapide vers le bas : vitesse en px/ms, et course minimale pour qu'il compte
export const FLICK_VELOCITY = 0.6
export const FLICK_MIN_OFFSET = 24
const FLICK_VELOCITY = 0.6
const FLICK_MIN_OFFSET = 24
// Deux points plus espacés que ça ne donnent pas une vitesse fiable
const VELOCITY_WINDOW_MS = 120
+2 -2
View File
@@ -33,7 +33,7 @@ function decodeName(bytes, utf8) {
return out
}
export function isZipSignature(bytes) {
function isZipSignature(bytes) {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && (bytes[2] === 0x03 || bytes[2] === 0x05) && (bytes[3] === 0x04 || bytes[3] === 0x06)
}
@@ -104,7 +104,7 @@ const MIME_BY_EXTENSION = {
}
export const extensionOf = (name) => (String(name).match(/\.([a-z0-9]{1,5})$/i)?.[1] || '').toLowerCase()
export const mimeTypeOf = (name) => MIME_BY_EXTENSION[extensionOf(name)] || ''
const mimeTypeOf = (name) => MIME_BY_EXTENSION[extensionOf(name)] || ''
// Fichier du zip qu'on peut rapprocher comme un fichier de dossier (nom, chemin), et ne lire qu'au moment
// de l'envoyer : toFile() le décompresse et renvoie un vrai File