Ménage : retirer onze modules de l'interface que plus rien n'importe

AddPersonButton, DropTrashZone, CreateMyTreeButton, EditModeOverlay.jsx (App importe EditSessionBar sous ce
nom, sa feuille de style reste), InitialTreeWizard, InlinePersonForm, ProximityFeedback, useAddPersonDrag,
graphAdapter et graphDataHelpers, avec leurs feuilles de style. Aucun import, test compris ; lint, 253 tests et
build verts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-20 14:27:49 +02:00
1 parent 4dd0db9699
commit 34454abc18
16 files changed
+1 -2033

No files matched your search

-172
View File
@@ -1,172 +0,0 @@
/* ============================================================
Add Person Button - FAB draggable pour ajouter personne
============================================================ */
.add-person-button {
position: fixed;
width: 64px;
height: 64px;
border-radius: 50%;
background: rgba(242, 239, 234, 0.95);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 2px solid rgba(147, 64, 42, 0.3);
box-shadow:
0 8px 32px rgba(42, 38, 34, 0.25),
0 2px 8px rgba(42, 38, 34, 0.12);
cursor: grab;
user-select: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1);
z-index: 900;
/* Position initiale : bas-gauche */
bottom: 24px;
left: 24px;
}
.add-person-button:hover {
transform: scale(1.12);
box-shadow:
0 12px 40px rgba(147, 64, 42, 0.35),
0 4px 12px rgba(42, 38, 34, 0.18);
border-color: rgba(147, 64, 42, 0.5);
}
.add-person-button.dragging {
cursor: grabbing;
transform: scale(1.2) rotate(5deg);
box-shadow:
0 16px 48px rgba(147, 64, 42, 0.45),
0 6px 16px rgba(42, 38, 34, 0.25);
border-color: rgba(147, 64, 42, 0.7);
z-index: 1100;
}
/* Icône + */
.add-person-button-icon {
font-size: 32px;
font-weight: 300;
color: #93402A;
line-height: 1;
filter: drop-shadow(0 1px 3px rgba(42, 38, 34, 0.15));
transition: transform 0.2s ease;
}
.add-person-button:hover .add-person-button-icon {
transform: rotate(90deg);
}
.add-person-button.dragging .add-person-button-icon {
transform: rotate(180deg);
}
/* Pulse animation sur apparition */
@keyframes addPersonPulse {
0% {
opacity: 0;
transform: scale(0.7);
}
50% {
transform: scale(1.15);
}
100% {
opacity: 1;
transform: scale(1);
}
}
.add-person-button.entering {
animation: addPersonPulse 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
/* Tooltip */
.add-person-button-tooltip {
position: absolute;
top: 50%;
left: calc(100% + 12px);
transform: translateY(-50%);
padding: 8px 14px;
background: rgba(42, 38, 34, 0.95);
color: #FFFFFF;
font-size: 14px;
font-weight: 500;
border-radius: 8px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
box-shadow: 0 4px 12px rgba(42, 38, 34, 0.2);
}
.add-person-button:hover .add-person-button-tooltip {
opacity: 1;
}
.add-person-button.dragging .add-person-button-tooltip {
opacity: 0;
}
/* Instructions drag (affichées pendant drag) */
.add-person-drag-hint {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 1050;
padding: 10px 18px;
background: rgba(242, 239, 234, 0.98);
backdrop-filter: blur(24px);
border-radius: 50px;
border: 1px solid rgba(147, 64, 42, 0.2);
font-size: 14px;
font-weight: 450;
color: #2A2622;
box-shadow:
0 6px 24px rgba(42, 38, 34, 0.18),
0 2px 8px rgba(42, 38, 34, 0.08);
animation: hintSlideDown 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes hintSlideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-10px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
/* Responsive mobile */
@media (max-width: 768px) {
.add-person-button {
width: 56px;
height: 56px;
bottom: 20px;
left: 20px;
}
.add-person-button-icon {
font-size: 28px;
}
.add-person-button-tooltip {
display: none;
}
}
@@ -1,84 +0,0 @@
import { useRef, useEffect, useState } from 'react'
import { Plus, Baby, Heart, Users, MapPin } from 'lucide-react'
import './AddPersonButton.css'
import { t } from '../i18n/index.js'
/**
* Bouton FAB draggable pour ajouter une personne
* Utilisé en mode édition pour drag-and-drop dans la galaxie
*
* @param {Object} props
* @param {boolean} props.isDragging - État dragging (du hook)
* @param {Object} props.dragPosition - Position drag {x, y}
* @param {Function} props.onDragStart - Handler début drag
* @param {string} props.linkType - Type de lien détecté ('child', 'spouse', 'sibling')
*/
function AddPersonButton({ isDragging, dragPosition, onDragStart, linkType }) {
const buttonRef = useRef(null)
const [entering, setEntering] = useState(true)
// Animation d'entrée
useEffect(() => {
const timer = setTimeout(() => setEntering(false), 500)
return () => clearTimeout(timer)
}, [])
const handleMouseDown = (e) => {
e.preventDefault()
if (!buttonRef.current) return
const rect = buttonRef.current.getBoundingClientRect()
const buttonPosition = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
}
onDragStart?.(e, buttonPosition)
}
// Position inline si dragging
const style = isDragging
? {
left: `${dragPosition.x - 32}px`,
top: `${dragPosition.y - 32}px`,
transition: 'none',
}
: {}
// Message tooltip selon linkType
let tooltipText = t('Glisser pour ajouter une personne')
if (isDragging) {
if (linkType === 'child') tooltipText = t('Ajouter un enfant')
else if (linkType === 'spouse') tooltipText = t('Ajouter un conjoint')
else if (linkType === 'sibling') tooltipText = t('Ajouter un frère/sœur')
else tooltipText = t('Relâcher pour créer')
}
return (
<>
<button
ref={buttonRef}
type="button"
className={`add-person-button ${isDragging ? 'dragging' : ''} ${entering ? 'entering' : ''}`}
style={style}
onMouseDown={handleMouseDown}
aria-label={t('Ajouter une personne')}
>
<span className="add-person-button-icon"><Plus size={28} strokeWidth={1.5} /></span>
<span className="add-person-button-tooltip">{tooltipText}</span>
</button>
{/* Hint pendant drag */}
{isDragging && (
<div className="add-person-drag-hint">
{linkType === 'child' && <><Baby size={16} strokeWidth={2} /> {t('Enfant')}</>}
{linkType === 'spouse' && <><Heart size={16} strokeWidth={2} /> {t('Conjoint')}</>}
{linkType === 'sibling' && <><Users size={16} strokeWidth={2} /> {t('Frère·Sœur')}</>}
{!linkType && <><MapPin size={16} strokeWidth={2} /> {t('Positionnez la personne')}</>}
</div>
)}
</>
)
}
export default AddPersonButton
@@ -1,261 +0,0 @@
/* ============================================================
CreateMyTreeButton — Pill + Panel morph, bas-droite
Même langage visuel que la ContextualNavbar
============================================================ */
/* ----------------------------------------------------------
Pill bouton (état fermé) — même aesthetic que la navbar
---------------------------------------------------------- */
.cmt-pill {
position: fixed;
bottom: 100px;
right: 20px;
z-index: 1001;
display: flex;
align-items: center;
padding: 13px 22px;
border: 1px solid rgba(42, 38, 34, 0.1);
border-radius: 50px;
background: rgba(242, 239, 234, 0.97);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
box-shadow:
0 4px 40px rgba(42, 38, 34, 0.22),
0 1px 6px rgba(42, 38, 34, 0.1);
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 14px;
font-weight: 500;
color: #2A2622;
cursor: pointer;
white-space: nowrap;
animation: cmtPillIn 0.5s cubic-bezier(0.22, 1, 0.36, 1);
transition: box-shadow 0.2s ease, transform 0.2s ease, background 0.2s ease;
}
@keyframes cmtPillIn {
from {
opacity: 0;
transform: translateY(12px) scale(0.9);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.cmt-pill:hover {
background: rgba(242, 239, 234, 1);
box-shadow:
0 8px 48px rgba(42, 38, 34, 0.28),
0 2px 8px rgba(42, 38, 34, 0.12);
transform: translateY(-2px);
}
.cmt-pill:active {
transform: translateY(0) scale(0.97);
}
/* ----------------------------------------------------------
Panel (état ouvert) — card qui morphe depuis la pill
---------------------------------------------------------- */
.cmt-panel {
position: fixed;
bottom: 100px;
right: 20px;
z-index: 1001;
width: 300px;
background: rgba(255, 255, 255, 0.97);
backdrop-filter: blur(28px);
-webkit-backdrop-filter: blur(28px);
border-radius: 20px;
border: 1px solid rgba(147, 64, 42, 0.18);
box-shadow:
0 12px 48px rgba(42, 38, 34, 0.22),
0 2px 10px rgba(42, 38, 34, 0.1);
transform-origin: bottom right;
animation: cmtMorphIn 0.38s cubic-bezier(0.22, 1, 0.36, 1);
overflow: hidden;
}
@keyframes cmtMorphIn {
0% {
opacity: 0;
transform: scale(0.55) translateY(8px);
border-radius: 50px;
}
60% {
border-radius: 22px;
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
border-radius: 20px;
}
}
/* Header */
.cmt-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px 12px;
border-bottom: 1px solid rgba(147, 64, 42, 0.1);
}
.cmt-panel-title {
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 13px;
font-weight: 600;
color: #2A2622;
line-height: 1.3;
}
.cmt-panel-close {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: none;
border-radius: 50%;
background: rgba(42, 38, 34, 0.07);
color: #6B645D;
font-size: 12px;
cursor: pointer;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.cmt-panel-close:hover {
background: rgba(42, 38, 34, 0.13);
color: #2A2622;
}
/* Body */
.cmt-panel-body {
padding: 16px 18px 18px;
}
.cmt-label {
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 12px;
color: #6B645D;
margin-bottom: 7px;
letter-spacing: 0.02em;
}
.cmt-input {
width: 100%;
box-sizing: border-box;
border-radius: 12px;
border: 1px solid rgba(147, 64, 42, 0.25);
background: rgba(255, 255, 255, 0.9);
color: #2a2622;
padding: 10px 13px;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 14px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.cmt-input:focus {
border-color: #d2694a;
box-shadow: 0 0 0 3px rgba(210, 105, 74, 0.12);
}
.cmt-slug-preview {
margin-top: 8px;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 11px;
color: #93402A;
}
.cmt-slug-preview code {
background: rgba(147, 64, 42, 0.1);
border-radius: 4px;
padding: 1px 5px;
color: #2a2622;
}
.cmt-error {
margin-top: 8px;
background: rgba(171, 49, 49, 0.08);
border: 1px solid rgba(171, 49, 49, 0.25);
color: #862f2f;
border-radius: 10px;
padding: 9px 12px;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 12px;
}
.cmt-btn-create {
margin-top: 14px;
width: 100%;
padding: 11px 16px;
border-radius: 12px;
border: none;
background: #2a2622;
color: #ffffff;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s, transform 0.15s;
}
.cmt-btn-create:hover:not(:disabled) {
background: linear-gradient(135deg, #93402a, #2a2622);
box-shadow: 0 4px 14px rgba(147, 64, 42, 0.32);
transform: translateY(-1px);
}
.cmt-btn-create:active:not(:disabled) {
transform: translateY(0);
}
.cmt-btn-create:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ----------------------------------------------------------
Mobile — bottom sheet
---------------------------------------------------------- */
@media (max-width: 768px) {
.cmt-pill {
bottom: 84px;
right: 12px;
font-size: 13px;
padding: 11px 18px;
}
.cmt-panel {
bottom: 0;
right: 0;
left: 0;
width: 100%;
border-radius: 20px 20px 0 0;
transform-origin: bottom center;
}
@keyframes cmtMorphIn {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.cmt-panel-body {
padding: 16px 20px 32px;
}
}
@@ -1,93 +0,0 @@
import { useState, useMemo } from 'react'
import './CreateMyTreeButton.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
function normalizeSlug(input) {
return input
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
}
const CreateMyTreeButton = ({ onCreateTree, loading }) => {
const [open, setOpen] = useState(false)
const [name, setName] = useState('')
const [error, setError] = useState('')
const slug = useMemo(() => normalizeSlug(name.trim()), [name])
const canSubmit = name.trim().length >= 2 && slug.length >= 3 && !loading
const handleOpen = () => {
setError('')
setOpen(true)
}
const handleClose = () => {
setOpen(false)
setName('')
setError('')
}
const handleSubmit = async (e) => {
e.preventDefault()
if (!canSubmit) return
setError('')
const result = await onCreateTree(name.trim())
if (result !== null && result !== undefined) {
setError(typeof result === 'string' ? result : t('Erreur lors de la création. Essayez un autre nom.'))
}
}
return (
<>
{!open && (
<button className="cmt-pill" type="button" onClick={handleOpen}>
{t('Créer mon arbre')}
</button>
)}
{open && (
<div className="cmt-panel">
<div className="cmt-panel-header">
<span className="cmt-panel-title">{t('Créer votre arbre dès maintenant')}</span>
<button type="button" className="cmt-panel-close" onClick={handleClose} aria-label={t('Fermer')}>
✕
</button>
</div>
<div className="cmt-panel-body">
<form onSubmit={handleSubmit}>
<label className="cmt-label" htmlFor="firstTreeName">
{t('Nom de la famille')}
</label>
<input
id="firstTreeName"
className="cmt-input"
type="text"
autoFocus
maxLength={120}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('Ex : Famille Martin')}
required
/>
{slug && (
<div className="cmt-slug-preview">
{t('Adresse :')} <code>{slug}</code>
</div>
)}
{error && <div className="cmt-error">{error}</div>}
<button type="submit" className="cmt-btn-create" disabled={!canSubmit}>
<PzBusy busy={loading} busyLabel={t("Création de l'arbre")}>{t("Créer l'arbre")}</PzBusy>
</button>
</form>
</div>
</div>
)}
</>
)
}
export default CreateMyTreeButton
@@ -1,50 +0,0 @@
import { useMemo } from 'react'
import { X } from 'lucide-react'
import './EditModeOverlay.css'
import { t } from '../i18n/index.js'
function EditModeOverlay({ active, hasDraft, role = 'contributor', onDeactivate, onSubmit }) {
const isAdmin = role === 'admin'
const borderClass = useMemo(() => (
`edit-mode-border ${isAdmin ? 'edit-mode-border--admin' : 'edit-mode-border--contributor'}`
), [isAdmin])
const toastLabel = isAdmin ? t('Mode édition actif') : t('Mode contribution actif')
const submitLabel = isAdmin ? t("Mettre à jour l'arbre") : t('Envoyer les modifications')
if (!active) return null
return (
<>
<div className={borderClass} aria-hidden="true" />
<div className={`edit-mode-toast ${isAdmin ? 'edit-mode-toast--admin' : ''}`} role="status" aria-live="polite">
<span className="edit-mode-toast-text">{toastLabel}</span>
{onSubmit && (
<button
type="button"
className={`edit-mode-submit ${isAdmin ? 'edit-mode-submit--admin' : ''}`}
onClick={onSubmit}
disabled={!hasDraft}
title={hasDraft ? submitLabel : t('Aucune modification à soumettre')}
>
{submitLabel}
</button>
)}
<button
type="button"
className="edit-mode-toast-close"
onClick={onDeactivate}
aria-label={t('Quitter le mode édition')}
>
<X size={16} strokeWidth={2} />
</button>
</div>
</>
)
}
export default EditModeOverlay
@@ -1,156 +0,0 @@
import { useMemo, useState } from 'react'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
const EMPTY_FORM = {
selfFirstName: '',
selfLastName: '',
selfBirthYear: '',
parent1FirstName: '',
parent1LastName: '',
parent2FirstName: '',
parent2LastName: '',
}
function toDateFromYear(yearText) {
const year = Number.parseInt(yearText, 10)
if (!Number.isFinite(year) || year < 1000 || year > 2999) {
return null
}
return `${year.toString().padStart(4, '0')}-01-01`
}
const InitialTreeWizard = ({ loading, errorMessage, onSubmit, onSkip }) => {
const [form, setForm] = useState(EMPTY_FORM)
const canSubmit = useMemo(() => {
return (
form.selfFirstName.trim().length > 0 &&
form.selfLastName.trim().length > 0 &&
form.parent1FirstName.trim().length > 0 &&
form.parent1LastName.trim().length > 0 &&
form.parent2FirstName.trim().length > 0 &&
form.parent2LastName.trim().length > 0 &&
!loading
)
}, [form, loading])
const updateField = (key, value) => {
setForm((current) => ({ ...current, [key]: value }))
}
const handleSubmit = async (event) => {
event.preventDefault()
if (!canSubmit) return
await onSubmit({
self: {
firstName: form.selfFirstName.trim(),
lastName: form.selfLastName.trim(),
birthDate: toDateFromYear(form.selfBirthYear.trim()),
},
parent1: {
firstName: form.parent1FirstName.trim(),
lastName: form.parent1LastName.trim(),
},
parent2: {
firstName: form.parent2FirstName.trim(),
lastName: form.parent2LastName.trim(),
},
})
}
return (
<div className="wizard-overlay">
<form className="wizard-card" onSubmit={handleSubmit}>
<div className="wizard-persons">
<div className="wizard-person">
<div className="wizard-person-label wizard-person-label--self">{t('Vous')}</div>
<div className="wizard-person-fields">
<input
type="text"
placeholder={t('Prénom')}
value={form.selfFirstName}
onChange={(e) => updateField('selfFirstName', e.target.value)}
required
/>
<input
type="text"
placeholder={t('Nom')}
value={form.selfLastName}
onChange={(e) => updateField('selfLastName', e.target.value)}
required
/>
</div>
<input
className="wizard-year"
type="number"
placeholder={t('Année de naissance (optionnel)')}
min="1000"
max="2999"
value={form.selfBirthYear}
onChange={(e) => updateField('selfBirthYear', e.target.value)}
/>
</div>
<div className="wizard-person">
<div className="wizard-person-label">{t('Parent 1')}</div>
<div className="wizard-person-fields">
<input
type="text"
placeholder={t('Prénom')}
value={form.parent1FirstName}
onChange={(e) => updateField('parent1FirstName', e.target.value)}
required
/>
<input
type="text"
placeholder={t('Nom')}
value={form.parent1LastName}
onChange={(e) => updateField('parent1LastName', e.target.value)}
required
/>
</div>
</div>
<div className="wizard-person">
<div className="wizard-person-label">{t('Parent 2')}</div>
<div className="wizard-person-fields">
<input
type="text"
placeholder={t('Prénom')}
value={form.parent2FirstName}
onChange={(e) => updateField('parent2FirstName', e.target.value)}
required
/>
<input
type="text"
placeholder={t('Nom')}
value={form.parent2LastName}
onChange={(e) => updateField('parent2LastName', e.target.value)}
required
/>
</div>
</div>
</div>
{errorMessage && <div className="wizard-error">{errorMessage}</div>}
<button type="submit" disabled={!canSubmit}>
<PzBusy busy={loading} busyLabel={t('Création en cours')}>{t('Créer les 3 personnes')}</PzBusy>
</button>
<button type="button" className="ghost" onClick={onSkip} disabled={loading}>
{t('Plus tard')}
</button>
</form>
</div>
)
}
export default InitialTreeWizard
@@ -1,262 +0,0 @@
/* ============================================================
Inline Person Form - Mini-formulaire après drop
============================================================ */
.inline-person-form-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(42, 38, 34, 0.4);
backdrop-filter: blur(4px);
z-index: 1200;
animation: overlayFadeIn 0.2s ease;
}
@keyframes overlayFadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.inline-person-form {
position: fixed;
padding: 24px;
background: rgba(242, 239, 234, 0.98);
backdrop-filter: blur(32px);
-webkit-backdrop-filter: blur(32px);
border-radius: 16px;
border: 1px solid rgba(42, 38, 34, 0.12);
box-shadow:
0 12px 48px rgba(42, 38, 34, 0.3),
0 4px 16px rgba(42, 38, 34, 0.15);
min-width: 320px;
max-width: 400px;
z-index: 1201;
animation: formPopIn 0.3s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes formPopIn {
from {
opacity: 0;
transform: translate(-50%, -45%) scale(0.9);
}
to {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
/* Header */
.inline-person-form-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid rgba(42, 38, 34, 0.1);
}
.inline-person-form-title {
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 18px;
font-weight: 700;
color: #2A2622;
display: flex;
align-items: center;
gap: 10px;
}
.inline-person-form-icon {
font-size: 24px;
}
.inline-person-form-close {
width: 32px;
height: 32px;
border-radius: 50%;
border: none;
background: transparent;
color: #6B645D;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.inline-person-form-close:hover {
background: rgba(42, 38, 34, 0.1);
color: #2A2622;
transform: scale(1.1);
}
/* Badge lien créé */
.inline-person-form-badge {
display: inline-block;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 16px;
}
.inline-person-form-badge.child {
background: rgba(76, 217, 100, 0.15);
color: #4CD964;
}
.inline-person-form-badge.spouse {
background: rgba(255, 59, 48, 0.15);
color: #FF3B30;
}
.inline-person-form-badge.sibling {
background: rgba(210, 105, 74, 0.15);
color: #93402A;
}
/* Fields */
.inline-person-form-fields {
display: flex;
flex-direction: column;
gap: 14px;
margin-bottom: 20px;
}
.inline-person-form-field {
display: flex;
flex-direction: column;
gap: 6px;
}
.inline-person-form-label {
font-size: 14px;
font-weight: 450;
color: #2A2622;
letter-spacing: 0.2px;
}
.inline-person-form-label .required {
color: #FF3B30;
margin-left: 2px;
}
.inline-person-form-input {
padding: 10px 14px;
border-radius: 10px;
border: 1.5px solid rgba(42, 38, 34, 0.15);
background: rgba(255, 255, 255, 0.6);
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 16px; /* ≥16px prevents iOS Safari from auto-zooming on focus */
color: #2A2622;
transition: all 0.2s ease;
}
.inline-person-form-input:focus {
outline: none;
border-color: #93402A;
background: rgba(255, 255, 255, 0.9);
box-shadow: 0 0 0 3px rgba(147, 64, 42, 0.1);
}
.inline-person-form-input::placeholder {
color: #6B645D;
opacity: 0.6;
}
/* Row (2 champs côte-à-côte) */
.inline-person-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
/* Actions */
.inline-person-form-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
}
.inline-person-form-button {
padding: 10px 20px;
border-radius: 10px;
border: none;
font-family: 'DM Sans', system-ui, sans-serif;
font-size: 14px;
font-weight: 450;
cursor: pointer;
transition: all 0.2s ease;
}
.inline-person-form-button.cancel {
background: transparent;
color: #6B645D;
}
.inline-person-form-button.cancel:hover {
background: rgba(42, 38, 34, 0.08);
color: #2A2622;
}
.inline-person-form-button.submit {
background: #2a2622;
color: #FFFFFF;
box-shadow: 0 4px 12px rgba(147, 64, 42, 0.3);
}
.inline-person-form-button.submit:hover {
transform: translateY(-2px);
box-shadow: 0 6px 16px rgba(147, 64, 42, 0.4);
}
.inline-person-form-button.submit:active {
transform: translateY(0);
}
.inline-person-form-button.submit:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* Loading state */
.inline-person-form.loading {
pointer-events: none;
opacity: 0.7;
}
/* Responsive mobile */
@media (max-width: 768px) {
.inline-person-form {
left: 12px;
right: 12px;
top: 50%;
transform: translateY(-50%);
min-width: auto;
max-width: none;
}
@keyframes formPopIn {
from {
opacity: 0;
transform: translateY(-45%) scale(0.9);
}
to {
opacity: 1;
transform: translateY(-50%) scale(1);
}
}
}
@@ -1,215 +0,0 @@
import { useState, useEffect, useRef } from 'react'
import { UserPlus, X } from 'lucide-react'
import './InlinePersonForm.css'
import PzBusy from './PzBusy.jsx'
import { t } from '../i18n/index.js'
/**
* Mini-formulaire inline pour créer une personne après drop
* Affiche le type de lien créé et demande les infos de base
*
* @param {Object} props
* @param {boolean} props.visible - Afficher le formulaire
* @param {Object} props.position - Position d'affichage {x, y}
* @param {string} props.linkType - Type de lien ('child', 'spouse', 'sibling', null)
* @param {Object} props.linkedPerson - Personne liée (si linkType)
* @param {Function} props.onSubmit - Callback soumission (formData)
* @param {Function} props.onCancel - Callback annulation
* @param {boolean} props.loading - État chargement
*/
function InlinePersonForm({
visible,
position,
linkType,
linkedPerson,
onSubmit,
onCancel,
loading = false,
}) {
const [formData, setFormData] = useState({
firstName: '',
lastName: '',
birthDate: '',
birthPlace: '',
})
const firstInputRef = useRef(null)
// Reset form au show
useEffect(() => {
if (visible) {
setFormData({
firstName: '',
lastName: '',
birthDate: '',
birthPlace: '',
})
// Focus premier input
setTimeout(() => firstInputRef.current?.focus(), 100)
}
}, [visible])
// Handle ESC
useEffect(() => {
if (!visible) return
const handleEscape = (e) => {
if (e.key === 'Escape') onCancel?.()
}
document.addEventListener('keydown', handleEscape)
return () => document.removeEventListener('keydown', handleEscape)
}, [visible, onCancel])
if (!visible) return null
const handleChange = (field, value) => {
setFormData((prev) => ({ ...prev, [field]: value }))
}
const handleSubmit = (e) => {
e.preventDefault()
if (!formData.firstName.trim()) return
onSubmit?.({
...formData,
linkType,
linkedPersonId: linkedPerson?.id,
})
}
const isValid = formData.firstName.trim().length > 0
// Labels type lien
const linkLabels = {
child: (name) => t('Enfant de {name}', { name }),
spouse: (name) => t('Conjoint de {name}', { name }),
sibling: (name) => t('Frère·Sœur de {name}', { name }),
}
const linkTitle = {
child: t('Nouvel enfant'),
spouse: t('Nouveau conjoint'),
sibling: t('Nouveau frère/sœur'),
}
const title = linkType ? linkTitle[linkType] : t('Nouvelle personne')
return (
<>
{/* Overlay */}
<div className="inline-person-form-overlay" onClick={onCancel} />
{/* Form */}
<form
className={`inline-person-form ${loading ? 'loading' : ''}`}
style={{
left: `${position.x}px`,
top: `${position.y}px`,
transform: 'translate(-50%, -50%)',
}}
onSubmit={handleSubmit}
>
{/* Header */}
<div className="inline-person-form-header">
<h3 className="inline-person-form-title">
<span className="inline-person-form-icon"><UserPlus size={20} strokeWidth={2} /></span>
{title}
</h3>
<button
type="button"
className="inline-person-form-close"
onClick={onCancel}
aria-label={t('Fermer')}
>
<X size={18} strokeWidth={2} />
</button>
</div>
{/* Badge lien */}
{linkType && linkedPerson && (
<div className={`inline-person-form-badge ${linkType}`}>
{linkLabels[linkType]?.([linkedPerson.person?.firstName, linkedPerson.person?.lastName].filter(Boolean).join(' '))}
</div>
)}
{/* Fields */}
<div className="inline-person-form-fields">
<div className="inline-person-form-field">
<label className="inline-person-form-label">
{t('Prénom')} <span className="required">*</span>
</label>
<input
ref={firstInputRef}
type="text"
className="inline-person-form-input"
placeholder={t('Jean')}
value={formData.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
disabled={loading}
/>
</div>
<div className="inline-person-form-field">
<label className="inline-person-form-label">{t('Nom')}</label>
<input
type="text"
className="inline-person-form-input"
placeholder={t('Dupont')}
value={formData.lastName}
onChange={(e) => handleChange('lastName', e.target.value)}
disabled={loading}
/>
</div>
<div className="inline-person-form-row">
<div className="inline-person-form-field">
<label className="inline-person-form-label">{t('Date naissance')}</label>
<input
type="text"
className="inline-person-form-input"
placeholder="1990-05-15"
value={formData.birthDate}
onChange={(e) => handleChange('birthDate', e.target.value)}
disabled={loading}
/>
</div>
<div className="inline-person-form-field">
<label className="inline-person-form-label">{t('Lieu naissance')}</label>
<input
type="text"
className="inline-person-form-input"
placeholder="Paris"
value={formData.birthPlace}
onChange={(e) => handleChange('birthPlace', e.target.value)}
disabled={loading}
/>
</div>
</div>
</div>
{/* Actions */}
<div className="inline-person-form-actions">
<button
type="button"
className="inline-person-form-button cancel"
onClick={onCancel}
disabled={loading}
>
{t('Annuler')}
</button>
<button
type="submit"
className="inline-person-form-button submit"
disabled={!isValid || loading}
>
<PzBusy busy={loading} busyLabel={t('Création en cours')}>{t('Créer')}</PzBusy>
</button>
</div>
</form>
</>
)
}
export default InlinePersonForm
@@ -1,146 +0,0 @@
/* ============================================================
Proximity Feedback - Feedback visuel drag proximity
============================================================ */
/* Ligne pointillée entre drag et personne proche */
.proximity-line {
position: fixed;
pointer-events: none;
z-index: 1000;
}
.proximity-line-svg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.proximity-line-path {
fill: none;
stroke-width: 2;
stroke-dasharray: 6, 4;
stroke-linecap: round;
animation: proximityLineDash 1s linear infinite;
}
.proximity-line-path.child {
stroke: rgba(76, 217, 100, 0.8);
}
.proximity-line-path.spouse {
stroke: rgba(255, 59, 48, 0.7);
}
.proximity-line-path.sibling {
stroke: rgba(210, 105, 74, 0.7);
}
.proximity-line-path.none {
stroke: rgba(142, 142, 147, 0.5);
}
@keyframes proximityLineDash {
to {
stroke-dashoffset: -10;
}
}
/* Badge type de lien sur la ligne */
.proximity-badge {
position: fixed;
padding: 6px 12px;
border-radius: 12px;
font-size: 14px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
white-space: nowrap;
pointer-events: none;
z-index: 1001;
box-shadow: 0 4px 12px rgba(42, 38, 34, 0.15);
animation: badgePulse 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes badgePulse {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.8);
}
100% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
}
.proximity-badge.child {
background: rgba(76, 217, 100, 0.95);
color: white;
}
.proximity-badge.spouse {
background: rgba(255, 59, 48, 0.95);
color: white;
}
.proximity-badge.sibling {
background: rgba(210, 105, 74, 0.95);
color: white;
}
/* Highlight glow sur la personne proche */
.proximity-highlight {
position: fixed;
pointer-events: none;
z-index: 999;
}
.proximity-highlight-glow {
width: 100px;
height: 100px;
border-radius: 50%;
opacity: 0.6;
animation: proximityGlow 1.5s ease-in-out infinite;
}
.proximity-highlight-glow.child {
background: radial-gradient(
circle,
rgba(76, 217, 100, 0.4) 0%,
rgba(76, 217, 100, 0.2) 50%,
transparent 100%
);
}
.proximity-highlight-glow.spouse {
background: radial-gradient(
circle,
rgba(255, 59, 48, 0.35) 0%,
rgba(255, 59, 48, 0.15) 50%,
transparent 100%
);
}
.proximity-highlight-glow.sibling {
background: radial-gradient(
circle,
rgba(210, 105, 74, 0.35) 0%,
rgba(210, 105, 74, 0.15) 50%,
transparent 100%
);
}
@keyframes proximityGlow {
0%, 100% {
transform: scale(1);
opacity: 0.6;
}
50% {
transform: scale(1.15);
opacity: 0.8;
}
}
@@ -1,72 +0,0 @@
import { t } from '../i18n/index.js'
import './ProximityFeedback.css'
/**
* Feedback visuel de proximité pendant le drag
* Affiche ligne pointillée + glow sur personne proche + badge type lien
*
* @param {Object} props
* @param {boolean} props.visible - Afficher le feedback
* @param {Object} props.dragPosition - Position drag {x, y}
* @param {Object} props.closestPerson - Personne proche {id, x, y, person}
* @param {string} props.linkType - Type de lien ('child', 'spouse', 'sibling')
*/
function ProximityFeedback({ visible, dragPosition, closestPerson, linkType }) {
if (!visible || !closestPerson || !linkType) return null
const { x: personX, y: personY } = closestPerson
const { x: dragX, y: dragY } = dragPosition
// Position badge au milieu de la ligne
const badgeX = (dragX + personX) / 2
const badgeY = (dragY + personY) / 2
// Labels
const linkLabels = {
child: t('Enfant'),
spouse: t('Conjoint'),
sibling: t('Frère·Sœur'),
}
return (
<>
{/* Ligne pointillée */}
<div className="proximity-line">
<svg className="proximity-line-svg">
<line
x1={dragX}
y1={dragY}
x2={personX}
y2={personY}
className={`proximity-line-path ${linkType}`}
/>
</svg>
</div>
{/* Badge type lien */}
<div
className={`proximity-badge ${linkType}`}
style={{
left: `${badgeX}px`,
top: `${badgeY}px`,
transform: 'translate(-50%, -50%)',
}}
>
{linkLabels[linkType] || t('Lien')}
</div>
{/* Glow sur personne proche */}
<div
className="proximity-highlight"
style={{
left: `${personX - 50}px`,
top: `${personY - 50}px`,
}}
>
<div className={`proximity-highlight-glow ${linkType}`} />
</div>
</>
)
}
export default ProximityFeedback
@@ -1,91 +0,0 @@
.drop-trash-zone {
position: fixed;
top: 24px;
left: 50%;
transform: translateX(-50%);
z-index: 1060;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 32px 14px;
border-radius: 20px;
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 2px dashed rgba(147, 64, 42, 0.3);
box-shadow:
0 8px 40px rgba(42, 38, 34, 0.2),
0 2px 8px rgba(42, 38, 34, 0.08);
animation: trashSlideDown 0.35s cubic-bezier(0.22, 1, 0.36, 1);
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
@keyframes trashSlideDown {
from {
opacity: 0;
transform: translateX(-50%) translateY(-20px) scale(0.85);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0) scale(1);
}
}
.drop-trash-zone__circle {
width: 52px;
height: 52px;
border-radius: 50%;
background: rgba(147, 64, 42, 0.1);
border: 2px solid rgba(147, 64, 42, 0.2);
display: flex;
align-items: center;
justify-content: center;
color: #6B645D;
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.drop-trash-zone__icon {
transition: all 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.drop-trash-zone__label {
font-size: 14px;
font-weight: 450;
color: #2A2622;
transition: all 0.2s ease;
white-space: nowrap;
}
/* --- Hover state: over trash --- */
.drop-trash-zone--over {
border-color: #C0392B;
background: rgba(192, 57, 43, 0.08);
transform: translateX(-50%) scale(1.08);
box-shadow:
0 12px 48px rgba(192, 57, 43, 0.25),
0 4px 12px rgba(192, 57, 43, 0.12);
}
.drop-trash-zone--over .drop-trash-zone__circle {
width: 64px;
height: 64px;
background: rgba(192, 57, 43, 0.12);
border-color: #C0392B;
color: #C0392B;
animation: trashPulse 0.8s ease infinite;
}
@keyframes trashPulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(192, 57, 43, 0.3);
}
50% {
box-shadow: 0 0 0 10px rgba(192, 57, 43, 0);
}
}
.drop-trash-zone--over .drop-trash-zone__label {
color: #C0392B;
font-weight: 700;
}
@@ -1,22 +0,0 @@
import { Trash2 } from 'lucide-react'
import { t } from '../../i18n/index.js'
import './DropTrashZone.css'
export default function DropTrashZone({ visible, isOver }) {
if (!visible) return null
return (
<div className={`drop-trash-zone${isOver ? ' drop-trash-zone--over' : ''}`}>
<div className="drop-trash-zone__circle">
<Trash2
size={isOver ? 28 : 22}
strokeWidth={2}
className="drop-trash-zone__icon"
/>
</div>
<span className="drop-trash-zone__label">
{isOver ? t('Relâcher pour supprimer') : t('Glisser ici pour supprimer')}
</span>
</div>
)
}
-183
View File
@@ -1,183 +0,0 @@
import { useState, useRef, useCallback, useEffect } from 'react'
/**
* Seuils de proximité pour détection de liens
*/
const PROXIMITY_THRESHOLDS = {
CHILD: 150, // Pixels en dessous pour détecter lien enfant
SPOUSE: 120, // Pixels côte-à-côte pour détecter lien conjoint
SIBLING: 100, // Pixels même niveau pour détecter lien frère/sœur
}
/**
* Détecte le type de lien selon position relative
* @param {Object} dragPos - Position drag {x, y}
* @param {Object} personPos - Position personne proche {x, y}
* @param {number} distance - Distance euclidienne
* @returns {string|null} Type de lien ('child', 'spouse', 'sibling') ou null
*/
function detectLinkType(dragPos, personPos, distance) {
const dx = dragPos.x - personPos.x
const dy = dragPos.y - personPos.y
// Angle pour déterminer direction
// ENFANT : en dessous (angle ~90°, dy > 0)
if (dy > 50 && Math.abs(dx) < PROXIMITY_THRESHOLDS.CHILD && distance < PROXIMITY_THRESHOLDS.CHILD) {
return 'child'
}
// CONJOINT : à côté (angle ~0° ou ~180°, dy faible)
if (Math.abs(dy) < 60 && Math.abs(dx) < PROXIMITY_THRESHOLDS.SPOUSE && distance < PROXIMITY_THRESHOLDS.SPOUSE) {
return 'spouse'
}
// FRÈRE/SŒUR : même niveau vertical (dy faible mais dx moyen)
if (Math.abs(dy) < 80 && Math.abs(dx) < PROXIMITY_THRESHOLDS.SIBLING + 50 && distance < PROXIMITY_THRESHOLDS.SIBLING + 50) {
return 'sibling'
}
return null
}
/**
* Hook pour gérer le drag-and-drop d'ajout de personne
* Détecte automatiquement la proximité avec personnes existantes
*
* @param {Object} options
* @param {Function} options.getPersonPositions - Fonction qui retourne [{id, x, y, person}]
* @param {Function} options.onDrop - Callback au drop (dragPos, closestPerson, linkType)
* @returns {Object} API du drag
*/
export function useAddPersonDrag({ getPersonPositions, onDrop }) {
const [isDragging, setIsDragging] = useState(false)
const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 })
const [closestPerson, setClosestPerson] = useState(null)
const [linkType, setLinkType] = useState(null)
const dragStartPos = useRef({ x: 0, y: 0 })
const buttonStartPos = useRef({ x: 0, y: 0 })
/**
* Démarre le drag
*/
const handleDragStart = useCallback((event, buttonPosition) => {
setIsDragging(true)
dragStartPos.current = {
x: event.clientX,
y: event.clientY,
}
buttonStartPos.current = buttonPosition
setDragPosition(buttonPosition)
}, [])
/**
* Update position pendant le drag + détection proximité
*/
const handleDragMove = useCallback((event) => {
if (!isDragging) return
const dx = event.clientX - dragStartPos.current.x
const dy = event.clientY - dragStartPos.current.y
const newPos = {
x: buttonStartPos.current.x + dx,
y: buttonStartPos.current.y + dy,
}
setDragPosition(newPos)
// Détection proximité
const personPositions = getPersonPositions?.() || []
if (personPositions.length === 0) {
setClosestPerson(null)
setLinkType(null)
return
}
// Trouver la personne la plus proche
let minDistance = Infinity
let closest = null
for (const personPos of personPositions) {
const dx = newPos.x - personPos.x
const dy = newPos.y - personPos.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < minDistance) {
minDistance = distance
closest = personPos
}
}
// Si une personne proche trouvée, déterminer le type de lien
if (closest && minDistance < 200) {
const detectedLinkType = detectLinkType(newPos, closest, minDistance)
setClosestPerson(closest)
setLinkType(detectedLinkType)
} else {
setClosestPerson(null)
setLinkType(null)
}
}, [isDragging, getPersonPositions])
/**
* Termine le drag
*/
const handleDragEnd = useCallback(() => {
if (!isDragging) return
setIsDragging(false)
// Callback avec infos drop
onDrop?.({
position: dragPosition,
closestPerson,
linkType,
})
// Reset
setClosestPerson(null)
setLinkType(null)
}, [isDragging, dragPosition, closestPerson, linkType, onDrop])
/**
* Cancel drag (ESC)
*/
const handleDragCancel = useCallback(() => {
setIsDragging(false)
setClosestPerson(null)
setLinkType(null)
setDragPosition({ x: 0, y: 0 })
}, [])
// Listeners globaux pour drag
useEffect(() => {
if (!isDragging) return
const handleMouseMove = (e) => handleDragMove(e)
const handleMouseUp = () => handleDragEnd()
const handleEscape = (e) => {
if (e.key === 'Escape') handleDragCancel()
}
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
document.addEventListener('keydown', handleEscape)
return () => {
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
document.removeEventListener('keydown', handleEscape)
}
}, [isDragging, handleDragMove, handleDragEnd, handleDragCancel])
return {
isDragging,
dragPosition,
closestPerson,
linkType,
handleDragStart,
handleDragCancel,
}
}
+1 -1
View File
@@ -197,7 +197,7 @@ export default {
'Création en cours': 'Creating',
'Créer': 'Create',
// Création d'un arbre (InitialTreeWizard, TreeCreationWizard, CreateMyTreeButton)
// Création d'un arbre (TreeCreationWizard)
'Vous': 'You',
'Année de naissance (optionnel)': 'Year of birth (optional)',
'Parent 1': 'Parent 1',
-108
View File
@@ -1,108 +0,0 @@
/**
* Adaptateur pour transformer les données de l'API backend
* vers le format attendu par le composant Galaxy
*/
import { t } from '../i18n/index.js'
/**
* Convertit une date ISO (YYYY-MM-DD) en année
* @param {string | null} isoDate - Date au format ISO
* @returns {number | null} - Année ou null
*/
function dateToYear(isoDate) {
if (!isoDate) return null
const year = parseInt(isoDate.split('-')[0], 10)
return isNaN(year) ? null : year
}
/**
* Détermine si une personne est vivante
* @param {string | null} deathDate - Date de décès
* @returns {boolean}
*/
function isAlive(deathDate) {
return !deathDate
}
/**
* Adapte les données du graphe API vers le format Galaxy
* @param {Object} apiGraph - Graphe depuis l'API { persons, unions, filiations, medias }
* @param {string} treeId - ID de l'arbre pour construire les URLs
* @returns {Object} - Format compatible Galaxy { persons, unions, filiations, medias }
*/
export function adaptGraphForGalaxy(apiGraph, _treeId) {
if (!apiGraph) {
return {
persons: [],
unions: [],
filiations: [],
medias: [],
}
}
const { persons = [], unions = [], filiations = [], medias = [] } = apiGraph
// Adapter les personnes
const adaptedPersons = persons.map((p) => ({
id: p.id,
firstName: p.firstName || '',
lastName: p.lastName || '',
birthName: p.birthName || null,
nickname: p.nickname || null,
birthYear: dateToYear(p.birthDate),
deathYear: dateToYear(p.deathDate),
isAlive: isAlive(p.deathDate),
sex: p.sex || null,
note: p.notes || null,
birthDate: p.birthDate,
deathDate: p.deathDate,
photo: p.avatarUrlPath ? `/api${p.avatarUrlPath}` : null,
avatarFromImmich: Boolean(p.avatarFromImmich),
avatarCrop: p.avatarCrop ?? null,
frameType: 'round', // Default
}))
// Adapter les unions
const adaptedUnions = unions.map((u) => ({
id: u.id,
partner1Id: u.partner1PersonId,
partner2Id: u.partner2PersonId,
unionType: u.unionType || 'union',
startYear: dateToYear(u.startDate),
endYear: dateToYear(u.endDate),
displayOrder: u.displayOrder,
startDate: u.startDate,
endDate: u.endDate,
}))
// Adapter les filiations
const adaptedFiliations = filiations.map((f) => ({
id: f.id,
childId: f.childPersonId,
parentId: f.parentPersonId,
unionId: f.viaUnionId,
parentageType: f.parentageType || 'biologique',
displayOrder: f.displayOrder,
}))
// Adapter les médias
const adaptedMedias = medias.map((m) => ({
id: m.id,
personId: m.personId,
type: m.type,
url: m.type === 'photo' ? `/api${m.urlPath}` : null,
urlHd: m.type === 'photo' ? `/api${m.urlPath}` : null,
label: m.caption || t('Média {n}', { n: m.displayOrder }),
source: m.source || null,
displayOrder: m.displayOrder,
isFeatured: m.isFeatured,
}))
return {
persons: adaptedPersons,
unions: adaptedUnions,
filiations: adaptedFiliations,
medias: adaptedMedias,
}
}
-117
View File
@@ -1,117 +0,0 @@
/**
* Fonctions helper pour manipuler les données du graphe
* Ces fonctions acceptent les données en paramètre au lieu d'utiliser les imports statiques
*/
/**
* Récupère les médias d'une personne
* @param {string} personId
* @param {Array} medias - Liste des médias
* @returns {Array}
*/
export function getPersonMedias(personId, medias = []) {
return medias
.filter((media) => media.personId === personId)
.sort((a, b) => {
const orderDiff = (a.displayOrder || 0) - (b.displayOrder || 0)
if (orderDiff !== 0) return orderDiff
return (a.id || '').localeCompare(b.id || '')
})
.slice(0, 8) // Max 8 médias
}
/**
* Récupère les parents d'une personne
* @param {string} personId
* @param {Array} persons
* @param {Array} unions
* @param {Array} filiations
* @returns {Array}
*/
export function getParents(personId, persons = [], unions = [], filiations = []) {
const fil = filiations.filter((filiation) => filiation.childId === personId)
const result = []
fil.forEach((f) => {
if (f.unionId) {
const union = unions.find((u) => u.id === f.unionId)
if (union) {
const p1 = persons.find((p) => p.id === union.partner1Id)
const p2 = persons.find((p) => p.id === union.partner2Id)
if (p1) result.push(p1)
if (p2) result.push(p2)
}
} else if (f.parentId) {
const parent = persons.find((p) => p.id === f.parentId)
if (parent) result.push(parent)
}
})
// Dédupliquer
const seen = new Set()
return result.filter((p) => {
if (seen.has(p.id)) return false
seen.add(p.id)
return true
})
}
/**
* Récupère les enfants d'une personne
* @param {string} personId
* @param {Array} persons
* @param {Array} unions
* @param {Array} filiations
* @returns {Array}
*/
export function getChildren(personId, persons = [], unions = [], filiations = []) {
const childIds = new Set()
// Via unions
const personUnions = unions.filter((union) => union.partner1Id === personId || union.partner2Id === personId)
personUnions.forEach((union) => {
const childrenOfUnion = filiations.filter((fil) => fil.unionId === union.id)
childrenOfUnion.forEach((fil) => childIds.add(fil.childId))
})
// Via parentId direct
const directChildren = filiations.filter((fil) => fil.parentId === personId)
directChildren.forEach((fil) => childIds.add(fil.childId))
return persons.filter((p) => childIds.has(p.id))
}
/**
* Récupère les unions d'une personne
* @param {string} personId
* @param {Array} unions
* @returns {Array}
*/
export function getPersonUnions(personId, unions = []) {
return unions
.filter((union) => union.partner1Id === personId || union.partner2Id === personId)
.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0))
}
/**
* Récupère la filiation d'une personne (comment elle est reliée à ses parents)
* @param {string} personId
* @param {Array} filiations
* @returns {Object | null}
*/
export function getChildFiliation(personId, filiations = []) {
const childFiliations = filiations
.filter((f) => f.childId === personId)
.sort((a, b) => (a.displayOrder || 0) - (b.displayOrder || 0))
if (childFiliations.length === 0) {
return null
}
const viaUnion = childFiliations.find((f) => Boolean(f.unionId))
if (viaUnion) {
return viaUnion
}
return childFiliations.find((f) => (f.parentageType || 'biologique') === 'biologique') || childFiliations[0]
}