@tekkare/romulus 0.1.240 → 0.1.242

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 (54) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/runtime/assets/styles/tokens.css +1 -1
  3. package/layer/app/app.config.ts +90 -0
  4. package/layer/app/assets/tekkare_logo.svg +20 -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 +711 -0
  10. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  11. package/layer/app/components/OipSourceGroups.vue +107 -0
  12. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  13. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  14. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  15. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  16. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  17. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  18. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  19. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  20. package/layer/app/composables/useOipEsIndices.ts +57 -0
  21. package/layer/app/composables/useOipFavorites.ts +69 -0
  22. package/layer/app/composables/useOipNotes.ts +90 -0
  23. package/layer/app/composables/useOipNotify.ts +21 -0
  24. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  25. package/layer/app/layouts/anonymous.vue +6 -0
  26. package/layer/app/layouts/error.vue +38 -0
  27. package/layer/app/notes/categories.ts +46 -0
  28. package/layer/app/notes/types.ts +100 -0
  29. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  30. package/layer/app/types/metadata.ts +38 -0
  31. package/layer/app/types/oip.ts +60 -0
  32. package/layer/app/types/tableGroups.ts +42 -0
  33. package/layer/app/utils/oipRail.ts +82 -0
  34. package/layer/nuxt.config.ts +32 -0
  35. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  36. package/layer/server/api/oip/documents.get.ts +92 -0
  37. package/layer/server/api/oip/es-indices.get.ts +21 -0
  38. package/layer/server/api/oip/favorites.get.ts +107 -0
  39. package/layer/server/api/oip/metadata.get.ts +109 -0
  40. package/layer/server/api/oip/metadata.patch.ts +163 -0
  41. package/layer/server/api/oip/note-options.get.ts +89 -0
  42. package/layer/server/api/oip/note.get.ts +95 -0
  43. package/layer/server/api/oip/notes.get.ts +173 -0
  44. package/layer/server/api/oip/notes.patch.ts +198 -0
  45. package/layer/server/api/oip/sources.patch.ts +145 -0
  46. package/layer/server/api/oip/sources.post.ts +88 -0
  47. package/layer/server/api/oip/table-groups.get.ts +145 -0
  48. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  49. package/layer/server/api/romulus/version.get.ts +4 -0
  50. package/layer/server/utils/oipAuth.ts +69 -0
  51. package/layer/server/utils/oipEsIndices.ts +99 -0
  52. package/layer/server/utils/oipGuards.ts +90 -0
  53. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  54. package/package.json +5 -3
@@ -0,0 +1,122 @@
1
+ <!--
2
+ Le volet « Tutoriels » du rail (layer OIP) : le catalogue des visites guidees.
3
+
4
+ Il ne joue rien : « Démarrer » pose la visite dans `useTutorials`, et c'est
5
+ le cadre de l'app qui la joue, parce que la visite designe des endroits
6
+ qui vivent hors de ce panneau (la barre de filtres, les autres tuiles) et
7
+ qu'elle ouvre elle-meme les panneaux dont elle parle.
8
+
9
+ Le titre reste vide : l'en-tete du panneau du rail porte deja « Tutoriels ».
10
+ -->
11
+ <template>
12
+ <RmTutorials
13
+ class="full_width"
14
+ :tutorials="items"
15
+ :done="done"
16
+ title=""
17
+ :description="
18
+ lang === 'en'
19
+ ? 'Guided tours: the app highlights what the text is talking about, step by step. You can leave at any time and pick it up later.'
20
+ : `Des visites guidées : l'application surligne ce dont le texte parle, étape par étape. On peut sortir à tout moment et reprendre plus tard.`
21
+ "
22
+ badge="Tekkare"
23
+ :lang="lang"
24
+ @start="start"
25
+ />
26
+
27
+ <section v-if="tutorialVideo?.src" class="tutorial-video">
28
+ <div class="tutorial-video__head">
29
+ <div>
30
+ <h2 class="tutorial-video__title">{{ tutorialVideo.title }}</h2>
31
+ <p v-if="tutorialVideo.description" class="tutorial-video__description">
32
+ {{ tutorialVideo.description }}
33
+ </p>
34
+ </div>
35
+ <RmIcon name="PlayCircle" :size="20" />
36
+ </div>
37
+ <video
38
+ class="tutorial-video__player"
39
+ :src="tutorialVideo.src"
40
+ controls
41
+ preload="metadata"
42
+ playsinline
43
+ ></video>
44
+ </section>
45
+ </template>
46
+
47
+ <script setup lang="ts">
48
+ import type { RmTutorial } from "@tekkare/romulus/utils/tours";
49
+
50
+ /**
51
+ * Le catalogue est propre a chaque app (ses visites, ses pages d'accueil) :
52
+ * l'app le passe par `panelProps.Tutorials`, souvent depuis son useTutorials.
53
+ * Le panneau ne fait que le rendre, toujours de la meme facon.
54
+ */
55
+ withDefaults(
56
+ defineProps<{
57
+ appKey?: string;
58
+ superAdmin?: boolean;
59
+ items?: RmTutorial[];
60
+ /** Identifiants des visites deja faites. */
61
+ done?: string[];
62
+ lang?: "fr" | "en";
63
+ /** Video de prise en main (cf. /api/oip/tutorial-video), si l'app en a une. */
64
+ tutorialVideo?: { title: string; description?: string; src: string } | null;
65
+ }>(),
66
+ { items: () => [], done: () => [], lang: "fr", tutorialVideo: null },
67
+ );
68
+
69
+ const emit = defineEmits<{ start: [id: string]; mounted: [] }>();
70
+
71
+ function start(id: string) {
72
+ emit("start", id);
73
+ }
74
+
75
+ // Les visites faites se lisent dans le navigateur : l'app les recharge ici.
76
+ onMounted(() => emit("mounted"));
77
+ </script>
78
+
79
+ <style scoped>
80
+ .full_width {
81
+ align-self: stretch;
82
+ width: 100%;
83
+ }
84
+
85
+ .tutorial-video {
86
+ align-self: stretch;
87
+ background: var(--bg-surface);
88
+ border: 1px solid var(--border-default);
89
+ border-radius: var(--radius-xl);
90
+ margin-top: var(--space-16);
91
+ overflow: hidden;
92
+ }
93
+
94
+ .tutorial-video__head {
95
+ align-items: flex-start;
96
+ color: var(--color-secondary);
97
+ display: flex;
98
+ gap: var(--space-12);
99
+ justify-content: space-between;
100
+ padding: var(--space-16);
101
+ }
102
+
103
+ .tutorial-video__title {
104
+ color: var(--text-primary);
105
+ font-size: var(--font-14);
106
+ font-weight: var(--weight-semibold);
107
+ }
108
+
109
+ .tutorial-video__description {
110
+ color: var(--text-secondary);
111
+ font-size: var(--font-12);
112
+ line-height: 1.5;
113
+ margin-top: var(--space-4);
114
+ }
115
+
116
+ .tutorial-video__player {
117
+ background: var(--bg-surface-3);
118
+ display: block;
119
+ max-height: 360px;
120
+ width: 100%;
121
+ }
122
+ </style>
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Libelle du dernier maillon du fil d'Ariane, quand la page n'est pas une
3
+ * entree de la navigation et que son segment d'URL ne dit rien (l'identifiant
4
+ * d'un projet). La page le pose au montage et le retire en partant ; le cadre
5
+ * le lit avant de retomber sur le segment d'URL.
6
+ *
7
+ * Meme cle que l'ancien useHubCrumbLabel de fr_drugs_app : une page qui
8
+ * l'utilise encore reste lue par le cadre du layer.
9
+ */
10
+ export const useOipCrumbLabel = () =>
11
+ useState<string | null>("oip-hub-crumb-label", () => null);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Les index Elasticsearch par id Directus (collection `ElasticIndices`), cote
3
+ * navigateur.
4
+ *
5
+ * Une app designe ses index par id ; le proxy Elasticsearch les resout. Ce
6
+ * composable ne sert qu'aux frontieres avec un autre systeme qui parle en
7
+ * NOMS (moteur d'alertes, registre des types de ressources, `docId` des
8
+ * evenements) :
9
+ * - `nameOf(id)` : le nom canonique a transmettre ;
10
+ * - `idOf(nom)` : l'id d'un index nomme par l'API (nom canonique ou
11
+ * physique) ;
12
+ * - `sameIndex(ref, nom)` : vrai si la reference du catalogue et le nom
13
+ * designent le meme index.
14
+ * Ce qui n'est pas connu du registre passe tel quel.
15
+ *
16
+ * `load()` est appele une fois par le plugin du layer quand
17
+ * app.config.oip.esIndices.preload est vrai : le reste est alors synchrone.
18
+ */
19
+ interface Row {
20
+ id: string;
21
+ name: string;
22
+ physical: string;
23
+ }
24
+
25
+ export function useOipEsIndices() {
26
+ const rows = useState<Row[]>("oip-es-indices", () => []);
27
+ const loaded = useState<boolean>("oip-es-indices-loaded", () => false);
28
+ const endpoint = useAppConfig().oip.esIndices.endpoint;
29
+
30
+ async function load() {
31
+ if (loaded.value) return;
32
+ try {
33
+ rows.value = await $fetch<Row[]>(endpoint);
34
+ loaded.value = true;
35
+ } catch {
36
+ // Registre indisponible : les references passent telles quelles, l'appel
37
+ // qui en a besoin echouera de lui-meme plutot que de bloquer l'app.
38
+ }
39
+ }
40
+
41
+ const byId = computed(() => new Map(rows.value.map((row) => [row.id, row])));
42
+ const byName = computed(() => {
43
+ const map = new Map<string, Row>();
44
+ for (const row of rows.value) {
45
+ map.set(row.physical, row);
46
+ map.set(row.name, row);
47
+ }
48
+ return map;
49
+ });
50
+
51
+ const nameOf = (ref: string) => byId.value.get(ref)?.name ?? ref;
52
+ const idOf = (name: string) => byName.value.get(name)?.id ?? name;
53
+ const sameIndex = (ref: string | null | undefined, name: string | null | undefined) =>
54
+ !!ref && !!name && (ref === name || idOf(name) === ref || nameOf(ref) === name);
55
+
56
+ return { load, loaded, nameOf, idOf, sameIndex };
57
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * The reader's starred topics, as set from the OIP platform menus.
3
+ *
4
+ * Read-only: the star lives in the platform, this app only lists what it
5
+ * holds. Loaded once per page load and shared by every caller through
6
+ * `useState`, the same way the sources referential is. The endpoint comes
7
+ * from app.config.oip.panels.Topics.endpoint (default: the layer route).
8
+ */
9
+ export interface OipFavoriteRow {
10
+ id: string;
11
+ menu_item?: any | null;
12
+ subject?: any | null;
13
+ }
14
+
15
+ /** One in-flight GET at a time, whoever asks. A promise cannot be serialised, so it stays out of useState. */
16
+ let inflight: Promise<void> | null = null;
17
+
18
+ export const useOipFavorites = () => {
19
+ const rows = useState<OipFavoriteRow[]>("oip-favorites", () => []);
20
+ const loaded = useState<boolean>("oip-favorites-loaded", () => false);
21
+ const pending = useState<boolean>("oip-favorites-pending", () => false);
22
+ /** Set when the collection is missing or closed to the token. */
23
+ const unavailable = useState<string | null>("oip-favorites-unavailable", () => null);
24
+
25
+ const { status } = useAuth();
26
+ // Lu ici, dans le contexte du composable : apres un `await`, useAppConfig
27
+ // n'a plus d'instance Nuxt.
28
+ const endpoint = useAppConfig().oip.panels.Topics.endpoint;
29
+
30
+ async function fetchRows() {
31
+ pending.value = true;
32
+ try {
33
+ rows.value = await $fetch<OipFavoriteRow[]>(endpoint);
34
+ unavailable.value = null;
35
+ loaded.value = true;
36
+ } catch (error: any) {
37
+ // A closed collection must not break the panel it fills. `loaded` stays
38
+ // false so a retry is possible; the `unavailable` guard below is what
39
+ // stops the retry flood.
40
+ unavailable.value = error?.data?.message || error?.message || "unavailable";
41
+ rows.value = [];
42
+ } finally {
43
+ pending.value = false;
44
+ }
45
+ }
46
+
47
+ async function load(force = false) {
48
+ // Client only: /api/favorites identifies the caller from the session
49
+ // cookie, and an internal $fetch during SSR carries none — the route would
50
+ // answer 401 and the payload would ship an empty list marked loaded.
51
+ if (import.meta.server) return;
52
+ if (status.value !== "authenticated") return;
53
+ if (inflight) {
54
+ await inflight;
55
+ if (!force) return;
56
+ }
57
+ if (loaded.value && !force) return;
58
+ // One failure is enough: without this the composable retries on every
59
+ // panel open, and a closed collection floods the console.
60
+ if (unavailable.value && !force) return;
61
+
62
+ inflight = fetchRows().finally(() => {
63
+ inflight = null;
64
+ });
65
+ return inflight;
66
+ }
67
+
68
+ return { rows, load, pending, loaded, unavailable };
69
+ };
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Les notes de l'ecran ouvert, pour la tuile « Notes » du rail (layer OIP).
3
+ *
4
+ * Elles viennent de Directus (collection `Notes`), servies par la route de
5
+ * app.config.oip.panels.Notes.endpoint (/api/oip/notes par defaut) :
6
+ * une correction de texte se fait dans le CMS, pas dans un deploiement. Le
7
+ * contenu est du texte balise, deja converti en blocs cote serveur.
8
+ *
9
+ * La liste sort entiere pour l'app, une fois par langue ; c'est ici qu'on
10
+ * retient celles de l'ecran, par deux chemins :
11
+ *
12
+ * - le chemin de la page, qu'une note cite par ses sujets du portail. Le
13
+ * rapprochement se fait par prefixe : une fiche reste sous sa liste.
14
+ * - les sources affichees, qu'une note cite par leur uuid. La note qui
15
+ * explique Medicam suit Medicam, quel que soit l'ecran qui l'affiche, et
16
+ * elle suit meme l'onglet, puisque les sources citees en changent.
17
+ *
18
+ * Les notes de l'ecran passent devant celles qui viennent d'une source : on
19
+ * ouvre le volet pour comprendre la page qu'on a sous les yeux.
20
+ *
21
+ * Chaque note dit par ou elle est arrivee (`match`) : le panneau l'affiche a
22
+ * l'equipe qui relit les rattachements, jamais au lecteur.
23
+ */
24
+ import type { MaybeRefOrGetter } from "vue";
25
+ import { noteCategories } from "../notes/categories";
26
+ import type { ApiNote, Note } from "../notes/types";
27
+
28
+ /** Une note de l'ecran, avec ce qui l'y a amenee. */
29
+ export type OipShownNote = Note & { match: "subject" | "source" };
30
+
31
+ /**
32
+ * `/en/hubr/heart`, `/hubr/heart` et `/hubr/heart/` designent le meme ecran.
33
+ * La query est conservee : un sujet du portail peut designer un onglet par
34
+ * elle (`/medicam/market?tab=x`).
35
+ */
36
+ function normalize(url: string) {
37
+ const [path = "", query] = url.split("?");
38
+ const clean = path.replace(/^\/(fr|en)(?=\/|$)/, "").replace(/\/+$/, "") || "/";
39
+ return query ? `${clean}?${query}` : clean;
40
+ }
41
+
42
+ /**
43
+ * `sourceUuids` : les `source_uuid` des jeux affiches par l'ecran. Chaque app
44
+ * les connait (elle les montre deja dans la tuile « Sources ») et les passe
45
+ * ici ; une note qui les cite suit ces jeux d'un ecran a l'autre.
46
+ */
47
+ export function useOipNotes(sourceUuids: MaybeRefOrGetter<string[] | undefined>) {
48
+ const { locale } = useI18n();
49
+ const route = useRoute();
50
+
51
+ const lang = computed(() => (locale.value === "en" ? "en" : "fr"));
52
+
53
+ // Une requete par langue, partagee par tous les ecrans : le rail se
54
+ // rouvre a chaque page, la liste ne se recharge pas pour autant.
55
+ const { data, refresh } = useFetch<ApiNote[]>(useAppConfig().oip.panels.Notes.endpoint, {
56
+ key: "oip-rail-notes",
57
+ query: { lang },
58
+ default: () => [],
59
+ });
60
+
61
+ const notes = computed<OipShownNote[]>(() => {
62
+ const path = normalize(route.path);
63
+ const full = normalize(route.fullPath);
64
+ const shown = new Set(toValue(sourceUuids) ?? []);
65
+ const page: OipShownNote[] = [];
66
+ const fromSource: OipShownNote[] = [];
67
+
68
+ const onPage = (subjectPath: string) => {
69
+ const target = normalize(subjectPath);
70
+ // Un sujet qui porte une query designe un onglet : c'est l'URL entiere
71
+ // qui doit la porter, le chemin seul ne suffit pas.
72
+ const current = target.includes("?") ? full : path;
73
+ return current === target || current.startsWith(`${target}/`);
74
+ };
75
+
76
+ for (const { paths, sources, ...note } of data.value ?? []) {
77
+ if (paths.some(onPage)) {
78
+ page.push({ ...note, match: "subject" });
79
+ } else if (sources.some((uuid) => shown.has(uuid))) {
80
+ fromSource.push({ ...note, match: "source" });
81
+ }
82
+ }
83
+
84
+ return [...page, ...fromSource];
85
+ });
86
+
87
+ const categories = computed(() => noteCategories(lang.value));
88
+
89
+ return { notes, categories, refresh };
90
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Confirmation breve d'une action d'un panneau du layer (« Métadonnées
3
+ * enregistrées »).
4
+ *
5
+ * Chaque app a son propre bandeau (store snackbar, toast...) : le layer ne
6
+ * l'importe pas, il emet le hook `oip:notify` et l'app le branche dans un
7
+ * plugin :
8
+ *
9
+ * export default defineNuxtPlugin((nuxtApp) => {
10
+ * nuxtApp.hook("oip:notify" as any, ({ text, kind }) => monBandeau(text, kind));
11
+ * });
12
+ *
13
+ * Sans plugin, rien ne s'affiche : l'action a quand meme eu lieu.
14
+ */
15
+ export type OipNotifyKind = "success" | "error" | "info";
16
+
17
+ export function useOipNotify() {
18
+ const nuxtApp = useNuxtApp();
19
+ return (text: string, kind: OipNotifyKind = "success") =>
20
+ nuxtApp.callHook("oip:notify" as any, { text, kind });
21
+ }
@@ -0,0 +1,54 @@
1
+ import type { MaybeRefOrGetter } from "vue";
2
+ import type { SubjectTableGroups, TableGroup } from "../types/tableGroups";
3
+
4
+ /**
5
+ * Les tablegroups Directus du sujet de l'ecran ouvert (layer OIP), lus par la
6
+ * tuile « Tables » du groupe Documentation.
7
+ *
8
+ * Le sujet retenu est celui dont le chemin prefixe le plus longuement celui de
9
+ * la page, meme peigne que les notes : `/en/hubr/x/` et `/hubr/x` se valent, et
10
+ * un sujet qui porte une query designe un onglet.
11
+ */
12
+ function normalize(url: string) {
13
+ const [path = "", query] = url.split("?");
14
+ const clean = path.replace(/^\/(fr|en)(?=\/|$)/, "").replace(/\/+$/, "") || "/";
15
+ return query ? `${clean}?${query}` : clean;
16
+ }
17
+
18
+ /**
19
+ * `canSeeTables` : l'onglet mene a Databank, il ne s'ouvre qu'a ceux qui y ont
20
+ * droit (licence `oip-databank`, admins). Le droit est connu de l'app, qui le
21
+ * passe ici.
22
+ */
23
+ export function useOipSourceTableGroups(canSeeTables: MaybeRefOrGetter<boolean>) {
24
+ const route = useRoute();
25
+
26
+ const { data, pending } = useFetch<{ readable: boolean; subjects: SubjectTableGroups[] }>(
27
+ useAppConfig().oip.panels.Sources.tablesEndpoint,
28
+ { key: "oip-rail-table-groups" },
29
+ );
30
+
31
+ const groups = computed<TableGroup[]>(() => {
32
+ const path = normalize(route.path);
33
+ const full = normalize(route.fullPath);
34
+ let best: SubjectTableGroups | null = null;
35
+ let bestLength = 0;
36
+ for (const s of data.value?.subjects ?? []) {
37
+ const target = normalize(s.relative_path);
38
+ if (target === "/") continue;
39
+ const current = target.includes("?") ? full : path;
40
+ if ((current === target || current.startsWith(`${target}/`)) && target.length > bestLength) {
41
+ best = s;
42
+ bestLength = target.length;
43
+ }
44
+ }
45
+ return best?.groups ?? [];
46
+ });
47
+
48
+ // L'onglet Tables n'existe que si l'ecran en a, ou le temps de le savoir :
49
+ // un onglet qui s'ouvre sur du vide se lit comme un bug.
50
+ const hasTables = computed(() => toValue(canSeeTables) && (pending.value || groups.value.length > 0));
51
+ const readable = computed(() => data.value?.readable ?? true);
52
+
53
+ return { groups, pending, readable, hasTables };
54
+ }
@@ -0,0 +1,6 @@
1
+ <!-- Ecrans hors cadre (connexion, lien magique, retour SSO). -->
2
+ <template>
3
+ <div>
4
+ <slot />
5
+ </div>
6
+ </template>
@@ -0,0 +1,38 @@
1
+ <!--
2
+ Cadre des pages d'erreur.
3
+
4
+ Meme chassis que les pages de l'app (RmAppShell), pour qu'une erreur reste
5
+ visiblement dans OIP 7. Ni navigation ni rail : la navigation se lit dans un
6
+ composable de page, et une erreur peut justement venir de la. Un cadre qui
7
+ ne depend de rien ne peut pas echouer a son tour. Le fil s'arrete au pays :
8
+ un maillon faux vaut moins qu'un maillon absent.
9
+ -->
10
+ <template>
11
+ <RmAppShell>
12
+ <template #header>
13
+ <RmAppHeader :crumbs="crumbs" />
14
+ </template>
15
+
16
+ <div class="oip-error-page">
17
+ <slot />
18
+ </div>
19
+ </RmAppShell>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ const { platform, home } = useAppConfig().oip;
24
+ const crumbs = [platform, ...(home ? [home] : [])];
25
+ </script>
26
+
27
+ <style scoped>
28
+ .oip-error-page {
29
+ /* Les apps qui coupent base.css de Romulus ne comptent pas les bordures
30
+ dans la boite : la marge interieure ferait deborder le cadre. */
31
+ box-sizing: border-box;
32
+ display: flex;
33
+ align-items: center;
34
+ justify-content: center;
35
+ min-height: 100%;
36
+ padding: var(--space-32) var(--space-24);
37
+ }
38
+ </style>
@@ -0,0 +1,46 @@
1
+ // Les categories du panneau « Notes ».
2
+ //
3
+ // Une categorie repond a « de quel genre d'explication ai-je besoin ». Elle
4
+ // remplace les anciens onglets du rail (Infos, Metrics, Tips, Methodo) : le
5
+ // genre devient un filtre dans une liste, plus une tuile a chercher.
6
+ //
7
+ // La liste est fermee et commune aux quatre sous-apps : deux apps qui
8
+ // rangeraient la meme explication sous deux libelles differents redonneraient
9
+ // le probleme qu'on vient de retirer. Les quatre premieres viennent de la
10
+ // maquette de septembre 2026, les trois suivantes du contenu deja ecrit dans
11
+ // les apps, qui n'y entrait pas.
12
+
13
+ import type { NoteTone } from "./types";
14
+
15
+ export interface NoteCategory {
16
+ key: string;
17
+ label: string;
18
+ icon: string;
19
+ tone: NoteTone;
20
+ }
21
+
22
+ const SHAPE: { key: string; icon: string; tone: NoteTone; fr: string; en: string }[] = [
23
+ // Comment le chiffre est fabrique : formule, agregation, perimetre de calcul.
24
+ { key: "method", icon: "BookOpen", tone: "info", fr: "Méthodologie", en: "Methodology" },
25
+ // Ce que designe un indicateur affiche a l'ecran.
26
+ { key: "metrics", icon: "Ruler", tone: "success", fr: "Métriques", en: "Metrics" },
27
+ // Ce que la donnee ne couvre pas, et ce qu'on ne peut donc pas en conclure.
28
+ { key: "limits", icon: "WarningCircle", tone: "warning", fr: "Limites", en: "Limitations" },
29
+ // Les choix d'affichage : arrondis, seuils, regles de lecture.
30
+ { key: "conventions", icon: "ListChecks", tone: "neutral", fr: "Conventions", en: "Conventions" },
31
+ // Le cadre francais : HAS, ANSM, acces precoce, SMR/ASMR.
32
+ { key: "regulatory", icon: "Scales", tone: "primary", fr: "Réglementaire", en: "Regulatory" },
33
+ // Les sigles, rien d'autre.
34
+ { key: "glossary", icon: "Book", tone: "primary", fr: "Glossaire", en: "Glossary" },
35
+ // Comment se servir de l'ecran : recherche, filtres, onglets.
36
+ { key: "help", icon: "Lifebuoy", tone: "info", fr: "Aide", en: "Help" },
37
+ ];
38
+
39
+ export function noteCategories(locale: "fr" | "en"): NoteCategory[] {
40
+ return SHAPE.map(({ key, icon, tone, fr, en }) => ({
41
+ key,
42
+ icon,
43
+ tone,
44
+ label: locale === "en" ? en : fr,
45
+ }));
46
+ }
@@ -0,0 +1,100 @@
1
+ // Forme d'une note du panneau « Notes », cote app.
2
+ //
3
+ // Le panneau (RmNotes, Romulus) ne recoit que des notes deja traduites et
4
+ // deja triees pour l'ecran ouvert. Ces types decrivent ce que l'app produit,
5
+ // pas ce que le composant affiche : ils sont volontairement structuraux, un
6
+ // changement de rendu dans Romulus ne doit pas obliger a rouvrir les contenus.
7
+
8
+ export type NoteTone = "primary" | "info" | "warning" | "success" | "neutral";
9
+
10
+ export type NoteBlock =
11
+ /** Paragraphe. `**gras**` est reconnu, rien d'autre. */
12
+ | { type: "text"; text: string }
13
+ | { type: "list"; items: string[] }
14
+ /** Definitions : le terme en gras, sa definition a la suite. */
15
+ | { type: "terms"; items: { term: string; def?: string }[] }
16
+ | { type: "callout"; text: string; tone?: "info" | "warning"; title?: string }
17
+ | { type: "example"; text: string }
18
+ /** Renvoi interne : le panneau remonte le clic, la page navigue. */
19
+ | { type: "link"; to: string; label: string };
20
+
21
+ export interface NoteSection {
22
+ title?: string;
23
+ blocks: NoteBlock[];
24
+ }
25
+
26
+ export interface Note {
27
+ id: string;
28
+ /** Cle de categorie, cf. `categories.ts`. */
29
+ category: string;
30
+ /**
31
+ * A quoi la note se rapporte. `page` : l'ecran ouvert, ses chiffres, ses
32
+ * onglets. `app` : ce qui vaut partout, la syntaxe de la barre de recherche
33
+ * ou le glossaire des sigles.
34
+ *
35
+ * Le panneau les rend dans l'ordre recu, et `useNotes` fait passer les notes
36
+ * de page devant : on ouvre le volet pour comprendre l'ecran qu'on a sous
37
+ * les yeux, l'aide generale se relit rarement et se retrouve par sa
38
+ * categorie. Absent vaut `page`, le cas courant.
39
+ */
40
+ scope?: "page" | "app";
41
+ /** Date ISO de derniere revision editoriale du texte. */
42
+ updated: string;
43
+ title: string;
44
+ /** Ce que la note repond, en une phrase : c'est tout ce qu'on lit replie. */
45
+ summary: string;
46
+ sections: NoteSection[];
47
+ }
48
+
49
+ /** Ce que les fabriques de notes savent de l'ecran ouvert. */
50
+ export interface NoteContext {
51
+ path: string;
52
+ locale: "fr" | "en";
53
+ t: (key: string) => string;
54
+ }
55
+
56
+ /**
57
+ * Une note telle que /api/notes la rend : la note, plus ce a quoi elle se
58
+ * rapporte. Elle sort sur les chemins qu'elle cite, et partout ou l'une des
59
+ * sources qu'elle cite est affichee.
60
+ */
61
+ export interface ApiNote extends Note {
62
+ /** Chemins des ecrans concernes (`relative_path` des sujets du portail). */
63
+ paths: string[];
64
+ /** `source_uuid` des jeux Directus que la note explique. */
65
+ sources: string[];
66
+ }
67
+
68
+ /**
69
+ * Une note telle que /api/note la rend a l'equipe qui la corrige : les deux
70
+ * langues cote a cote, les identifiants Directus, les lignes de liaison.
71
+ *
72
+ * Ces formes vivent ici plutot qu'a cote des routes parce que le dialogue de
73
+ * correction est un composant de l'app : le projet TS de l'app ne resout pas
74
+ * `~/server/...`, ou `~` designe `app/`.
75
+ */
76
+ export interface AdminNoteTranslation {
77
+ /** Absent : la traduction n'existe pas encore, elle sera creee. */
78
+ id?: number;
79
+ languages_code: string;
80
+ title: string;
81
+ summary: string;
82
+ sections: NoteSection[];
83
+ }
84
+
85
+ export interface AdminNote {
86
+ id: string;
87
+ status: string;
88
+ category: string | null;
89
+ translations: AdminNoteTranslation[];
90
+ /** Lignes de liaison : leur id sert a defaire un rattachement. */
91
+ subjects: { linkId: number; subjectId: string }[];
92
+ sources: { linkId: number; sourceUuid: string }[];
93
+ }
94
+
95
+ /** Les listes que l'editeur propose : categories, ecrans de l'app, jeux de donnees. */
96
+ export interface NoteOptions {
97
+ categories: { id: string; fr: string; en: string }[];
98
+ subjects: { id: string; path: string }[];
99
+ sources: { uuid: string; label: string }[];
100
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Charge le registre des index quand l'app designe ses index par id Directus
3
+ * (app.config.oip.esIndices.preload) : au demarrage si la session est deja
4
+ * la, sinon des la connexion. Les autres apps ne paient rien.
5
+ */
6
+ export default defineNuxtPlugin(async () => {
7
+ if (!useAppConfig().oip.esIndices.preload) return;
8
+ const { status } = useAuth();
9
+ const { load } = useOipEsIndices();
10
+ // Avant le premier rendu quand on arrive connecte : `nameOf` est alors
11
+ // synchrone pour toute la session.
12
+ if (status.value === "authenticated") await load();
13
+ // La route est reservee aux personnes connectees : apres une connexion
14
+ // dans l'app, on charge a ce moment-la.
15
+ watch(status, (value) => {
16
+ if (value === "authenticated") load();
17
+ });
18
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Les metadonnees Directus de cette app, pour le panneau « Métadonnées » du
3
+ * rail (superadmins) : la fiche `OipApplication` et ses `OipSubject`, chacun
4
+ * avec ses traductions ramenees a un couple nom / description, quel que soit
5
+ * le nom des colonnes dans le CMS.
6
+ */
7
+ export interface MetadataTranslation {
8
+ id?: number;
9
+ languages_code: string;
10
+ name: string;
11
+ description: string;
12
+ }
13
+
14
+ export interface MetadataApplication {
15
+ id: string;
16
+ status: string;
17
+ prod_url: string;
18
+ staging_url: string;
19
+ github_front_url: string;
20
+ categories: string[];
21
+ date_updated: string | null;
22
+ translations: MetadataTranslation[];
23
+ }
24
+
25
+ export interface MetadataSubject {
26
+ id: string;
27
+ status: string;
28
+ relative_path: string;
29
+ keywords: string;
30
+ is_specialized_report: boolean;
31
+ date_updated: string | null;
32
+ translations: MetadataTranslation[];
33
+ }
34
+
35
+ export interface Metadata {
36
+ application: MetadataApplication | null;
37
+ subjects: MetadataSubject[];
38
+ }