Chercher quelqu'un : recherche repliée après résultats, cartes plus complètes

La recherche tient en une ligne au-dessus des résultats. Chaque carte donne les
années de vie, l'âge au décès, des phrases accordées au sexe et la raison de sa note.
WikiTree limité au même nom de famille ; flèche des liens collée au texte.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Opus 5.5 committed 2026-10-25 17:51:43 +01:00
1 parent bff3f54b00
commit f9fefd83e1
3 files changed
+111 -13

No files matched your search

+16 -5
View File
@@ -36,14 +36,25 @@
.sp-results { gap: 8px; }
.sp-results-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.sp-results-title { margin: 0; font: 400 17px/1.25 var(--pz-serif); color: var(--pz-ink); }
.sp-results-sub { margin: 1px 0 0; font: 400 12px var(--pz-sans); color: var(--pz-muted); }
/* Recherche repliée : une ligne au-dessus des résultats */
.sp-summary { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 12px; border-radius: var(--pz-r-m); background: var(--pz-bg); border: 1px solid var(--pz-line); }
.sp-summary-text { min-width: 0; }
.sp-summary-who { margin: 0; font: 400 16px/1.25 var(--pz-serif); color: var(--pz-ink); }
.sp-summary-who em { font-style: italic; font-weight: 300; }
.sp-summary .pz-hint { margin-top: 2px; }
.sp-result { padding: 12px; border: 1px solid var(--pz-line); border-radius: var(--pz-r-m); background: var(--pz-surface); }
.sp-result--done { background: var(--pz-success-bg); border-color: transparent; }
.sp-result-head { display: flex; justify-content: space-between; align-items: baseline; gap: 10px; }
.sp-result-head .sp-match { flex: none; }
.sp-result-name { font: 400 16px/1.25 var(--pz-serif); color: var(--pz-ink); }
.sp-result-years { margin: 2px 0 4px; font: 400 12.5px var(--pz-sans); color: var(--pz-muted); font-variant-numeric: tabular-nums; }
.sp-result-reason { margin: 6px 0 0; font: 400 12px/1.4 var(--pz-sans); color: var(--pz-muted); }
.sp-result-name em { font-style: italic; font-weight: 300; }
.sp-result-line { margin: 3px 0 0; font: 400 13px/1.45 var(--pz-sans); color: var(--pz-ink-2); font-variant-numeric: tabular-nums; }
.sp-result-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 10px; }
.sp-result-foot .pz-btn { margin-left: auto; }
.sp-result-foot { display: flex; align-items: center; justify-content: space-between; gap: 8px 12px; margin-top: 10px; }
.sp-result-link { display: inline-flex; align-items: center; gap: 2px; font-size: 12.5px; }
.sp-result-applied { margin: 8px 0 0; font: 500 12.5px var(--pz-sans); color: var(--pz-success); }
@@ -62,9 +73,9 @@
.sp-link-list { list-style: none; margin: 0; padding: 0; }
.sp-link { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; min-height: 40px; padding: 9px 0; border-bottom: 1px solid var(--pz-line-soft); text-decoration: none; color: var(--pz-ink); }
.sp-link-list li:last-child .sp-link { border-bottom: 0; }
.sp-link-name { display: inline-flex; align-items: center; gap: 3px; font: 500 14px var(--pz-sans); }
.sp-link-name svg { color: var(--pz-faint); }
.sp-link-detail { font: 400 12.5px var(--pz-sans); color: var(--pz-muted); text-align: right; }
.sp-link-name { font: 500 14px/1.35 var(--pz-sans); }
.sp-link-name svg { display: inline-block; color: var(--pz-faint); vertical-align: -1px; margin-left: 3px; }
.sp-link-detail { max-width: 55%; font: 400 12.5px var(--pz-sans); color: var(--pz-muted); text-align: right; }
@media (hover: hover) and (pointer: fine) {
.sp-link:hover .sp-link-name { text-decoration: underline; text-decoration-color: var(--pz-a300); text-underline-offset: 3px; }
}
+75 -8
View File
@@ -15,11 +15,13 @@ const SOURCE_INFO = {
name: t('Décès en France depuis 1970'),
detail: t("Fichier de l'INSEE, par matchID. Date et lieu de décès, rien avant 1970. Une femme se cherche sous son nom de naissance."),
sent: t("Envoie le nom, le prénom et l'année de naissance à matchid.io"),
short: t("Fichier de l'INSEE, par matchID"),
},
wikitree: {
name: 'WikiTree',
detail: t('Un arbre mondial tenu par ses membres. Profils publics seulement.'),
sent: t("Envoie le nom, le prénom et l'année de naissance à wikitree.com"),
short: t('Arbre tenu par ses membres'),
},
}
@@ -30,11 +32,50 @@ function formatDate(value) {
if (!month) return String(year)
const date = new Date(Date.UTC(year, month - 1, day || 1))
const options = day ? { day: 'numeric', month: 'long', year: 'numeric' } : { month: 'long', year: 'numeric' }
return date.toLocaleDateString(dateLocale, { ...options, timeZone: 'UTC' })
const text = date.toLocaleDateString(dateLocale, { ...options, timeZone: 'UTC' })
// « 1er janvier », pas « 1 janvier »
return dateLocale === 'fr-FR' && day === 1 ? text.replace(/^1 /, '1er ') : text
}
function eventLine(event) {
return [formatDate(event?.date), event?.place].filter(Boolean).join(', ')
// « Né le 24 août 1898 à Monségur (40) », accordé au sexe quand la source le donne
const VERBS = {
birth: { M: (when) => t('Né {when}', { when }), F: (when) => t('Née {when}', { when }), null: (when) => t('Né(e) {when}', { when }) },
death: { M: (when) => t('Mort {when}', { when }), F: (when) => t('Morte {when}', { when }), null: (when) => t('Mort(e) {when}', { when }) },
}
function eventSentence(kind, event, sex) {
if (!event?.date && !event?.place) return null
const parts = []
if (event.date) parts.push(event.date.length === 10 ? t('le {date}', { date: formatDate(event.date) }) : t('en {date}', { date: formatDate(event.date) }))
if (event.place) parts.push(t('à {place}', { place: event.place }))
return (VERBS[kind][sex] || VERBS[kind].null)(parts.join(' '))
}
// « 1898 – 1987, 89 ans au décès » ; l'âge seulement quand les deux dates sont complètes
function lifespan(result) {
const birth = result.birth?.date
const death = result.death?.date
// Une seule date : la phrase « Né le … » la donne déjà
if (!birth || !death) return ''
const years = `${birth.slice(0, 4)} – ${death.slice(0, 4)}`
if (birth.length !== 10 || death.length !== 10) return years
const age = Number(death.slice(0, 4)) - Number(birth.slice(0, 4)) - (death.slice(5) < birth.slice(5) ? 1 : 0)
return `${years}, ${t('{age} ans au décès', { age })}`
}
// Pourquoi « Correspond bien » ou « À comparer » : l'année et le lieu cherchés, face à ceux du résultat
function matchReason(result, query) {
const reasons = []
const year = Number(result.birth?.date?.slice(0, 4)) || null
if (query.birthYear) {
if (!year) reasons.push(t("pas d'année de naissance"))
else reasons.push(Math.abs(year - query.birthYear) <= 1 ? t('même année') : t('autre année'))
}
if (query.place) {
if (!result.birth?.place) reasons.push(t('pas de lieu de naissance'))
else reasons.push(plain(result.birth.place).startsWith(plain(query.place)) ? t('même lieu') : t('autre lieu de naissance'))
}
return reasons.length ? reasons.join(', ') : t('seuls le nom et le prénom sont comparés')
}
function personYears(person) {
@@ -65,6 +106,9 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
const [region, setRegion] = useState('fr')
const [picker, setPicker] = useState(null)
const [applied, setApplied] = useState({})
// Après une recherche, le formulaire se replie en une ligne : les résultats passent devant
const [searched, setSearched] = useState(null)
const [editing, setEditing] = useState(true)
useEffect(() => {
if (!visible || !treeId || !token || loadedSources.treeId === treeId) return
@@ -100,6 +144,8 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
event.preventDefault()
if (!canSearch) return
setSearching(true)
setSearched(query)
setEditing(false)
setPicker(null)
setResults(Object.fromEntries(checked.map((id) => [id, { status: 'loading', items: [] }])))
await Promise.all(checked.map(async (id) => {
@@ -168,6 +214,18 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
</div>
<div className="apn-scroll">
{!editing && searched && (
<div className="sp-summary">
<div className="sp-summary-text">
<p className="sp-summary-who">{searched.firstName} <em>{searched.lastName}</em></p>
{(searched.birthYear || searched.place) && (
<p className="pz-hint">{[searched.birthYear && t('né(e) vers {year}', { year: searched.birthYear }), searched.place].filter(Boolean).join(', ')}</p>
)}
</div>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => setEditing(true)}>{t('Modifier')}</button>
</div>
)}
{editing && (
<form className="sp-form" onSubmit={handleSearch}>
<div className="pz-row2">
<div className="pz-field">
@@ -218,11 +276,15 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
)}
</div>
</form>
)}
{Object.entries(results).map(([sourceId, { status, items }]) => (
<section key={sourceId} className="apn-section sp-results" aria-live="polite">
<div className="sp-results-head">
<h3 className="sp-results-title">{SOURCE_INFO[sourceId].name}</h3>
<div>
<h3 className="sp-results-title">{SOURCE_INFO[sourceId].name}</h3>
<p className="sp-results-sub">{SOURCE_INFO[sourceId].short}</p>
</div>
{status === 'done' && <span className="pz-hint">{items.length === 0 ? t('Rien trouvé') : items.length === 1 ? t('1 trouvé') : t('{n} trouvés', { n: items.length })}</span>}
</div>
{status === 'loading' && <p className="pz-hint">{t('Recherche…')}</p>}
@@ -236,13 +298,18 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f
const isPicking = picker?.key === key
return (
<article key={key} className={`sp-result${applied[key] ? ' sp-result--done' : ''}`}>
<div className="sp-result-name">{result.firstName} <em>{result.lastName}</em></div>
{result.birth?.date || result.birth?.place ? <p className="sp-result-line">{t('Naissance')} : {eventLine(result.birth)}</p> : null}
{result.death?.date || result.death?.place ? <p className="sp-result-line">{t('Décès')} : {eventLine(result.death)}</p> : null}
<div className="sp-result-foot">
<div className="sp-result-head">
<div className="sp-result-name">{result.firstName} <em>{result.lastName}</em></div>
<span className={`sp-match${result.match === 'good' ? ' sp-match--good' : ''}`}>
{result.match === 'good' ? t('Correspond bien') : t('À comparer')}
</span>
</div>
{lifespan(result) && <p className="sp-result-years">{lifespan(result)}</p>}
{[eventSentence('birth', result.birth, result.sex), eventSentence('death', result.death, result.sex)].filter(Boolean).map((line) => (
<p key={line} className="sp-result-line">{line}</p>
))}
<p className="sp-result-reason">{matchReason(result, searched || query)}</p>
<div className="sp-result-foot">
<a className="pz-link sp-result-link" href={result.url} target="_blank" rel="noopener noreferrer">
{t('Voir la source')}<ArrowUpRight size={13} aria-hidden="true" />
</a>
+20
View File
@@ -23,6 +23,26 @@ export default {
"Personne sous ce nom. Essayez sans l'année, ou avec le nom de naissance.": 'Nobody under this name. Try without the year, or with the maiden name.',
"Ce serveur a épuisé ses recherches sans clé. Son administrateur peut ajouter une clé matchID gratuite (MATCHID_TOKEN).": 'This server has used up its searches without a key. Its administrator can add a free matchID key (MATCHID_TOKEN).',
'Correspond bien': 'Good match',
"Fichier de l'INSEE, par matchID": 'INSEE records, via matchID',
'Arbre tenu par ses membres': 'Tree kept by its members',
'Né {when}': 'Born {when}',
'Née {when}': 'Born {when}',
'Né(e) {when}': 'Born {when}',
'Mort {when}': 'Died {when}',
'Morte {when}': 'Died {when}',
'Mort(e) {when}': 'Died {when}',
'le {date}': 'on {date}',
'en {date}': 'in {date}',
'à {place}': 'in {place}',
"pas d'année de naissance": 'no birth year',
'même année': 'same year',
'autre année': 'another year',
'pas de lieu de naissance': 'no birthplace',
'même lieu': 'same place',
'autre lieu de naissance': 'another birthplace',
'seuls le nom et le prénom sont comparés': 'only the names are compared',
'né(e) vers {year}': 'born around {year}',
'Modifier': 'Edit',
'À comparer': 'Worth comparing',
'Voir la source': 'See the source',
"Ajouter à l'arbre": 'Add to the tree',