@tekkare/romulus 0.1.242 → 0.1.244

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 (45) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/components/AppUpdateToast.vue +157 -0
  3. package/layer/app/components/OipShell.vue +2 -0
  4. package/layer/app/components/auth/SplitLayout.vue +261 -0
  5. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  6. package/layer/app/components/layout/firstVisitVideo.vue +165 -0
  7. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  8. package/layer/app/components/layout/tool/alerts.vue +969 -0
  9. package/layer/app/components/layout/tool/export.vue +590 -0
  10. package/layer/app/components/layout/tool/views.vue +933 -0
  11. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  12. package/layer/app/components/layout/viewPill.vue +550 -0
  13. package/layer/app/components/panels/OipPanelProjects.vue +94 -0
  14. package/layer/app/composables/useAdminAccess.ts +86 -0
  15. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  16. package/layer/app/composables/useAlertSource.ts +210 -0
  17. package/layer/app/composables/useAlerts.ts +345 -0
  18. package/layer/app/composables/useExportSource.ts +236 -0
  19. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  20. package/layer/app/composables/usePermalink.ts +108 -0
  21. package/layer/app/composables/useSavedExports.ts +286 -0
  22. package/layer/app/composables/useViewCardActions.ts +500 -0
  23. package/layer/app/composables/useViewEditing.ts +11 -0
  24. package/layer/app/composables/useViewTemplates.ts +369 -0
  25. package/layer/app/composables/useViews.ts +574 -0
  26. package/layer/app/oip-defaults/alerts.ts +34 -0
  27. package/layer/app/oip-defaults/api.ts +18 -0
  28. package/layer/app/oip-defaults/export.ts +23 -0
  29. package/layer/app/oip-defaults/ui.ts +21 -0
  30. package/layer/app/oip-defaults/views.ts +59 -0
  31. package/layer/app/utils/alertFieldLabels.ts +191 -0
  32. package/layer/app/utils/countries.ts +30 -0
  33. package/layer/app/utils/exportPage.ts +418 -0
  34. package/layer/app/utils/headerTabs.ts +15 -0
  35. package/layer/app/utils/hybridRows.ts +22 -0
  36. package/layer/app/utils/oipScopeStore.ts +112 -0
  37. package/layer/app/utils/oipScopes.ts +376 -0
  38. package/layer/app/utils/postLoginRedirect.ts +58 -0
  39. package/layer/app/utils/valueTable.ts +126 -0
  40. package/layer/app/utils/viewPages.ts +30 -0
  41. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  42. package/layer/app/utils/viewTemplates.ts +116 -0
  43. package/layer/nuxt.config.ts +9 -0
  44. package/layer/server/api/oip/notes.get.ts +1 -1
  45. package/package.json +4 -2
package/dist/module.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "compatibility": {
5
5
  "nuxt": ">=3.16.0"
6
6
  },
7
- "version": "0.1.241",
7
+ "version": "0.1.243",
8
8
  "builder": {
9
9
  "@nuxt/module-builder": "0.8.4",
10
10
  "unbuild": "2.0.0"
@@ -0,0 +1,157 @@
1
+ <!-- "A new version is available" prompt.
2
+ Nuxt's own check-outdated-build plugin polls /_nuxt/builds/latest.json and
3
+ fires `app:manifest:update` when the deployed build id stops matching the
4
+ one this tab booted with. Nuxt then hard-reloads on the next route change —
5
+ silently, and possibly mid-task. This toast surfaces that instead, so the
6
+ user reloads when it suits them; the built-in reload stays as the fallback. -->
7
+ <template>
8
+ <Transition name="update-toast">
9
+ <div v-if="visible" class="update-toast" role="status" aria-live="polite">
10
+ <span class="update-toast-mark" aria-hidden="true">
11
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
12
+ <path d="M21 12a9 9 0 1 1-2.64-6.36" />
13
+ <path d="M21 3v6h-6" />
14
+ </svg>
15
+ </span>
16
+ <div class="update-toast-body">
17
+ <p class="update-toast-title">
18
+ {{ locale === "en" ? "A new version is available" : "Une nouvelle version est disponible" }}
19
+ </p>
20
+ <p class="update-toast-text">
21
+ {{ locale === "en" ? "Reload to get the latest updates." : "Rechargez la page pour profiter des dernières mises à jour." }}
22
+ </p>
23
+ <div class="update-toast-actions">
24
+ <button type="button" class="update-toast-reload" @click="reload">
25
+ {{ locale === "en" ? "Reload" : "Recharger" }}
26
+ </button>
27
+ <button type="button" class="update-toast-later" @click="visible = false">
28
+ {{ locale === "en" ? "Later" : "Plus tard" }}
29
+ </button>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </Transition>
34
+ </template>
35
+
36
+ <script setup>
37
+ const nuxtApp = useNuxtApp();
38
+ // Textes bilingues par ternaire, comme les autres panneaux du cadre.
39
+ const { locale } = useI18n();
40
+
41
+ const visible = ref(false);
42
+
43
+ // Only ever fires client-side, so the server render and the hydrating render
44
+ // both agree on `false`.
45
+ const unhook = nuxtApp.hook('app:manifest:update', () => {
46
+ visible.value = true;
47
+ });
48
+ onUnmounted(() => unhook());
49
+
50
+ // force: true — reloadNuxtApp otherwise no-ops when the same path was already
51
+ // reloaded in the last 10s (its sessionStorage guard), which would leave the
52
+ // button dead. The path carries the query so filters survive the reload, and
53
+ // persistState stays false: the point is to boot the new build clean.
54
+ const reload = () =>
55
+ reloadNuxtApp({
56
+ path: window.location.pathname + window.location.search,
57
+ persistState: false,
58
+ force: true,
59
+ });
60
+ </script>
61
+
62
+ <style scoped>
63
+ .update-toast {
64
+ position: fixed;
65
+ right: 20px;
66
+ bottom: 20px;
67
+ z-index: 70;
68
+ display: flex;
69
+ gap: 11px;
70
+ max-width: 340px;
71
+ padding: 14px 16px;
72
+ border-radius: 12px;
73
+ border: 1px solid #dbdef0;
74
+ background: var(--bg-surface);
75
+ box-shadow: 0 10px 30px rgba(30, 41, 59, 0.13);
76
+ }
77
+
78
+ .update-toast-body {
79
+ min-width: 0;
80
+ }
81
+
82
+ .update-toast-title {
83
+ font-weight: 600;
84
+ font-size: 14px;
85
+ color: #1e293b;
86
+ }
87
+
88
+ .update-toast-text {
89
+ margin-top: 2px;
90
+ font-size: 12px;
91
+ color: #878ca6;
92
+ }
93
+
94
+ .update-toast-actions {
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 8px;
98
+ margin-top: 10px;
99
+ }
100
+
101
+ .update-toast-mark {
102
+ flex-shrink: 0;
103
+ display: flex;
104
+ align-items: center;
105
+ justify-content: center;
106
+ width: 28px;
107
+ height: 28px;
108
+ border-radius: 9999px;
109
+ color: var(--primary, #2017ad);
110
+ background: color-mix(in srgb, var(--primary, #2017ad) 9%, transparent);
111
+ }
112
+
113
+ .update-toast-reload,
114
+ .update-toast-later {
115
+ font-size: 13px;
116
+ font-weight: 600;
117
+ border-radius: 7px;
118
+ padding: 6px 12px;
119
+ transition: background-color 0.15s ease, color 0.15s ease;
120
+ }
121
+
122
+ .update-toast-reload {
123
+ color: #ffffff;
124
+ background: var(--primary, #2017ad);
125
+ }
126
+
127
+ .update-toast-reload:hover {
128
+ filter: brightness(0.9);
129
+ }
130
+
131
+ .update-toast-later {
132
+ color: #878ca6;
133
+ }
134
+
135
+ .update-toast-later:hover {
136
+ color: #475569;
137
+ background: var(--bg-surface-3);
138
+ }
139
+
140
+ .update-toast-enter-active,
141
+ .update-toast-leave-active {
142
+ transition: opacity 0.22s ease, transform 0.22s ease;
143
+ }
144
+
145
+ .update-toast-enter-from,
146
+ .update-toast-leave-to {
147
+ opacity: 0;
148
+ transform: translateY(10px);
149
+ }
150
+
151
+ @media (prefers-reduced-motion: reduce) {
152
+ .update-toast-enter-active,
153
+ .update-toast-leave-active {
154
+ transition: none;
155
+ }
156
+ }
157
+ </style>
@@ -29,6 +29,7 @@ import {
29
29
  import OipPanelDocuments from "./panels/OipPanelDocuments.vue";
30
30
  import OipPanelMetadata from "./panels/OipPanelMetadata.vue";
31
31
  import OipPanelNotes from "./panels/OipPanelNotes.vue";
32
+ import OipPanelProjects from "./panels/OipPanelProjects.vue";
32
33
  import OipPanelSources from "./panels/OipPanelSources.vue";
33
34
  import OipPanelSourceTables from "./panels/OipPanelSourceTables.vue";
34
35
  import OipPanelTopics from "./panels/OipPanelTopics.vue";
@@ -42,6 +43,7 @@ const LAYER_PANELS: Partial<Record<OipRailTileKey, Component>> = {
42
43
  Documents: OipPanelDocuments,
43
44
  Metadata: OipPanelMetadata,
44
45
  Notes: OipPanelNotes,
46
+ Projects: OipPanelProjects,
45
47
  Sources: OipPanelSources,
46
48
  SourcesTables: OipPanelSourceTables,
47
49
  Topics: OipPanelTopics,
@@ -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,143 @@
1
+ <!--
2
+ Demande posee quand une vue surveillee par une alerte de marque change de
3
+ produits.
4
+
5
+ Le composant ne rend rien tant que `useAlertBrandSync` n'a rien a demander :
6
+ il est monte dans les layouts a cote de la snack bar, parce que la question
7
+ nait de deux endroits (le panneau « Mes vues » et la pastille de vue de la
8
+ barre de filtres) et qu'il n'y a pas de raison d'en avoir deux copies.
9
+ -->
10
+ <script setup lang="ts">
11
+ const { locale } = useI18n();
12
+ const sync = useAlertBrandSync();
13
+
14
+ const fr = computed(() => locale.value !== "en");
15
+
16
+ /**
17
+ * Les marques ajoutees et retirees, plutot que les deux listes entieres.
18
+ *
19
+ * C'est la difference qui decide, et une vue de trente produits rendait la
20
+ * question illisible des qu'on montrait les listes cote a cote.
21
+ */
22
+ const added = computed(() => {
23
+ const current = new Set(sync.pending.value?.current ?? []);
24
+ return (sync.pending.value?.brands ?? []).filter((brand) => !current.has(brand));
25
+ });
26
+
27
+ const removed = computed(() => {
28
+ const next = new Set(sync.pending.value?.brands ?? []);
29
+ return (sync.pending.value?.current ?? []).filter((brand) => !next.has(brand));
30
+ });
31
+
32
+ /** Au-dela, la liste ne s'enumere plus : elle se compte. */
33
+ const NAMES = 5;
34
+
35
+ function summarize(list: string[]): string {
36
+ if (list.length <= NAMES) return list.join(", ");
37
+ const rest = list.length - NAMES;
38
+ return `${list.slice(0, NAMES).join(", ")} ${fr.value ? `et ${rest} autre${rest > 1 ? "s" : ""}` : `and ${rest} more`}`;
39
+ }
40
+
41
+ const title = computed(() =>
42
+ fr.value ? "Mettre l’alerte à jour ?" : "Update the alert?",
43
+ );
44
+ </script>
45
+
46
+ <template>
47
+ <RmModal
48
+ :model-value="!!sync.pending.value"
49
+ mode="free"
50
+ size="sm"
51
+ :label="title"
52
+ @update:model-value="sync.dismiss()"
53
+ >
54
+ <template #header>{{ title }}</template>
55
+
56
+ <div v-if="sync.pending.value" class="abs_body">
57
+ <p class="abs_lead">
58
+ {{
59
+ fr
60
+ ? `Cette vue est surveillée par « ${sync.pending.value.alert.title} », qui suit encore les produits d’avant.`
61
+ : `This view is watched by “${sync.pending.value.alert.title}”, still following the previous products.`
62
+ }}
63
+ </p>
64
+
65
+ <ul class="abs_changes">
66
+ <li v-if="added.length">
67
+ <strong>{{ fr ? "Ajoutées" : "Added" }}</strong> : {{ summarize(added) }}
68
+ </li>
69
+ <li v-if="removed.length">
70
+ <strong>{{ fr ? "Retirées" : "Removed" }}</strong> : {{ summarize(removed) }}
71
+ </li>
72
+ </ul>
73
+
74
+ <!-- Dit d'avance ce que « Mettre à jour » coûte : l'alerte repart d'une
75
+ référence neuve, donc elle ne rapportera rien de ce qui a bougé
76
+ entre-temps. Le découvrir après coup passerait pour une panne. -->
77
+ <p class="abs_note">
78
+ {{
79
+ fr
80
+ ? "L’alerte repart d’une nouvelle référence : elle ne signalera que ce qui bouge à partir de maintenant."
81
+ : "The alert starts from a fresh baseline: it will only report what moves from now on."
82
+ }}
83
+ </p>
84
+
85
+ <div class="abs_actions">
86
+ <RmButton variant="ghost" size="sm" @click="sync.dismiss()">
87
+ {{ fr ? "Laisser telle quelle" : "Leave it as is" }}
88
+ </RmButton>
89
+ <RmButton
90
+ variant="primary"
91
+ size="sm"
92
+ :disabled="sync.syncing.value"
93
+ @click="sync.confirm()"
94
+ >
95
+ {{ fr ? "Mettre à jour" : "Update" }}
96
+ </RmButton>
97
+ </div>
98
+ </div>
99
+ </RmModal>
100
+ </template>
101
+
102
+ <style scoped>
103
+ .abs_body {
104
+ display: flex;
105
+ flex-direction: column;
106
+ gap: 12px;
107
+ }
108
+
109
+ .abs_lead {
110
+ margin: 0;
111
+ color: var(--text-primary);
112
+ font-size: 13px;
113
+ line-height: 1.5;
114
+ }
115
+
116
+ .abs_changes {
117
+ margin: 0;
118
+ padding: 10px 12px;
119
+ border-radius: 8px;
120
+ background: var(--bg-surface-3);
121
+ list-style: none;
122
+ }
123
+
124
+ .abs_changes li {
125
+ color: var(--text-secondary);
126
+ font-size: 12px;
127
+ line-height: 1.6;
128
+ }
129
+
130
+ .abs_note {
131
+ margin: 0;
132
+ color: var(--text-secondary);
133
+ font-size: 12px;
134
+ line-height: 1.5;
135
+ }
136
+
137
+ .abs_actions {
138
+ display: flex;
139
+ justify-content: flex-end;
140
+ gap: 8px;
141
+ margin-top: 4px;
142
+ }
143
+ </style>