Merge pull request #119 from IliesAllali/chore/code-mort

Ménage : retirer onze modules de l'interface que plus rien n'importe
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-20 14:37:26 +02:00
commit 111a5d3df5
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]
}