Journal des visites, recherche qui mène à la personne, croix de retour à l'arbre

- 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) <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-09-25 18:44:07 +02:00
1 parent 8a63943497
commit 5fc0c08c1c
19 files changed
+530 -5

No files matched your search

@@ -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;
+21
View File
@@ -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
+2
View File
@@ -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) {
+75
View File
@@ -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<void> {
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')
}
}
+7
View File
@@ -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: {
+85
View File
@@ -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,
})),
})
})
}
+126
View File
@@ -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', () => {
+15 -1
View File
@@ -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}
/>
</div>
@@ -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é) */}
+19
View File
@@ -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',
+18
View File
@@ -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 = ({
</div>
)}
<div className="pz-field">
<label htmlFor="visitorFirstName">Votre prénom</label>
<input
id="visitorFirstName"
type="text"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
disabled={loading}
autoComplete="given-name"
maxLength={60}
/>
<p className="pz-hint">Facultatif. Il sert à savoir qui est venu voir l'arbre.</p>
</div>
<div className="pz-field">
<label htmlFor="sharePassword">Mot de passe de partage</label>
<input
@@ -19,6 +19,7 @@ const AccountDashboard = ({
onLogout,
onDeleteAccount,
onBackToAccess,
onBackToTree,
onUseDemo,
}) => {
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 && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pz-screen-close" onClick={onBackToTree} aria-label="Revenir à l'arbre">
<X size={20} aria-hidden="true" />
</button>
)
if (!authenticated) {
return (
<div className="account-dashboard pz-screen">
{closeButton}
<div className="pz-screen-inner">
<img src={logoUrl} alt="Parenthèse" className="pz-screen-logo" />
@@ -203,6 +212,7 @@ const AccountDashboard = ({
return (
<div className="account-dashboard pz-screen">
{closeButton}
<div className="pz-screen-inner">
<img src={logoUrl} alt="Parenthèse" className="pz-screen-logo" />
+4
View File
@@ -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 = ({
<div className="pz-tabs" role="tablist">
<button type="button" role="tab" aria-selected={section === 'tree'} className="pz-tab" onClick={() => setSection('tree')}>Votre arbre</button>
<button type="button" role="tab" aria-selected={section === 'visits'} className="pz-tab" onClick={() => setSection('visits')}>Visites</button>
<button type="button" role="tab" aria-selected={section === 'data'} className="pz-tab" onClick={() => setSection('data')}>Données</button>
<button type="button" role="tab" aria-selected={section === 'danger'} className="pz-tab" onClick={() => setSection('danger')}>Avancé</button>
</div>
@@ -284,6 +286,8 @@ const AdminPanel = ({
{errorMessage && <div className="pz-error" role="alert">{errorMessage}</div>}
{successMessage && <div className="pz-success" role="status">{successMessage}</div>}
{section === 'visits' && <TreeVisitsSection treeId={treeId} authToken={authToken} />}
{section === 'tree' && (
<form className="ts-panel" onSubmit={handleSaveTree}>
<div className="pz-field">
+14
View File
@@ -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,
@@ -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); }
@@ -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 <p className="pz-small ts-loading">Chargement…</p>
if (state.error) return <div className="pz-error" role="alert">{state.error}</div>
const { summary, recent } = state.data
return (
<div className="ts-panel tv">
<div className="tv-stats">
{[['7 derniers jours', summary.last7], ['30 derniers jours', summary.last30]].map(([label, s]) => (
<div key={label} className="tv-stat">
<p className="pz-eyebrow">{label}</p>
<p className="tv-stat-value">{plural(s.visits, 'visite', 'visites')}</p>
<p className="pz-small">{plural(s.visitors, 'personne', 'personnes')}</p>
</div>
))}
</div>
<p className="pz-hint">Vos propres visites ne sont pas comptées. Le prénom apparaît quand le visiteur l'a donné en ouvrant l'arbre.</p>
<div className="tv-list">
<p className="pz-eyebrow">Dernières visites</p>
{recent.length === 0 ? (
<p className="pz-small">Aucune visite pour l'instant. Le journal a commencé le 25 septembre 2026.</p>
) : (
<ul>
{recent.map((visit) => (
<li key={visit.id} className={`tv-row ${visit.isYou ? 'is-you' : ''}`}>
<span className="tv-name">{visitorLabel(visit)}</span>
<span className="tv-meta pz-small">
{whenLabel(visit.createdAt)} · {DEVICE_LABEL[visit.device] || visit.device} · {ACCESS_LABEL[visit.accessKind] || visit.accessKind}
</span>
</li>
))}
</ul>
)}
</div>
</div>
)
}
export default TreeVisitsSection
+4 -1
View File
@@ -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
@@ -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"
/>
<button
@@ -127,7 +134,7 @@ function ExpandableSearch({
</div>
{/* Dropdown résultats */}
{query.trim().length >= 2 && (
{query.trim().length >= 2 && !resultsHidden && (
<SearchResultsDropdown
results={results}
query={query}
+2
View File
@@ -158,6 +158,8 @@
.pz-screen-inner { width: min(100%, 440px); display: grid; gap: 22px; }
.pz-screen-logo { justify-self: center; height: 26px; width: auto; display: block; }
.pz-screen-card { padding: 30px; display: grid; gap: 20px; }
.pz-screen-close { position: fixed; top: 20px; right: 20px; z-index: 1; }
@media (max-width: 480px) { .pz-screen-close { top: 12px; right: 12px; } }
@media (max-width: 480px) { .pz-screen-card { padding: 24px 20px; } }
/* ── Menus ────────────────────────────────────────────────────── */
@@ -61,6 +61,7 @@
<p><strong>Les médias.</strong> Photos, vidéos, enregistrements audio, documents, tracés GPS, citations et liens YouTube posés sur une personne, avec leur légende et leur source. Une photo fait 5 Mo au plus, un autre fichier 20 Mo. S'y ajoutent les annotations posées sur l'arbre lui-même, textes, dessins, autocollants, photos.</p>
<p><strong>Les contributions.</strong> Quand quelqu'un propose une modification, Parenthèse garde la proposition, l'état avant et après, qui l'a soumise (l'adresse email d'un membre, ou « Contributeur » pour un accès par mot de passe) et la décision prise.</p>
<p><strong>Le journal de l'arbre.</strong> Chaque création, modification et suppression est datée et attribuée à un compte ou à un accès par mot de passe. Le propriétaire peut le consulter. Il sert à savoir qui a changé quoi.</p>
<p><strong>Les visites.</strong> Chaque ouverture d'un arbre est notée, avec la date, le type d'appareil (téléphone, tablette ou ordinateur), le compte s'il y en a un, et le prénom si la personne l'a donné en ouvrant l'arbre. L'adresse IP n'est pas gardée, seule une empreinte impossible à retourner sert à compter les visiteurs distincts. Le propriétaire de l'arbre voit ces visites. Elles sont effacées au bout d'un an.</p>
<p><strong>Les journaux techniques.</strong> Comme tout serveur web, celui de Parenthèse enregistre les requêtes reçues (adresse IP, page demandée, erreurs) pour faire fonctionner et dépanner le service.</p>
<h2>Pourquoi</h2>
@@ -69,7 +70,7 @@
<h2>Qui peut voir quoi</h2>
<p>Un arbre n'est jamais public. L'application interdit son indexation par les moteurs de recherche, et rien de son contenu n'est visible sans le lien et un mot de passe.</p>
<ul>
<li><strong>Le propriétaire</strong>, le compte qui a créé l'arbre, voit et modifie tout. Il fixe les mots de passe, valide les contributions, consulte le journal, exporte et supprime l'arbre.</li>
<li><strong>Le propriétaire</strong>, le compte qui a créé l'arbre, voit et modifie tout. Il fixe les mots de passe, valide les contributions, consulte le journal et les visites, exporte et supprime l'arbre.</li>
<li><strong>Un contributeur</strong> a reçu le lien et le mot de passe contributeur. Il consulte tout et propose des modifications. Par défaut, elles attendent la validation du propriétaire avant d'apparaître.</li>
<li><strong>Un visiteur</strong> a reçu le lien et le mot de passe visiteur. Il consulte, sans rien modifier.</li>
</ul>