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',