Files
parenthese/backend/src/utils/graph-payload.ts
T
IliesAllaliandClaude Opus 5.5 291dde8073 Aperçus flous (BlurHash) pendant le chargement des photos et des portraits
Les photos partent en taille réelle, sans miniature : sur un réseau lent, une vignette restait un carré
vide et un portrait montrait ses initiales. Chaque photo et chaque portrait a maintenant un aperçu flou
aux bonnes couleurs (une chaîne de 28 caractères), affiché jusqu'à l'arrivée de l'image, sans fondu.

- Base : media_items.blurhash et persons.avatarBlurhash (migration additive 20261027_001).
- API : calculé à l'envoi d'une photo et d'un portrait (sharp, 32 px), publié dans le graphe ; les autres
  chemins (Immich, import GEDCOM, propositions acceptées) et les photos d'avant sont rattrapés en tâche de
  fond (lots de 25, puis une passe par minute). Un fichier illisible reçoit un aperçu vide.
- Navigateur : vignettes de la fiche, de la frise et du gestionnaire de souvenirs, portrait de la fiche,
  portraits et souvenirs dessinés sur l'arbre.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:17:58 +01:00

166 lines
5.3 KiB
TypeScript

import type { Annotation, MediaItem, MediaPersonTag, ParentChildLink, Person, Union } from '../generated/prisma/client.js'
import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js'
import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js'
import { isImmichAvatarPath } from '../lib/media-files.js'
import { sexOrNull } from '../lib/person-sex.js'
export type GraphPayload = {
persons: Array<{
id: string
firstName: string
lastName: string
birthName: string | null
nickname: string | null
birthDate: string | null
deathDate: string | null
isDeceased: boolean
toVerify: boolean
birthPlace: string | null
deathPlace: string | null
profession: string | null
region: string | null
nationality: string | null
avatarUrlPath: string | null
sex: string | null
notes: string | null
avatarFromImmich: boolean
avatarCrop: AvatarCrop | null
avatarBlurhash: string | null
}>
unions: Array<{
id: string
partner1PersonId: string
partner2PersonId: string | null
unionType: string | null
startDate: string | null
endDate: string | null
displayOrder: number
}>
filiations: Array<{
id: string
parentPersonId: string
childPersonId: string
viaUnionId: string | null
parentageType: string | null
displayOrder: number
}>
medias: Array<{
id: string
personId: string
type: string
mimeType: string
urlPath: string
caption: string | null
source: string | null
displayOrder: number
isFeatured: boolean
blurhash: string | null
// Personnes identifiées sur le souvenir, point facultatif sur le visage (0 à 1)
people: Array<{ personId: string; x: number | null; y: number | null }>
}>
annotations: Array<{
id: string
type: string
x: number
y: number
content: string
style: unknown
zIndex: number
createdBy: string | null
}>
}
function formatDate(value: Date | null): string | null {
if (!value) {
return null
}
return value.toISOString().split('T')[0]
}
export function buildGraphPayload(
treeId: string,
persons: Person[],
unions: Union[],
links: ParentChildLink[],
medias: MediaItem[] = [],
annotations: Annotation[] = [],
mediaPeople: Pick<MediaPersonTag, 'mediaId' | 'personId' | 'x' | 'y'>[] = [],
): GraphPayload {
// Une personne supprimée n'est plus montrée sur les photos où elle était identifiée
const livingIds = new Set(persons.map((person) => person.id))
const peopleByMedia = new Map<string, Array<{ personId: string; x: number | null; y: number | null }>>()
for (const tag of mediaPeople) {
if (!livingIds.has(tag.personId)) continue
const list = peopleByMedia.get(tag.mediaId) ?? []
list.push({ personId: tag.personId, x: tag.x, y: tag.y })
peopleByMedia.set(tag.mediaId, list)
}
// Keep DB order from route queries to preserve sibling and union ordering semantics.
return {
persons: persons.map((person) => ({
id: person.id,
firstName: person.firstName,
lastName: person.lastName,
birthName: person.birthName,
nickname: person.nickname,
birthDate: formatDate(person.birthDate),
deathDate: formatDate(person.deathDate),
isDeceased: Boolean(person.isDeceased) && !person.deathDate,
toVerify: Boolean(person.toVerify),
birthPlace: person.birthPlace,
deathPlace: person.deathPlace,
profession: person.profession,
region: person.region,
nationality: person.nationality,
avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null,
sex: sexOrNull(person.sex),
notes: person.notes,
avatarFromImmich: isImmichAvatarPath(person.avatarPath),
avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null,
avatarBlurhash: (person.avatarPath && person.avatarBlurhash) || null,
})),
unions: unions.map((union) => ({
id: union.id,
partner1PersonId: union.partner1PersonId,
partner2PersonId: union.partner2PersonId,
unionType: union.unionType,
startDate: formatDate(union.startDate),
endDate: formatDate(union.endDate),
displayOrder: union.displayOrder,
})),
filiations: links.map((link) => ({
id: link.id,
parentPersonId: link.parentPersonId,
childPersonId: link.childPersonId,
viaUnionId: link.viaUnionId,
parentageType: link.parentageType,
displayOrder: link.displayOrder,
})),
medias: medias.map((media) => ({
id: media.id,
personId: media.personId,
type: media.type,
mimeType: media.mimeType,
urlPath: media.mimeType === 'video/youtube' ? media.filePath : `/trees/${treeId}/media/${media.id}`,
caption: media.caption,
source: media.source,
displayOrder: media.displayOrder,
isFeatured: media.isFeatured,
blurhash: media.blurhash || null,
people: peopleByMedia.get(media.id) ?? [],
})),
annotations: annotations.map((ann) => ({
id: ann.id,
type: ann.type,
x: ann.x,
y: ann.y,
content: sanitizeAnnotationContentForRead(ann.content),
style: ann.style,
zIndex: ann.zIndex,
createdBy: ann.createdBy,
})),
}
}