From f9fefd83e14d17c1c04637604c698dbf6e6c94bb Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Sun, 25 Oct 2026 17:51:43 +0100 Subject: [PATCH] =?UTF-8?q?Chercher=20quelqu'un=20:=20recherche=20repli?= =?UTF-8?q?=C3=A9e=20apr=C3=A8s=20r=C3=A9sultats,=20cartes=20plus=20compl?= =?UTF-8?q?=C3=A8tes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../src/components/SearchSourcesPanel.css | 21 +++-- .../src/components/SearchSourcesPanel.jsx | 83 +++++++++++++++++-- frontend/src/i18n/en/recherche.js | 20 +++++ 3 files changed, 111 insertions(+), 13 deletions(-) diff --git a/frontend/src/components/SearchSourcesPanel.css b/frontend/src/components/SearchSourcesPanel.css index 1bf1d9b..80b1c2d 100644 --- a/frontend/src/components/SearchSourcesPanel.css +++ b/frontend/src/components/SearchSourcesPanel.css @@ -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; } } diff --git a/frontend/src/components/SearchSourcesPanel.jsx b/frontend/src/components/SearchSourcesPanel.jsx index b7c14b5..cac4d99 100644 --- a/frontend/src/components/SearchSourcesPanel.jsx +++ b/frontend/src/components/SearchSourcesPanel.jsx @@ -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
+ {!editing && searched && ( +
+
+

{searched.firstName} {searched.lastName}

+ {(searched.birthYear || searched.place) && ( +

{[searched.birthYear && t('né(e) vers {year}', { year: searched.birthYear }), searched.place].filter(Boolean).join(', ')}

+ )} +
+ +
+ )} + {editing && (
@@ -218,11 +276,15 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f )}
+ )} {Object.entries(results).map(([sourceId, { status, items }]) => (
-

{SOURCE_INFO[sourceId].name}

+
+

{SOURCE_INFO[sourceId].name}

+

{SOURCE_INFO[sourceId].short}

+
{status === 'done' && {items.length === 0 ? t('Rien trouvé') : items.length === 1 ? t('1 trouvé') : t('{n} trouvés', { n: items.length })}}
{status === 'loading' &&

{t('Recherche…')}

} @@ -236,13 +298,18 @@ function SearchSourcesPanel({ visible, treeId, token, persons = [], canApply = f const isPicking = picker?.key === key return (
-
{result.firstName} {result.lastName}
- {result.birth?.date || result.birth?.place ?

{t('Naissance')} : {eventLine(result.birth)}

: null} - {result.death?.date || result.death?.place ?

{t('Décès')} : {eventLine(result.death)}

: null} -
+
+
{result.firstName} {result.lastName}
{result.match === 'good' ? t('Correspond bien') : t('À comparer')} +
+ {lifespan(result) &&

{lifespan(result)}

} + {[eventSentence('birth', result.birth, result.sex), eventSentence('death', result.death, result.sex)].filter(Boolean).map((line) => ( +

{line}

+ ))} +

{matchReason(result, searched || query)}

+
{t('Voir la source')} diff --git a/frontend/src/i18n/en/recherche.js b/frontend/src/i18n/en/recherche.js index 6bd4ea8..0f8161d 100644 --- a/frontend/src/i18n/en/recherche.js +++ b/frontend/src/i18n/en/recherche.js @@ -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',