@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.
- package/dist/module.json +1 -1
- package/layer/app/app.config.ts +90 -0
- package/layer/app/assets/tekkare_logo.svg +20 -0
- package/layer/app/components/AppUpdateToast.vue +157 -0
- package/layer/app/components/OipDirectusSources.vue +192 -0
- package/layer/app/components/OipDocumentPreview.vue +158 -0
- package/layer/app/components/OipMetadataEditDialog.vue +350 -0
- package/layer/app/components/OipNoteEditDialog.vue +382 -0
- package/layer/app/components/OipShell.vue +713 -0
- package/layer/app/components/OipSourceEditDialog.vue +419 -0
- package/layer/app/components/OipSourceGroups.vue +107 -0
- package/layer/app/components/auth/SplitLayout.vue +261 -0
- package/layer/app/components/layout/firstVisitVideo.vue +165 -0
- package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
- package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
- package/layer/app/components/panels/OipPanelNotes.vue +99 -0
- package/layer/app/components/panels/OipPanelProjects.vue +94 -0
- package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
- package/layer/app/components/panels/OipPanelSources.vue +361 -0
- package/layer/app/components/panels/OipPanelTopics.vue +339 -0
- package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
- package/layer/app/composables/useOipCrumbLabel.ts +11 -0
- package/layer/app/composables/useOipEsIndices.ts +57 -0
- package/layer/app/composables/useOipFavorites.ts +69 -0
- package/layer/app/composables/useOipNotes.ts +90 -0
- package/layer/app/composables/useOipNotify.ts +21 -0
- package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
- package/layer/app/composables/useViewEditing.ts +11 -0
- package/layer/app/layouts/anonymous.vue +6 -0
- package/layer/app/layouts/error.vue +38 -0
- package/layer/app/notes/categories.ts +46 -0
- package/layer/app/notes/types.ts +100 -0
- package/layer/app/plugins/oip-es-indices.client.ts +18 -0
- package/layer/app/types/metadata.ts +38 -0
- package/layer/app/types/oip.ts +60 -0
- package/layer/app/types/tableGroups.ts +42 -0
- package/layer/app/utils/countries.ts +30 -0
- package/layer/app/utils/exportPage.ts +418 -0
- package/layer/app/utils/headerTabs.ts +15 -0
- package/layer/app/utils/hybridRows.ts +22 -0
- package/layer/app/utils/oipRail.ts +82 -0
- package/layer/app/utils/postLoginRedirect.ts +58 -0
- package/layer/app/utils/valueTable.ts +126 -0
- package/layer/nuxt.config.ts +32 -0
- package/layer/server/api/oip/asset/[id].get.ts +89 -0
- package/layer/server/api/oip/documents.get.ts +92 -0
- package/layer/server/api/oip/es-indices.get.ts +21 -0
- package/layer/server/api/oip/favorites.get.ts +107 -0
- package/layer/server/api/oip/metadata.get.ts +109 -0
- package/layer/server/api/oip/metadata.patch.ts +163 -0
- package/layer/server/api/oip/note-options.get.ts +89 -0
- package/layer/server/api/oip/note.get.ts +95 -0
- package/layer/server/api/oip/notes.get.ts +173 -0
- package/layer/server/api/oip/notes.patch.ts +198 -0
- package/layer/server/api/oip/sources.patch.ts +145 -0
- package/layer/server/api/oip/sources.post.ts +88 -0
- package/layer/server/api/oip/table-groups.get.ts +145 -0
- package/layer/server/api/oip/tutorial-video.get.ts +44 -0
- package/layer/server/api/romulus/version.get.ts +4 -0
- package/layer/server/utils/oipAuth.ts +69 -0
- package/layer/server/utils/oipEsIndices.ts +99 -0
- package/layer/server/utils/oipGuards.ts +90 -0
- package/layer/server/utils/oipNoteMarkup.ts +197 -0
- 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>
|