{toastLabel}
diff --git a/frontend/src/components/galaxy/constants.js b/frontend/src/components/galaxy/constants.js
index 94d84b1..b4c161f 100644
--- a/frontend/src/components/galaxy/constants.js
+++ b/frontend/src/components/galaxy/constants.js
@@ -1,3 +1,6 @@
+// Identifiant des couples « séparés » créés par le placement (deux parents sans union enregistrée)
+export const SEPARATED_PAIR_PREFIX = 'pair:'
+
// --- Constantes visuelles ---
export const PERSON_W = 120
export const PERSON_H = 120
diff --git a/frontend/src/components/galaxy/elkLayout.js b/frontend/src/components/galaxy/elkLayout.js
index d4d69eb..9151b81 100644
--- a/frontend/src/components/galaxy/elkLayout.js
+++ b/frontend/src/components/galaxy/elkLayout.js
@@ -3,7 +3,7 @@ import {
PERSON_W, PERSON_H, UNION_W, UNION_H,
UNKNOWN_W, UNKNOWN_H, COMPACT_W, COMPACT_JOIN_GAP,
JITTER_RADIUS, FLOAT_AMPLITUDE, FLOAT_SPEED,
- ANGLE_VARIATION, MAX_ORBIT_MEDIAS, ORBIT_RADIUS,
+ ANGLE_VARIATION, MAX_ORBIT_MEDIAS, ORBIT_RADIUS, SEPARATED_PAIR_PREFIX,
} from './constants'
// ============================================================
@@ -40,6 +40,38 @@ function makePairKey(a, b) {
return left < right ? `${left}|${right}` : `${right}|${left}`
}
+// Deux parents d'un même enfant sans couple enregistré (séparés, jamais mariés) : un couple « séparé »
+// propre au dessin les relie en pointillés, au lieu d'un « Inconnu » à côté de chacun. Rien en base.
+
+export function separatedPairUnions(filiations = [], unions = []) {
+ const coupled = new Set(unions.filter(u => u.partner1Id && u.partner2Id).map(u => makePairKey(u.partner1Id, u.partner2Id)))
+ const unionPartners = new Map(unions.map(u => [u.id, [u.partner1Id, u.partner2Id]]))
+ const byChild = new Map()
+ filiations.forEach((f) => {
+ if (!f.childId) return
+ if (!byChild.has(f.childId)) byChild.set(f.childId, { direct: new Set(), viaUnion: new Set() })
+ const entry = byChild.get(f.childId)
+ if (f.unionId) (unionPartners.get(f.unionId) || []).forEach(id => id != null && entry.viaUnion.add(String(id)))
+ else if (f.parentId) entry.direct.add(String(f.parentId))
+ })
+ const pairs = new Map()
+ byChild.forEach(({ direct, viaUnion }) => {
+ const parents = [...direct].filter(id => !viaUnion.has(id)).sort()
+ if (parents.length < 2) return
+ const key = makePairKey(parents[0], parents[1])
+ if (coupled.has(key) || pairs.has(key)) return
+ const original = (id) => filiations.find(f => String(f.parentId) === id)?.parentId ?? id
+ pairs.set(key, {
+ id: `${SEPARATED_PAIR_PREFIX}${parents[0]}:${parents[1]}`,
+ partner1Id: original(parents[0]),
+ partner2Id: original(parents[1]),
+ separated: true,
+ displayOrder: Number.MAX_SAFE_INTEGER,
+ })
+ })
+ return [...pairs.values()]
+}
+
// ============================================================
// Normalisation des filiations
// ============================================================
@@ -170,8 +202,9 @@ const CLUSTER_SPREAD_FACTOR = 6
export function computeLayout(graphData = null) {
const persons = graphData?.persons || globalPersons
- const unions = graphData?.unions || globalUnions
const filiations = graphData?.filiations || globalFiliations
+ const savedUnions = graphData?.unions || globalUnions
+ const unions = [...savedUnions, ...separatedPairUnions(filiations, savedUnions)]
const normalizedFiliations = normalizeFiliationsForLayout(filiations, unions)
@@ -330,7 +363,7 @@ export function computeLayout(graphData = 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: false,
+ elkId: `u-${u.id}`, p1Key: `p-${u.partner1Id}`, p2Key: `p-${u.partner2Id}`, virtual: Boolean(u.separated),
})),
...virtualUnions.map(vu => ({
elkId: vu.elkId, p1Key: `p-${vu.parentId}`, p2Key: vu.unknownElkId, virtual: true,
diff --git a/frontend/src/components/galaxy/elkLayout.test.js b/frontend/src/components/galaxy/elkLayout.test.js
index f5a718c..dee8f93 100644
--- a/frontend/src/components/galaxy/elkLayout.test.js
+++ b/frontend/src/components/galaxy/elkLayout.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'
-import { computeLayout, normalizeFiliationsForLayout } from './elkLayout'
+import { computeLayout, normalizeFiliationsForLayout, separatedPairUnions, coupleBarMeta } from './elkLayout'
import { PERSON_W } from './constants'
describe('normalizeFiliationsForLayout', () => {
@@ -239,3 +239,31 @@ describe('computeLayout', () => {
expect(cX < bXs[0] || cX > bXs[1]).toBe(true)
})
})
+
+describe('separated parents', () => {
+ // Philippe et Marie, parents d'Anaïs, sans couple enregistré (séparés)
+ const persons = [{ id: 'phil' }, { id: 'marie' }, { id: 'anais' }]
+ const filiations = [
+ { id: 'f1', childId: 'anais', parentId: 'phil', unionId: null, parentageType: 'biologique', displayOrder: 1 },
+ { id: 'f2', childId: 'anais', parentId: 'marie', unionId: null, parentageType: 'biologique', displayOrder: 2 },
+ ]
+
+ it('links the two parents with a dashed couple instead of two unknowns', () => {
+ const result = computeLayout({ persons, unions: [], filiations })
+ const ids = result.children.map((node) => node.id)
+ expect(ids.some((id) => id.startsWith('unknown-'))).toBe(false)
+ const pair = coupleBarMeta.find((entry) => entry.elkId.startsWith('u-pair:'))
+ expect(pair).toMatchObject({ virtual: true })
+ expect([pair.p1Key, pair.p2Key].sort()).toEqual(['p-marie', 'p-phil'])
+ expect(result.edges.some((edge) => edge.sources[0] === pair.elkId && edge.targets[0] === 'p-anais')).toBe(true)
+ })
+
+ it('does not add a pair when the parents already have a union', () => {
+ const unions = [{ id: 'u1', partner1Id: 'phil', partner2Id: 'marie', displayOrder: 1 }]
+ expect(separatedPairUnions(filiations, unions)).toEqual([])
+ })
+
+ it('keeps a lone parent with an unknown partner', () => {
+ expect(separatedPairUnions([filiations[0]], [])).toEqual([])
+ })
+})
diff --git a/frontend/src/components/galaxy/siblingReorder.js b/frontend/src/components/galaxy/siblingReorder.js
index b551075..2260e45 100644
--- a/frontend/src/components/galaxy/siblingReorder.js
+++ b/frontend/src/components/galaxy/siblingReorder.js
@@ -1,5 +1,6 @@
// Réordonner une fratrie dans l'arbre : logique pure (sans canvas ni React), testée à part.
// Une fratrie = les enfants reliés à une même source (union `u-…` ou parent seul `vu-…`).
+import { SEPARATED_PAIR_PREFIX } from './constants'
const centerOf = (node) => node.x + node.width / 2
@@ -68,7 +69,10 @@ function filiationsOf(filiations, unions, source, childId) {
if (source.startsWith('u-')) {
const unionId = source.slice(2)
const union = unions.find((u) => String(u.id) === unionId)
- const partners = union ? [union.partner1Id, union.partner2Id].filter((id) => id != null).map(String) : []
+ // Couple « séparé » propre au dessin (elkLayout) : ses deux partenaires sont dans l'identifiant
+ const partners = union
+ ? [union.partner1Id, union.partner2Id].filter((id) => id != null).map(String)
+ : unionId.startsWith(SEPARATED_PAIR_PREFIX) ? unionId.slice(SEPARATED_PAIR_PREFIX.length).split(':') : []
const viaUnion = filiations.filter((f) => sameChild(f) && String(f.unionId) === unionId)
// Union reconstituée par le placement : deux liens directs vers chacun des partenaires
return viaUnion.length ? viaUnion : filiations.filter((f) => sameChild(f) && !f.unionId && partners.includes(String(f.parentId)))
diff --git a/frontend/src/components/galaxy/siblingReorder.test.js b/frontend/src/components/galaxy/siblingReorder.test.js
index a2ac056..2155e42 100644
--- a/frontend/src/components/galaxy/siblingReorder.test.js
+++ b/frontend/src/components/galaxy/siblingReorder.test.js
@@ -114,3 +114,18 @@ describe('ordre de la fratrie dans les données', () => {
expect(graph.filiations.every((f) => f.displayOrder === 1)).toBe(true)
})
})
+
+describe('fratrie de parents séparés', () => {
+ it('retrouve les liens directs vers les deux parents du couple « séparé »', () => {
+ const filiations = [
+ { id: 'x1', childId: 'k1', parentId: 'phil', unionId: null },
+ { id: 'x2', childId: 'k1', parentId: 'marie', unionId: null },
+ { id: 'x3', childId: 'k2', parentId: 'phil', unionId: null },
+ { id: 'x4', childId: 'k2', parentId: 'marie', unionId: null },
+ ]
+ expect(buildOrderPayload(filiations, [], 'u-pair:marie:phil', ['p-k2', 'p-k1'])).toEqual([
+ { childId: 'k2', linkIds: ['x3', 'x4'] },
+ { childId: 'k1', linkIds: ['x1', 'x2'] },
+ ])
+ })
+})
diff --git a/frontend/src/components/navbar/ContextualNavbar.css b/frontend/src/components/navbar/ContextualNavbar.css
index 9801b43..564c8c1 100644
--- a/frontend/src/components/navbar/ContextualNavbar.css
+++ b/frontend/src/components/navbar/ContextualNavbar.css
@@ -190,14 +190,12 @@
/* Active tool — gradient fill */
.nav-annot-btn--active {
- background: linear-gradient(140deg, #93402a, #2a2622);
+ background: var(--pz-ink);
color: #ffffff;
- box-shadow: 0 2px 10px rgba(147, 64, 42, 0.35);
}
.nav-annot-btn--active:hover:not(:disabled) {
- background: linear-gradient(140deg, #93402a, #3d3731);
- transform: translateY(-1px);
+ background: #3a3530;
}
/* Panel-open indicator — tiny dot below */
diff --git a/frontend/src/components/navbar/ContextualNavbar.jsx b/frontend/src/components/navbar/ContextualNavbar.jsx
index 4517d94..eee10eb 100644
--- a/frontend/src/components/navbar/ContextualNavbar.jsx
+++ b/frontend/src/components/navbar/ContextualNavbar.jsx
@@ -1,7 +1,7 @@
import { useState, useRef, useEffect } from 'react'
import {
Edit3, Share2, User, UserPlus,
- X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle, Globe,
+ X, MousePointer2, Pencil, Type, Star, Trash2, Image, ImagePlus, MoreHorizontal, HelpCircle, Globe, ArrowLeft,
} from 'lucide-react'
import NavButton from './NavButton'
import AvatarMenu from './AvatarMenu'
@@ -21,6 +21,14 @@ const ANNOT_TOOLS = [
{ id: 'photo', label: t('Photo'), Icon: Image },
]
+// Mobile : les outils de dessin attendent dans « Plus », la barre ne montre que l'essentiel
+const ANNOT_MENU = [
+ { id: 'drawing', label: t('Dessiner'), Icon: Pencil },
+ { id: 'text', label: t('Écrire un texte'), Icon: Type },
+ { id: 'sticker', label: t('Poser un sticker'), Icon: Star },
+ { id: 'photo', label: t('Poser une photo'), Icon: Image },
+]
+
function ContextualNavbar({
userRole,
pendingContributions = 0,
@@ -47,6 +55,7 @@ function ContextualNavbar({
}) {
const [avatarMenuOpen, setAvatarMenuOpen] = useState(false)
const [annotPanel, setAnnotPanel] = useState(null)
+ const [annotMode, setAnnotMode] = useState(false)
const [moreOpen, setMoreOpen] = useState(false)
const moreRef = useRef(null)
const [isMobile, setIsMobile] = useState(() =>
@@ -102,7 +111,10 @@ function ContextualNavbar({
}, [])
useEffect(() => {
- if (!editModeActive) setAnnotPanel(null)
+ if (!editModeActive) {
+ setAnnotPanel(null)
+ setAnnotMode(false)
+ }
}, [editModeActive])
const { navbarType, authenticated } = userRole
@@ -131,6 +143,19 @@ function ContextualNavbar({
}
}
+ // Corbeille des annotations : visible seulement quand il y a quelque chose à effacer
+ const renderAnnotTrash = () => (
+
+ )
+
// Famille arrivée par le lien : les deux gestes utiles écrits en toutes lettres, le reste dans « Plus »
const isFamily = (navbarType === 'contributor_anon' || navbarType === 'contributor_auth') && Boolean(onAddSouvenirClick)
const renderFamilyButtons = () => (
@@ -345,7 +370,7 @@ function ContextualNavbar({
)}
- {showMobileAnnot ? (
+ {showMobileAnnot && !annotMode ? (
<>
+ {/* Libellé court : la barre doit tenir à droite du bouton de zoom */}
+
+
+
+
+ {moreOpen && (
+
+ {ANNOT_MENU.map((tool) => (
+
+ ))}
+
+ )}
+
+
+ {(annotationTools.selectedAnnotationId || annotationTools.isDragging) && renderAnnotTrash()}
+ >
+ ) : showMobileAnnot ? (
+ <>
+
+
{ANNOT_TOOLS.map((tool) => (
@@ -370,27 +450,12 @@ function ContextualNavbar({
))}
-
-
-
-
-
+ {(annotationTools.selectedAnnotationId || annotationTools.isDragging) && (
+ <>
+
+ {renderAnnotTrash()}
+ >
+ )}
>
) : (
<>
diff --git a/frontend/src/i18n/en/compte.js b/frontend/src/i18n/en/compte.js
index d90300c..90ea2bf 100644
--- a/frontend/src/i18n/en/compte.js
+++ b/frontend/src/i18n/en/compte.js
@@ -182,6 +182,11 @@ export default {
"Ajouter une personne": "Add a person",
"Plus": "More",
"Dessiner ou écrire sur l'arbre": "Draw or write on the tree",
+ "Dessiner": "Draw",
+ "Écrire un texte": "Write a text",
+ "Poser un sticker": "Add a sticker",
+ "Poser une photo": "Add a photo",
+ "Terminé": "Done",
"Le mode d'emploi": "User guide",
"Modifier l'arbre": "Edit the tree",
"Contributions": "Contributions",
diff --git a/frontend/src/utils/dateInput.js b/frontend/src/utils/dateInput.js
index 98b68d1..a47bb22 100644
--- a/frontend/src/utils/dateInput.js
+++ b/frontend/src/utils/dateInput.js
@@ -13,6 +13,13 @@ export function dateToInputText(value) {
return year ? year[1] : raw
}
+// Chiffres seuls au-delà d'une année (5 à 8 chiffres) : on pose les barres, « 12031950 » → « 12/03/1950 »
+export function formatDigitsAsDate(text) {
+ const raw = String(text ?? '')
+ if (!/^\d{5,8}$/.test(raw)) return raw
+ return `${raw.slice(0, 2)}/${raw.slice(2, 4)}/${raw.slice(4)}`
+}
+
// Texte du champ vers la valeur stockée : '' si vide, « AAAA-MM-JJ » si valable, undefined si on ne comprend pas
export function parseDateInput(text) {
const raw = String(text ?? '').trim()
diff --git a/frontend/src/utils/dateInput.test.js b/frontend/src/utils/dateInput.test.js
index e0a0b8f..5b0b668 100644
--- a/frontend/src/utils/dateInput.test.js
+++ b/frontend/src/utils/dateInput.test.js
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { dateToInputText, isYearOnly, parseDateInput } from './dateInput.js'
+import { dateToInputText, formatDigitsAsDate, isYearOnly, parseDateInput } from './dateInput.js'
describe('saisie d’une date (issue #77)', () => {
it('une année seule ou une date complète', () => {
@@ -24,3 +24,17 @@ describe('saisie d’une date (issue #77)', () => {
expect(isYearOnly('1950-03-12')).toBe(false)
})
})
+
+describe('formatDigitsAsDate', () => {
+ it('pose les barres au-delà de quatre chiffres, pour le clavier numérique du téléphone', () => {
+ expect(formatDigitsAsDate('1950')).toBe('1950')
+ expect(formatDigitsAsDate('12031')).toBe('12/03/1')
+ expect(formatDigitsAsDate('12031950')).toBe('12/03/1950')
+ expect(parseDateInput(formatDigitsAsDate('12031950'))).toBe('1950-03-12')
+ })
+
+ it('laisse tel quel ce qui contient déjà un séparateur', () => {
+ expect(formatDigitsAsDate('12/03/19')).toBe('12/03/19')
+ expect(formatDigitsAsDate('1/3/1950')).toBe('1/3/1950')
+ })
+})