@tekkare/romulus 0.1.241 → 0.1.243

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.
Files changed (64) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +90 -0
  3. package/layer/app/assets/tekkare_logo.svg +20 -0
  4. package/layer/app/components/AppUpdateToast.vue +157 -0
  5. package/layer/app/components/OipDirectusSources.vue +192 -0
  6. package/layer/app/components/OipDocumentPreview.vue +158 -0
  7. package/layer/app/components/OipMetadataEditDialog.vue +350 -0
  8. package/layer/app/components/OipNoteEditDialog.vue +382 -0
  9. package/layer/app/components/OipShell.vue +713 -0
  10. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  11. package/layer/app/components/OipSourceGroups.vue +107 -0
  12. package/layer/app/components/auth/SplitLayout.vue +261 -0
  13. package/layer/app/components/layout/firstVisitVideo.vue +165 -0
  14. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  15. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  16. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  17. package/layer/app/components/panels/OipPanelProjects.vue +94 -0
  18. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  19. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  20. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  21. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  22. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  23. package/layer/app/composables/useOipEsIndices.ts +57 -0
  24. package/layer/app/composables/useOipFavorites.ts +69 -0
  25. package/layer/app/composables/useOipNotes.ts +90 -0
  26. package/layer/app/composables/useOipNotify.ts +21 -0
  27. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  28. package/layer/app/composables/useViewEditing.ts +11 -0
  29. package/layer/app/layouts/anonymous.vue +6 -0
  30. package/layer/app/layouts/error.vue +38 -0
  31. package/layer/app/notes/categories.ts +46 -0
  32. package/layer/app/notes/types.ts +100 -0
  33. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  34. package/layer/app/types/metadata.ts +38 -0
  35. package/layer/app/types/oip.ts +60 -0
  36. package/layer/app/types/tableGroups.ts +42 -0
  37. package/layer/app/utils/countries.ts +30 -0
  38. package/layer/app/utils/exportPage.ts +418 -0
  39. package/layer/app/utils/headerTabs.ts +15 -0
  40. package/layer/app/utils/hybridRows.ts +22 -0
  41. package/layer/app/utils/oipRail.ts +82 -0
  42. package/layer/app/utils/postLoginRedirect.ts +58 -0
  43. package/layer/app/utils/valueTable.ts +126 -0
  44. package/layer/nuxt.config.ts +32 -0
  45. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  46. package/layer/server/api/oip/documents.get.ts +92 -0
  47. package/layer/server/api/oip/es-indices.get.ts +21 -0
  48. package/layer/server/api/oip/favorites.get.ts +107 -0
  49. package/layer/server/api/oip/metadata.get.ts +109 -0
  50. package/layer/server/api/oip/metadata.patch.ts +163 -0
  51. package/layer/server/api/oip/note-options.get.ts +89 -0
  52. package/layer/server/api/oip/note.get.ts +95 -0
  53. package/layer/server/api/oip/notes.get.ts +173 -0
  54. package/layer/server/api/oip/notes.patch.ts +198 -0
  55. package/layer/server/api/oip/sources.patch.ts +145 -0
  56. package/layer/server/api/oip/sources.post.ts +88 -0
  57. package/layer/server/api/oip/table-groups.get.ts +145 -0
  58. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  59. package/layer/server/api/romulus/version.get.ts +4 -0
  60. package/layer/server/utils/oipAuth.ts +69 -0
  61. package/layer/server/utils/oipEsIndices.ts +99 -0
  62. package/layer/server/utils/oipGuards.ts +90 -0
  63. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  64. package/package.json +6 -3
@@ -0,0 +1,261 @@
1
+ <template>
2
+ <div class="auth-split">
3
+ <!-- Left brand panel (generic OIP Healthcare — reusable across apps) -->
4
+ <aside class="brand-panel">
5
+ <div class="blob b1"></div>
6
+ <div class="blob b2"></div>
7
+ <div class="blob b3"></div>
8
+
9
+ <div class="brand-logo">
10
+ <img src="https://rcd-media.com/skills/Tekkare_Baseline_White.svg" alt="Tekkare" />
11
+ </div>
12
+
13
+ <div class="brand-copy">
14
+ <span class="brand-eyebrow">OIP Healthcare<sup>®</sup></span>
15
+ <h1 v-if="fr">Des données de santé, <em>enfin exploitables</em>.</h1>
16
+ <h1 v-else>Health data, <em>finally usable</em>.</h1>
17
+ <p>{{ fr ? "Une vue à 360 degrés des systèmes de santé dans le monde, construite entièrement sur des données ouvertes officielles." : "A 360 degree view of health systems worldwide, built entirely on official open data." }}</p>
18
+ <div class="brand-points">
19
+ <div class="brand-point"><i class="ph ph-database"></i> {{ fr ? "Données ouvertes officielles, normalisées et prêtes à l'emploi" : "Official open data, normalized and ready to use" }}</div>
20
+ <div class="brand-point"><i class="ph ph-squares-four"></i> {{ fr ? "Des applications spécialisées dans chaque domaine de la santé" : "Specialized apps across every health domain" }}</div>
21
+ <div class="brand-point"><i class="ph ph-shield-check"></i> {{ fr ? "Uniquement des données ouvertes, conformes au RGPD par conception" : "Open data only, GDPR compliant by design" }}</div>
22
+ </div>
23
+ </div>
24
+
25
+ <div class="brand-foot">
26
+ <strong>TEKKARE®</strong><br />
27
+ Inspired by Healthcare
28
+ </div>
29
+ </aside>
30
+
31
+ <!-- Right form panel — host app drops its content here -->
32
+ <main class="form-panel">
33
+ <div class="form-panel-inner">
34
+ <slot />
35
+ </div>
36
+ </main>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup lang="ts">
41
+ const { locale } = useI18n();
42
+ const fr = computed(() => locale.value === "fr");
43
+ </script>
44
+
45
+ <style scoped>
46
+ .auth-split {
47
+ --tkr-primary: var(--primary);
48
+ --tkr-secondary: #b485ff;
49
+ --tkr-darkest: #1e293b;
50
+ --tkr-dark: #525670;
51
+ --tkr-medium: #878ca6;
52
+ --tkr-light: #dbdef0;
53
+ --tkr-lightest: #f5f5f9;
54
+ --tkr-bg: #f9f9fa;
55
+ --tkr-grad: linear-gradient(135deg, #4dccf7 0%, #a06bf5 50%, #ff5cf0 100%);
56
+ --tkr-grad-btn: linear-gradient(135deg, var(--primary), var(--primary-hover));
57
+ --tkr-error: #e53535;
58
+
59
+ display: grid;
60
+ grid-template-columns: 1.05fr 1fr;
61
+ min-height: 100vh;
62
+ color: var(--tkr-darkest);
63
+ font-family: "Figtree", "Avenir", Arial, sans-serif;
64
+ }
65
+
66
+ /* ── Brand panel ── */
67
+ .brand-panel {
68
+ position: relative;
69
+ background: #16115c;
70
+ color: #fff;
71
+ padding: 56px 64px;
72
+ display: flex;
73
+ flex-direction: column;
74
+ justify-content: space-between;
75
+ overflow: hidden;
76
+ }
77
+
78
+ .brand-panel .blob {
79
+ position: absolute;
80
+ z-index: 0;
81
+ border-radius: 50%;
82
+ filter: blur(66px);
83
+ will-change: transform;
84
+ }
85
+
86
+ .brand-panel .blob.b1 {
87
+ width: 420px;
88
+ height: 420px;
89
+ background: #4dccf7;
90
+ top: -120px;
91
+ right: -80px;
92
+ opacity: 0.46;
93
+ animation: brandDrift 30s ease-in-out infinite;
94
+ }
95
+
96
+ .brand-panel .blob.b2 {
97
+ width: 360px;
98
+ height: 360px;
99
+ background: #a06bf5;
100
+ bottom: -100px;
101
+ left: -60px;
102
+ opacity: 0.45;
103
+ animation: brandDrift 34s ease-in-out infinite;
104
+ animation-delay: -11s;
105
+ }
106
+
107
+ .brand-panel .blob.b3 {
108
+ width: 300px;
109
+ height: 300px;
110
+ background: #ff5cf0;
111
+ top: 38%;
112
+ right: 18%;
113
+ opacity: 0.3;
114
+ animation: brandDrift 38s ease-in-out infinite;
115
+ animation-delay: -22s;
116
+ }
117
+
118
+ @keyframes brandDrift {
119
+ 0%,
120
+ 100% {
121
+ transform: translate(0, 0);
122
+ }
123
+ 50% {
124
+ transform: translate(-26px, 32px);
125
+ }
126
+ }
127
+
128
+ .brand-panel > *:not(.blob) {
129
+ position: relative;
130
+ z-index: 1;
131
+ }
132
+
133
+ .brand-logo img {
134
+ height: 34px;
135
+ }
136
+
137
+ .brand-copy {
138
+ max-width: 440px;
139
+ }
140
+
141
+ .brand-eyebrow {
142
+ display: inline-flex;
143
+ align-items: center;
144
+ gap: 8px;
145
+ background: rgba(255, 255, 255, 0.12);
146
+ border: 1px solid rgba(255, 255, 255, 0.22);
147
+ padding: 6px 14px;
148
+ border-radius: 999px;
149
+ font-size: 12px;
150
+ font-weight: 600;
151
+ letter-spacing: 0.04em;
152
+ margin-bottom: 26px;
153
+ backdrop-filter: blur(4px);
154
+ }
155
+
156
+ .brand-eyebrow sup {
157
+ font-size: 0.7em;
158
+ vertical-align: super;
159
+ line-height: 0;
160
+ margin-left: 1px;
161
+ }
162
+
163
+ .brand-copy h1 {
164
+ font-weight: 600;
165
+ font-size: 40px;
166
+ line-height: 1.16;
167
+ margin: 0 0 18px;
168
+ }
169
+
170
+ .brand-copy h1 em {
171
+ font-family: "Caudex", Georgia, serif;
172
+ font-weight: 700;
173
+ font-style: normal;
174
+ background: var(--tkr-grad);
175
+ -webkit-background-clip: text;
176
+ background-clip: text;
177
+ color: transparent;
178
+ }
179
+
180
+ .brand-copy p {
181
+ font-size: 15px;
182
+ line-height: 1.6;
183
+ color: rgba(255, 255, 255, 0.85);
184
+ margin: 0;
185
+ }
186
+
187
+ .brand-points {
188
+ display: flex;
189
+ flex-direction: column;
190
+ gap: 13px;
191
+ margin-top: 32px;
192
+ }
193
+
194
+ .brand-point {
195
+ display: flex;
196
+ align-items: center;
197
+ gap: 12px;
198
+ font-size: 14px;
199
+ color: rgba(255, 255, 255, 0.92);
200
+ }
201
+
202
+ .brand-point i {
203
+ font-size: 18px;
204
+ color: #fff;
205
+ background: rgba(255, 255, 255, 0.12);
206
+ border-radius: 8px;
207
+ width: 34px;
208
+ height: 34px;
209
+ display: inline-flex;
210
+ align-items: center;
211
+ justify-content: center;
212
+ flex-shrink: 0;
213
+ }
214
+
215
+ .brand-foot {
216
+ font-size: 12px;
217
+ color: rgba(255, 255, 255, 0.7);
218
+ line-height: 1.6;
219
+ }
220
+
221
+ /* ── Form panel (host content via slot) ── */
222
+ .form-panel {
223
+ display: flex;
224
+ padding: 48px 32px;
225
+ background: var(--tkr-bg);
226
+ overflow-y: auto;
227
+ }
228
+
229
+ .form-panel-inner {
230
+ margin: auto;
231
+ width: 100%;
232
+ display: flex;
233
+ justify-content: center;
234
+ }
235
+
236
+ .brand-panel *:focus-visible {
237
+ outline: 2px solid var(--tkr-secondary);
238
+ outline-offset: 2px;
239
+ }
240
+
241
+ /* ── Responsive ── */
242
+ @media (max-width: 880px) {
243
+ .auth-split {
244
+ grid-template-columns: 1fr;
245
+ }
246
+
247
+ .brand-panel {
248
+ display: none;
249
+ }
250
+
251
+ .form-panel {
252
+ padding: 40px 24px;
253
+ }
254
+ }
255
+
256
+ @media (prefers-reduced-motion: reduce) {
257
+ .brand-panel .blob {
258
+ animation: none;
259
+ }
260
+ }
261
+ </style>
@@ -0,0 +1,165 @@
1
+ <template>
2
+ <aside v-if="visible && video?.src" class="first-visit-video" aria-label="Tutoriel vidéo">
3
+ <div class="first-visit-video__head">
4
+ <div class="first-visit-video__heading">
5
+ <RmIcon name="PlayCircle" :size="18" />
6
+ <strong>{{ lang === "en" ? "Getting started" : "Bien démarrer" }}</strong>
7
+ </div>
8
+ <button
9
+ type="button"
10
+ class="first-visit-video__close"
11
+ :aria-label="lang === 'en' ? 'Close' : 'Fermer'"
12
+ @click="dismiss"
13
+ >
14
+ <RmIcon name="X" :size="16" />
15
+ </button>
16
+ </div>
17
+
18
+ <video
19
+ class="first-visit-video__player"
20
+ :src="video.src"
21
+ autoplay
22
+ controls
23
+ muted
24
+ playsinline
25
+ preload="metadata"
26
+ ></video>
27
+
28
+ <div class="first-visit-video__footer">
29
+ <span>{{ video.title }}</span>
30
+ <button type="button" class="first-visit-video__dismiss" @click="dismiss">
31
+ {{ lang === "en" ? "Got it" : "J’ai compris" }}
32
+ </button>
33
+ </div>
34
+ </aside>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ interface TutorialVideo {
39
+ id: number;
40
+ title: string;
41
+ description: string;
42
+ src: string;
43
+ }
44
+
45
+ const props = defineProps<{
46
+ video: TutorialVideo | null;
47
+ lang: "fr" | "en";
48
+ }>();
49
+
50
+ const STORAGE_KEY = "oip-first-visit-video-seen";
51
+ const visible = ref(false);
52
+
53
+ function hasSeenVideo() {
54
+ try {
55
+ return localStorage.getItem(STORAGE_KEY) === "1";
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function dismiss() {
62
+ visible.value = false;
63
+ try {
64
+ localStorage.setItem(STORAGE_KEY, "1");
65
+ } catch {
66
+ // Le navigateur peut refuser le stockage, la fenêtre reste fermée pour la session.
67
+ }
68
+ }
69
+
70
+ watch(
71
+ () => props.video?.src,
72
+ (src) => {
73
+ if (src && !hasSeenVideo()) visible.value = true;
74
+ },
75
+ { immediate: true },
76
+ );
77
+ </script>
78
+
79
+ <style scoped>
80
+ .first-visit-video {
81
+ position: fixed;
82
+ right: 24px;
83
+ bottom: 24px;
84
+ z-index: 1100;
85
+ width: min(460px, calc(100vw - 32px));
86
+ overflow: hidden;
87
+ border: 1px solid var(--border-default);
88
+ border-radius: var(--radius-xl);
89
+ background: var(--bg-surface);
90
+ box-shadow: 0 18px 48px rgb(15 23 42 / 24%);
91
+ }
92
+
93
+ .first-visit-video__head,
94
+ .first-visit-video__footer {
95
+ align-items: center;
96
+ display: flex;
97
+ gap: var(--space-8);
98
+ justify-content: space-between;
99
+ padding: var(--space-12) var(--space-16);
100
+ }
101
+
102
+ .first-visit-video__head {
103
+ color: var(--color-secondary);
104
+ }
105
+
106
+ .first-visit-video__heading {
107
+ align-items: center;
108
+ display: flex;
109
+ gap: var(--space-8);
110
+ }
111
+
112
+ .first-visit-video__close {
113
+ align-items: center;
114
+ background: transparent;
115
+ border: 0;
116
+ color: var(--text-muted);
117
+ cursor: pointer;
118
+ display: flex;
119
+ justify-content: center;
120
+ padding: var(--space-4);
121
+ }
122
+
123
+ .first-visit-video__close:hover {
124
+ color: var(--text-primary);
125
+ }
126
+
127
+ .first-visit-video__player {
128
+ background: var(--bg-surface-3);
129
+ display: block;
130
+ max-height: 290px;
131
+ width: 100%;
132
+ }
133
+
134
+ .first-visit-video__footer {
135
+ color: var(--text-secondary);
136
+ font-size: var(--font-12);
137
+ }
138
+
139
+ .first-visit-video__footer > span {
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ white-space: nowrap;
143
+ }
144
+
145
+ .first-visit-video__dismiss {
146
+ background: transparent;
147
+ border: 0;
148
+ color: var(--color-secondary);
149
+ cursor: pointer;
150
+ flex-shrink: 0;
151
+ font: inherit;
152
+ font-weight: var(--weight-semibold);
153
+ }
154
+
155
+ .first-visit-video__dismiss:hover {
156
+ color: var(--color-primary);
157
+ }
158
+
159
+ @media (max-width: 600px) {
160
+ .first-visit-video {
161
+ bottom: 16px;
162
+ right: 16px;
163
+ }
164
+ }
165
+ </style>
@@ -0,0 +1,220 @@
1
+ <!--
2
+ Panneau « Documents » du rail, servi par le layer OIP (tuile Documents).
3
+
4
+ Les documents de reference du portail OIP 7 (licences de reutilisation,
5
+ rapports CEPS et Assurance Maladie, nomenclatures PMSI…), restreints a ceux
6
+ qui parlent des donnees de cette app. La selection, l'ordre et les libelles
7
+ des categories viennent de app.config.oip.panels.Documents ; la route
8
+ (`endpoint`) fait le tri, le panneau ne fait que rendre.
9
+
10
+ Une trentaine de lignes ne se parcourt pas d'un coup d'oeil : elles sont
11
+ regroupees par theme dans des sections repliables, chacune avec le nombre de
12
+ documents qu'elle porte. La premiere section est ouverte, les autres non.
13
+
14
+ Un PDF s'ouvre en apercu dans un tiroir, sans quitter la page : c'est fait
15
+ pour jeter un oeil, pas pour lire. Les autres formats (tableur, CSV) n'ont
16
+ pas d'apercu dans un navigateur et partent dans un onglet.
17
+
18
+ Lecture seule : les documents s'ajoutent dans Directus, pas ici.
19
+ -->
20
+ <script setup lang="ts">
21
+ interface DocumentRow {
22
+ id: number;
23
+ category: string;
24
+ title: string;
25
+ src: string;
26
+ downloadSrc: string;
27
+ mime: string | null;
28
+ }
29
+
30
+ const props = defineProps<{
31
+ /** Partie de l'app qui ouvre le panneau, passee par OipShell (`appKey`). */
32
+ appKey?: string;
33
+ superAdmin?: boolean;
34
+ /** Remplace `appKey` pour le filtre des documents. Vide : toute la liste. */
35
+ app?: string;
36
+ }>();
37
+
38
+ const options = useAppConfig().oip.panels.Documents;
39
+ const app = computed(() => props.app ?? props.appKey ?? "");
40
+
41
+ const { locale } = useI18n();
42
+
43
+ // Une seule requete pour toute la session : la liste ne bouge pas d'une
44
+ // ouverture du panneau a l'autre. `lazy` evite un `await` de premier niveau,
45
+ // qui rendrait ce composant asynchrone et ferait resuspendre la page a chaque
46
+ // ouverture de la tuile.
47
+ const { data, pending, error } = useAsyncData<DocumentRow[]>(
48
+ `oip-documents-${app.value || "all"}`,
49
+ () => $fetch<DocumentRow[]>(options.endpoint, { query: { app: app.value || undefined } }),
50
+ { server: false, lazy: true },
51
+ );
52
+
53
+ /** Libelles des categories (app.config), dans l'ordre ou le serveur les renvoie. */
54
+ const CATEGORIES = options.categories as Record<string, { fr: string; en: string }>;
55
+
56
+ const search = ref("");
57
+
58
+ const shown = computed(() => {
59
+ const query = search.value.trim().toLowerCase();
60
+ const rows = data.value ?? [];
61
+ if (!query) return rows;
62
+ return rows.filter((row) => row.title.toLowerCase().includes(query));
63
+ });
64
+
65
+ /** Les documents groupes par categorie, l'ordre du serveur conserve. */
66
+ const groups = computed(() => {
67
+ const out: { key: string; label: string; rows: DocumentRow[] }[] = [];
68
+ for (const row of shown.value) {
69
+ const label = CATEGORIES[row.category]?.[locale.value === "en" ? "en" : "fr"] ?? row.category;
70
+ const last = out[out.length - 1];
71
+ if (last?.key === row.category) last.rows.push(row);
72
+ else out.push({ key: row.category, label, rows: [row] });
73
+ }
74
+ return out;
75
+ });
76
+
77
+ /**
78
+ * Sections ouvertes. La premiere l'est au chargement, et une recherche ouvre
79
+ * tout : replier ce qui vient de repondre a la recherche n'aurait pas de sens.
80
+ */
81
+ const opened = ref<Record<string, boolean>>({});
82
+ const isOpen = (key: string, index: number) =>
83
+ opened.value[key] ?? (!!search.value.trim() || index === 0);
84
+ const setOpen = (key: string, value: boolean) => {
85
+ opened.value[key] = value;
86
+ };
87
+
88
+ /** Seuls les PDF s'affichent dans un cadre ; le reste se telechargerait. */
89
+ const previewable = (row: DocumentRow) => !!row.mime?.includes("pdf");
90
+
91
+ const preview = ref<DocumentRow | null>(null);
92
+ const previewOpen = computed({
93
+ get: () => !!preview.value,
94
+ set: (value: boolean) => {
95
+ if (!value) preview.value = null;
96
+ },
97
+ });
98
+
99
+ function openDocument(row: DocumentRow) {
100
+ if (previewable(row)) preview.value = row;
101
+ else window.open(row.src, "_blank", "noopener");
102
+ }
103
+ </script>
104
+
105
+ <template>
106
+ <div class="documents">
107
+ <p class="documents_intro">
108
+ {{
109
+ locale === "en"
110
+ ? "Reference documents behind the data of this app: licences, reports and coding manuals."
111
+ : "Les documents de référence derrière les données de cette app : licences, rapports et nomenclatures."
112
+ }}
113
+ </p>
114
+
115
+ <input
116
+ v-model="search"
117
+ class="documents_search"
118
+ type="search"
119
+ :placeholder="locale === 'en' ? 'Search a document' : 'Rechercher un document'"
120
+ >
121
+
122
+ <p v-if="pending && !data?.length" class="documents_note">
123
+ {{ locale === "en" ? "Loading…" : "Chargement…" }}
124
+ </p>
125
+
126
+ <p v-else-if="error" class="documents_note">
127
+ {{
128
+ locale === "en"
129
+ ? "Documents could not be loaded."
130
+ : "Les documents n'ont pas pu être chargés."
131
+ }}
132
+ </p>
133
+
134
+ <template v-else-if="groups.length">
135
+ <RmCollapse
136
+ v-for="(group, index) in groups"
137
+ :key="group.key"
138
+ :title="group.label"
139
+ :count="group.rows.length"
140
+ :model-value="isOpen(group.key, index)"
141
+ @update:model-value="setOpen(group.key, $event)"
142
+ >
143
+ <ul class="documents_list">
144
+ <li v-for="row in group.rows" :key="row.id">
145
+ <RmFileRow
146
+ :name="row.title"
147
+ :format="row.mime ?? ''"
148
+ :action="previewable(row) ? 'preview' : 'open'"
149
+ @select="openDocument(row)"
150
+ />
151
+ </li>
152
+ </ul>
153
+ </RmCollapse>
154
+ </template>
155
+
156
+ <p v-else-if="search.trim()" class="documents_note">
157
+ {{ locale === "en" ? "No document matches that search." : "Aucun document ne correspond." }}
158
+ </p>
159
+
160
+ <p v-else class="documents_note">
161
+ {{ locale === "en" ? "No document available." : "Aucun document disponible." }}
162
+ </p>
163
+
164
+ <!-- L'apercu : le PDF tel quel, dans la visionneuse du navigateur. Le
165
+ tiroir garde les deux sorties (onglet, telechargement) sous la main,
166
+ parce qu'un document qu'on lit vraiment se lit en plein ecran. Le
167
+ dessin vit dans `OipDocumentPreview`, partage avec les ecrans qui ouvrent
168
+ leurs propres PDF. -->
169
+ <OipDocumentPreview
170
+ v-model="previewOpen"
171
+ :src="preview?.src ?? null"
172
+ :title="preview?.title"
173
+ :download-src="preview?.downloadSrc ?? null"
174
+ />
175
+ </div>
176
+ </template>
177
+
178
+ <style scoped>
179
+ .documents {
180
+ display: flex;
181
+ flex-direction: column;
182
+ gap: 12px;
183
+ align-self: stretch;
184
+ width: 100%;
185
+ }
186
+
187
+ .documents_intro,
188
+ .documents_note {
189
+ margin: 0;
190
+ color: var(--text-muted);
191
+ font-size: var(--font-12);
192
+ line-height: 1.5;
193
+ }
194
+
195
+ .documents_search {
196
+ padding: 8px 10px;
197
+ border: 1px solid var(--border-default);
198
+ border-radius: var(--radius-md);
199
+ background: var(--bg-surface);
200
+ color: var(--text-primary);
201
+ font-family: var(--font-sans);
202
+ font-size: var(--font-13);
203
+ outline: none;
204
+ }
205
+
206
+ .documents_search:focus {
207
+ border-color: var(--color-secondary);
208
+ box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.1);
209
+ }
210
+
211
+ .documents_list {
212
+ display: flex;
213
+ flex-direction: column;
214
+ gap: 2px;
215
+ margin: 0;
216
+ padding: 0;
217
+ list-style: none;
218
+ }
219
+
220
+ </style>