mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-12 16:16:09 +00:00
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:
1 parent
4dd0db9699
commit
34454abc18
16 files changed
+1
-2033
No files matched your search
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
Reference in new issue
Block a user