mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 08:16:09 +00:00
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:
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;
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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é) */}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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" />
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user