diff --git a/backend/src/lib/annotation-changes.ts b/backend/src/lib/annotation-changes.ts index b561306..b6b9e14 100644 --- a/backend/src/lib/annotation-changes.ts +++ b/backend/src/lib/annotation-changes.ts @@ -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 { await db.annotationPhotoUpload.deleteMany({ where: { id: photoId } }) } - -// Chemin relatif sûr d'une photo d'annotation (sous /annotation-photos/) -export function annotationPhotoRelativePath(treeId: string, photoId: string, extension: string): string { - return path.posix.join(treeId, 'annotation-photos', `${photoId}${extension}`) -} diff --git a/backend/src/lib/avatar-crop.ts b/backend/src/lib/avatar-crop.ts index d386377..4ff3eb6 100644 --- a/backend/src/lib/avatar-crop.ts +++ b/backend/src/lib/avatar-crop.ts @@ -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 diff --git a/backend/src/lib/gedcom-events.ts b/backend/src/lib/gedcom-events.ts index c5cf8fe..34ff8f4 100644 --- a/backend/src/lib/gedcom-events.ts +++ b/backend/src/lib/gedcom-events.ts @@ -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, } diff --git a/backend/src/lib/immich-secret.ts b/backend/src/lib/immich-secret.ts index 5a9d181..b9ca2c1 100644 --- a/backend/src/lib/immich-secret.ts +++ b/backend/src/lib/immich-secret.ts @@ -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') diff --git a/backend/src/lib/immich.ts b/backend/src/lib/immich.ts index b6bd2e4..92cb981 100644 --- a/backend/src/lib/immich.ts +++ b/backend/src/lib/immich.ts @@ -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) } diff --git a/backend/src/lib/media-people.ts b/backend/src/lib/media-people.ts index a8227f7..ef2adda 100644 --- a/backend/src/lib/media-people.ts +++ b/backend/src/lib/media-people.ts @@ -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): 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 { - 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( diff --git a/backend/src/lib/oidc.ts b/backend/src/lib/oidc.ts index 97a78ee..b3c222b 100644 --- a/backend/src/lib/oidc.ts +++ b/backend/src/lib/oidc.ts @@ -31,7 +31,7 @@ export function isOidcEnabled(): boolean { let configuration: Promise | null = null // Découverte faite au premier besoin puis gardée ; un échec (fournisseur injoignable) sera retenté à la connexion suivante -export function getOidcConfiguration(): Promise { +function getOidcConfiguration(): Promise { 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 -} diff --git a/backend/src/lib/sealed-secret.ts b/backend/src/lib/sealed-secret.ts new file mode 100644 index 0000000..0677809 --- /dev/null +++ b/backend/src/lib/sealed-secret.ts @@ -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 } +} diff --git a/backend/src/lib/search-sources.ts b/backend/src/lib/search-sources.ts index 335ebc2..d2f4ba0 100644 --- a/backend/src/lib/search-sources.ts +++ b/backend/src/lib/search-sources.ts @@ -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() } diff --git a/backend/src/lib/share-password.ts b/backend/src/lib/share-password.ts index f121fac..32bcfe9 100644 --- a/backend/src/lib/share-password.ts +++ b/backend/src/lib/share-password.ts @@ -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') diff --git a/backend/src/lib/tree-access.ts b/backend/src/lib/tree-access.ts index 03685c5..f491d92 100644 --- a/backend/src/lib/tree-access.ts +++ b/backend/src/lib/tree-access.ts @@ -36,7 +36,7 @@ export async function canReadTree(app: FastifyInstance, treeId: string, actor: A // Les images sont chargées par , 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): string { const payload: MediaJwtPayload = { diff --git a/backend/src/lib/tree-purge.ts b/backend/src/lib/tree-purge.ts index 76fc701..b2895d4 100644 --- a/backend/src/lib/tree-purge.ts +++ b/backend/src/lib/tree-purge.ts @@ -10,7 +10,7 @@ export type PurgeLogger = { error: (details: Record, message: string) => void } -export function getMediaStorageRoot(): string { +function getMediaStorageRoot(): string { return path.resolve(env.MEDIA_STORAGE_PATH) } diff --git a/backend/src/lib/tree-visits.ts b/backend/src/lib/tree-visits.ts index 84dd2d7..27f9926 100644 --- a/backend/src/lib/tree-visits.ts +++ b/backend/src/lib/tree-visits.ts @@ -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) } diff --git a/backend/src/lib/unlock-guard.ts b/backend/src/lib/unlock-guard.ts index 16e4bfe..fae3b42 100644 --- a/backend/src/lib/unlock-guard.ts +++ b/backend/src/lib/unlock-guard.ts @@ -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() } diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 219c60f..4a43be8 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -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({ diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index 563581e..8ee2a5a 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -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, ' ') diff --git a/backend/test/gedcom-place.test.ts b/backend/test/gedcom-place.test.ts index d82b0bc..5ca5ebc 100644 --- a/backend/test/gedcom-place.test.ts +++ b/backend/test/gedcom-place.test.ts @@ -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', () => { diff --git a/backend/test/security-hardening.test.ts b/backend/test/security-hardening.test.ts index f5080ad..01c8561 100644 --- a/backend/test/security-hardening.test.ts +++ b/backend/test/security-hardening.test.ts @@ -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) diff --git a/frontend/src/App.css b/frontend/src/App.css index ba1ac67..f1edc44 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -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; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 86d5a82..49c03ae 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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, diff --git a/frontend/src/api/authApi.js b/frontend/src/api/authApi.js index db6e02f..54ca5e9 100644 --- a/frontend/src/api/authApi.js +++ b/frontend/src/api/authApi.js @@ -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 diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index b6e6efb..32dc575 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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 } diff --git a/frontend/src/api/immichApi.js b/frontend/src/api/immichApi.js index 3c01293..dc3691e 100644 --- a/frontend/src/api/immichApi.js +++ b/frontend/src/api/immichApi.js @@ -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}`) diff --git a/frontend/src/api/managersApi.js b/frontend/src/api/managersApi.js index 79fad1a..aa4fcf7 100644 --- a/frontend/src/api/managersApi.js +++ b/frontend/src/api/managersApi.js @@ -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) { diff --git a/frontend/src/api/searchSourcesApi.js b/frontend/src/api/searchSourcesApi.js index b056be6..61edaf8 100644 --- a/frontend/src/api/searchSourcesApi.js +++ b/frontend/src/api/searchSourcesApi.js @@ -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 : [] diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 8a83ac8..45c640a 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -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, diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/frontend/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/frontend/src/components/AccessGate.jsx b/frontend/src/components/AccessGate.jsx index 03f0d6c..ff85eb8 100644 --- a/frontend/src/components/AccessGate.jsx +++ b/frontend/src/components/AccessGate.jsx @@ -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 , {slot} reçoit un nœud. -function richText(text, slotNode = null) { - return text.split(/(\{slot\})/).flatMap((chunk, chunkIndex) => ( - chunk === '{slot}' - ? [{slotNode}] - : chunk.split('*').map((part, index) => (index % 2 ? {part} : part)) - )) -} +import { richText } from '../utils/richText.jsx' const AccessGate = ({ initialTreeId, diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index 7705b06..392fe69 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -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 , {slot} reçoit un nœud. -function richText(text, slotNode = null) { - return text.split(/(\{slot\})/).flatMap((chunk, chunkIndex) => ( - chunk === '{slot}' - ? [{slotNode}] - : chunk.split('*').map((part, index) => (index % 2 ? {part} : part)) - )) -} +import { richText } from '../utils/richText.jsx' const AccountDashboard = ({ authenticated, diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index 4cb1dce..7018d77 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -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 et garde son italique -function withEmphasis(text) { - const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) - return <>{before}{emphasized}{after} -} +import { withEmphasis } from '../utils/richText.jsx' /** * Panneau slide-in gauche pour ajouter une personne diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 38415b7..b06f18a 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -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 }) diff --git a/frontend/src/components/MediaViewer.css b/frontend/src/components/MediaViewer.css index b9e7cf1..bf0cd23 100644 --- a/frontend/src/components/MediaViewer.css +++ b/frontend/src/components/MediaViewer.css @@ -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; diff --git a/frontend/src/components/PhotoStartPanel.jsx b/frontend/src/components/PhotoStartPanel.jsx index 7df1f2d..5c1aed0 100644 --- a/frontend/src/components/PhotoStartPanel.jsx +++ b/frontend/src/components/PhotoStartPanel.jsx @@ -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}{emphasized}{after} -} +import { withEmphasis } from '../utils/richText.jsx' function lifeYears(person) { if (!person.birthYear) return '' diff --git a/frontend/src/components/SearchSourcesPanel.jsx b/frontend/src/components/SearchSourcesPanel.jsx index c9b176a..e94644e 100644 --- a/frontend/src/components/SearchSourcesPanel.jsx +++ b/frontend/src/components/SearchSourcesPanel.jsx @@ -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 et garde son italique (comme « Ajouter quelqu'un ») -function withEmphasis(text) { - const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) - return <>{before}{emphasized}{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'), diff --git a/frontend/src/components/TreeCreationWizard.jsx b/frontend/src/components/TreeCreationWizard.jsx index 90c1a29..1168e3c 100644 --- a/frontend/src/components/TreeCreationWizard.jsx +++ b/frontend/src/components/TreeCreationWizard.jsx @@ -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 et garde son italique -function withEmphasis(text) { - const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) - return <>{before}{emphasized}{after} -} - function TreeCreationWizard({ visible, loading, diff --git a/frontend/src/components/coGestion.test.jsx b/frontend/src/components/coGestion.test.jsx index 986be6e..be4e085 100644 --- a/frontend/src/components/coGestion.test.jsx +++ b/frontend/src/components/coGestion.test.jsx @@ -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() - expect(html).toContain('Co-gestionnaire') - expect(html).not.toContain('>Admin<') - }) - it('le panneau a un onglet Gestion', () => { const html = renderToStaticMarkup() expect(html).toContain('Gestion') diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index c02f0ee..668c9e0 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -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() -} diff --git a/frontend/src/components/navbar/ContextualNavbar.css b/frontend/src/components/navbar/ContextualNavbar.css index 96bb973..92e5fad 100644 --- a/frontend/src/components/navbar/ContextualNavbar.css +++ b/frontend/src/components/navbar/ContextualNavbar.css @@ -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; diff --git a/frontend/src/components/navbar/NavButton.css b/frontend/src/components/navbar/NavButton.css deleted file mode 100644 index d794262..0000000 --- a/frontend/src/components/navbar/NavButton.css +++ /dev/null @@ -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); } diff --git a/frontend/src/components/navbar/NavButton.jsx b/frontend/src/components/navbar/NavButton.jsx deleted file mode 100644 index a368460..0000000 --- a/frontend/src/components/navbar/NavButton.jsx +++ /dev/null @@ -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 ( - - ) -} - -export default NavButton diff --git a/frontend/src/components/navbar/TreeDropdown.css b/frontend/src/components/navbar/TreeDropdown.css deleted file mode 100644 index 1df4ca9..0000000 --- a/frontend/src/components/navbar/TreeDropdown.css +++ /dev/null @@ -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; -} diff --git a/frontend/src/components/navbar/TreeDropdown.jsx b/frontend/src/components/navbar/TreeDropdown.jsx deleted file mode 100644 index 86f2cdb..0000000 --- a/frontend/src/components/navbar/TreeDropdown.jsx +++ /dev/null @@ -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 ( -
- {/* Mes arbres */} - {myTrees.length > 0 && ( -
-
{t('Mes arbres')}
- {myTrees.map(tree => ( - - ))} -
- )} - - {/* Arbres partagés */} - {sharedTrees.length > 0 && ( -
-
{t('Arbres partagés')}
- {sharedTrees.map(tree => ( - - ))} -
- )} - - {/* Arbre mock */} - {mockTree && ( -
-
{t('Exemple')}
- -
- )} - - {/* Actions */} -
- -
-
- ) -} - -export default TreeDropdown diff --git a/frontend/src/components/navbar/index.js b/frontend/src/components/navbar/index.js deleted file mode 100644 index 125f80a..0000000 --- a/frontend/src/components/navbar/index.js +++ /dev/null @@ -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' diff --git a/frontend/src/components/search/index.js b/frontend/src/components/search/index.js deleted file mode 100644 index da76d1c..0000000 --- a/frontend/src/components/search/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// Export central pour les composants search -export { default as ExpandableSearch } from './ExpandableSearch' -export { default as SearchResultsDropdown } from './SearchResultsDropdown' diff --git a/frontend/src/data/mockData.js b/frontend/src/data/mockData.js index bd7e4f5..736b4e1 100644 --- a/frontend/src/data/mockData.js +++ b/frontend/src/data/mockData.js @@ -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 diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js index 4865a5e..6a35d7f 100644 --- a/frontend/src/hooks/useAuth.js +++ b/frontend/src/hooks/useAuth.js @@ -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)) diff --git a/frontend/src/hooks/useUndoable.js b/frontend/src/hooks/useUndoable.js index 7247c63..4be2a71 100644 --- a/frontend/src/hooks/useUndoable.js +++ b/frontend/src/hooks/useUndoable.js @@ -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). diff --git a/frontend/src/i18n/en/arbre.js b/frontend/src/i18n/en/arbre.js index c89418e..ac68e77 100644 --- a/frontend/src/i18n/en/arbre.js +++ b/frontend/src/i18n/en/arbre.js @@ -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...', diff --git a/frontend/src/i18n/en/compte.js b/frontend/src/i18n/en/compte.js index d255ba4..127e5b1 100644 --- a/frontend/src/i18n/en/compte.js +++ b/frontend/src/i18n/en/compte.js @@ -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", diff --git a/frontend/src/i18n/en/contribution.js b/frontend/src/i18n/en/contribution.js index ff5c543..ba25719 100644 --- a/frontend/src/i18n/en/contribution.js +++ b/frontend/src/i18n/en/contribution.js @@ -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', diff --git a/frontend/src/i18n/en/fiche.js b/frontend/src/i18n/en/fiche.js index f040e88..b0181c4 100644 --- a/frontend/src/i18n/en/fiche.js +++ b/frontend/src/i18n/en/fiche.js @@ -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 quelqu'un": 'Add someone', '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 votre famille ?": "What's your family 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', diff --git a/frontend/src/i18n/en/photos.js b/frontend/src/i18n/en/photos.js index 6d809b9..a798f82 100644 --- a/frontend/src/i18n/en/photos.js +++ b/frontend/src/i18n/en/photos.js @@ -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.', diff --git a/frontend/src/index.css b/frontend/src/index.css index bbe2891..7aed9b8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } } diff --git a/frontend/src/styles/pz.css b/frontend/src/styles/pz.css index d814e97..fb975ae 100644 --- a/frontend/src/styles/pz.css +++ b/frontend/src/styles/pz.css @@ -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; } diff --git a/frontend/src/utils/geoMedia.js b/frontend/src/utils/geoMedia.js index 118a262..c04f95a 100644 --- a/frontend/src/utils/geoMedia.js +++ b/frontend/src/utils/geoMedia.js @@ -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)) } diff --git a/frontend/src/utils/ghosts.js b/frontend/src/utils/ghosts.js index c3b2b50..bb6355f 100644 --- a/frontend/src/utils/ghosts.js +++ b/frontend/src/utils/ghosts.js @@ -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() } } diff --git a/frontend/src/utils/kinship/facts.js b/frontend/src/utils/kinship/facts.js index 0268ba2..dd2ac47 100644 --- a/frontend/src/utils/kinship/facts.js +++ b/frontend/src/utils/kinship/facts.js @@ -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' diff --git a/frontend/src/utils/mediaToken.js b/frontend/src/utils/mediaToken.js index 91d465a..954cb9d 100644 --- a/frontend/src/utils/mediaToken.js +++ b/frontend/src/utils/mediaToken.js @@ -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) { diff --git a/frontend/src/utils/mediaUpload.js b/frontend/src/utils/mediaUpload.js index 9b35882..ef9e447 100644 --- a/frontend/src/utils/mediaUpload.js +++ b/frontend/src/utils/mediaUpload.js @@ -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() diff --git a/frontend/src/utils/portraitCrop.js b/frontend/src/utils/portraitCrop.js index e50fbd7..4f5e35e 100644 --- a/frontend/src/utils/portraitCrop.js +++ b/frontend/src/utils/portraitCrop.js @@ -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). diff --git a/frontend/src/utils/richText.jsx b/frontend/src/utils/richText.jsx new file mode 100644 index 0000000..f07fbe3 --- /dev/null +++ b/frontend/src/utils/richText.jsx @@ -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 et garde son italique +export function withEmphasis(text) { + const [before, emphasized = '', after = ''] = text.split(/<\/?em>/) + return <>{before}{emphasized}{after} +} + +// Phrase traduite d'un bloc : les passages entre astérisques passent en , {slot} reçoit un nœud. +export function richText(text, slotNode = null) { + return text.split(/(\{slot\})/).flatMap((chunk, chunkIndex) => ( + chunk === '{slot}' + ? [{slotNode}] + : chunk.split('*').map((part, index) => (index % 2 ? {part} : part)) + )) +} diff --git a/frontend/src/utils/sheetDrag.js b/frontend/src/utils/sheetDrag.js index 7bb0355..d4a19a8 100644 --- a/frontend/src/utils/sheetDrag.js +++ b/frontend/src/utils/sheetDrag.js @@ -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 diff --git a/frontend/src/utils/zipReader.js b/frontend/src/utils/zipReader.js index 955c975..43c01a6 100644 --- a/frontend/src/utils/zipReader.js +++ b/frontend/src/utils/zipReader.js @@ -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