mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-15 08:32:03 +00:00
- Onglet Vie : pendant le premier chargement, la place de la frise au lieu de « Pas encore de date connue », qui s'affichait puis disparaissait une seconde plus tard. - Propositions à relire, co-gestionnaires, visites, paramètres de l'arbre, sources de recherche : des lignes grises à la forme de ce qui arrive, au lieu d'un « Chargement… ». - Composant Skeleton : rien avant 300 ms (une réponse rapide ne fait pas clignoter), aucun mouvement, libellé pour les lecteurs d'écran. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
120 lines
5.0 KiB
React
120 lines
5.0 KiB
React
import { useState } from 'react'
|
||
import { Plus, Play, FileText, Mic, Paperclip } from 'lucide-react'
|
||
import { t } from '../i18n/index.js'
|
||
import { buildLifeTimeline } from '../utils/lifeTimeline.js'
|
||
import './LifeTimeline.css'
|
||
import { blurPlaceholder } from '../utils/blurhash.js'
|
||
import Skeleton from './Skeleton.jsx'
|
||
|
||
// Onglet Vie de la fiche : naissance, unions et décès en grands repères, les autres événements
|
||
// plus petits entre eux, la famille proche en retrait. Les sources restent repliées.
|
||
function Sources({ text }) {
|
||
const [open, setOpen] = useState(false)
|
||
const list = text.split('\n').map((s) => s.trim()).filter(Boolean)
|
||
if (list.length === 0) return null
|
||
return (
|
||
<div className="lt-sources">
|
||
<button type="button" className="lt-src" aria-expanded={open} onClick={() => setOpen((v) => !v)}>
|
||
{list.length === 1 ? t('1 source') : t('{n} sources', { n: list.length })}
|
||
</button>
|
||
{open && (
|
||
<ul className="lt-src-list">
|
||
{list.map((source) => <li key={source}>{source}</li>)}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const FILE_LABELS = { video: t('Vidéo'), audio: t('Enregistrement'), document: t('Document') }
|
||
|
||
// Souvenirs rattachés au moment : les photos en vignettes, le reste (vidéo, acte, voix) en pastilles
|
||
function Attachments({ medias, onOpen }) {
|
||
if (medias.length === 0) return null
|
||
const photos = medias.filter((m) => m.type === 'photo' && m.url)
|
||
const others = medias.filter((m) => !(m.type === 'photo' && m.url))
|
||
return (
|
||
<div className="lt-attach">
|
||
{photos.length > 0 && (
|
||
<div className="lt-photos">
|
||
{photos.map((media) => (
|
||
<button key={media.id} type="button" className="lt-photo" onClick={() => onOpen?.(media)} aria-label={t('Voir « {title} »', { title: media.label })}>
|
||
<img src={media.url} alt="" loading="lazy" {...blurPlaceholder(media.blurhash)} />
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{others.map((media) => {
|
||
const Icon = media.type === 'video' ? Play : media.type === 'audio' ? Mic : media.type === 'document' ? FileText : Paperclip
|
||
return (
|
||
<button key={media.id} type="button" className="lt-file" onClick={() => onOpen?.(media)}>
|
||
<Icon size={14} strokeWidth={1.8} aria-hidden="true" />
|
||
<span className="lt-file-label">{media.label || FILE_LABELS[media.type] || t('Souvenir')}</span>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default function LifeTimeline({
|
||
person, family, life, loading = false, failed = false, medias = [],
|
||
canAdd = false, canEdit = false, notice = '', onAdd, onEdit, onOpenMedia,
|
||
}) {
|
||
const entries = buildLifeTimeline(person, family, life)
|
||
const mediaById = new Map(medias.map((m) => [String(m.id), m]))
|
||
const addButton = canAdd && (
|
||
<button type="button" className="pz-btn pz-btn--soft pz-btn--block lt-add" onClick={onAdd}>
|
||
<Plus size={16} strokeWidth={2} />
|
||
{t('Ajouter un moment')}
|
||
</button>
|
||
)
|
||
const noticeLine = notice && <p className="pz-success lt-notice" role="status">{notice}</p>
|
||
|
||
// Premier chargement : la place de la frise, au lieu d'un « pas encore de date » qui serait faux une seconde après
|
||
if (loading && !life) return <Skeleton variant="timeline" count={4} label={t('Chargement de la frise')} />
|
||
|
||
if (entries.length === 0) {
|
||
return (
|
||
<div className="pc-empty">
|
||
<p className="pz-sub">{t('Pas encore de date connue pour {name}.', { name: person.firstName })}</p>
|
||
<p className="pz-small">{t('Les dates de naissance, d’union et de décès, et celles de sa famille, apparaîtront ici.')}</p>
|
||
{noticeLine}
|
||
{addButton}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="lt" aria-busy={loading || undefined}>
|
||
{noticeLine}
|
||
<ol className="lt-rail">
|
||
{entries.map((entry) => {
|
||
const editable = canEdit && (entry.event || entry.milestone)
|
||
const attached = (entry.mediaIds || []).map((id) => mediaById.get(String(id))).filter(Boolean)
|
||
return (
|
||
<li key={entry.key} className={`lt-ev lt-ev--${entry.kind}`}>
|
||
<span className="lt-year">{entry.year ?? ''}</span>
|
||
<div className="lt-body">
|
||
{editable ? (
|
||
<button type="button" className="lt-title lt-title--edit" onClick={() => onEdit?.(entry)} aria-label={t('Modifier « {title} »', { title: entry.title })}>
|
||
{entry.title}
|
||
</button>
|
||
) : (
|
||
<div className="lt-title">{entry.title}</div>
|
||
)}
|
||
{entry.meta && <div className="lt-meta">{entry.meta}</div>}
|
||
{entry.note && <p className="lt-note">{entry.note}</p>}
|
||
{entry.sources && <Sources text={entry.sources} />}
|
||
<Attachments medias={attached} onOpen={onOpenMedia} />
|
||
</div>
|
||
</li>
|
||
)
|
||
})}
|
||
</ol>
|
||
{failed && <p className="pz-small lt-failed">{t('Les autres événements n’ont pas pu être chargés.')}</p>}
|
||
{addButton}
|
||
</div>
|
||
)
|
||
}
|