mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 00:12:14 +00:00
Fantômes : propositions en attente, brouillon et envois de la famille dessinés sur l'arbre ; acceptation ou refus depuis le fantôme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ddc2231a8a
commit
0c74adb012
15 files changed
+820
-51
No files matched your search
@@ -86,6 +86,9 @@ import './App.css'
|
||||
import './styles/pz.css'
|
||||
import { setBooting } from './bootSignal.js'
|
||||
import { saveContributorName } from './utils/contributorName'
|
||||
import { ghostsFromChanges, hasGhosts, mergeGhosts } from './utils/ghosts'
|
||||
import ProposalCard from './components/ProposalCard'
|
||||
import { summarizeChanges } from './utils/proposalSummary'
|
||||
|
||||
function yearToIsoDate(value) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
@@ -688,6 +691,7 @@ function App() {
|
||||
}, [auth.accountTrees, tree.treeContext.treeId, tree.treeContext.treeName])
|
||||
|
||||
const isDemoMode = tree.treeContext.accessMode === 'demo'
|
||||
|
||||
const canEditInCurrentMode = tree.canSubmitContribution || isDemoMode
|
||||
|
||||
// Recherche de personnes avec scoring
|
||||
@@ -699,6 +703,56 @@ function App() {
|
||||
canEdit: canEditInCurrentMode,
|
||||
})
|
||||
|
||||
// Fantômes (1.5.1) : ce qui est proposé et pas encore dans l'arbre, dessiné en transparence à sa place.
|
||||
// Qui gère l'arbre voit les propositions en attente ; la famille voit son brouillon et ce qu'elle a envoyé.
|
||||
const ghostTreeId = tree.treeContext.treeId
|
||||
const ghosts = useMemo(() => {
|
||||
if (!ghostTreeId || isDemoMode) return null
|
||||
const graph = { unions }
|
||||
const list = []
|
||||
if (userRole.isAdmin) {
|
||||
for (const session of contrib.contribState.sessions || []) {
|
||||
if (session.status !== 'pending') continue
|
||||
list.push(ghostsFromChanges(session.changes, {
|
||||
source: 'pending', sessionId: session.id, label: session.submittedByLabel, createdAt: session.createdAt,
|
||||
summary: summarizeChanges(session.changes),
|
||||
}, graph))
|
||||
}
|
||||
} else if (tree.canSubmitContribution) {
|
||||
for (const proposal of contrib.sentProposals || []) {
|
||||
list.push(ghostsFromChanges(proposal.changes, {
|
||||
source: 'sent', sessionId: proposal.sessionId, label: proposal.label, createdAt: proposal.createdAt,
|
||||
summary: summarizeChanges(proposal.changes),
|
||||
}, graph))
|
||||
}
|
||||
if (editMode.hasDraft) {
|
||||
const draftChanges = buildContributionChangesFromDraft(editMode.restoreDraft())
|
||||
list.push(ghostsFromChanges(draftChanges, { source: 'draft', key: 'brouillon', summary: summarizeChanges(draftChanges) }, graph))
|
||||
}
|
||||
}
|
||||
const merged = mergeGhosts(list)
|
||||
// Photo posée sur l'arbre : l'adresse se refait depuis son chemin, seulement pour une photo de cet arbre
|
||||
const photoPrefix = `/trees/${ghostTreeId}/annotation-photos/`
|
||||
merged.annotations = merged.annotations.map((ann) => {
|
||||
if (ann.type !== 'photo') return ann
|
||||
try {
|
||||
const parsed = JSON.parse(ann.content)
|
||||
const ownPhoto = typeof parsed.photoPath === 'string' && parsed.photoPath.startsWith(photoPrefix)
|
||||
&& /^[A-Za-z0-9-]+$/.test(parsed.photoPath.slice(photoPrefix.length))
|
||||
if (!ownPhoto) return null
|
||||
return { ...ann, content: JSON.stringify({ ...parsed, url: buildMediaUrl(parsed.photoPath, import.meta.env.VITE_API_BASE_URL || '/api') }) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}).filter(Boolean)
|
||||
return hasGhosts(merged) ? merged : null
|
||||
}, [ghostTreeId, isDemoMode, userRole.isAdmin, tree.canSubmitContribution, contrib.contribState.sessions, contrib.sentProposals, editMode.hasDraft, editMode.draftRevision, tree.graphRevision]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
const [selectedGhost, setSelectedGhost] = useState(null)
|
||||
// Une proposition relue (ou un brouillon envoyé) emporte son fantôme : la carte ouverte se ferme
|
||||
useEffect(() => {
|
||||
if (selectedGhost && !ghosts?.persons.some((p) => p.id === selectedGhost.id)) setSelectedGhost(null)
|
||||
}, [ghosts, selectedGhost])
|
||||
|
||||
const localEntityCounterRef = useRef(0)
|
||||
const makeLocalId = useCallback((prefix) => {
|
||||
localEntityCounterRef.current += 1
|
||||
@@ -895,9 +949,18 @@ function App() {
|
||||
const handlePersonSelect = useCallback(async (personLike) => {
|
||||
const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike
|
||||
if (personId === null || personId === undefined) {
|
||||
setSelectedGhost(null)
|
||||
await chooseSelectedPerson(null)
|
||||
return
|
||||
}
|
||||
// Personne proposée : sa carte de proposition, pas une fiche (elle n'existe pas encore dans l'arbre)
|
||||
if (personLike && typeof personLike === 'object' && personLike.ghost) {
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
await chooseSelectedPerson(null)
|
||||
setSelectedGhost(personLike)
|
||||
return
|
||||
}
|
||||
setSelectedGhost(null)
|
||||
|
||||
const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null
|
||||
if (!(await leaveSelectedPerson())) return
|
||||
@@ -1534,6 +1597,16 @@ function App() {
|
||||
await contrib.handleOpenContributionPanel()
|
||||
}, [contrib, resolveContributionShareUrl, tree.treeContext.treeId, tree.canEditCurrentTree, tree.treeContext.accessToken, auth.userAuth.token]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Accepter ou refuser une proposition depuis son fantôme. Une proposition se relit en entier ; « Voir le
|
||||
// détail » ouvre le panneau pour trier changement par changement.
|
||||
const reviewFromTree = (sessionId) => ({
|
||||
busy: contrib.contribState.reviewingSessionId === sessionId,
|
||||
error: contrib.contribState.error,
|
||||
onAccept: () => contrib.handleReviewContributionSession(sessionId, { decision: 'approved' }),
|
||||
onReject: () => contrib.handleReviewContributionSession(sessionId, { decision: 'rejected' }),
|
||||
onOpenDetails: () => { setSelectedGhost(null); handleOpenContributionPanel() },
|
||||
})
|
||||
|
||||
const handleContributionPasswordRemember = useCallback(async ({ password }) => {
|
||||
const accessToken = auth.userAuth.token || tree.treeContext.accessToken
|
||||
if (!tree.treeContext.treeId || !accessToken) {
|
||||
@@ -3265,8 +3338,17 @@ function App() {
|
||||
annotationViewRef={annotationViewRef}
|
||||
onAnnotationsAnchored={persistAnnotationAnchors}
|
||||
selfPersonId={linkedSelfPersonId || null}
|
||||
ghosts={ghosts}
|
||||
/>
|
||||
<ZoomControl zoomApiRef={galaxyZoomApiRef} />
|
||||
{selectedGhost && !tree.selectedPerson && (
|
||||
<ProposalCard
|
||||
person={selectedGhost}
|
||||
summary={selectedGhost.ghost?.summary || ''}
|
||||
review={userRole.isAdmin && selectedGhost.ghost?.source === 'pending' ? reviewFromTree(selectedGhost.ghost.sessionId) : null}
|
||||
onClose={() => setSelectedGhost(null)}
|
||||
/>
|
||||
)}
|
||||
{tree.selectedPerson && (
|
||||
<PersonCard
|
||||
person={tree.selectedPerson}
|
||||
@@ -3291,6 +3373,10 @@ function App() {
|
||||
onRemoveRelation={handleRemoveRelation}
|
||||
onSetParentage={handleSetParentage}
|
||||
pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null}
|
||||
proposal={ghosts?.patches.get(String(tree.selectedPerson.id)) || null}
|
||||
proposalReview={userRole.isAdmin && ghosts?.patches.get(String(tree.selectedPerson.id))?.ghost?.source === 'pending'
|
||||
? reviewFromTree(ghosts.patches.get(String(tree.selectedPerson.id)).ghost.sessionId)
|
||||
: null}
|
||||
relationActionLoading={relationFeedback.loading}
|
||||
relationActionSuccess={relationFeedback.success}
|
||||
relationActionError={relationFeedback.error}
|
||||
|
||||
@@ -404,6 +404,18 @@ export async function reviewContributionSession(treeId, sessionId, payload, toke
|
||||
return data
|
||||
}
|
||||
|
||||
// État d'une proposition envoyée (en attente, acceptée, refusée) : la famille retire ses fantômes une fois relue
|
||||
export async function fetchContributionStatus(treeId, sessionId, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/contributions/sessions/${encodeURIComponent(sessionId)}/status`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
return data?.status || null
|
||||
}
|
||||
|
||||
export async function uploadAnnotationPhoto(treeId, payload, token) {
|
||||
const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/annotation-photos`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { PERSON_R, FLOAT_SPEED, FLOAT_AMPLITUDE, REPULSION_RADIUS, REPULSION_STR
|
||||
import { easeOutCubic } from './galaxy/utils'
|
||||
import { computeLayout, generateNodeRandomData, coupleBarMeta } from './galaxy/elkLayout'
|
||||
import { computeBlockLayout, familyTabsOf, lineToFamily, subgraphOf } from './galaxy/blockLayout'
|
||||
import { hasGhosts, withGhosts } from '../utils/ghosts'
|
||||
|
||||
// Placement par blocs familiaux par défaut (mesures : tools/layout-bench). ?layout=sugiyama = placement Sugiyama.
|
||||
const USE_BLOCK_LAYOUT = typeof window === 'undefined' || new URLSearchParams(window.location.search).get('layout') !== 'sugiyama'
|
||||
@@ -43,7 +44,7 @@ const DROP_SPRING = { stiffness: 480, dampingRatio: 0.72 }
|
||||
const centerX = (node) => node.x + node.width / 2
|
||||
const prefersReducedMotion = () => typeof window !== 'undefined' && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
|
||||
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null }) => {
|
||||
const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlightIds = [], graphRevision = 0, annotationHandlers = null, transformReadRef = null, zoomApiRef = null, focusPersonId = null, assetRevision = 0, reorderEnabled = false, onReorderSiblings = null, familyTabs = true, annotationViewRef = null, onAnnotationsAnchored = null, selfPersonId = null, ghosts = null }) => {
|
||||
const canvasRef = useRef(null)
|
||||
const layoutDataRef = useRef(null)
|
||||
// Onglets de familles (réglage `familyTabs`) : la liste des familles de l'arbre, calculée une fois par
|
||||
@@ -56,6 +57,9 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
const activeTabRef = useRef(null)
|
||||
const familyTabsRef = useRef(familyTabs)
|
||||
useEffect(() => { familyTabsRef.current = familyTabs }, [familyTabs])
|
||||
// Propositions pas encore relues (utils/ghosts.js) : placées et dessinées en transparence, jamais dans les données
|
||||
const ghostsRef = useRef(ghosts)
|
||||
useEffect(() => { ghostsRef.current = ghosts; needsRedrawRef.current = true }, [ghosts])
|
||||
const onAnnotationsAnchoredRef = useRef(onAnnotationsAnchored)
|
||||
useEffect(() => { onAnnotationsAnchoredRef.current = onAnnotationsAnchored }, [onAnnotationsAnchored])
|
||||
const startRefRef = useRef(focusPersonId)
|
||||
@@ -216,7 +220,8 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
// Placement complet pour un ordre de la fratrie, recalé sur la source (les parents ne bougent pas)
|
||||
const layoutForOrder = useCallback((group, order, base) => {
|
||||
const tab = activeTabRef.current
|
||||
const src = tab?.personIds ? subgraphOf(tab.personIds) : { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }
|
||||
const tabSrc = tab?.personIds ? subgraphOf(tab.personIds) : null
|
||||
const src = withGhosts(tabSrc || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghostsRef.current, !tabSrc)
|
||||
const graph = {
|
||||
persons: src.persons,
|
||||
unions: src.unions,
|
||||
@@ -492,7 +497,10 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
// Dans l'onglet d'une autre famille, la personne commune (« C'est moi », sinon la personne de départ) reste
|
||||
// là, reliée par sa ligne directe (Patch, 21/10 : « je veux me voir tout le temps »)
|
||||
const line = tab?.personIds ? lineToFamily(selfPersonId ?? startRefRef.current ?? tabsCacheRef.current.tabs[0]?.resolvedRef, tab.personIds) : new Set()
|
||||
const graph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
|
||||
const tabGraph = tab?.personIds ? subgraphOf(new Set([...tab.personIds, ...line])) : null
|
||||
const graph = hasGhosts(ghosts)
|
||||
? withGhosts(tabGraph || { persons: graphPersons, unions: graphUnions, filiations: graphFiliations }, ghosts, !tabGraph)
|
||||
: tabGraph
|
||||
// Annotations d'avant l'ancrage (x, y seuls) : rattachées une fois à la personne la plus proche dans le
|
||||
// placement de l'onglet de départ, puis enregistrées. Calculé avant l'arbre affiché (coupleBarMeta, voir plus haut)
|
||||
const legacy = annotationData.some((a) => !readAnchor(a))
|
||||
@@ -653,7 +661,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
pendingFocusRef.current = null
|
||||
requestAnimationFrame(() => zoomApiRef?.current?.focusPerson?.(id))
|
||||
}
|
||||
}, [graphRevision, activeKey, familyTabs, selfPersonId])
|
||||
}, [graphRevision, activeKey, familyTabs, selfPersonId, ghosts])
|
||||
|
||||
// Sync selectedPersonId
|
||||
useEffect(() => {
|
||||
@@ -929,6 +937,7 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
entrance, entranceActive, elapsed,
|
||||
view,
|
||||
liftScales: liftScalesRef.current,
|
||||
ghostPatches: ghostsRef.current?.patches || null,
|
||||
})
|
||||
drawRenvoiPills(ctx)
|
||||
// Annotations dessinées APRES les noeuds pour que stickers/texte soient au-dessus.
|
||||
@@ -949,7 +958,11 @@ const Galaxy = ({ onPersonSelect, onMediaSelect, selectedPersonId, searchHighlig
|
||||
if (entranceAlpha > 0) {
|
||||
drawAnnotations(
|
||||
ctx,
|
||||
editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations,
|
||||
[
|
||||
...(editingId ? anchoredAnnotations.filter((a) => a.id !== editingId) : anchoredAnnotations),
|
||||
// Annotations proposées : dessinées, jamais touchables (hors de `shown`)
|
||||
...placeAnnotations(ghostsRef.current?.annotations || [], annotationCenters),
|
||||
],
|
||||
annotationHandlers?.selectedAnnotationId ?? null,
|
||||
annotationHandlers?.drawingPathRef?.current ?? null,
|
||||
stickerPreview,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { isYearOnly } from '../utils/dateInput.js'
|
||||
import DateField from './DateField.jsx'
|
||||
import { runOnce } from '../utils/saveOnLeave.js'
|
||||
import { normalizeSex, SEX_CHOICES } from '../utils/personSex.js'
|
||||
import { ProposalBanner } from './ProposalCard.jsx'
|
||||
|
||||
// Aucune modification en cours (référence stable)
|
||||
const NO_EDITS = Object.freeze({})
|
||||
@@ -66,6 +67,9 @@ const PersonCard = ({
|
||||
onRemoveRelation,
|
||||
onSetParentage,
|
||||
pendingParentage = null,
|
||||
// Modification proposée et pas encore relue (fantôme, 1.5.1) : { fields, deleted, ghost }, et sa relecture
|
||||
proposal = null,
|
||||
proposalReview = null,
|
||||
relationActionLoading = false,
|
||||
relationActionSuccess = '',
|
||||
relationActionError = '',
|
||||
@@ -785,6 +789,7 @@ const PersonCard = ({
|
||||
</label>
|
||||
)}
|
||||
</header>
|
||||
{!editMode && proposal && <ProposalBanner patch={proposal} review={proposalReview} />}
|
||||
|
||||
{avatarActionError && <div className="pz-error pc-inline-msg">{avatarActionError}</div>}
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Proposition pas encore relue (1.5.1) : même place et même cadre que la fiche, bordure en pointillés */
|
||||
.proposal-card {
|
||||
border-style: dashed;
|
||||
border-color: var(--pz-a300);
|
||||
}
|
||||
|
||||
.proposal-photo {
|
||||
background: var(--pz-a50);
|
||||
outline: 1.5px dashed var(--pz-a500);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.proposal-eyebrow {
|
||||
color: var(--pz-a700);
|
||||
}
|
||||
|
||||
.proposal-years {
|
||||
margin: 2px 0 0;
|
||||
color: var(--pz-muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.proposal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.proposal-who,
|
||||
.proposal-summary,
|
||||
.proposal-change {
|
||||
margin: 0;
|
||||
color: var(--pz-ink-2);
|
||||
font-size: 14px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.proposal-summary {
|
||||
color: var(--pz-muted);
|
||||
}
|
||||
|
||||
.proposal-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proposal-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.proposal-details {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.proposal-error {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Fiche existante dont une modification est proposée */
|
||||
.proposal-banner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border: 1px dashed var(--pz-a300);
|
||||
border-radius: 12px;
|
||||
background: var(--pz-a50);
|
||||
}
|
||||
|
||||
.proposal-fields {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
font-size: 14px;
|
||||
color: var(--pz-ink);
|
||||
}
|
||||
|
||||
.proposal-field {
|
||||
color: var(--pz-muted);
|
||||
margin-right: 4px;
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { X } from 'lucide-react'
|
||||
|
||||
import { t, dateLocale } from '../i18n/index.js'
|
||||
import { firstNameLabel } from '../utils/personName.js'
|
||||
import PzBusy from './PzBusy'
|
||||
import './ProposalCard.css'
|
||||
|
||||
// Proposition pas encore relue, ouverte depuis son fantôme sur l'arbre (1.5.1). Qui gère l'arbre l'accepte ou la
|
||||
// refuse sur place ; la famille y lit qu'elle attend. Une proposition se relit en entier : « Voir le détail »
|
||||
// ouvre le panneau des propositions pour trier changement par changement.
|
||||
|
||||
const FIELD_LABELS = {
|
||||
firstName: t('Prénom'),
|
||||
lastName: t('Nom'),
|
||||
birthName: t('Nom de naissance'),
|
||||
nickname: t('Surnom'),
|
||||
sex: t('Sexe'),
|
||||
birthDate: t('Naissance'),
|
||||
deathDate: t('Décès'),
|
||||
birthPlace: t('Lieu de naissance'),
|
||||
deathPlace: t('Lieu de décès'),
|
||||
profession: t('Profession'),
|
||||
region: t('Région'),
|
||||
nationality: t('Nationalité'),
|
||||
notes: t('Note'),
|
||||
isDeceased: t('Décédé(e)'),
|
||||
toVerify: t('À vérifier'),
|
||||
}
|
||||
|
||||
function formatValue(key, value) {
|
||||
if (value === null || value === undefined || value === '') return t('vide')
|
||||
if (typeof value === 'boolean') return value ? t('oui') : t('non')
|
||||
if ((key === 'birthDate' || key === 'deathDate') && /^\d{4}-\d{2}-\d{2}/.test(String(value))) return String(value).slice(0, 4)
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function whoAndWhen(ghost) {
|
||||
if (ghost?.source === 'draft') return t('Pas encore envoyé : il part avec « Envoyer »')
|
||||
if (ghost?.source === 'sent') return t('Envoyé, en attente de relecture par la personne qui gère l’arbre')
|
||||
const date = ghost?.createdAt ? new Date(ghost.createdAt) : null
|
||||
const when = date && !Number.isNaN(date.getTime()) ? date.toLocaleDateString(dateLocale, { day: 'numeric', month: 'long' }) : ''
|
||||
if (ghost?.label && when) return t('Proposé par {name}, le {date}', { name: ghost.label, date: when })
|
||||
if (ghost?.label) return t('Proposé par {name}', { name: ghost.label })
|
||||
return t('Proposé par la famille')
|
||||
}
|
||||
|
||||
function ReviewActions({ ghost, review }) {
|
||||
if (!review || ghost?.source !== 'pending') return null
|
||||
const { busy, error, onAccept, onReject, onOpenDetails } = review
|
||||
return (
|
||||
<div className="proposal-actions">
|
||||
{error && <p className="pz-error proposal-error" role="alert">{error}</p>}
|
||||
<div className="proposal-buttons">
|
||||
<button type="button" className="pz-btn pz-btn--primary pz-btn--sm" onClick={onAccept} disabled={busy}>
|
||||
<PzBusy busy={busy} busyLabel={t('Relecture en cours')}>{t('Accepter')}</PzBusy>
|
||||
</button>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onReject} disabled={busy}>{t('Refuser')}</button>
|
||||
{onOpenDetails && (
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm proposal-details" onClick={onOpenDetails} disabled={busy}>{t('Voir le détail')}</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} props
|
||||
* @param {Object} props.person - la personne proposée (fantôme, `person.ghost` porte la provenance)
|
||||
* @param {string} props.summary - ce que contient la proposition entière (summarizeChanges)
|
||||
* @param {Object|null} props.review - { busy, error, onAccept, onReject, onOpenDetails } pour qui gère l'arbre
|
||||
*/
|
||||
export default function ProposalCard({ person, summary = '', review = null, onClose }) {
|
||||
const ghost = person?.ghost
|
||||
const years = [person?.birthYear, person?.deathYear].filter(Boolean).join(' – ')
|
||||
const name = [firstNameLabel(person), person?.lastName].filter(Boolean).join(' ')
|
||||
const initials = `${(person?.firstName || '?')[0]}${(person?.lastName || '')[0] || ''}`.toUpperCase()
|
||||
return (
|
||||
<div className="person-card proposal-card" role="dialog" aria-label={t('Proposition : {name}', { name })}>
|
||||
<button type="button" className="pz-btn pz-btn--ghost pz-btn--icon pc-close" onClick={onClose} aria-label={t('Fermer')}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
</button>
|
||||
<header className="pc-head">
|
||||
<div className="pc-photo-wrap">
|
||||
<div className="pc-photo proposal-photo"><span className="pc-photo-initials">{initials}</span></div>
|
||||
</div>
|
||||
<div className="pc-id">
|
||||
<p className="pz-eyebrow proposal-eyebrow">{ghost?.source === 'draft' ? t('Brouillon') : ghost?.source === 'sent' ? t('En attente') : t('Proposition')}</p>
|
||||
<h2 className="pc-name">
|
||||
{person?.firstName ? person.firstName : <span className="pc-name-unknown">{firstNameLabel(person)}</span>}
|
||||
{person?.lastName && <>{' '}<em>{person.lastName}</em></>}
|
||||
</h2>
|
||||
{years && <p className="proposal-years">{years}</p>}
|
||||
</div>
|
||||
</header>
|
||||
<div className="proposal-body">
|
||||
<p className="proposal-who">{whoAndWhen(ghost)}</p>
|
||||
{summary && <p className="proposal-summary">{t('Dans cette proposition : {summary}.', { summary })}</p>}
|
||||
<ReviewActions ghost={ghost} review={review} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bandeau d'une fiche existante dont une modification est proposée.
|
||||
* @param {Object} props.patch - { fields, deleted, ghost }
|
||||
*/
|
||||
export function ProposalBanner({ patch, review = null }) {
|
||||
if (!patch) return null
|
||||
const entries = Object.entries(patch.fields || {}).filter(([key]) => FIELD_LABELS[key])
|
||||
return (
|
||||
<section className="proposal-banner" aria-label={t('Modification proposée')}>
|
||||
<p className="proposal-who">{whoAndWhen(patch.ghost)}</p>
|
||||
{patch.deleted ? (
|
||||
<p className="proposal-change">{t('Supprimer cette personne de l’arbre')}</p>
|
||||
) : (
|
||||
<ul className="proposal-fields">
|
||||
{entries.map(([key, value]) => (
|
||||
<li key={key}><span className="proposal-field">{FIELD_LABELS[key]}</span> {formatValue(key, value)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<ReviewActions ghost={patch.ghost} review={review} />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -397,7 +397,7 @@ export function computeLayout(graphData = null, referenceId = null) {
|
||||
// Entrées d'union unifiées (réelles + virtuelles)
|
||||
const allUnionEntries = [
|
||||
...unions.map(u => ({
|
||||
elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated),
|
||||
elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated), ghost: Boolean(u.ghost),
|
||||
})),
|
||||
...virtualUnions.map(vu => ({
|
||||
elkId: vu.elkId, p1Key: `p-${vu.parentId}`, p2Key: vu.unknownElkId, virtual: true,
|
||||
@@ -592,6 +592,8 @@ export function computeLayout(graphData = null, referenceId = null) {
|
||||
id: `e-${f.id}`, sources: [sourceId], targets: [targetId],
|
||||
_parentageType: f.parentageType,
|
||||
_displayOrder: f.displayOrder,
|
||||
// Lien proposé, pas encore relu (utils/ghosts.js) : dessiné en pointillés
|
||||
_ghost: Boolean(f.ghost),
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -100,6 +100,9 @@ function computeCouplePoints(p1Pos, p2Pos, unionPos, p1R, p2R, nodeRandomData, s
|
||||
// Coins arrondis de rayon fixe (accolade franche, forme A).
|
||||
// ============================================================
|
||||
export const FIL = { color: '#E3A590', width: 1.1, dot: '#D2694A', dotR: 2.6 }
|
||||
// Proposé, pas encore relu (utils/ghosts.js) : pointillés terracotta, portrait délavé, étiquette au-dessus
|
||||
export const GHOST = { color: '#D2694A', dash: [5, 4], wash: 'rgba(251, 250, 247, 0.55)', annotationAlpha: 0.45 }
|
||||
const GHOST_LABELS = { draft: () => t('Brouillon'), sent: () => t('En attente'), pending: () => t('Proposé') }
|
||||
// Ligne de la fratrie : sous les prénoms et années des parents, au-dessus des enfants
|
||||
const BUS_DY = 100
|
||||
// Accolade : coins arrondis de rayon fixe (forme A, choisie par Patch). En dessous de deux rayons d'écart,
|
||||
@@ -148,7 +151,7 @@ export function traceSibship(ctx, sourceId, sourcePos, items, lift = 0) {
|
||||
// Courbes de couple (Bézier)
|
||||
// ============================================================
|
||||
export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomData, entrance, entranceActive, elapsed, view = null) {
|
||||
coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual }) => {
|
||||
coupleBarMeta.forEach(({ elkId, p1Key, p2Key, virtual, ghost }) => {
|
||||
const p1Pos = posMap.get(p1Key)
|
||||
const p2Pos = posMap.get(p2Key)
|
||||
const unionPos = posMap.get(elkId)
|
||||
@@ -177,9 +180,9 @@ export function drawCoupleLinks(ctx, coupleBarMeta, posMap, nodeMap, nodeRandomD
|
||||
p1PosKeyed, p2PosKeyed, unionPos, p1R, p2R, nodeRandomData, nodeMap.get(elkId)?._compact
|
||||
)
|
||||
|
||||
ctx.strokeStyle = virtual ? COLORS.linkUnknown : FIL.color
|
||||
ctx.strokeStyle = ghost ? GHOST.color : virtual ? COLORS.linkUnknown : FIL.color
|
||||
ctx.lineWidth = FIL.width
|
||||
ctx.setLineDash(virtual ? [4, 4] : [])
|
||||
ctx.setLineDash(ghost ? GHOST.dash : virtual ? [4, 4] : [])
|
||||
ctx.lineCap = 'round'
|
||||
ctx.globalAlpha = coupleProgress
|
||||
|
||||
@@ -307,16 +310,21 @@ export function drawFiliations(ctx, edges, posMap, nodeMap, nodeRandomData, entr
|
||||
return
|
||||
}
|
||||
// Les enfants d'une même union (même type de filiation) forment une seule fratrie, tracée d'un coup
|
||||
const key = `${sourceId}|${pType}`
|
||||
if (!sibships.has(key)) sibships.set(key, { sourceId, sourcePos, pType, items: [], progress: 0 })
|
||||
// Un enfant proposé a sa propre ligne, en pointillés, par-dessus celle de ses frères et sœurs
|
||||
const ghost = Boolean(edge._ghost || targetNode?._data?.ghost)
|
||||
const key = `${sourceId}|${pType}|${ghost ? 'g' : ''}`
|
||||
if (!sibships.has(key)) sibships.set(key, { sourceId, sourcePos, pType, ghost, items: [], progress: 0 })
|
||||
const sib = sibships.get(key)
|
||||
sib.items.push({ cx: targetPos.cx, cy: targetPos.cy - childR })
|
||||
sib.progress += edgeProgress
|
||||
})
|
||||
|
||||
for (const { sourceId, sourcePos, pType, items, progress } of sibships.values()) {
|
||||
for (const { sourceId, sourcePos, pType, ghost, items, progress } of sibships.values()) {
|
||||
// Adoption : trait plein comme une filiation de naissance (le mode d'adoption se dit dans la fiche)
|
||||
if (pType === 'inconnu') {
|
||||
if (ghost) {
|
||||
ctx.setLineDash(GHOST.dash)
|
||||
ctx.strokeStyle = GHOST.color
|
||||
} else if (pType === 'inconnu') {
|
||||
ctx.setLineDash([3, 3])
|
||||
ctx.strokeStyle = COLORS.linkUnknown
|
||||
} else {
|
||||
@@ -1089,6 +1097,8 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela
|
||||
|
||||
if (node._type === 'person') {
|
||||
drawPersonNode(ctx, node, pos, nodeProgress, renderState)
|
||||
if (node._data?.ghost) drawGhostMark(ctx, node, pos, nodeProgress, view)
|
||||
else if (renderState.ghostPatches?.has(String(node._data?.id))) drawPatchMark(ctx, node, pos, nodeProgress, renderState.ghostPatches.get(String(node._data.id)))
|
||||
} else if (node._type === 'union') {
|
||||
ctx.globalAlpha = nodeProgress
|
||||
ctx.fillStyle = FIL.dot
|
||||
@@ -1128,6 +1138,70 @@ export function drawNodes(ctx, layoutData, posMap, entrance, entranceActive, ela
|
||||
})
|
||||
}
|
||||
|
||||
// Personne proposée : portrait délavé, cercle en pointillés, étiquette (« Proposé », « En attente », « Brouillon »)
|
||||
function drawGhostMark(ctx, node, pos, progress, view) {
|
||||
const r = nodeRadius(node)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = progress
|
||||
ctx.fillStyle = GHOST.wash
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos.cx, pos.cy, r + 1, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = GHOST.color
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.setLineDash(GHOST.dash)
|
||||
ctx.beginPath()
|
||||
ctx.arc(pos.cx, pos.cy, r + 5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
if ((view?.scale ?? 1) >= 0.35) drawGhostTag(ctx, (GHOST_LABELS[node._data.ghost.source] || GHOST_LABELS.pending)(), pos.cx, pos.cy - r - 14)
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
// Personne existante dont une modification est proposée : une pastille en haut à droite du portrait
|
||||
function drawPatchMark(ctx, node, pos, progress, patch) {
|
||||
const r = nodeRadius(node)
|
||||
const angle = -Math.PI / 4
|
||||
const x = pos.cx + Math.cos(angle) * (r + 2)
|
||||
const y = pos.cy + Math.sin(angle) * (r + 2)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = progress
|
||||
ctx.fillStyle = '#FBFAF7'
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 8, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.fillStyle = GHOST.color
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, 5.5, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
if (patch?.deleted) {
|
||||
ctx.strokeStyle = '#FBFAF7'
|
||||
ctx.lineWidth = 1.6
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x - 2.6, y)
|
||||
ctx.lineTo(x + 2.6, y)
|
||||
ctx.stroke()
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function drawGhostTag(ctx, text, cx, cy) {
|
||||
ctx.font = '500 10px "DM Sans", system-ui, sans-serif'
|
||||
const w = ctx.measureText(text).width + 12
|
||||
const h = 16
|
||||
ctx.fillStyle = '#FBFAF7'
|
||||
ctx.strokeStyle = GHOST.color
|
||||
ctx.lineWidth = 1
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(cx - w / 2, cy - h / 2, w, h, h / 2)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
ctx.fillStyle = GHOST.color
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
ctx.fillText(text, cx, cy + 0.5)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Annotations (drawing, sticker, text)
|
||||
// ============================================================
|
||||
@@ -1148,7 +1222,7 @@ function drawDrawingAnnotation(ctx, ann) {
|
||||
ctx.lineWidth = path.width || ann.style?.brushWidth || 3
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.globalAlpha = ann.style?.opacity ?? 1
|
||||
ctx.globalAlpha *= ann.style?.opacity ?? 1
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(path.points[0].x, path.points[0].y)
|
||||
@@ -1163,7 +1237,7 @@ function drawDrawingAnnotation(ctx, ann) {
|
||||
function drawStickerAnnotation(ctx, ann) {
|
||||
const size = ann.style?.fontSize || 40
|
||||
ctx.save()
|
||||
ctx.globalAlpha = ann.style?.opacity ?? 1
|
||||
ctx.globalAlpha *= ann.style?.opacity ?? 1
|
||||
ctx.font = `${size}px "Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji", sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
@@ -1177,7 +1251,7 @@ function drawTextAnnotation(ctx, ann) {
|
||||
const stabilo = ann.style?.stabilo || false
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha = ann.style?.opacity ?? 1
|
||||
ctx.globalAlpha *= ann.style?.opacity ?? 1
|
||||
ctx.font = `400 ${fontSize}px "Newsreader", Georgia, serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'middle'
|
||||
@@ -1321,7 +1395,7 @@ export function drawAnnotations(
|
||||
const lift = isBeingDragged ? dragLiftProgress : 0
|
||||
|
||||
ctx.save()
|
||||
ctx.globalAlpha *= entranceAlpha
|
||||
ctx.globalAlpha *= entranceAlpha * (ann.ghost ? GHOST.annotationAlpha : 1)
|
||||
ctx.translate(ann.x, ann.y)
|
||||
|
||||
// Lift/fly effect when dragging: scale up + drop shadow, animated via dragLiftProgress
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
fetchContributionStatus,
|
||||
listContributionSessions,
|
||||
submitContributionSession,
|
||||
reviewContributionSession,
|
||||
batchAnnotations,
|
||||
} from '../api/treeApi'
|
||||
import { forgetSentProposal, readSentProposals, rememberSentProposal } from '../utils/sentProposals'
|
||||
import { getAccountErrorMessage } from '../utils/errorMessages'
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
@@ -41,6 +42,34 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
|
||||
return () => { cancelled = true; document.removeEventListener('visibilitychange', onVisible) }
|
||||
}, [canEditCurrentTree, treeId, accessToken])
|
||||
|
||||
// Ce que la famille a envoyé, gardé dans son navigateur jusqu'à la relecture : dessiné en fantôme « En attente »
|
||||
// (utils/ghosts.js). Chaque proposition est revérifiée au chargement et au retour sur l'onglet.
|
||||
const [sentProposals, setSentProposals] = useState([])
|
||||
const refreshSentProposals = useCallback(async () => {
|
||||
if (!treeId || !accessToken || canEditCurrentTree) { setSentProposals([]); return }
|
||||
const stored = readSentProposals(treeId)
|
||||
setSentProposals(stored)
|
||||
const stillPending = []
|
||||
for (const proposal of stored) {
|
||||
try {
|
||||
const status = await fetchContributionStatus(treeId, proposal.sessionId, accessToken)
|
||||
if (status === 'pending') stillPending.push(proposal)
|
||||
else forgetSentProposal(treeId, proposal.sessionId)
|
||||
} catch (error) {
|
||||
// Introuvable : relue puis effacée, ou d'un autre arbre. Hors ligne : on la garde
|
||||
if (error?.status === 404) forgetSentProposal(treeId, proposal.sessionId)
|
||||
else stillPending.push(proposal)
|
||||
}
|
||||
}
|
||||
setSentProposals(stillPending)
|
||||
}, [treeId, accessToken, canEditCurrentTree])
|
||||
useEffect(() => {
|
||||
refreshSentProposals()
|
||||
const onVisible = () => { if (document.visibilityState === 'visible') refreshSentProposals() }
|
||||
document.addEventListener('visibilitychange', onVisible)
|
||||
return () => document.removeEventListener('visibilitychange', onVisible)
|
||||
}, [refreshSentProposals])
|
||||
|
||||
const resetContribState = () => {
|
||||
setContribState({ ...EMPTY_CONTRIB_STATE })
|
||||
}
|
||||
@@ -121,6 +150,14 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
|
||||
|
||||
if (result?.session?.status === 'approved') {
|
||||
await refreshCurrentTreeGraph()
|
||||
} else if (result?.session?.id && !canEditCurrentTree) {
|
||||
rememberSentProposal(treeContext.treeId, {
|
||||
sessionId: result.session.id,
|
||||
label: payload.submittedByLabel || null,
|
||||
createdAt: result.session.createdAt || new Date().toISOString(),
|
||||
changes: payload.changes,
|
||||
})
|
||||
setSentProposals(readSentProposals(treeContext.treeId))
|
||||
}
|
||||
|
||||
setContribState((current) => ({
|
||||
@@ -211,38 +248,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
|
||||
treeContext.accessToken,
|
||||
)
|
||||
|
||||
// Appliquer côté frontend les annotations approuvées (le backend ne les gère pas)
|
||||
const session = contribState.sessions.find((s) => s.id === sessionId)
|
||||
if (session) {
|
||||
const isAllApproved = reviewPayload.decision === 'approved'
|
||||
const approvedIds = new Set(
|
||||
(reviewPayload.changeDecisions || [])
|
||||
.filter((d) => d.decision === 'approved')
|
||||
.map((d) => d.changeId),
|
||||
)
|
||||
const approvedAnnotations = (session.changes || []).filter((c) =>
|
||||
c.entityType === 'annotation' && (isAllApproved || approvedIds.has(c.id)),
|
||||
)
|
||||
if (approvedAnnotations.length > 0) {
|
||||
const batchPayload = {
|
||||
create: approvedAnnotations
|
||||
.filter((c) => c.action === 'create')
|
||||
.map((c) => c.afterJson || c.after),
|
||||
update: approvedAnnotations
|
||||
.filter((c) => c.action === 'update')
|
||||
.map((c) => ({ id: c.entityId, ...(c.afterJson || c.after) })),
|
||||
delete: approvedAnnotations
|
||||
.filter((c) => c.action === 'delete')
|
||||
.map((c) => c.entityId),
|
||||
}
|
||||
try {
|
||||
await batchAnnotations(treeContext.treeId, batchPayload, treeContext.accessToken)
|
||||
} catch (annErr) {
|
||||
console.error('Failed to apply approved annotations:', annErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Les annotations acceptées sont posées par le serveur, dans la même transaction (1.5.1)
|
||||
await refreshCurrentTreeGraph()
|
||||
|
||||
await refreshContributionSessions()
|
||||
@@ -271,5 +277,7 @@ export function useContributions({ canEditCurrentTree, canSubmitContribution, tr
|
||||
handleSubmitContributionUpdatePerson,
|
||||
handleSubmitContributionDeletePerson,
|
||||
handleReviewContributionSession,
|
||||
sentProposals,
|
||||
refreshSentProposals,
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,8 @@ export function useEditMode({ treeId, canEdit }) {
|
||||
const [isActive, setIsActive] = useState(false)
|
||||
const [hasDraft, setHasDraft] = useState(false)
|
||||
const [draftTimestamp, setDraftTimestamp] = useState(null)
|
||||
// Monte à chaque changement du brouillon : ses fantômes se redessinent (utils/ghosts.js)
|
||||
const [draftRevision, setDraftRevision] = useState(0)
|
||||
const autoSaveTimerRef = useRef(null)
|
||||
const draftDataRef = useRef({})
|
||||
|
||||
@@ -151,6 +153,7 @@ export function useEditMode({ treeId, canEdit }) {
|
||||
}
|
||||
// Considérer qu'il y a désormais un draft actif (permet le bouton "Mettre à jour l'arbre")
|
||||
setHasDraft(true)
|
||||
setDraftRevision((r) => r + 1)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
@@ -164,6 +167,7 @@ export function useEditMode({ treeId, canEdit }) {
|
||||
draftDataRef.current = {}
|
||||
setHasDraft(false)
|
||||
setDraftTimestamp(null)
|
||||
setDraftRevision((r) => r + 1)
|
||||
} catch (err) {
|
||||
console.error('Failed to clear draft:', err)
|
||||
}
|
||||
@@ -180,6 +184,7 @@ export function useEditMode({ treeId, canEdit }) {
|
||||
// État
|
||||
isActive,
|
||||
hasDraft,
|
||||
draftRevision,
|
||||
draftTimestamp,
|
||||
|
||||
// Actions
|
||||
|
||||
@@ -195,4 +195,33 @@ export default {
|
||||
'Des modifications non enregistrées vous attendent': 'You have unsaved changes waiting',
|
||||
'Restaurer': 'Restore',
|
||||
'Ignorer': 'Dismiss',
|
||||
|
||||
// ─── Fantômes : propositions pas encore relues, sur l'arbre (1.5.1) ───
|
||||
'Brouillon': 'Draft',
|
||||
'En attente': 'Waiting',
|
||||
'Proposé': 'Suggested',
|
||||
'Proposition': 'Suggestion',
|
||||
'oui': 'yes',
|
||||
'non': 'no',
|
||||
'Pas encore envoyé : il part avec « Envoyer »': 'Not sent yet: it goes with “Send”',
|
||||
'Envoyé, en attente de relecture par la personne qui gère l’arbre': 'Sent, waiting for the person who manages the tree to review it',
|
||||
'Proposé par {name}, le {date}': 'Suggested by {name} on {date}',
|
||||
'Proposé par {name}': 'Suggested by {name}',
|
||||
'Proposé par la famille': 'Suggested by the family',
|
||||
'Relecture en cours': 'Reviewing',
|
||||
'Voir le détail': 'See details',
|
||||
'Proposition : {name}': 'Suggestion: {name}',
|
||||
'Dans cette proposition : {summary}.': 'In this suggestion: {summary}.',
|
||||
'Modification proposée': 'Suggested change',
|
||||
'Supprimer cette personne de l’arbre': 'Remove this person from the tree',
|
||||
'{n} personne': '{n} person',
|
||||
'{n} personnes': '{n} people',
|
||||
'{n} fiche modifiée': '{n} profile edited',
|
||||
'{n} fiches modifiées': '{n} profiles edited',
|
||||
'{n} lien': '{n} link',
|
||||
'{n} liens': '{n} links',
|
||||
"{n} ajout sur l'arbre": '{n} addition on the tree',
|
||||
"{n} ajouts sur l'arbre": '{n} additions on the tree',
|
||||
'{n} autre changement': '{n} other change',
|
||||
'{n} autres changements': '{n} other changes',
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
// Fantômes (1.5.1) : ce qui est proposé mais pas encore dans l'arbre, dessiné en transparence à sa place.
|
||||
// Trois sources, même format de changements que l'envoi d'une proposition :
|
||||
// - « draft » : le brouillon de la famille, pas encore envoyé ;
|
||||
// - « sent » : ce que la famille a envoyé, gardé dans son navigateur jusqu'à la relecture ;
|
||||
// - « pending » : les propositions en attente, vues par qui gère l'arbre.
|
||||
// Les fantômes ne touchent jamais aux données de l'arbre : ils ne s'ajoutent qu'au placement et au dessin.
|
||||
|
||||
function afterOf(change) {
|
||||
const after = change?.afterJson ?? change?.after
|
||||
return after && typeof after === 'object' && !Array.isArray(after) ? after : null
|
||||
}
|
||||
|
||||
function yearOf(isoDate) {
|
||||
const match = typeof isoDate === 'string' ? isoDate.match(/^(\d{4})/) : null
|
||||
return match ? Number(match[1]) : null
|
||||
}
|
||||
|
||||
function unionIdFor(parentId, unions) {
|
||||
const own = unions.filter((u) => String(u.partner1Id) === String(parentId) || String(u.partner2Id) === String(parentId))
|
||||
return own.length === 1 ? String(own[0].id) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* Fantômes d'une liste de changements.
|
||||
* @param {Array} changes - changements d'une proposition ({ entityType, action, entityId, after | afterJson, id })
|
||||
* @param {Object} meta - { source, key, sessionId?, label?, createdAt? } ; `key` rend les identifiants uniques
|
||||
* @param {Object} graph - { unions } de l'arbre, pour rattacher un enfant proposé au couple de son parent
|
||||
*/
|
||||
export function ghostsFromChanges(changes = [], meta = {}, graph = {}) {
|
||||
const key = String(meta.key || meta.sessionId || 'brouillon')
|
||||
const ghost = { ...meta, key }
|
||||
const refs = new Map()
|
||||
const persons = []
|
||||
const unions = []
|
||||
const filiations = []
|
||||
const annotations = []
|
||||
const patches = new Map()
|
||||
const resolve = (id) => (id == null ? null : refs.get(String(id)) || String(id))
|
||||
|
||||
changes.forEach((change, index) => {
|
||||
if (change?.entityType !== 'person' || change.action !== 'create') return
|
||||
const after = afterOf(change)
|
||||
if (!after) return
|
||||
const id = `ghost:${key}:${after.ref || change.id || index}`
|
||||
if (after.ref) refs.set(String(after.ref), id)
|
||||
persons.push({
|
||||
id,
|
||||
firstName: after.firstName || '',
|
||||
lastName: after.lastName || '',
|
||||
birthName: after.birthName || null,
|
||||
nickname: after.nickname || null,
|
||||
sex: after.sex || null,
|
||||
birthDate: after.birthDate || null,
|
||||
deathDate: after.deathDate || null,
|
||||
birthYear: yearOf(after.birthDate),
|
||||
deathYear: yearOf(after.deathDate),
|
||||
isAlive: !after.deathDate && !after.isDeceased,
|
||||
photo: null,
|
||||
frameType: 'round',
|
||||
ghost,
|
||||
})
|
||||
})
|
||||
|
||||
changes.forEach((change, index) => {
|
||||
if (change?.entityType !== 'union' || change.action !== 'create') return
|
||||
const after = afterOf(change)
|
||||
if (!after?.partner1PersonId || !after?.partner2PersonId) return
|
||||
unions.push({
|
||||
id: `ghost:${key}:u${change.id || index}`,
|
||||
partner1Id: resolve(after.partner1PersonId),
|
||||
partner2Id: resolve(after.partner2PersonId),
|
||||
unionType: after.unionType || 'mariage',
|
||||
displayOrder: 9999,
|
||||
ghost,
|
||||
})
|
||||
})
|
||||
|
||||
const allUnions = [...(graph.unions || []), ...unions]
|
||||
changes.forEach((change, index) => {
|
||||
const after = afterOf(change)
|
||||
if (!after) return
|
||||
if (change.entityType === 'parent_child_link' && change.action === 'create') {
|
||||
if (!after.parentPersonId || !after.childPersonId) return
|
||||
const parentId = resolve(after.parentPersonId)
|
||||
const unionId = after.viaUnionId ? String(after.viaUnionId) : unionIdFor(parentId, allUnions)
|
||||
filiations.push({
|
||||
id: `ghost:${key}:f${change.id || index}`,
|
||||
childId: resolve(after.childPersonId),
|
||||
parentId: unionId ? null : parentId,
|
||||
unionId,
|
||||
parentageType: after.parentageType || 'biologique',
|
||||
displayOrder: 9999,
|
||||
ghost,
|
||||
})
|
||||
} else if (change.entityType === 'person' && (change.action === 'update' || change.action === 'delete') && change.entityId) {
|
||||
const previous = patches.get(String(change.entityId))
|
||||
patches.set(String(change.entityId), {
|
||||
...previous,
|
||||
fields: { ...(previous?.fields || {}), ...(change.action === 'update' ? after : {}) },
|
||||
deleted: change.action === 'delete' || Boolean(previous?.deleted),
|
||||
ghost,
|
||||
})
|
||||
} else if (change.entityType === 'annotation' && change.action === 'create' && meta.source !== 'draft') {
|
||||
// Le brouillon dessine déjà ses annotations : seules celles envoyées ou en attente deviennent fantômes
|
||||
annotations.push({
|
||||
id: `ghost:${key}:a${change.id || index}`,
|
||||
type: after.type,
|
||||
x: Number(after.x) || 0,
|
||||
y: Number(after.y) || 0,
|
||||
content: after.content || '',
|
||||
style: after.style || {},
|
||||
zIndex: after.zIndex || 0,
|
||||
ghost,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return { persons, unions, filiations, annotations, patches }
|
||||
}
|
||||
|
||||
export function emptyGhosts() {
|
||||
return { persons: [], unions: [], filiations: [], annotations: [], patches: new Map() }
|
||||
}
|
||||
|
||||
export function mergeGhosts(list = []) {
|
||||
const merged = emptyGhosts()
|
||||
for (const g of list) {
|
||||
if (!g) continue
|
||||
merged.persons.push(...g.persons)
|
||||
merged.unions.push(...g.unions)
|
||||
merged.filiations.push(...g.filiations)
|
||||
merged.annotations.push(...g.annotations)
|
||||
for (const [id, patch] of g.patches) if (!merged.patches.has(id)) merged.patches.set(id, patch)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
export function hasGhosts(g) {
|
||||
return Boolean(g && (g.persons.length || g.unions.length || g.filiations.length || g.annotations.length || g.patches.size))
|
||||
}
|
||||
|
||||
/**
|
||||
* Le graphe à placer, fantômes compris. Dans un onglet de famille, seuls les fantômes reliés (de proche en
|
||||
* proche) à une personne de l'onglet y entrent ; l'arbre entier les prend tous.
|
||||
* @param {{persons, unions, filiations}} base
|
||||
* @param {ReturnType<typeof ghostsFromChanges>} ghosts
|
||||
* @param {boolean} whole - base = l'arbre entier
|
||||
*/
|
||||
export function withGhosts(base, ghosts, whole = false) {
|
||||
if (!hasGhosts(ghosts)) return base
|
||||
const keep = new Set(base.persons.map((p) => String(p.id)))
|
||||
const ghostById = new Map(ghosts.persons.map((p) => [String(p.id), p]))
|
||||
const allUnions = [...base.unions, ...ghosts.unions]
|
||||
const unionById = new Map(allUnions.map((u) => [String(u.id), u]))
|
||||
const parentsOf = (f) => (f.unionId ? [unionById.get(String(f.unionId))?.partner1Id, unionById.get(String(f.unionId))?.partner2Id] : [f.parentId]).filter((id) => id != null).map(String)
|
||||
|
||||
if (whole) {
|
||||
for (const id of ghostById.keys()) keep.add(id)
|
||||
} else {
|
||||
let grew = true
|
||||
while (grew) {
|
||||
grew = false
|
||||
const touch = (a, b) => {
|
||||
if (keep.has(a) && ghostById.has(b) && !keep.has(b)) { keep.add(b); grew = true }
|
||||
if (keep.has(b) && ghostById.has(a) && !keep.has(a)) { keep.add(a); grew = true }
|
||||
}
|
||||
for (const u of ghosts.unions) touch(String(u.partner1Id), String(u.partner2Id))
|
||||
for (const f of ghosts.filiations) for (const p of parentsOf(f)) touch(p, String(f.childId))
|
||||
}
|
||||
}
|
||||
|
||||
const persons = [...base.persons, ...[...ghostById.values()].filter((p) => keep.has(String(p.id)))]
|
||||
const unions = [...base.unions, ...ghosts.unions.filter((u) => keep.has(String(u.partner1Id)) && keep.has(String(u.partner2Id)))]
|
||||
const unionIds = new Set(unions.map((u) => String(u.id)))
|
||||
const filiations = [
|
||||
...base.filiations,
|
||||
...ghosts.filiations.filter((f) => keep.has(String(f.childId)) && (f.unionId ? unionIds.has(String(f.unionId)) : keep.has(String(f.parentId)))),
|
||||
]
|
||||
return { persons, unions, filiations }
|
||||
}
|
||||
|
||||
export function isGhostId(id) {
|
||||
return typeof id === 'string' && (id.startsWith('ghost:') || id.startsWith('p-ghost:'))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { ghostsFromChanges, hasGhosts, isGhostId, mergeGhosts, withGhosts } from './ghosts'
|
||||
|
||||
const base = {
|
||||
persons: [{ id: 'jean' }, { id: 'marie' }, { id: 'paul' }],
|
||||
unions: [{ id: 'u1', partner1Id: 'jean', partner2Id: 'marie' }],
|
||||
filiations: [{ id: 'f1', childId: 'paul', unionId: 'u1', parentId: null }],
|
||||
}
|
||||
|
||||
describe('fantômes des propositions', () => {
|
||||
it("un enfant proposé à Jean se rattache au couple Jean et Marie, pas à un parent inconnu", () => {
|
||||
const g = ghostsFromChanges([
|
||||
{ id: 'c1', entityType: 'person', action: 'create', afterJson: { ref: 'tmp:a', firstName: 'Lucie', lastName: 'Martin', birthDate: '1990-03-01' } },
|
||||
{ id: 'c2', entityType: 'parent_child_link', action: 'create', afterJson: { parentPersonId: 'jean', childPersonId: 'tmp:a' } },
|
||||
], { source: 'pending', sessionId: 's1', label: 'Cousine' }, base)
|
||||
expect(g.persons).toHaveLength(1)
|
||||
expect(g.persons[0]).toMatchObject({ firstName: 'Lucie', birthYear: 1990, ghost: { source: 'pending', sessionId: 's1' } })
|
||||
expect(g.filiations[0]).toMatchObject({ childId: g.persons[0].id, unionId: 'u1', parentId: null })
|
||||
expect(isGhostId(g.persons[0].id)).toBe(true)
|
||||
})
|
||||
|
||||
it('les changements sur une personne existante deviennent une marque, pas un double', () => {
|
||||
const g = ghostsFromChanges([
|
||||
{ entityType: 'person', action: 'update', entityId: 'paul', after: { birthPlace: 'Lille' } },
|
||||
{ entityType: 'person', action: 'update', entityId: 'paul', after: { profession: 'Facteur' } },
|
||||
], { source: 'sent', key: 'k' }, base)
|
||||
expect(g.persons).toHaveLength(0)
|
||||
expect(g.patches.get('paul').fields).toEqual({ birthPlace: 'Lille', profession: 'Facteur' })
|
||||
})
|
||||
|
||||
it("le brouillon ne double pas ses annotations, déjà dessinées ; une proposition envoyée oui", () => {
|
||||
const change = { entityType: 'annotation', action: 'create', after: { type: 'text', x: 1, y: 2, content: 'Mamie' } }
|
||||
expect(ghostsFromChanges([change], { source: 'draft' }).annotations).toHaveLength(0)
|
||||
expect(ghostsFromChanges([change], { source: 'sent', key: 's' }).annotations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("dans un onglet, seuls les fantômes reliés de proche en proche à une personne de l'onglet entrent", () => {
|
||||
const g = ghostsFromChanges([
|
||||
{ entityType: 'person', action: 'create', after: { ref: 'tmp:a', firstName: 'Enfant' } },
|
||||
{ entityType: 'person', action: 'create', after: { ref: 'tmp:b', firstName: 'Petit-enfant' } },
|
||||
{ entityType: 'person', action: 'create', after: { ref: 'tmp:c', firstName: 'Ailleurs' } },
|
||||
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'paul', childPersonId: 'tmp:a' } },
|
||||
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'tmp:a', childPersonId: 'tmp:b' } },
|
||||
{ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: 'inconnu-hors-onglet', childPersonId: 'tmp:c' } },
|
||||
], { source: 'pending', sessionId: 's2' }, base)
|
||||
const tab = withGhosts(base, g)
|
||||
expect(tab.persons.map((p) => p.firstName).filter(Boolean)).toEqual(['Enfant', 'Petit-enfant'])
|
||||
expect(tab.filiations).toHaveLength(3)
|
||||
expect(withGhosts(base, g, true).persons).toHaveLength(6)
|
||||
})
|
||||
|
||||
it("deux propositions qui utilisent la même référence provisoire ne se mélangent pas", () => {
|
||||
const one = ghostsFromChanges([{ entityType: 'person', action: 'create', after: { ref: 'tmp:x', firstName: 'A' } }], { source: 'pending', sessionId: 's1' })
|
||||
const two = ghostsFromChanges([{ entityType: 'person', action: 'create', after: { ref: 'tmp:x', firstName: 'B' } }], { source: 'pending', sessionId: 's2' })
|
||||
const merged = mergeGhosts([one, two])
|
||||
expect(new Set(merged.persons.map((p) => p.id)).size).toBe(2)
|
||||
expect(hasGhosts(merged)).toBe(true)
|
||||
expect(withGhosts(base, mergeGhosts([]))).toBe(base)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from '../i18n/index.js'
|
||||
|
||||
function plural(count, one, many) {
|
||||
return count > 1 ? t(many, { n: count }) : t(one, { n: count })
|
||||
}
|
||||
|
||||
// Ce que contient une proposition, en une ligne : « 1 personne, 2 liens, 1 photo sur l'arbre »
|
||||
export function summarizeChanges(changes = []) {
|
||||
const count = { person: 0, update: 0, link: 0, annotation: 0, other: 0 }
|
||||
for (const change of changes) {
|
||||
if (change.entityType === 'person' && change.action === 'create') count.person += 1
|
||||
else if (change.entityType === 'person') count.update += 1
|
||||
else if (change.entityType === 'union' || change.entityType === 'parent_child_link') count.link += 1
|
||||
else if (change.entityType === 'annotation') count.annotation += 1
|
||||
else count.other += 1
|
||||
}
|
||||
return [
|
||||
count.person && plural(count.person, '{n} personne', '{n} personnes'),
|
||||
count.update && plural(count.update, '{n} fiche modifiée', '{n} fiches modifiées'),
|
||||
count.link && plural(count.link, '{n} lien', '{n} liens'),
|
||||
count.annotation && plural(count.annotation, "{n} ajout sur l'arbre", "{n} ajouts sur l'arbre"),
|
||||
count.other && plural(count.other, '{n} autre changement', '{n} autres changements'),
|
||||
].filter(Boolean).join(', ')
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
// Propositions envoyées par la famille depuis ce navigateur, gardées jusqu'à leur relecture pour les dessiner
|
||||
// en fantôme « En attente ». Ce sont ses propres changements : rien d'autre n'est gardé. Le stockage peut manquer
|
||||
// (navigation privée, quota) : on dessine alors simplement sans fantômes.
|
||||
|
||||
const MAX_KEPT = 30
|
||||
// Au-delà, une proposition jamais relue n'est plus dessinée (le relecteur l'a peut-être oubliée, pas la famille)
|
||||
const MAX_AGE_MS = 60 * 24 * 60 * 60 * 1000
|
||||
|
||||
const keyOf = (treeId) => `parenthese-propositions-${treeId}`
|
||||
|
||||
export function readSentProposals(treeId, now = Date.now()) {
|
||||
if (!treeId) return []
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem(keyOf(treeId)) || '[]')
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.filter((p) => p && typeof p.sessionId === 'string' && Array.isArray(p.changes)
|
||||
&& now - (Date.parse(p.createdAt) || now) < MAX_AGE_MS)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function write(treeId, list) {
|
||||
try {
|
||||
if (list.length) localStorage.setItem(keyOf(treeId), JSON.stringify(list.slice(-MAX_KEPT)))
|
||||
else localStorage.removeItem(keyOf(treeId))
|
||||
} catch {
|
||||
// Stockage indisponible : pas de fantômes « En attente », rien d'autre ne change
|
||||
}
|
||||
}
|
||||
|
||||
// Une photo posée sur l'arbre garde dans son contenu l'adresse affichée, avec le jeton médias : seul le chemin
|
||||
// est gardé, l'adresse est refaite au dessin
|
||||
function withoutPhotoUrl(change) {
|
||||
if (change?.entityType !== 'annotation' || typeof change.after?.content !== 'string' || !change.after.content.startsWith('{')) return change
|
||||
try {
|
||||
const { url: _url, ...rest } = JSON.parse(change.after.content)
|
||||
return { ...change, after: { ...change.after, content: JSON.stringify(rest) } }
|
||||
} catch {
|
||||
return change
|
||||
}
|
||||
}
|
||||
|
||||
export function rememberSentProposal(treeId, proposal) {
|
||||
if (!treeId || !proposal?.sessionId) return
|
||||
const kept = { ...proposal, changes: (proposal.changes || []).map(withoutPhotoUrl) }
|
||||
write(treeId, [...readSentProposals(treeId).filter((p) => p.sessionId !== proposal.sessionId), kept])
|
||||
}
|
||||
|
||||
export function forgetSentProposal(treeId, sessionId) {
|
||||
if (!treeId) return
|
||||
write(treeId, readSentProposals(treeId).filter((p) => p.sessionId !== sessionId))
|
||||
}
|
||||
Reference in new issue
Block a user