@tekkare/romulus 0.1.254 → 0.1.256

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,298 @@
1
+ <!--
2
+ Tableau d'articles PubMed pour la fiche patient.
3
+
4
+ Reprend CrossPublicationPubmed de Discovery : un compte, une recherche
5
+ (PMID, titre, type d'article, auteurs), une periode, un filtre par type
6
+ d'article et le tableau PMID / date / type / titre / auteurs / lien. La page
7
+ passe la clause qui definit le perimetre (l'article source, ou les articles
8
+ sur les genes de la fiche).
9
+
10
+ Filtres et recherche partent au serveur ; les 1 000 premieres lignes, triees
11
+ de la plus recente a la plus ancienne, se paginent dans la table.
12
+ -->
13
+ <template>
14
+ <RmTableCard source="PubMed" :lang="lang">
15
+ <template #header>
16
+ <div class="oip_list gap-12 full_width">
17
+ <div class="oip_row v-center gap-8 wrap">
18
+ <h2 class="pubs_title">{{ title }}</h2>
19
+ <span class="pubs_count">
20
+ {{ total === null ? "" : fr ? `${number(total)} résultat${total > 1 ? "s" : ""}` : `${number(total)} result${total > 1 ? "s" : ""}` }}
21
+ </span>
22
+ </div>
23
+ <p v-if="total !== null && total > MAX_ROWS" class="pubs_note">
24
+ {{
25
+ fr
26
+ ? `Les ${number(MAX_ROWS)} plus récents sont affichés : affinez avec la recherche ou les filtres.`
27
+ : `The ${number(MAX_ROWS)} most recent are shown: narrow down with the search or the filters.`
28
+ }}
29
+ </p>
30
+ <div class="oip_row v-end gap-12 wrap">
31
+ <RmInput
32
+ v-model="searchText"
33
+ class="pubs_search"
34
+ :placeholder="fr ? 'Rechercher par PMID, titre, auteur, type d\'article…' : 'Search by PMID, title, author, article type…'"
35
+ />
36
+ <RmDropdownSelect v-model="periodLabel" :options="periods.map((p) => p.label)" :search="false" :label="fr ? 'Période' : 'Period'" :lang="lang" />
37
+ <RmMultipleSelect
38
+ v-model="articleTypes"
39
+ :options="typeOptions"
40
+ :label="fr ? 'Type d\'article' : 'Article type'"
41
+ :loading="loading"
42
+ :lang="lang"
43
+ />
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <RmErrorState
49
+ v-if="error"
50
+ icon="CloudWarning"
51
+ :title="fr ? 'Articles indisponibles' : 'Papers unavailable'"
52
+ :text="fr ? 'L\'index PubMed n\'a pas répondu.' : 'The PubMed index did not answer.'"
53
+ :action-label="fr ? 'Réessayer' : 'Retry'"
54
+ @action="load()"
55
+ />
56
+ <RmTable
57
+ v-else
58
+ :columns="columns"
59
+ :rows="rows"
60
+ :loading="loading"
61
+ :page-size="25"
62
+ row-key="pmid"
63
+ :caption="title"
64
+ :empty-text="fr ? 'Aucun article' : 'No paper'"
65
+ max-height="none"
66
+ class="pubs_table"
67
+ @row-click="(row) => (openPmid = row.pmid)"
68
+ >
69
+ <template #pmid="{ value }">
70
+ <a class="pubs_link pubs_mono" :href="pubmedUrl(value)" target="_blank" rel="noopener" @click.stop>{{ value }}</a>
71
+ </template>
72
+ <template #title="{ value, row }">
73
+ <button type="button" class="pubs_open" @click.stop="openPmid = row.pmid">
74
+ <RmTextHighlight :text="value || '-'" :queries="highlight" :lang="lang" />
75
+ </button>
76
+ </template>
77
+ <template #authors="{ value }">
78
+ <RmTextHighlight :text="value || '-'" :queries="highlight" :lang="lang" />
79
+ </template>
80
+ <template #link="{ row }">
81
+ <a class="pubs_link" :href="pubmedUrl(row.pmid)" target="_blank" rel="noopener" @click.stop>
82
+ {{ fr ? "Voir sur PubMed" : "See on PubMed" }}
83
+ <RmIcon name="ArrowSquareOut" :size="12" />
84
+ </a>
85
+ </template>
86
+ </RmTable>
87
+
88
+ <OipPatientsPubmedDrawer v-model:pmid="openPmid" :highlight="highlight" :lang="lang" />
89
+ </RmTableCard>
90
+ </template>
91
+
92
+ <script setup lang="ts">
93
+ import { oipEsSearch } from "../../composables/useOipResources";
94
+ import { OIP_PATIENT_INDEXES } from "../../oip-patients/indexes";
95
+
96
+ const props = withDefaults(
97
+ defineProps<{
98
+ title: string;
99
+ /** La clause qui definit le perimetre du tableau. */
100
+ query: Record<string, unknown>;
101
+ lang?: "fr" | "en";
102
+ }>(),
103
+ { lang: "fr" },
104
+ );
105
+
106
+ const MAX_ROWS = 1000;
107
+
108
+ const lang = computed(() => props.lang);
109
+ const fr = computed(() => lang.value === "fr");
110
+ const number = (n: number) => new Intl.NumberFormat(fr.value ? "fr-FR" : "en-US").format(n);
111
+ const pubmedUrl = (pmid: string) => `https://pubmed.ncbi.nlm.nih.gov/${pmid}/`;
112
+
113
+ interface PubmedAuthor {
114
+ forename?: string;
115
+ lastname?: string;
116
+ }
117
+ interface PubmedHit {
118
+ PubMed_ID: string;
119
+ title?: string;
120
+ article_date?: string;
121
+ publication_types?: string[];
122
+ authors?: PubmedAuthor[];
123
+ }
124
+
125
+ /** L'article ouvert dans le tiroir de detail. */
126
+ const openPmid = ref<string | null>(null);
127
+
128
+ const searchText = ref("");
129
+ const debouncedSearch = ref("");
130
+ let searchTimer: ReturnType<typeof setTimeout> | undefined;
131
+ watch(searchText, (text) => {
132
+ clearTimeout(searchTimer);
133
+ searchTimer = setTimeout(() => (debouncedSearch.value = text.trim()), 300);
134
+ });
135
+ const highlight = computed(() => (debouncedSearch.value ? [debouncedSearch.value] : []));
136
+
137
+ /** Les periodes du filtre de Discovery, sur la date de publication. */
138
+ const periods = computed(() => [
139
+ { label: fr.value ? "Toutes" : "All", gte: null },
140
+ { label: fr.value ? "30 derniers jours" : "Last 30 days", gte: "now-30d/d" },
141
+ { label: fr.value ? "90 derniers jours" : "Last 90 days", gte: "now-90d/d" },
142
+ { label: fr.value ? "180 derniers jours" : "Last 180 days", gte: "now-180d/d" },
143
+ { label: fr.value ? "360 derniers jours" : "Last 360 days", gte: "now-360d/d" },
144
+ { label: fr.value ? "3 dernières années" : "Last 3 years", gte: "now-3y/d" },
145
+ { label: fr.value ? "5 dernières années" : "Last 5 years", gte: "now-5y/d" },
146
+ ]);
147
+ const periodIndex = ref(0);
148
+ const periodLabel = computed({
149
+ get: () => periods.value[periodIndex.value]?.label,
150
+ set: (label) => {
151
+ const index = periods.value.findIndex((p) => p.label === label);
152
+ periodIndex.value = index < 0 ? 0 : index;
153
+ },
154
+ });
155
+
156
+ const articleTypes = ref<string[]>([]);
157
+ const typeOptions = ref<string[]>([]);
158
+
159
+ const results = ref<PubmedHit[]>([]);
160
+ const total = ref<number | null>(null);
161
+ const loading = ref(true);
162
+ const error = ref(false);
163
+
164
+ function searchClause(text: string) {
165
+ const fields = ["PubMed_ID^5", "title^3", "publication_types"];
166
+ return {
167
+ bool: {
168
+ should: [
169
+ { multi_match: { query: text, fields, type: "best_fields", fuzziness: 0 } },
170
+ { multi_match: { query: text, fields, type: "phrase_prefix" } },
171
+ {
172
+ nested: {
173
+ path: "authors",
174
+ query: { multi_match: { query: text, fields: ["authors.lastname^2", "authors.forename"], type: "best_fields", fuzziness: 0 } },
175
+ },
176
+ },
177
+ ],
178
+ },
179
+ };
180
+ }
181
+
182
+ let requestId = 0;
183
+
184
+ /**
185
+ * Une requete pour les lignes et le compte, l'agregation des types comptee
186
+ * sans son propre filtre : cocher un type ne fait pas disparaitre les autres.
187
+ */
188
+ async function load() {
189
+ const id = ++requestId;
190
+ loading.value = true;
191
+ error.value = false;
192
+ const must: unknown[] = [props.query];
193
+ if (debouncedSearch.value) must.push(searchClause(debouncedSearch.value));
194
+ const gte = periods.value[periodIndex.value]?.gte;
195
+ if (gte) must.push({ range: { article_date: { gte } } });
196
+ const typeFilter = articleTypes.value.length ? [{ terms: { "publication_types.keyword": articleTypes.value } }] : [];
197
+
198
+ try {
199
+ const res: any = await oipEsSearch("science", OIP_PATIENT_INDEXES.PUBMED, {
200
+ size: MAX_ROWS,
201
+ track_total_hits: true,
202
+ _source: ["PubMed_ID", "title", "authors", "article_date", "publication_types"],
203
+ sort: [{ article_date: { order: "desc", missing: "_last" } }],
204
+ query: { bool: { must } },
205
+ post_filter: typeFilter.length ? { bool: { filter: typeFilter } } : undefined,
206
+ aggs: { types: { terms: { field: "publication_types.keyword", size: 50 } } },
207
+ });
208
+ if (id !== requestId) return;
209
+ results.value = (res?.hits?.hits ?? []).map((hit: any) => hit._source);
210
+ total.value = res?.hits?.total?.value ?? 0;
211
+ typeOptions.value = (res?.aggregations?.types?.buckets ?? []).map((b: any) => b.key);
212
+ } catch (err) {
213
+ if (id !== requestId) return;
214
+ console.error("PubMed papers failed:", err);
215
+ error.value = true;
216
+ } finally {
217
+ if (id === requestId) loading.value = false;
218
+ }
219
+ }
220
+
221
+ watch([() => props.query, debouncedSearch, periodIndex, articleTypes], load, { immediate: true, deep: true });
222
+
223
+ const columns = computed(() => [
224
+ { key: "pmid", label: "PMID", width: "110px" },
225
+ { key: "date", label: fr.value ? "Publication" : "First published", width: "120px" },
226
+ { key: "types", label: fr.value ? "Type d'article" : "Article type", width: "180px" },
227
+ { key: "title", label: fr.value ? "Titre" : "Title", minWidth: "280px" },
228
+ { key: "authors", label: fr.value ? "Auteurs" : "Authors", minWidth: "200px" },
229
+ { key: "link", label: fr.value ? "Lien" : "Link", width: "150px" },
230
+ ]);
231
+
232
+ const formatDate = (iso?: string) =>
233
+ iso ? new Date(iso).toLocaleDateString(fr.value ? "fr-FR" : "en-US", { year: "numeric", month: "short", day: "numeric" }) : "-";
234
+
235
+ const rows = computed(() =>
236
+ results.value.map((item) => ({
237
+ pmid: item.PubMed_ID,
238
+ date: formatDate(item.article_date),
239
+ types: (item.publication_types ?? []).join(", ") || "-",
240
+ title: item.title ?? "",
241
+ authors: (item.authors ?? []).map((a) => [a.forename, a.lastname].filter(Boolean).join(" ")).join(", "),
242
+ link: item.PubMed_ID,
243
+ })),
244
+ );
245
+ </script>
246
+
247
+ <style scoped>
248
+ .pubs_title {
249
+ margin: 0;
250
+ font-size: 16px;
251
+ font-weight: 700;
252
+ }
253
+
254
+ .pubs_count,
255
+ .pubs_note {
256
+ margin: 0;
257
+ font-size: 13px;
258
+ color: var(--text-muted);
259
+ }
260
+
261
+ .pubs_search {
262
+ flex: 1 1 260px;
263
+ min-width: 220px;
264
+ }
265
+
266
+ .pubs_table :deep(.rm-table__row) {
267
+ cursor: pointer;
268
+ }
269
+
270
+ .pubs_open {
271
+ padding: 0;
272
+ border: 0;
273
+ background: none;
274
+ font: inherit;
275
+ color: inherit;
276
+ text-align: left;
277
+ cursor: pointer;
278
+ }
279
+
280
+ .pubs_open:hover,
281
+ .pubs_open:focus-visible {
282
+ color: var(--primary);
283
+ text-decoration: underline;
284
+ }
285
+
286
+ .pubs_mono {
287
+ font-family: var(--font-mono, ui-monospace, monospace);
288
+ }
289
+
290
+ .pubs_link {
291
+ display: inline-flex;
292
+ align-items: center;
293
+ gap: 4px;
294
+ white-space: nowrap;
295
+ color: var(--primary);
296
+ text-decoration: underline;
297
+ }
298
+ </style>
@@ -0,0 +1,379 @@
1
+ <!--
2
+ Detail d'un article PubMed dans un tiroir.
3
+
4
+ Reprend la fiche PubMed de France Augmented Science (pubmed/[id]) : son
5
+ bandeau (titre, onglets Resume et Auteurs) et, dans l'onglet Resume, le
6
+ sommaire colle a gauche et les cartes Introduction, sections du resume et
7
+ Liens. L'onglet Essais cliniques et l'appariement RPPS des auteurs sont
8
+ propres aux donnees francaises : ils ne sont pas repris.
9
+ Le tableau des publications l'ouvre sur un clic de ligne, sans quitter la
10
+ fiche patient. L'onglet ouvert reste local au tiroir : `?tab=` appartient a
11
+ la fiche patient.
12
+ -->
13
+ <template>
14
+ <RmDrawer
15
+ :model-value="pmid !== null"
16
+ size="min(1180px, calc(100vw - 48px))"
17
+ :label="fr ? `Article PubMed ${pmid ?? ''}` : `PubMed paper ${pmid ?? ''}`"
18
+ :close-label="fr ? 'Fermer le détail' : 'Close the detail'"
19
+ @update:model-value="(open) => !open && emit('update:pmid', null)"
20
+ >
21
+ <!-- Le bandeau de la fiche d'origine (RmPageHeader, ses onglets dossier) tient
22
+ le haut du tiroir ; l'en-tete du tiroir s'efface (cf. style global). -->
23
+ <div class="oip-patients pubmed_drawer">
24
+ <RmPageHeader
25
+ :eyebrow="`PubMed · PMID ${pmid ?? ''}`"
26
+ :tabs="paper ? headerTabs : []"
27
+ :tabs-label="fr ? 'Sections de l\'article' : 'Paper sections'"
28
+ @tab="(key) => (tab = key as 'summary' | 'authors')"
29
+ >
30
+ <template #title>
31
+ <h2>
32
+ <RmTextHighlight v-if="paper" :text="paper.title || '-'" :queries="highlight" :lang="lang" />
33
+ </h2>
34
+ </template>
35
+ <template #actions>
36
+ <RmButton
37
+ variant="tertiary"
38
+ size="md"
39
+ icon-only
40
+ ghost
41
+ prefix-icon="X"
42
+ :aria-label="fr ? 'Fermer le détail' : 'Close the detail'"
43
+ @click="emit('update:pmid', null)"
44
+ />
45
+ </template>
46
+ <div v-if="paper?.delete || paper?.deleted">
47
+ <RmTag variant="rose">{{ fr ? "L'article a été supprimé" : "Article has been deleted" }}</RmTag>
48
+ </div>
49
+ </RmPageHeader>
50
+
51
+ <div class="drawer_scroll">
52
+ <RmLoadingState v-if="loading" :lines="6" />
53
+ <RmErrorState
54
+ v-else-if="error"
55
+ icon="CloudWarning"
56
+ :title="fr ? 'Article indisponible' : 'Paper unavailable'"
57
+ :text="fr ? 'L\'index PubMed n\'a pas répondu.' : 'The PubMed index did not answer.'"
58
+ :action-label="fr ? 'Réessayer' : 'Retry'"
59
+ @action="load()"
60
+ />
61
+ <RmEmptyState v-else-if="!paper" icon="Prohibit" :title="fr ? 'Article introuvable' : 'Paper not found'" />
62
+
63
+ <RmTable
64
+ v-else-if="tab === 'authors'"
65
+ :columns="authorColumns"
66
+ :rows="authors"
67
+ :caption="fr ? 'Auteurs' : 'Authors'"
68
+ search
69
+ :search-placeholder="fr ? 'Chercher un auteur' : 'Search an author'"
70
+ :empty-text="fr ? 'Aucun auteur' : 'No authors'"
71
+ />
72
+
73
+ <div v-else class="oip_row gap-16 drawer_layout">
74
+ <RmChaptersNav class="drawer_chapters" :chapters="chapters" @select="scrollToChapter" />
75
+
76
+ <div class="oip_list gap-16 drawer_content">
77
+ <RmSectionCard id="pubmed-introduction">
78
+ <template #header>
79
+ <h3 class="drawer_section">Introduction</h3>
80
+ </template>
81
+ <div class="oip_list gap-24">
82
+ <div class="oip_list gap-4">
83
+ <p class="drawer_field">{{ fr ? "Titre complet" : "Full title" }}</p>
84
+ <p class="drawer_text">
85
+ <RmTextHighlight :text="paper.title || '-'" :queries="highlight" :lang="lang" />
86
+ </p>
87
+ </div>
88
+ <div class="oip_list gap-4">
89
+ <p class="drawer_field">{{ fr ? "Première publication" : "First published" }}</p>
90
+ <p class="drawer_text">{{ formatDate(paper.article_date) }}</p>
91
+ </div>
92
+
93
+ <div v-for="group in tagGroups" :key="group.label" class="oip_list gap-4">
94
+ <div class="oip_row v-center gap-4 drawer_label">
95
+ <RmIcon :name="group.icon" :size="12" />
96
+ <span>{{ group.label }}</span>
97
+ </div>
98
+ <div v-if="group.values.length" class="oip_row gap-8 wrap">
99
+ <RmTag v-for="value in group.values" :key="value" variant="gray">
100
+ <RmTextHighlight :text="value" :queries="highlight" :lang="lang" />
101
+ </RmTag>
102
+ </div>
103
+ <p v-else class="drawer_none">{{ fr ? `Aucun élément (${group.label.toLowerCase()})` : `No ${group.label.toLowerCase()}` }}</p>
104
+ </div>
105
+ </div>
106
+ </RmSectionCard>
107
+
108
+ <RmSectionCard v-for="(section, index) in sections" :id="`pubmed-section-${index}`" :key="index">
109
+ <template #header>
110
+ <h3 class="drawer_section">{{ sectionName(section.label) }}</h3>
111
+ </template>
112
+ <p class="drawer_text drawer_abstract">
113
+ <RmTextHighlight :text="section.text || '-'" :queries="highlight" :lang="lang" />
114
+ </p>
115
+ </RmSectionCard>
116
+
117
+ <RmSectionCard v-if="hasLinks" id="pubmed-links">
118
+ <template #header>
119
+ <h3 class="drawer_section">{{ fr ? "Liens" : "Links" }}</h3>
120
+ </template>
121
+ <div class="oip_list gap-24">
122
+ <div v-if="!paper.delete" class="oip_list gap-4">
123
+ <div class="oip_row v-center gap-4 drawer_label">
124
+ <RmIcon name="Link" :size="12" />
125
+ <span>{{ fr ? "Article PubMed" : "PubMed article" }}</span>
126
+ </div>
127
+ <a class="drawer_link" :href="`https://pubmed.ncbi.nlm.nih.gov/${pmid}/`" target="_blank" rel="noopener">
128
+ https://pubmed.ncbi.nlm.nih.gov/<b>{{ pmid }}</b>/
129
+ </a>
130
+ </div>
131
+ <div v-if="paper.doi" class="oip_list gap-4">
132
+ <div class="oip_row v-center gap-4 drawer_label">
133
+ <RmIcon name="Link" :size="12" />
134
+ <span>{{ fr ? "Article complet" : "Complete article" }}</span>
135
+ </div>
136
+ <a class="drawer_link" :href="`https://www.doi.org/${paper.doi}`" target="_blank" rel="noopener">
137
+ https://www.doi.org/<b>{{ paper.doi }}</b>
138
+ </a>
139
+ </div>
140
+ </div>
141
+ </RmSectionCard>
142
+ </div>
143
+ </div>
144
+ </div>
145
+ </div>
146
+ </RmDrawer>
147
+ </template>
148
+
149
+ <script setup lang="ts">
150
+ import { oipEsSearch } from "../../composables/useOipResources";
151
+ import { OIP_PATIENT_INDEXES } from "../../oip-patients/indexes";
152
+
153
+ const props = withDefaults(
154
+ defineProps<{
155
+ /** PMID de l'article ouvert, `null` quand le tiroir est ferme. */
156
+ pmid: string | null;
157
+ /** Termes a surligner, ceux de la recherche du tableau. */
158
+ highlight?: string[];
159
+ lang?: "fr" | "en";
160
+ }>(),
161
+ { highlight: () => [], lang: "fr" },
162
+ );
163
+
164
+ const emit = defineEmits<{ "update:pmid": [value: string | null] }>();
165
+
166
+ const lang = computed(() => props.lang);
167
+ const fr = computed(() => lang.value === "fr");
168
+
169
+ interface PubmedPaper {
170
+ PubMed_ID: string;
171
+ title?: string;
172
+ article_date?: string;
173
+ journal?: string;
174
+ doi?: string;
175
+ delete?: boolean;
176
+ deleted?: boolean;
177
+ publication_types?: string[];
178
+ chemical_list?: string[];
179
+ keywords?: string[];
180
+ mesh_terms?: string[];
181
+ rare_diseases?: { concat_disease_name_orpha_code?: string }[];
182
+ diseases?: { icd10?: { combined_label?: string }[]; section?: { label?: string }[] }[];
183
+ sections?: { label?: string; text?: string; order?: number }[];
184
+ authors?: { forename?: string; lastname?: string; affiliation?: string }[];
185
+ }
186
+
187
+ const paper = ref<PubmedPaper | null>(null);
188
+ const loading = ref(false);
189
+ const error = ref(false);
190
+ let requestId = 0;
191
+
192
+ async function load() {
193
+ const pmid = props.pmid;
194
+ if (pmid === null) return;
195
+ const id = ++requestId;
196
+ loading.value = true;
197
+ error.value = false;
198
+ paper.value = null;
199
+ try {
200
+ const res: any = await oipEsSearch("science", OIP_PATIENT_INDEXES.PUBMED, {
201
+ size: 1,
202
+ _source: { excludes: ["embedding", "search_text"] },
203
+ query: { term: { PubMed_ID: pmid } },
204
+ });
205
+ if (id !== requestId) return;
206
+ paper.value = res?.hits?.hits?.[0]?._source ?? null;
207
+ } catch (err) {
208
+ if (id !== requestId) return;
209
+ console.error("PubMed paper failed:", err);
210
+ error.value = true;
211
+ } finally {
212
+ if (id === requestId) loading.value = false;
213
+ }
214
+ }
215
+
216
+ watch(() => props.pmid, load, { immediate: true });
217
+
218
+ /** Chaque article s'ouvre sur son resume. */
219
+ const tab = ref<"summary" | "authors">("summary");
220
+ watch(
221
+ () => props.pmid,
222
+ () => (tab.value = "summary"),
223
+ );
224
+
225
+ const headerTabs = computed(() => [
226
+ { key: "summary", label: fr.value ? "Résumé" : "Summary", active: tab.value === "summary" },
227
+ { key: "authors", label: fr.value ? "Auteurs" : "Authors", count: authors.value.length, active: tab.value === "authors" },
228
+ ]);
229
+
230
+ const unique = (values: (string | undefined)[]) => [...new Set(values.filter((v): v is string => !!v && v.trim() !== ""))];
231
+
232
+ /** Les groupes de l'introduction, vides compris : la fiche d'origine dit « Aucun élément ». */
233
+ const tagGroups = computed(() => {
234
+ const p = paper.value;
235
+ if (!p) return [];
236
+ return [
237
+ { icon: "Article", label: fr.value ? "Type d'article" : "Article type", values: unique(p.publication_types ?? []) },
238
+ { icon: "BookOpen", label: fr.value ? "Nom de la revue" : "Journal name", values: unique([p.journal]) },
239
+ { icon: "Molecule", label: fr.value ? "Molécules" : "Molecules", values: unique(p.chemical_list ?? []) },
240
+ { icon: "TextAa", label: fr.value ? "Mots-clés" : "Keywords", values: unique(p.keywords ?? []) },
241
+ { icon: "Tag", label: fr.value ? "Termes MeSH" : "MeSH terms", values: unique(p.mesh_terms ?? []) },
242
+ { icon: "Bandaids", label: fr.value ? "Maladies rares" : "Rare diseases", values: unique((p.rare_diseases ?? []).map((d) => d.concat_disease_name_orpha_code)) },
243
+ { icon: "FaceMask", label: fr.value ? "CIM-10" : "ICD-10", values: unique((p.diseases ?? []).flatMap((d) => (d.icd10 ?? []).map((i) => i.combined_label))) },
244
+ { icon: "ListMagnifyingGlass", label: fr.value ? "Termes oncologiques" : "Oncology terms", values: unique((p.diseases ?? []).flatMap((d) => (d.section ?? []).map((s) => s.label))) },
245
+ ];
246
+ });
247
+
248
+ const sections = computed(() => [...(paper.value?.sections ?? [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)));
249
+
250
+ /** Les libelles de section arrivent en majuscules (« BACKGROUND ») ou vides. */
251
+ const sectionName = (label?: string) => {
252
+ if (!label || label.toUpperCase() === "UNASSIGNED") return fr.value ? "Résumé" : "Abstract";
253
+ return label.charAt(0).toUpperCase() + label.slice(1).toLowerCase();
254
+ };
255
+
256
+ const authors = computed(() =>
257
+ (paper.value?.authors ?? []).map((a) => ({
258
+ name: [a.forename, a.lastname].filter(Boolean).join(" ") || "-",
259
+ affiliation: a.affiliation || "-",
260
+ })),
261
+ );
262
+
263
+ const authorColumns = computed(() => [
264
+ { key: "name", label: fr.value ? "Nom" : "Name", sortable: true, rowHeader: true },
265
+ { key: "affiliation", label: "Affiliation" },
266
+ ]);
267
+
268
+ const hasLinks = computed(() => !paper.value?.delete || !!paper.value?.doi);
269
+
270
+ /** Le sommaire de l'onglet Resume : Introduction, les sections du resume, Liens. */
271
+ const chapters = computed(() => [
272
+ { label: "Introduction", value: "pubmed-introduction" },
273
+ ...sections.value.map((section, index) => ({ label: sectionName(section.label), value: `pubmed-section-${index}` })),
274
+ ...(hasLinks.value ? [{ label: fr.value ? "Liens" : "Links", value: "pubmed-links" }] : []),
275
+ ]);
276
+
277
+ /** RmChaptersNav defile la fenetre ; ici c'est le corps du tiroir qui defile. */
278
+ function scrollToChapter(id: string) {
279
+ document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
280
+ }
281
+
282
+ const formatDate = (iso?: string) =>
283
+ iso ? new Date(iso).toLocaleDateString(fr.value ? "fr-FR" : "en-US", { year: "numeric", month: "long", day: "numeric" }) : "-";
284
+ </script>
285
+
286
+ <style scoped>
287
+ /* Le bandeau reste en haut, seul le contenu defile : le sommaire colle a 0. */
288
+ .pubmed_drawer {
289
+ display: flex;
290
+ flex-direction: column;
291
+ height: 100%;
292
+ }
293
+
294
+ .pubmed_drawer :deep(.rm-page-header__title) {
295
+ font-size: 24px;
296
+ line-height: 1.25;
297
+ }
298
+
299
+ .drawer_scroll {
300
+ flex: 1 1 auto;
301
+ min-height: 0;
302
+ overflow-y: auto;
303
+ padding: var(--space-24);
304
+ }
305
+
306
+ .drawer_layout {
307
+ align-items: flex-start;
308
+ }
309
+
310
+ /* Le sommaire colle en haut du corps du tiroir, qui est le conteneur qui defile. */
311
+ .drawer_chapters {
312
+ position: sticky;
313
+ top: 0;
314
+ flex: 0 0 auto;
315
+ }
316
+
317
+ /* Sans min-width, l'item flex ne descend pas sous son enfant le plus large. */
318
+ .drawer_content {
319
+ flex: 1 1 auto;
320
+ min-width: 0;
321
+ }
322
+
323
+ .drawer_section {
324
+ margin: 0;
325
+ font-size: 16px;
326
+ font-weight: 700;
327
+ }
328
+
329
+ .drawer_field {
330
+ margin: 0;
331
+ font-size: 12px;
332
+ font-weight: 600;
333
+ color: var(--text-primary);
334
+ text-decoration: underline;
335
+ }
336
+
337
+ .drawer_label {
338
+ font-size: 12px;
339
+ color: var(--text-muted);
340
+ }
341
+
342
+ .drawer_text {
343
+ margin: 0;
344
+ font-size: 14px;
345
+ line-height: 1.55;
346
+ color: var(--text-primary);
347
+ }
348
+
349
+ .drawer_abstract {
350
+ white-space: pre-line;
351
+ }
352
+
353
+ .drawer_none {
354
+ margin: 0;
355
+ font-size: 12px;
356
+ font-style: italic;
357
+ color: var(--text-secondary);
358
+ }
359
+
360
+ .drawer_link {
361
+ width: fit-content;
362
+ font-size: 14px;
363
+ color: var(--primary);
364
+ text-decoration: underline;
365
+ word-break: break-all;
366
+ }
367
+ </style>
368
+
369
+ <style>
370
+ /* Hors portee : l'en-tete et le corps appartiennent au tiroir (teleporte). */
371
+ .rm-drawer:has(.pubmed_drawer) > .rm-drawer__header {
372
+ display: none;
373
+ }
374
+
375
+ .rm-drawer:has(.pubmed_drawer) > .rm-drawer__body {
376
+ padding: 0;
377
+ overflow: hidden;
378
+ }
379
+ </style>