@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,713 @@
1
+ <!--
2
+ Cadre OIP 7 commun aux apps, extrait de fr_drugs_app (layout/shell.vue).
3
+
4
+ Le chrome vient de Romulus (RmAppShell / RmSidebarLeft / RmAppHeader /
5
+ RmSidebarRight / RmRailPanel). Ce composant le cable sur ce que l'app lui
6
+ donne : sa navigation et ses tuiles en props, ce qui ne bouge pas (plateforme,
7
+ pays, parties du hub, langues) dans app.config.oip.
8
+
9
+ Ce qui depend d'une fonction de l'app (vues, alertes, tutoriels, projets,
10
+ metadonnees) n'est pas ici : l'app le pose dans les slots (`tool` pour les
11
+ panneaux, le slot par defaut pour la pastille de vue ou la visite guidee) et
12
+ pilote la tuile ouverte par `v-model:tool`. Seule l'accessibilite est servie
13
+ par le cadre : elle ne depend de rien.
14
+ -->
15
+ <script setup lang="ts">
16
+ import type { Component } from "vue";
17
+ import type {
18
+ OipExternalCrumb,
19
+ OipNavSection,
20
+ OipRailTile,
21
+ } from "../types/oip";
22
+ import {
23
+ OIP_RAIL_GROUPS,
24
+ OIP_RAIL_TILES,
25
+ type OipRailGroupKey,
26
+ type OipRailTileKey,
27
+ type OipRailTileStates,
28
+ } from "../utils/oipRail";
29
+ import OipPanelDocuments from "./panels/OipPanelDocuments.vue";
30
+ import OipPanelMetadata from "./panels/OipPanelMetadata.vue";
31
+ import OipPanelNotes from "./panels/OipPanelNotes.vue";
32
+ import OipPanelProjects from "./panels/OipPanelProjects.vue";
33
+ import OipPanelSources from "./panels/OipPanelSources.vue";
34
+ import OipPanelSourceTables from "./panels/OipPanelSourceTables.vue";
35
+ import OipPanelTopics from "./panels/OipPanelTopics.vue";
36
+ import OipPanelTutorials from "./panels/OipPanelTutorials.vue";
37
+
38
+ // Relatif et non `~/` : dans un layer, `~` designe l'app qui l'etend.
39
+ import tekkareLogoRaw from "../assets/tekkare_logo.svg?raw";
40
+
41
+ /** Panneaux portes par le layer, par tuile. */
42
+ const LAYER_PANELS: Partial<Record<OipRailTileKey, Component>> = {
43
+ Documents: OipPanelDocuments,
44
+ Metadata: OipPanelMetadata,
45
+ Notes: OipPanelNotes,
46
+ Projects: OipPanelProjects,
47
+ Sources: OipPanelSources,
48
+ SourcesTables: OipPanelSourceTables,
49
+ Topics: OipPanelTopics,
50
+ Tutorials: OipPanelTutorials,
51
+ };
52
+
53
+ const props = withDefaults(
54
+ defineProps<{
55
+ /** Identite affichee en tete de la barre gauche. */
56
+ title: string;
57
+ /** Code ISO alpha-2 du pays de l'app, dessine devant ce titre. */
58
+ flag?: string;
59
+ /** Navigation, dans la forme attendue par RmSidebarLeft. */
60
+ sections: OipNavSection[];
61
+ /** Partie du hub ouverte, parmi app.config.oip.parts. */
62
+ appKey?: string;
63
+ /** Maillon de partie pose a la main, pour une app sans `parts`. */
64
+ part?: { label: string; to: string } | null;
65
+ /** Remplace app.config.oip.home quand le maillon pays est dynamique. */
66
+ home?: OipExternalCrumb | null;
67
+ /**
68
+ * Etat des tuiles pour l'ecran, par cle du catalogue (utils/oipRail).
69
+ * L'ordre, le nom et l'icone viennent du catalogue ; une tuile absente
70
+ * sort grisee. Objet vide : le rail ne propose rien (rapports, artefacts).
71
+ */
72
+ tiles?: OipRailTileStates;
73
+ /** Sert les tuiles reservees aux superadmins (metadonnees, templates). */
74
+ superAdmin?: boolean;
75
+ /** Utilisateur connecte, pour le profil en haut du rail. */
76
+ userName?: string;
77
+ /**
78
+ * Masque les onglets du panneau ouvert. Pour un panneau qui ne doit pas
79
+ * etre quitte par un onglet (un formulaire en cours de saisie).
80
+ */
81
+ hideTabs?: boolean;
82
+ /**
83
+ * Props passees au panneau du layer d'une tuile, pour ce qui depend du
84
+ * layout (ex. `{ Documents: { app: "heart" } }`). Les options fixes vont
85
+ * dans app.config.oip.panels.
86
+ */
87
+ panelProps?: Partial<Record<OipRailTileKey, Record<string, unknown>>>;
88
+ }>(),
89
+ { flag: "fr", appKey: "", part: null, home: undefined, tiles: () => ({}), superAdmin: false, userName: "", hideTabs: false, panelProps: () => ({}) },
90
+ );
91
+
92
+ /** Tuile ouverte, ou null quand la toolbar est fermee. */
93
+ const tool = defineModel<string | null>("tool", { default: null });
94
+
95
+ const config = useAppConfig().oip;
96
+ const route = useRoute();
97
+ const { locale, locales, setLocale } = useI18n();
98
+ const localePath = useLocalePath();
99
+ const en = computed(() => locale.value === "en");
100
+
101
+ /**
102
+ * Les liens de navigation passent par localePath et portent parfois un slash
103
+ * final ou une query ; le chemin courant, lui, est brut et prefixe de la
104
+ * locale. Tout est ramene au meme peigne avant comparaison, sans quoi
105
+ * "/fr/medicam/market" ne reconnait pas l'entree "/medicam/market".
106
+ */
107
+ const localeCodes = computed(() =>
108
+ (locales.value as any[]).map((l) => (typeof l === "string" ? l : l.code)),
109
+ );
110
+
111
+ const normalize = (path: string) => {
112
+ let out = (path.split("?")[0] || "").replace(/\/+$/, "") || "/";
113
+ for (const code of localeCodes.value) {
114
+ if (out === `/${code}`) return "/";
115
+ if (out.startsWith(`/${code}/`)) {
116
+ out = out.slice(code.length + 1);
117
+ break;
118
+ }
119
+ }
120
+ return out || "/";
121
+ };
122
+
123
+ const currentPath = computed(() => normalize(route.path));
124
+
125
+ /**
126
+ * Entree active : la plus longue dont le chemin prefixe celui de la page.
127
+ * Un prefixe simple ferait briller "Prix" en meme temps que "Prix > JO".
128
+ */
129
+ const activeTo = computed(() => {
130
+ let best = "";
131
+ for (const section of props.sections) {
132
+ for (const item of section.items) {
133
+ if (!item.to) continue;
134
+ const to = normalize(item.to);
135
+ const hit = currentPath.value === to || currentPath.value.startsWith(to + "/");
136
+ if (hit && to.length > best.length) best = to;
137
+ }
138
+ }
139
+ return best;
140
+ });
141
+
142
+ /** RmSidebarLeft compare `item.to` a `activePath` : on lui donne le verdict. */
143
+ const navSections = computed<OipNavSection[]>(() =>
144
+ props.sections.map((section) => ({
145
+ ...section,
146
+ items: section.items.map((item) => ({
147
+ ...item,
148
+ active: !!item.to && normalize(item.to) === activeTo.value,
149
+ })),
150
+ })),
151
+ );
152
+
153
+ const activeItem = computed(() => {
154
+ for (const section of props.sections) {
155
+ for (const item of section.items) {
156
+ if (item.to && normalize(item.to) === activeTo.value) return item;
157
+ }
158
+ }
159
+ return null;
160
+ });
161
+
162
+ const partLabel = (part: { label: string; labelEn?: string }) =>
163
+ en.value ? part.labelEn ?? part.label : part.label;
164
+
165
+ const currentPart = computed(() => config.parts.find((p) => p.key === props.appKey) ?? null);
166
+
167
+ /** Route d'accueil de la partie courante, sinon de l'app. */
168
+ const appTo = computed(() => localePath(currentPart.value?.to ?? props.part?.to ?? "/"));
169
+
170
+ const crumbLabel = useOipCrumbLabel();
171
+
172
+ const activeLabel = computed(() => {
173
+ if (activeItem.value) return activeItem.value.label;
174
+ // Une page hors navigation peut nommer son maillon elle-meme (un projet).
175
+ if (crumbLabel.value) return crumbLabel.value;
176
+ // Sinon le dernier segment d'URL (une fiche produit porte sa marque).
177
+ const last = currentPath.value.split("/").filter(Boolean).pop() ?? "";
178
+ return decodeURIComponent(last).replace(/-/g, " ").replace(/^\w/, (c) => c.toUpperCase());
179
+ });
180
+
181
+ /**
182
+ * Derniere page listee dans la navigation. Une fiche s'ouvre depuis une liste :
183
+ * sans ce maillon, le fil sautait de la partie a la fiche sans offrir le
184
+ * retour. Gardee en session pour survivre a un rechargement de la fiche.
185
+ */
186
+ const ORIGIN_KEY = "oip-hub-origin-page";
187
+ const originPage = useState<{ label: string; to: string } | null>(ORIGIN_KEY, () => null);
188
+
189
+ watchEffect(() => {
190
+ const item = activeItem.value;
191
+ if (item?.to) originPage.value = { label: item.label, to: item.to };
192
+ });
193
+
194
+ onMounted(() => {
195
+ if (!originPage.value) {
196
+ try {
197
+ const raw = sessionStorage.getItem(ORIGIN_KEY);
198
+ if (raw) originPage.value = JSON.parse(raw);
199
+ } catch {
200
+ // Session illisible : le fil se passe du maillon d'origine.
201
+ }
202
+ }
203
+ watch(
204
+ originPage,
205
+ (value) => {
206
+ try {
207
+ if (value) sessionStorage.setItem(ORIGIN_KEY, JSON.stringify(value));
208
+ } catch {
209
+ // Ecriture refusee (navigation privee) : sans consequence.
210
+ }
211
+ },
212
+ { immediate: true },
213
+ );
214
+ });
215
+
216
+ /** Le maillon de partie ouvre les autres parties du hub. */
217
+ const partMenu = computed(() =>
218
+ config.parts
219
+ .filter((p) => !p.hidden)
220
+ .map((p) => ({
221
+ label: partLabel(p),
222
+ icon: p.icon,
223
+ to: localePath(p.to),
224
+ active: p.key === props.appKey,
225
+ })),
226
+ );
227
+
228
+ /** Le dernier maillon ouvre toutes les pages de la partie, sections confondues. */
229
+ const pageMenu = computed(() =>
230
+ props.sections.flatMap((section) =>
231
+ section.items
232
+ .filter((item) => item.to)
233
+ .map((item) => ({
234
+ label: item.label,
235
+ icon: item.icon,
236
+ to: item.to,
237
+ group: section.label,
238
+ active: normalize(item.to!) === activeTo.value,
239
+ })),
240
+ ),
241
+ );
242
+
243
+ /** Plateforme > pays > partie > page d'origine > page. */
244
+ const crumbs = computed(() => {
245
+ const home = props.home !== undefined ? props.home : config.home;
246
+ return [
247
+ config.platform,
248
+ ...(home ? [home] : []),
249
+ ...(currentPart.value
250
+ ? [{ label: partLabel(currentPart.value), to: appTo.value, menu: partMenu.value }]
251
+ : props.part
252
+ ? [{ label: props.part.label, to: localePath(props.part.to) }]
253
+ : []),
254
+ // Sur une fiche, la page d'origine reste dans le fil avant elle.
255
+ ...(activeItem.value || !originPage.value || !props.sections.length
256
+ ? []
257
+ : [{ label: originPage.value.label, to: originPage.value.to, menu: pageMenu.value }]),
258
+ ...(props.sections.length && activeLabel.value
259
+ ? [{ label: activeLabel.value, ...(activeItem.value ? { menu: pageMenu.value } : {}) }]
260
+ : []),
261
+ ];
262
+ });
263
+
264
+ const initials = computed(() => {
265
+ const parts = props.userName.split(/[\s@._-]+/).filter(Boolean);
266
+ const letters = (parts[0]?.[0] ?? "") + (parts.length > 1 ? parts[1]![0] : "");
267
+ return letters.toUpperCase() || "?";
268
+ });
269
+
270
+ // Changer de langue rejoue la route courante dans la nouvelle locale, sinon
271
+ // le prefixe d'URL et la langue divergent.
272
+ async function switchLocale(code: string) {
273
+ if (code === locale.value) return;
274
+ await setLocale(code as any);
275
+ await navigateTo(localePath(route.path));
276
+ }
277
+
278
+ /**
279
+ * Les tuiles, toujours les memes et dans le meme ordre d'un ecran et d'une app
280
+ * a l'autre (catalogue OIP_RAIL_TILES). Celles que l'ecran ne sert pas sortent
281
+ * grisees au lieu de disparaitre, sinon tout le rail se decale d'une page a
282
+ * l'autre. Les tuiles du cadre (accessibilite, tutoriels) ne dependent pas de
283
+ * la page.
284
+ */
285
+ const A11Y_TILE_KEY = "Accessibility";
286
+
287
+ if (import.meta.dev) {
288
+ watchEffect(() => {
289
+ const unknown = Object.keys(props.tiles).filter((key) => !OIP_RAIL_TILES.some((e) => e.key === key));
290
+ if (unknown.length) console.warn(`[OipShell] tuiles hors catalogue ignorees : ${unknown.join(", ")}`);
291
+ });
292
+ }
293
+
294
+ // Rien quand la page ne declare aucune tuile : les rapports et artefacts
295
+ // pleine largeur ne doivent pas pouvoir ouvrir un panneau par-dessus.
296
+ const baseTiles = computed<(OipRailTile & { ownCount?: boolean })[]>(() => {
297
+ if (!Object.keys(props.tiles).length) return [];
298
+ return OIP_RAIL_TILES.filter((entry) => !entry.admin || props.superAdmin).map((entry) => {
299
+ // Une tuile du cadre est servie sauf si l'ecran la refuse (`false`).
300
+ const state = props.tiles[entry.key] ?? !!entry.frame;
301
+ const extra = typeof state === "object" ? state : {};
302
+ return {
303
+ key: entry.key,
304
+ label: en.value ? entry.label.en : entry.label.fr,
305
+ icon: entry.icon,
306
+ ownCount: entry.ownCount,
307
+ ...extra,
308
+ disabled: !state || !!extra.disabled,
309
+ };
310
+ });
311
+ });
312
+
313
+ /**
314
+ * Tuiles regroupees en une seule, lue en onglets. `tool` garde la cle du
315
+ * membre ouvert. Un groupe dont un seul membre est present n'est pas forme.
316
+ */
317
+ const groups = computed(() =>
318
+ (Object.keys(OIP_RAIL_GROUPS) as OipRailGroupKey[])
319
+ .map((key) => ({
320
+ key,
321
+ label: en.value ? OIP_RAIL_GROUPS[key].label.en : OIP_RAIL_GROUPS[key].label.fr,
322
+ icon: OIP_RAIL_GROUPS[key].icon,
323
+ members: baseTiles.value.filter((tile) =>
324
+ OIP_RAIL_TILES.some((e) => e.key === tile.key && e.group === key),
325
+ ),
326
+ }))
327
+ .filter((group) => group.members.length > 1),
328
+ );
329
+
330
+ const railTiles = computed<OipRailTile[]>(() => {
331
+ const out: OipRailTile[] = [];
332
+ for (const tile of baseTiles.value) {
333
+ const group = groups.value.find((g) => g.members.some((m) => m.key === tile.key));
334
+ if (!group) {
335
+ out.push(tile);
336
+ continue;
337
+ }
338
+ // Le groupe prend la place de son premier membre dans le rail.
339
+ if (group.members[0]!.key !== tile.key) continue;
340
+ const count = group.members.reduce(
341
+ (sum, m) => sum + (m.disabled || m.ownCount ? 0 : m.count ?? 0),
342
+ 0,
343
+ );
344
+ out.push({
345
+ key: group.key,
346
+ label: group.label,
347
+ icon: group.icon ?? tile.icon,
348
+ width: group.members.find((m) => m.width)?.width,
349
+ count: count || undefined,
350
+ dot: group.members.some((m) => m.dot),
351
+ hint: group.members.find((m) => m.hint)?.hint,
352
+ // Grise seulement quand aucun de ses panneaux ne sert sur l'ecran.
353
+ disabled: group.members.every((m) => m.disabled),
354
+ });
355
+ }
356
+ return out;
357
+ });
358
+
359
+ /**
360
+ * Tuile reellement ouverte. Une page peut retirer ses tuiles en cours de
361
+ * route alors que `tool` garde la derniere ouverte : sans ce filtre, le
362
+ * panneau s'ouvrirait vide.
363
+ */
364
+ const openGroup = computed(
365
+ () => groups.value.find((g) => g.members.some((m) => m.key === tool.value)) ?? null,
366
+ );
367
+
368
+ const openTile = computed(
369
+ () => railTiles.value.find((tile) => tile.key === (openGroup.value?.key ?? tool.value)) ?? null,
370
+ );
371
+
372
+ /** Cle du panneau lu : le membre ouvert, pour une tuile regroupee. */
373
+ const openKey = computed(() => (openTile.value ? tool.value : null));
374
+
375
+ // Le panneau ouvert devient sans objet (on change d'ecran) : son groupe passe
376
+ // au premier panneau qui sert, sinon il se ferme.
377
+ watch(
378
+ () => baseTiles.value.find((tile) => tile.key === tool.value)?.disabled,
379
+ (disabled) => {
380
+ if (!disabled) return;
381
+ tool.value = openGroup.value?.members.find((m) => !m.disabled)?.key ?? null;
382
+ },
383
+ );
384
+
385
+ /** Une deuxieme pression sur la tuile ouverte referme le panneau. */
386
+ function selectTool(key: string) {
387
+ if (openTile.value?.key === key) {
388
+ tool.value = null;
389
+ return;
390
+ }
391
+ const group = groups.value.find((g) => g.key === key);
392
+ // Un groupe s'ouvre sur son premier panneau qui sert sur l'ecran.
393
+ tool.value = (group?.members.find((m) => !m.disabled) ?? group?.members[0])?.key ?? key;
394
+ }
395
+
396
+ const panelTabs = computed(() => {
397
+ if (props.hideTabs) return [];
398
+ const members = openGroup.value?.members ?? [];
399
+ return members.length > 1
400
+ ? members.map((m) => ({
401
+ key: m.key,
402
+ label: m.label,
403
+ active: m.key === tool.value,
404
+ count: m.count,
405
+ disabled: m.disabled,
406
+ }))
407
+ : [];
408
+ });
409
+
410
+ /** Largeur du panneau ouvert : celle de la tuile, sinon la largeur commune. */
411
+ const panelWidth = computed(() => openTile.value?.width ?? config.panelWidth);
412
+
413
+ /**
414
+ * La simulation du daltonisme ne sort qu'en local et en preproduction : elle
415
+ * degrade l'affichage pour nous montrer ce que la page donne pour quelqu'un
416
+ * d'autre, ce qui n'a pas sa place devant un client.
417
+ */
418
+ const cvdSimulation = computed(
419
+ () =>
420
+ import.meta.client &&
421
+ (window.location.host.includes("localhost") || window.location.host.includes("staging")),
422
+ );
423
+
424
+ const themeLabels = computed<[string, string]>(() =>
425
+ en.value ? ["Light", "Dark"] : ["Jour", "Nuit"],
426
+ );
427
+
428
+ /**
429
+ * Largeur de la colonne de contenu, publiee en variable CSS : la barre de
430
+ * filtres traverse tout le cadre par marges negatives et doit la connaitre.
431
+ */
432
+ let mainSize: ResizeObserver | null = null;
433
+
434
+ function publishMainWidth() {
435
+ const main = document.querySelector(".rm-shell__main") as HTMLElement | null;
436
+ if (!main) return;
437
+ document.documentElement.style.setProperty("--oip-main-w", `${main.clientWidth}px`);
438
+ }
439
+
440
+ onMounted(() => {
441
+ const main = document.querySelector(".rm-shell__main");
442
+ if (!main) return;
443
+ mainSize = new ResizeObserver(() => publishMainWidth());
444
+ mainSize.observe(main);
445
+ publishMainWidth();
446
+ });
447
+
448
+ onBeforeUnmount(() => {
449
+ mainSize?.disconnect();
450
+ mainSize = null;
451
+ });
452
+
453
+ /**
454
+ * Chatwoot passe derriere le panneau du rail. Le SDK accroche ses noeuds au
455
+ * `body` avec un z-index maximal ; le cadre, en `position: fixed`, forme son
456
+ * propre contexte d'empilement. La seule sortie est de ramener ces noeuds dans
457
+ * un plan du cadre. On ne prend que ce qui pend au `body` : deux coquilles
458
+ * coexistent le temps d'une navigation et se disputeraient les noeuds.
459
+ */
460
+ const CHATWOOT_NODES = ".woot--bubble-holder, .woot-widget-holder";
461
+ const chatwootLayer = ref<HTMLElement | null>(null);
462
+ let chatwootWatch: MutationObserver | null = null;
463
+
464
+ function adoptChatwoot() {
465
+ const layer = chatwootLayer.value;
466
+ if (!layer) return;
467
+ document.querySelectorAll<HTMLElement>(CHATWOOT_NODES).forEach((node) => {
468
+ if (node.parentElement === document.body) layer.appendChild(node);
469
+ });
470
+ }
471
+
472
+ onMounted(() => {
473
+ adoptChatwoot();
474
+ chatwootWatch = new MutationObserver(() => adoptChatwoot());
475
+ chatwootWatch.observe(document.body, { childList: true });
476
+ });
477
+
478
+ // Les noeuds repartent sur le `body` avec la coquille, sinon le widget
479
+ // disparaitrait avec elle.
480
+ onBeforeUnmount(() => {
481
+ chatwootWatch?.disconnect();
482
+ chatwootWatch = null;
483
+ chatwootLayer.value
484
+ ?.querySelectorAll<HTMLElement>(CHATWOOT_NODES)
485
+ .forEach((node) => document.body.appendChild(node));
486
+ });
487
+
488
+ /** Le violet de marque suit `--color-primary`, qui s'eclaircit en mode nuit. */
489
+ const tekkareLogo = tekkareLogoRaw.replaceAll('fill="#2100AD"', 'fill="currentColor"');
490
+ </script>
491
+
492
+ <template>
493
+ <RmAppShell>
494
+ <!-- Sans navigation, la barre gauche ne sort pas. -->
495
+ <template #nav="{ collapsed, open, toggle }">
496
+ <RmSidebarLeft
497
+ v-if="sections.length"
498
+ :title="title"
499
+ :flag="flag"
500
+ :title-to="appTo"
501
+ :sections="navSections"
502
+ :active-path="currentPath"
503
+ :collapsed="collapsed"
504
+ :open="open"
505
+ @toggle="toggle"
506
+ >
507
+ <template #nav-footer>
508
+ <slot name="nav-footer" />
509
+ </template>
510
+ <template #footer>
511
+ <div v-if="!collapsed" class="oip-powered-by">
512
+ <span class="oip-powered-by__caption">Powered by</span>
513
+ <a
514
+ class="oip-powered-by__logo"
515
+ href="https://tekkare.com/"
516
+ target="_blank"
517
+ rel="noopener"
518
+ :aria-label="en ? 'Tekkare (opens in a new tab)' : 'Tekkare (ouvre dans un nouvel onglet)'"
519
+ v-html="tekkareLogo"
520
+ />
521
+ </div>
522
+ </template>
523
+ </RmSidebarLeft>
524
+ </template>
525
+
526
+ <template #header>
527
+ <RmAppHeader :crumbs="crumbs">
528
+ <template #actions>
529
+ <slot name="header-actions" />
530
+ </template>
531
+ </RmAppHeader>
532
+ <!-- Sous l'en-tete, hors de la zone qui defile (bandeau de projet...). -->
533
+ <slot name="header-after" />
534
+ </template>
535
+
536
+ <slot />
537
+
538
+ <template #rail>
539
+ <RmRailPanel
540
+ :open="!!openTile"
541
+ :title="openTile?.label"
542
+ :width="panelWidth"
543
+ :tabs="panelTabs"
544
+ :tabs-label="openTile?.label"
545
+ :close-label="en ? 'Close the panel' : 'Fermer le panneau'"
546
+ @tab="tool = $event"
547
+ @close="tool = null"
548
+ >
549
+ <!-- Un panneau se resout du plus libre au plus encadre : le slot
550
+ `panel-<Tuile>` de l'app s'il existe, sinon le panneau du layer
551
+ (avec ses `panelProps`), sinon le slot `tool` pour les panneaux
552
+ que le layer ne porte pas encore. `panel-<Tuile>-after` ajoute
553
+ du contenu sous le panneau. -->
554
+ <slot v-if="openKey && $slots[`panel-${openKey}`]" :name="`panel-${openKey}`" />
555
+ <RmA11yPanel
556
+ v-else-if="openKey === A11Y_TILE_KEY"
557
+ :lang="en ? 'en' : 'fr'"
558
+ :cvd-simulation="cvdSimulation"
559
+ />
560
+ <component
561
+ :is="LAYER_PANELS[openKey as OipRailTileKey]"
562
+ v-else-if="openKey && LAYER_PANELS[openKey as OipRailTileKey]"
563
+ :app-key="appKey"
564
+ :super-admin="superAdmin"
565
+ v-bind="panelProps[openKey as OipRailTileKey] ?? {}"
566
+ />
567
+ <slot v-else name="tool" :active="openKey" />
568
+ <!-- Complement de l'app sous le panneau ouvert, sans le remplacer. -->
569
+ <slot v-if="openKey" :name="`panel-${openKey}-after`" />
570
+ </RmRailPanel>
571
+
572
+ <RmSidebarRight
573
+ :user="{ initials, name: userName }"
574
+ :profile-to="localePath('/profile')"
575
+ :profile-label="en ? 'Profile' : 'Profil'"
576
+ :tiles="railTiles"
577
+ :active="openTile?.key ?? null"
578
+ :offset="openTile ? panelWidth : '0px'"
579
+ :expanded="!!openTile"
580
+ :theme-labels="themeLabels"
581
+ :locales="config.locales"
582
+ :locale="locale"
583
+ @select="selectTool"
584
+ @update:locale="switchLocale"
585
+ >
586
+ <!-- Actions de page : chaque page y teleporte ses boutons, qui y
587
+ prennent la forme d'une tuile du rail. Les conteneurs se replient
588
+ quand la page n'a rien a y mettre (:empty). -->
589
+ <div id="oip-rail-export" class="oip-rail-action" data-label="Export" />
590
+ <div id="oip-rail-pdf" class="oip-rail-action" data-label="PDF" />
591
+ <div id="oip-rail-scope" class="oip-rail-action" :data-label="en ? 'My views' : 'Mes vues'" />
592
+ </RmSidebarRight>
593
+ </template>
594
+
595
+ <template #overlay>
596
+ <div ref="chatwootLayer" class="oip-chatwoot-layer" />
597
+ </template>
598
+ </RmAppShell>
599
+ </template>
600
+
601
+ <style scoped>
602
+ .oip-powered-by {
603
+ display: flex;
604
+ flex-direction: column;
605
+ align-items: center;
606
+ gap: 8px;
607
+ padding: 16px 16px 20px;
608
+ border-top: 1px solid var(--rm-shell-border);
609
+ }
610
+
611
+ .oip-powered-by__caption {
612
+ font-size: 13px;
613
+ letter-spacing: 0.2em;
614
+ text-transform: uppercase;
615
+ color: var(--rm-shell-text-muted);
616
+ }
617
+
618
+ .oip-powered-by__logo {
619
+ display: flex;
620
+ justify-content: center;
621
+ width: 100%;
622
+ color: var(--color-primary);
623
+ border-radius: 4px;
624
+ transition: opacity 150ms ease-out;
625
+ }
626
+
627
+ .oip-powered-by__logo:hover {
628
+ opacity: 0.8;
629
+ }
630
+
631
+ .oip-powered-by__logo:focus-visible {
632
+ outline: 2px solid var(--color-primary);
633
+ outline-offset: 4px;
634
+ }
635
+
636
+ .oip-powered-by__logo :deep(svg) {
637
+ width: 100%;
638
+ max-width: 190px;
639
+ height: auto;
640
+ }
641
+ </style>
642
+
643
+ <!-- Global : les noeuds de Chatwoot et les boutons teleportes par les pages
644
+ ne portent pas l'attribut de portee de ce composant. -->
645
+ <style>
646
+ .oip-chatwoot-layer .woot--bubble-holder,
647
+ .oip-chatwoot-layer .woot-widget-bubble {
648
+ /* Rail (56px) + bord du cadre (9px) + une marge : la bulle passe a gauche
649
+ du rail au lieu de couvrir la bascule de langue, en bas. */
650
+ right: 105px !important;
651
+ }
652
+
653
+ /* Plan qui accueille les noeuds de Chatwoot dans le cadre (cf.
654
+ OipShell.vue). Le SDK les pose sur le `body` avec un z-index maximal :
655
+ la fenetre ouverte recouvrait le panneau du rail et cachait son contenu.
656
+ Le plan est un contexte d'empilement a lui seul, ce qui enferme ce z-index
657
+ maximal : ses 350 le placent au-dessus du contenu de page et sous le voile
658
+ (400), le panneau (401) et le rail (402).
659
+
660
+ Il couvre la fenetre entiere sans rien intercepter : seuls les noeuds de
661
+ Chatwoot, qui se positionnent eux-memes, reprennent les clics. */
662
+ .oip-chatwoot-layer {
663
+ position: fixed;
664
+ inset: 0;
665
+ z-index: 350;
666
+ pointer-events: none;
667
+ }
668
+
669
+ .oip-chatwoot-layer > * {
670
+ pointer-events: auto;
671
+ }
672
+
673
+ /* Actions de page dans le rail droit.
674
+ ------------------------------------------------------------------
675
+ Chaque page teleporte son bouton d'export et son selecteur de scope vers
676
+ les conteneurs poses par OipShell.vue. Ce sont les composants Auriga eux-memes, pas des copies :
677
+ leur logique, leur etat de chargement et leur menu restent ceux de la page.
678
+ Seule leur forme change — ils se replient sur l'anatomie d'une tuile du
679
+ rail, pastille de 32px et libelle de 10px, pour ne pas faire tache a cote
680
+ de Sources ou Support. Le libelle vient du conteneur (data-label) : les
681
+ trente composants d'export en amont ne portent pas le meme intitule, et le
682
+ rail n'a pas la place de les afficher. */
683
+ .oip-rail-action {
684
+ display: flex;
685
+ flex-direction: column;
686
+ align-items: center;
687
+ gap: 4px;
688
+ padding: 12px 0 8px;
689
+ width: 100%;
690
+ }
691
+
692
+ /* Une page sans export — ou dont le bouton est masque par v-show tant qu'il
693
+ n'y a pas de selection — ne laisse pas un libelle orphelin dans le rail. */
694
+ .oip-rail-action:empty,
695
+ .oip-rail-action:not(:has(> :not([style*="display: none"]))) {
696
+ display: none;
697
+ }
698
+
699
+ .oip-rail-action::after {
700
+ content: attr(data-label);
701
+ font-size: 10px;
702
+ line-height: 1;
703
+ color: var(--rm-shell-text-muted);
704
+ white-space: nowrap;
705
+ }
706
+
707
+ /* Le halo colore d'Auriga est porte par l'enveloppe du bouton, pas par le
708
+ bouton : sans cette regle il subsiste un liseré vert autour de l'export. */
709
+ .oip-rail-action [class*="_shadow"] {
710
+ box-shadow: none !important;
711
+ filter: none !important;
712
+ }
713
+ </style>