From 5fc0c08c1c3a5966504fe0694939bbc7a82b6028 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Fri, 25 Sep 2026 18:44:07 +0200 Subject: [PATCH] =?UTF-8?q?Journal=20des=20visites,=20recherche=20qui=20m?= =?UTF-8?q?=C3=A8ne=20=C3=A0=20la=20personne,=20croix=20de=20retour=20?= =?UTF-8?q?=C3=A0=20l'arbre?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Visites : chaque ouverture de l'arbre est notée (prénom facultatif demandé avec le mot de passe, appareil, compte), sans adresse IP, effacée après un an ; onglet Visites dans les paramètres, réservé aux administrateurs - Recherche : un résultat choisi recentre l'arbre sur la personne, la liste se replie - Écran de compte ouvert depuis l'arbre : croix pour y revenir - Enfant presque sous l'union : trait direct au lieu d'un petit S - Page vie privée : paragraphe sur les visites Co-Authored-By: Claude Opus 5.5 (1M context) --- .../20260925_004_tree_visits/migration.sql | 25 ++++ backend/prisma/schema.prisma | 21 +++ backend/src/app.ts | 2 + backend/src/lib/tree-visits.ts | 75 +++++++++++ backend/src/routes/graph.ts | 7 + backend/src/routes/visits.ts | 85 ++++++++++++ backend/test/api-routes.test.ts | 126 ++++++++++++++++++ frontend/src/App.jsx | 16 ++- frontend/src/api/treeApi.js | 19 +++ frontend/src/components/AccessGate.jsx | 18 +++ frontend/src/components/AccountDashboard.jsx | 10 ++ frontend/src/components/AdminPanel.jsx | 4 + frontend/src/components/Galaxy.jsx | 14 ++ frontend/src/components/SettingsPanels.css | 11 ++ frontend/src/components/TreeVisitsSection.jsx | 81 +++++++++++ frontend/src/components/galaxy/renderers.js | 5 +- .../components/search/ExpandableSearch.jsx | 11 +- frontend/src/styles/pz.css | 2 + .../public/donnees-et-vie-privee/index.html | 3 +- 19 files changed, 530 insertions(+), 5 deletions(-) create mode 100644 backend/prisma/migrations/20260925_004_tree_visits/migration.sql create mode 100644 backend/src/lib/tree-visits.ts create mode 100644 backend/src/routes/visits.ts create mode 100644 frontend/src/components/TreeVisitsSection.jsx diff --git a/backend/prisma/migrations/20260925_004_tree_visits/migration.sql b/backend/prisma/migrations/20260925_004_tree_visits/migration.sql new file mode 100644 index 0000000..0ea4ba2 --- /dev/null +++ b/backend/prisma/migrations/20260925_004_tree_visits/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "tree_visits" ( + "id" TEXT NOT NULL, + "treeId" TEXT NOT NULL, + "userId" TEXT, + "accessKind" TEXT NOT NULL, + "visitorName" TEXT, + "visitorKey" TEXT NOT NULL, + "device" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "tree_visits_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "tree_visits_treeId_createdAt_idx" ON "tree_visits"("treeId", "createdAt"); + +-- CreateIndex +CREATE INDEX "tree_visits_treeId_visitorKey_createdAt_idx" ON "tree_visits"("treeId", "visitorKey", "createdAt"); + +-- AddForeignKey +ALTER TABLE "tree_visits" ADD CONSTRAINT "tree_visits_treeId_fkey" FOREIGN KEY ("treeId") REFERENCES "trees"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "tree_visits" ADD CONSTRAINT "tree_visits_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a552e40..a80a8d5 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -91,6 +91,7 @@ model User { sharedTreeAccesses UserTreeAccess[] submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy") reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy") + treeVisits TreeVisit[] lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull) @@index([lastOpenedAt]) @@ -120,6 +121,7 @@ model Tree { contributionSessions ContributionSession[] auditLogs AuditLog[] annotations Annotation[] + visits TreeVisit[] lastOpenedByUsers User[] @relation("UserLastOpenedTree") @@index([ownerUserId]) @@ -349,6 +351,25 @@ model AuditLog { @@map("audit_log") } +// Journal des visites d'un arbre, lu par ses administrateurs (onglet Visites). +// Pas d'adresse IP : visitorKey est une empreinte (arbre + IP + navigateur) qui sert à compter les visiteurs distincts. +model TreeVisit { + id String @id @default(cuid()) + treeId String + userId String? + accessKind String + visitorName String? + visitorKey String + device String + createdAt DateTime @default(now()) + tree Tree @relation(fields: [treeId], references: [id], onDelete: Cascade) + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + + @@index([treeId, createdAt]) + @@index([treeId, visitorKey, createdAt]) + @@map("tree_visits") +} + model WaitlistEntry { id String @id @default(cuid()) email String @unique diff --git a/backend/src/app.ts b/backend/src/app.ts index 0b88031..a3cf2f3 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -15,6 +15,7 @@ import { publicRoutes } from './routes/public.js' import { annotationRoutes } from './routes/annotations.js' import { gedcomRoutes } from './routes/gedcom.js' import { treeRoutes } from './routes/trees.js' +import { visitRoutes } from './routes/visits.js' import type { AnyJwtPayload } from './types/auth.js' export function createApp() { @@ -115,6 +116,7 @@ export function createApp() { app.register(contributionRoutes) app.register(annotationRoutes) app.register(gedcomRoutes) + app.register(visitRoutes) app.setErrorHandler((error: FastifyError | Error, request, reply) => { if (error instanceof Prisma.PrismaClientInitializationError) { diff --git a/backend/src/lib/tree-visits.ts b/backend/src/lib/tree-visits.ts new file mode 100644 index 0000000..e2a1098 --- /dev/null +++ b/backend/src/lib/tree-visits.ts @@ -0,0 +1,75 @@ +import { createHash } from 'node:crypto' +import type { FastifyInstance, FastifyRequest } from 'fastify' + +import { env } from '../config/env.js' + +// Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre +// (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois. +export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000 +// Au-delà, les visites sont effacées (lecture de l'onglet Visites) +export const VISIT_RETENTION_DAYS = 365 + +export type VisitAccessKind = 'share' | 'member' | 'shared_account' + +export 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 { + if (typeof value !== 'string' || !value) return null + let decoded = value + try { + decoded = decodeURIComponent(value) + } catch { + return null + } + const trimmed = decoded.replace(/\s+/g, ' ').trim().slice(0, 60) + return trimmed || null +} + +export function visitorKeyOf(treeId: string, ip: string, userAgent: string): string { + return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32) +} + +// N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre +export async function recordTreeVisit( + app: FastifyInstance, + request: FastifyRequest, + treeId: string, + accessKind: VisitAccessKind, + userId: string | null, +): Promise { + try { + const userAgent = String(request.headers['user-agent'] || '') + const visitorKey = userId ? `user:${userId}` : visitorKeyOf(treeId, request.ip, userAgent) + const visitorName = visitorNameFromHeader(request.headers['x-visitor-name']) + + const recent = await app.prisma.treeVisit.findFirst({ + where: { treeId, visitorKey, createdAt: { gte: new Date(Date.now() - VISIT_DEDUP_WINDOW_MS) } }, + select: { id: true, visitorName: true }, + }) + if (recent) { + // Prénom donné après coup (champ rempli à la reconnexion) : on complète la visite en cours + if (visitorName && !recent.visitorName) { + await app.prisma.treeVisit.update({ where: { id: recent.id }, data: { visitorName } }) + } + return + } + + await app.prisma.treeVisit.create({ + data: { + treeId, + userId, + accessKind, + visitorName, + visitorKey, + device: deviceFromUserAgent(userAgent), + }, + }) + } catch (error) { + request.log.warn({ err: error, treeId }, 'tree_visit_record_failed') + } +} diff --git a/backend/src/routes/graph.ts b/backend/src/routes/graph.ts index 73cdb85..5100ddc 100644 --- a/backend/src/routes/graph.ts +++ b/backend/src/routes/graph.ts @@ -1,6 +1,7 @@ import type { FastifyPluginAsync } from 'fastify' import { z } from 'zod' +import { recordTreeVisit, type VisitAccessKind } from '../lib/tree-visits.js' import { buildGraphPayload } from '../utils/graph-payload.js' const treeIdParamSchema = z.object({ @@ -17,6 +18,7 @@ export const graphRoutes: FastifyPluginAsync = async (app) => { const treeId = params.data.id const actor = request.actor let rootPersonId: string | null = null + let visitKind: VisitAccessKind = 'share' if (!actor) { return reply.code(401).send({ error: 'authentication_required' }) @@ -65,6 +67,7 @@ export const graphRoutes: FastifyPluginAsync = async (app) => { if (membership && !membership.tree.deletedAt) { rootPersonId = membership.tree.rootPersonId + visitKind = 'member' } else { const shared = await app.prisma.userTreeAccess.findUnique({ where: { @@ -88,9 +91,13 @@ export const graphRoutes: FastifyPluginAsync = async (app) => { } rootPersonId = shared.tree.rootPersonId + visitKind = 'shared_account' } } + // Journal des visites (onglet Visites des administrateurs), sans attendre + void recordTreeVisit(app, request, treeId, visitKind, actor.kind === 'user' ? actor.userId : null) + const [persons, unions, links, medias, annotations] = await Promise.all([ app.prisma.person.findMany({ where: { diff --git a/backend/src/routes/visits.ts b/backend/src/routes/visits.ts new file mode 100644 index 0000000..9a45a7c --- /dev/null +++ b/backend/src/routes/visits.ts @@ -0,0 +1,85 @@ +import type { FastifyPluginAsync } from 'fastify' +import { z } from 'zod' + +import { VISIT_RETENTION_DAYS } from '../lib/tree-visits.js' + +const treeIdParamSchema = z.object({ + id: z.string().min(1), +}) + +const DAY_MS = 24 * 60 * 60 * 1000 +const RECENT_LIMIT = 60 + +function summarize(visits: { visitorKey: string }[]) { + return { visits: visits.length, visitors: new Set(visits.map(v => v.visitorKey)).size } +} + +// Journal des visites, réservé aux administrateurs de l'arbre (propriétaire compris). +// Les visites de l'administrateur qui consulte sont marquées isYou et hors des totaux. +export const visitRoutes: FastifyPluginAsync = async (app) => { + app.get('/trees/:id/visits', async (request, reply) => { + const actor = request.actor + if (!actor || actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treeIdParamSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_tree_id' }) + } + const treeId = params.data.id + + const membership = await app.prisma.treeMembership.findUnique({ + where: { treeId_userId: { treeId, userId: actor.userId } }, + include: { tree: { select: { deletedAt: true } } }, + }) + if (!membership || membership.tree.deletedAt || (membership.role !== 'owner' && membership.role !== 'admin')) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const now = Date.now() + await app.prisma.treeVisit.deleteMany({ + where: { treeId, createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * DAY_MS) } }, + }) + + const since30 = new Date(now - 30 * DAY_MS) + const [last30, recent] = await Promise.all([ + app.prisma.treeVisit.findMany({ + // OR explicite : un NOT sur userId écarterait aussi les visites anonymes (userId NULL) + where: { treeId, createdAt: { gte: since30 }, OR: [{ userId: null }, { userId: { not: actor.userId } }] }, + select: { visitorKey: true, createdAt: true }, + }), + app.prisma.treeVisit.findMany({ + where: { treeId }, + orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], + take: RECENT_LIMIT, + select: { + id: true, + createdAt: true, + accessKind: true, + visitorName: true, + device: true, + userId: true, + user: { select: { email: true } }, + }, + }), + ]) + + const since7 = now - 7 * DAY_MS + return reply.send({ + summary: { + last7: summarize(last30.filter(v => v.createdAt.getTime() >= since7)), + last30: summarize(last30), + }, + recent: recent.map(v => ({ + id: v.id, + createdAt: v.createdAt, + accessKind: v.accessKind, + name: v.visitorName, + email: v.user?.email ?? null, + device: v.device, + isYou: v.userId === actor.userId, + })), + }) + }) +} diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 5ec9b42..fdec471 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -84,6 +84,13 @@ const { prismaMock } = vi.hoisted(() => ({ create: vi.fn(), findMany: vi.fn(), }, + treeVisit: { + findFirst: vi.fn(), + findMany: vi.fn(), + create: vi.fn(), + update: vi.fn(), + deleteMany: vi.fn(), + }, $transaction: vi.fn(), $disconnect: vi.fn(), }, @@ -436,6 +443,125 @@ describe('Graph routes', () => { expect(response.json().rootPersonId).toBe('p-1') await app.close() }) + + it('records a visit with the visitor first name when the graph opens', async () => { + const app = createApp() + await app.ready() + + const treeAccessToken = (app as any).jwt.sign({ + kind: 'tree_access', + sub: 'tree:tree-1:visitor', + treeId: 'tree-1', + role: 'visitor', + accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(), + }) + + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'p-1' }) + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.mediaItem.findMany.mockResolvedValue([]) + prismaMock.treeVisit.findFirst.mockResolvedValue(null) + prismaMock.treeVisit.create.mockResolvedValue({}) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/graph', + headers: { + authorization: `Bearer ${treeAccessToken}`, + 'user-agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148', + 'x-visitor-name': encodeURIComponent('Hélène'), + }, + }) + + expect(response.statusCode).toBe(200) + await vi.waitFor(() => expect(prismaMock.treeVisit.create).toHaveBeenCalled()) + const data = prismaMock.treeVisit.create.mock.calls[0][0].data + expect(data).toMatchObject({ treeId: 'tree-1', userId: null, accessKind: 'share', visitorName: 'Hélène', device: 'phone' }) + expect(data.visitorKey).toMatch(/^[0-9a-f]{32}$/) + await app.close() + }) + + it('does not record a second visit for the same visitor within the window', async () => { + const app = createApp() + await app.ready() + + const treeAccessToken = (app as any).jwt.sign({ + kind: 'tree_access', + sub: 'tree:tree-1:visitor', + treeId: 'tree-1', + role: 'visitor', + accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(), + }) + + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: 'p-1' }) + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.mediaItem.findMany.mockResolvedValue([]) + prismaMock.treeVisit.findFirst.mockResolvedValue({ id: 'v-1', visitorName: 'Hélène' }) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/graph', + headers: { authorization: `Bearer ${treeAccessToken}` }, + }) + + expect(response.statusCode).toBe(200) + await vi.waitFor(() => expect(prismaMock.treeVisit.findFirst).toHaveBeenCalled()) + expect(prismaMock.treeVisit.create).not.toHaveBeenCalled() + await app.close() + }) +}) + +describe('Visit log', () => { + it('refuses the visit log to a non-admin member', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null } }) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/visits', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(403) + await app.close() + }) + + it('returns totals without the admin own visits, and recent visits', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'owner', tree: { deletedAt: null } }) + prismaMock.treeVisit.deleteMany.mockResolvedValue({ count: 0 }) + const now = Date.now() + prismaMock.treeVisit.findMany + .mockResolvedValueOnce([ + { visitorKey: 'a', createdAt: new Date(now - 1000) }, + { visitorKey: 'a', createdAt: new Date(now - 2 * 86400000) }, + { visitorKey: 'b', createdAt: new Date(now - 10 * 86400000) }, + ]) + .mockResolvedValueOnce([ + { id: 'v-2', createdAt: new Date(now - 1000), accessKind: 'share', visitorName: 'Hélène', device: 'phone', userId: null, user: null }, + { id: 'v-1', createdAt: new Date(now - 5000), accessKind: 'member', visitorName: null, device: 'desktop', userId: 'user-1', user: { email: 'user@example.com' } }, + ]) + + const response = await app.inject({ + method: 'GET', + url: '/trees/tree-1/visits', + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + const body = response.json() + expect(body.summary).toEqual({ last7: { visits: 2, visitors: 1 }, last30: { visits: 3, visitors: 2 } }) + expect(body.recent[0]).toMatchObject({ name: 'Hélène', device: 'phone', isYou: false }) + expect(body.recent[1]).toMatchObject({ email: 'user@example.com', isYou: true }) + const totalsWhere = prismaMock.treeVisit.findMany.mock.calls[0][0].where + expect(totalsWhere.OR).toEqual([{ userId: null }, { userId: { not: 'user-1' } }]) + await app.close() + }) }) describe('Health check', () => { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 9cc8584..7afa5e2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -447,6 +447,8 @@ function App() { useEffect(() => { setBooting(tree.bootState === 'loading') }, [tree.bootState]) // Arbre partagé d'où l'on vient quand on ouvre l'écran compte : on y revient après connexion/création const pendingSharedTreeRef = useRef(null) + // Écran de compte ouvert depuis un arbre affiché : la croix y ramène + const [accountFromTree, setAccountFromTree] = useState(false) const refreshTreeAndKeepSelection = useCallback(async (personId, options = {}) => { await tree.refreshCurrentTreeGraph({ relayout: options.relayout !== false, @@ -2018,10 +2020,18 @@ function App() { media.resetMediaManager() setAdminPanelVisible(false) contrib.resetContribState() + setAccountFromTree(tree.bootState === 'ready') tree.setBootState('account') } + const handleBackToTree = () => { + auth.setAccountError('') + setAccountFromTree(false) + tree.setBootState('ready') + } + const handleBackToAccess = () => { + setAccountFromTree(false) setTreeNotFound(false) auth.setAccountError('') setAccountPanelVisible(false) @@ -2267,6 +2277,7 @@ function App() { } function resetSignedOutState() { + setAccountFromTree(false) tree.setWizardState({ visible: false, loading: false, error: '' }) setAccountPanelVisible(false) setTreeWizardVisible(false) @@ -2400,6 +2411,7 @@ function App() { onLogout={handleLogout} onDeleteAccount={handleDeleteAccount} onBackToAccess={handleBackToAccess} + onBackToTree={accountFromTree ? handleBackToTree : undefined} onUseDemo={handleUseDemo} /> @@ -2453,7 +2465,9 @@ function App() { onClear={search.clearSearch} onResultClick={(person) => { const fullPerson = persons.find(p => p.id === person.id) - if (fullPerson) tree.setSelectedPerson(fullPerson) + if (!fullPerson) return + tree.setSelectedPerson(fullPerson) + galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id) }} /> {/* Draft Restore Banner (au reload si draft trouvé) */} diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 0eb5a08..4b110b5 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -1,4 +1,5 @@ import { apiRequest } from './client' +import { readContributorName } from '../utils/contributorName.js' export async function resolveTreeBySlug(slug) { const data = await apiRequest(`/api/arbre/${encodeURIComponent(slug)}`) @@ -41,11 +42,14 @@ export async function linkTreeAccess(treeId, accessToken, userToken) { } export async function fetchTreeGraph(treeId, token) { + // Prénom retenu sur cet appareil : le serveur l'inscrit au journal des visites + const visitorName = readContributorName() const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/graph?t=${Date.now()}`, { method: 'GET', cache: 'no-store', headers: { Authorization: `Bearer ${token}`, + ...(visitorName ? { 'X-Visitor-Name': encodeURIComponent(visitorName) } : {}), }, }) @@ -294,6 +298,21 @@ export async function listTreeAuditLogs(treeId, token, options = {}) { } } +// 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}`, + }, + }) + + return { + summary: data?.summary || { last7: { visits: 0, visitors: 0 }, last30: { visits: 0, visitors: 0 } }, + recent: Array.isArray(data?.recent) ? data.recent : [], + } +} + export async function submitContributionSession(treeId, payload, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions`, { method: 'POST', diff --git a/frontend/src/components/AccessGate.jsx b/frontend/src/components/AccessGate.jsx index 1471c63..d9d79d5 100644 --- a/frontend/src/components/AccessGate.jsx +++ b/frontend/src/components/AccessGate.jsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react' import logoUrl from '../assets/parenthese-logo.svg?url' import './AccountScreens.css' import PzBusy from './PzBusy.jsx' +import { readContributorName, saveContributorName } from '../utils/contributorName.js' const AccessGate = ({ initialTreeId, @@ -16,6 +17,8 @@ const AccessGate = ({ }) => { const [treeId, setTreeId] = useState(initialTreeId || '') const [password, setPassword] = useState('') + // Facultatif : la personne qui partage l'arbre voit qui est passé (onglet Visites) + const [firstName, setFirstName] = useState(() => readContributorName()) useEffect(() => { setTreeId(initialTreeId || '') @@ -27,6 +30,7 @@ const AccessGate = ({ return } + saveContributorName(firstName) await onSubmit(treeId.trim(), password) } @@ -87,6 +91,20 @@ const AccessGate = ({ )} +
+ + setFirstName(event.target.value)} + disabled={loading} + autoComplete="given-name" + maxLength={60} + /> +

Facultatif. Il sert à savoir qui est venu voir l'arbre.

+
+
{ const [mode, setMode] = useState(defaultMode === 'register' ? 'register' : 'login') @@ -89,9 +90,17 @@ const AccountDashboard = ({ await onLogin(safeEmail, password) } + // Arrivé depuis un arbre ouvert : une croix y ramène + const closeButton = onBackToTree && ( + + ) + if (!authenticated) { return (
+ {closeButton}
Parenthèse @@ -203,6 +212,7 @@ const AccountDashboard = ({ return (
+ {closeButton}
Parenthèse diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index 968ace9..29e3fd8 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -11,6 +11,7 @@ import { updateTreeSettings, } from '../api/treeApi' import PzBusy from './PzBusy.jsx' +import TreeVisitsSection from './TreeVisitsSection.jsx' function normalizeSlug(value) { return String(value || '') @@ -272,6 +273,7 @@ const AdminPanel = ({
+
@@ -284,6 +286,8 @@ const AdminPanel = ({ {errorMessage &&
{errorMessage}
} {successMessage &&
{successMessage}
} + {section === 'visits' && } + {section === 'tree' && (
diff --git a/frontend/src/components/Galaxy.jsx b/frontend/src/components/Galaxy.jsx index 16da583..e9c7b67 100644 --- a/frontend/src/components/Galaxy.jsx +++ b/frontend/src/components/Galaxy.jsx @@ -135,6 +135,20 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig targetTransformRef.current = { ...initialTransformRef.current } } }, + // Recherche : glisse jusqu'à la personne, dans la partie de l'écran que la fiche ne couvre pas + // (fiche à gauche sur ordinateur, 408 px ; par le bas sur téléphone) + focusPerson: (personId) => { + const canvas = canvasRef.current + const node = layoutDataRef.current?.children.find(n => n.id === `p-${personId}`) + if (!canvas || !node) return + const rect = canvas.getBoundingClientRect() + const pos = getAnimatedPos(node, performance.now()) + const scale = Math.min(MAX_SCALE, Math.max(targetTransformRef.current.scale, 1)) + const phone = rect.width <= 768 + const screenX = phone ? rect.width / 2 : (408 + rect.width) / 2 + const screenY = phone ? rect.height * 0.22 : rect.height / 2 + targetTransformRef.current = { scale, x: screenX - pos.cx * scale, y: screenY - pos.cy * scale } + }, getScale: () => transformRef.current.scale, MIN_SCALE, MAX_SCALE, diff --git a/frontend/src/components/SettingsPanels.css b/frontend/src/components/SettingsPanels.css index dd823a6..5590e36 100644 --- a/frontend/src/components/SettingsPanels.css +++ b/frontend/src/components/SettingsPanels.css @@ -30,6 +30,17 @@ .ts-choice-text small { font: 400 13px/1.45 var(--pz-sans); color: var(--pz-muted); } .ts-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px; border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); } + +/* ── Visites ── */ +.tv-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.tv-stat { display: grid; gap: 2px; padding: 14px 16px; border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); background: var(--pz-surface); } +.tv-stat-value { margin: 4px 0 0; font: 400 24px/1.15 var(--pz-serif); color: var(--pz-ink); } +.tv-list { display: grid; gap: 8px; padding-top: 16px; border-top: 1px solid var(--pz-line); } +.tv-list ul { list-style: none; margin: 0; padding: 0; display: grid; max-height: 320px; overflow: auto; } +.tv-row { display: grid; gap: 1px; padding: 9px 0; border-bottom: 1px solid var(--pz-line); } +.tv-row:last-child { border-bottom: 0; } +.tv-name { font: 500 14px/1.35 var(--pz-sans); color: var(--pz-ink); } +.tv-row.is-you .tv-name { color: var(--pz-muted); } .ts-row--muted { opacity: .6; } .ts-row-text { display: grid; gap: 4px; min-width: 0; } .ts-row-text strong { font: 500 14.5px var(--pz-sans); color: var(--pz-ink); } diff --git a/frontend/src/components/TreeVisitsSection.jsx b/frontend/src/components/TreeVisitsSection.jsx new file mode 100644 index 0000000..663e12a --- /dev/null +++ b/frontend/src/components/TreeVisitsSection.jsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' +import { listTreeVisits } from '../api/treeApi' + +// Onglet Visites des paramètres de l'arbre : qui est venu, combien de fois. +// Le journal est tenu par le serveur à chaque ouverture de l'arbre (backend/src/lib/tree-visits.ts). + +const DEVICE_LABEL = { phone: 'téléphone', tablet: 'tablette', desktop: 'ordinateur' } +const ACCESS_LABEL = { share: 'avec le lien', member: 'membre', shared_account: 'avec son compte' } + +function visitorLabel(visit) { + if (visit.isYou) return 'Vous' + if (visit.name) return visit.name + if (visit.email) return visit.email.split('@')[0] + return 'Un visiteur' +} + +function whenLabel(value) { + const date = new Date(value) + const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }) + const startOfDay = (d) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime() + const days = Math.round((startOfDay(new Date()) - startOfDay(date)) / 86400000) + if (days === 0) return `aujourd'hui à ${time}` + if (days === 1) return `hier à ${time}` + return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })} à ${time}` +} + +function plural(n, one, many) { + return `${n} ${n > 1 ? many : one}` +} + +const TreeVisitsSection = ({ treeId, authToken }) => { + const [state, setState] = useState({ loading: true, error: '', data: null }) + + useEffect(() => { + let active = true + setState({ loading: true, error: '', data: null }) + listTreeVisits(treeId, authToken) + .then((data) => { if (active) setState({ loading: false, error: '', data }) }) + .catch(() => { if (active) setState({ loading: false, error: 'Impossible de charger les visites.', data: null }) }) + return () => { active = false } + }, [treeId, authToken]) + + if (state.loading) return

Chargement…

+ if (state.error) return
{state.error}
+ + const { summary, recent } = state.data + return ( +
+
+ {[['7 derniers jours', summary.last7], ['30 derniers jours', summary.last30]].map(([label, s]) => ( +
+

{label}

+

{plural(s.visits, 'visite', 'visites')}

+

{plural(s.visitors, 'personne', 'personnes')}

+
+ ))} +
+

Vos propres visites ne sont pas comptées. Le prénom apparaît quand le visiteur l'a donné en ouvrant l'arbre.

+ +
+

Dernières visites

+ {recent.length === 0 ? ( +

Aucune visite pour l'instant. Le journal a commencé le 25 septembre 2026.

+ ) : ( +
    + {recent.map((visit) => ( +
  • + {visitorLabel(visit)} + + {whenLabel(visit.createdAt)} · {DEVICE_LABEL[visit.device] || visit.device} · {ACCESS_LABEL[visit.accessKind] || visit.accessKind} + +
  • + ))} +
+ )} +
+
+ ) +} + +export default TreeVisitsSection diff --git a/frontend/src/components/galaxy/renderers.js b/frontend/src/components/galaxy/renderers.js index 2c7d259..566c560 100644 --- a/frontend/src/components/galaxy/renderers.js +++ b/frontend/src/components/galaxy/renderers.js @@ -97,6 +97,8 @@ export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 } const BUS_DY = 100 // Rayon horizontal des virages (plafonné à la moitié de l'écart union → enfant) const BUS_TURN = 56 +// En dessous de cet écart union → enfant, pas de virage +const BUS_STRAIGHT_DX = 30 function busY(sourceId, sy) { let h = 0 @@ -109,7 +111,8 @@ function traceBus(ctx, sx, sy, by, ex, ey) { const dx = ex - sx ctx.beginPath() ctx.moveTo(sx, sy) - if (Math.abs(dx) < 1) { ctx.lineTo(ex, ey); ctx.stroke(); return } + // Enfant presque sous l'union (enfant unique, décalage aléatoire de quelques px) : trait direct, pas de S + if (Math.abs(dx) < BUS_STRAIGHT_DX) { ctx.lineTo(ex, ey); ctx.stroke(); return } const dir = Math.sign(dx) const turn = Math.min(BUS_TURN, Math.abs(dx) / 2) // Descente de l'union, virage large vers la ligne de la fratrie diff --git a/frontend/src/components/search/ExpandableSearch.jsx b/frontend/src/components/search/ExpandableSearch.jsx index c6a08c6..695ee69 100644 --- a/frontend/src/components/search/ExpandableSearch.jsx +++ b/frontend/src/components/search/ExpandableSearch.jsx @@ -27,12 +27,15 @@ function ExpandableSearch({ }) { const [expanded, setExpanded] = useState(false) const [query, setQuery] = useState('') + // Après un choix, la liste se replie pour laisser voir la personne dans l'arbre ; toucher le champ la rouvre + const [resultsHidden, setResultsHidden] = useState(false) const inputRef = useRef(null) const containerRef = useRef(null) const handleCollapse = useCallback(() => { setExpanded(false) setQuery('') + setResultsHidden(false) onClear?.() }, [onClear]) @@ -79,12 +82,15 @@ function ExpandableSearch({ const handleInputChange = (e) => { const value = e.target.value setQuery(value) + setResultsHidden(false) onSearch?.(value) } const handleResultClick = (person) => { onResultClick?.(person) - // On ne collapse pas après clic pour permettre de cliquer sur d'autres résultats + // La recherche reste ouverte (le champ garde la saisie), seule la liste se replie + setResultsHidden(true) + inputRef.current?.blur() } return ( @@ -114,6 +120,7 @@ function ExpandableSearch({ placeholder="Un prénom, un nom, une ville" value={query} onChange={handleInputChange} + onFocus={() => setResultsHidden(false)} aria-label="Rechercher une personne" />