mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 08:32:03 +00:00
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:
63 files changed
+218
-1598
No files matched your search
@@ -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}`)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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')
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,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', () => {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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 +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 |
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
@@ -1,3 +0,0 @@
|
||||
// Export central pour les composants search
|
||||
export { default as ExpandableSearch } from './ExpandableSearch'
|
||||
export { default as SearchResultsDropdown } from './SearchResultsDropdown'
|
||||
@@ -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
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,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,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).
|
||||
|
||||
@@ -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))
|
||||
))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user