mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
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