@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,382 @@
1
+ <template>
2
+ <RmModal
3
+ v-model="open"
4
+ size="lg"
5
+ mode="free"
6
+ :label="isFr ? 'Modifier la note' : 'Edit note'"
7
+ >
8
+ <template #header>
9
+ <h2 class="note_edit_title">{{ isFr ? "Modifier la note" : "Edit note" }}</h2>
10
+ </template>
11
+
12
+ <template #tabs>
13
+ <div class="note_edit_tabs">
14
+ <RmTabBar
15
+ v-model="activeLang"
16
+ :tabs="langTabs"
17
+ :aria-label="isFr ? 'Langue de la note' : 'Note language'"
18
+ />
19
+ </div>
20
+ </template>
21
+
22
+ <div v-if="loading" class="note_edit_body">
23
+ <RmSkeleton v-for="i in 4" :key="i" width="100%" :height="60" radius="8px" />
24
+ </div>
25
+
26
+ <div v-else-if="note" class="note_edit_body">
27
+ <section class="note_edit_section">
28
+ <p class="note_edit_eyebrow">{{ isFr ? "Texte" : "Text" }}</p>
29
+
30
+ <p v-if="!activeTranslation?.id" class="note_edit_hint">
31
+ {{ isFr
32
+ ? "Rien dans cette langue. Elle sera créée à l'enregistrement."
33
+ : "Nothing in this language. It will be created on save." }}
34
+ </p>
35
+
36
+ <RmInput
37
+ v-model="form.title"
38
+ :label="isFr ? 'Titre' : 'Title'"
39
+ />
40
+ <RmTextarea
41
+ :model-value="form.summary"
42
+ :label="isFr ? 'Résumé (ce qui se lit replié)' : 'Summary (read when collapsed)'"
43
+ :rows="2"
44
+ @update:model-value="form.summary = $event"
45
+ />
46
+ <RmNoteEditor
47
+ v-model="form.sections"
48
+ :label="isFr ? 'Contenu' : 'Content'"
49
+ :lang="isFr ? 'fr' : 'en'"
50
+ />
51
+ </section>
52
+
53
+ <section class="note_edit_section">
54
+ <p class="note_edit_eyebrow">{{ isFr ? "Classement" : "Filing" }}</p>
55
+ <div class="note_edit_row">
56
+ <RmDropdownSelect
57
+ v-model="categoryLabel"
58
+ :label="isFr ? 'Catégorie' : 'Category'"
59
+ :options="categoryLabels"
60
+ :search="false"
61
+ :lang="isFr ? 'fr' : 'en'"
62
+ />
63
+ <RmDropdownSelect
64
+ v-model="statusLabel"
65
+ :label="isFr ? 'Statut' : 'Status'"
66
+ :options="statusLabels"
67
+ :search="false"
68
+ :lang="isFr ? 'fr' : 'en'"
69
+ />
70
+ </div>
71
+ </section>
72
+
73
+ <section class="note_edit_section">
74
+ <p class="note_edit_eyebrow">{{ isFr ? "Rattachements" : "Attachments" }}</p>
75
+ <p class="note_edit_hint">
76
+ {{ isFr
77
+ ? "Une note sort sur les écrans qu'elle cite, et partout où l'une de ses sources est affichée. Sans aucun rattachement, elle ne sort nulle part."
78
+ : "A note shows on the screens it lists, and wherever one of its sources is displayed. With no attachment at all, it shows nowhere." }}
79
+ </p>
80
+
81
+ <RmComplexSelect
82
+ v-model="subjects"
83
+ :label="isFr ? 'Écrans' : 'Screens'"
84
+ :options="subjectOptions"
85
+ :lang="isFr ? 'fr' : 'en'"
86
+ />
87
+ <RmComplexSelect
88
+ v-model="sources"
89
+ :label="isFr ? 'Sources' : 'Sources'"
90
+ :options="sourceOptions"
91
+ :lang="isFr ? 'fr' : 'en'"
92
+ />
93
+
94
+ <RmCallout v-if="!subjects.length && !sources.length" tone="warning">
95
+ {{ isFr
96
+ ? "Cette note ne sera affichée sur aucun écran."
97
+ : "This note will not be shown on any screen." }}
98
+ </RmCallout>
99
+ </section>
100
+
101
+ <p v-if="error" class="note_edit_error">{{ error }}</p>
102
+ </div>
103
+
104
+ <template #footer>
105
+ <RmButton variant="tertiary" @click="open = false">
106
+ {{ isFr ? "Annuler" : "Cancel" }}
107
+ </RmButton>
108
+ <RmButton variant="primary" :disabled="saving || loading" @click="save">
109
+ {{ saving ? (isFr ? "Enregistrement..." : "Saving...") : (isFr ? "Enregistrer" : "Save") }}
110
+ </RmButton>
111
+ </template>
112
+ </RmModal>
113
+ </template>
114
+
115
+ <script setup lang="ts">
116
+ /**
117
+ * Correction d'une note du rail, reservee aux superadmins.
118
+ *
119
+ * Meme geste que pour une source : un crayon sur la fiche, un formulaire, une
120
+ * route serveur qui revalide le droit. Trois differences tiennent a ce qu'est
121
+ * une note.
122
+ *
123
+ * Le contenu n'est pas du texte libre mais des blocs typés, ceux que le
124
+ * panneau rend : il s'edite dans RmNoteEditor, qui montre chaque bloc comme il
125
+ * sortira. Le serveur seul sait passer des blocs au texte que Directus garde.
126
+ *
127
+ * Les deux langues s'editent ici, et seule celle qu'on a touchee part a
128
+ * l'enregistrement : reecrire la traduction qu'on n'a pas ouverte la
129
+ * reformaterait pour rien.
130
+ *
131
+ * Les rattachements font partie du formulaire. Une note ne sort que sur les
132
+ * ecrans qu'elle cite ou avec les sources qu'elle explique : les corriger
133
+ * depuis le CMS demandait de connaitre les identifiants, et la note restait
134
+ * invisible en attendant.
135
+ */
136
+ import type { AdminNote, AdminNoteTranslation, NoteOptions, NoteSection } from "../notes/types";
137
+
138
+ const props = defineProps<{ noteId: string; lang: string }>();
139
+ const emit = defineEmits<{ saved: [] }>();
140
+
141
+ const open = defineModel<boolean>({ default: false });
142
+
143
+ const isFr = computed(() => props.lang === "fr");
144
+
145
+ const LANGUAGES = [
146
+ { value: "fr-FR", name: "Français" },
147
+ { value: "en-US", name: "English" },
148
+ ];
149
+ const langTabs = LANGUAGES;
150
+
151
+ const STATUSES = [
152
+ { value: "published", fr: "Publiée", en: "Published" },
153
+ { value: "draft", fr: "Brouillon", en: "Draft" },
154
+ { value: "archived", fr: "Archivée", en: "Archived" },
155
+ ];
156
+
157
+ const note = ref<AdminNote | null>(null);
158
+ const options = ref<NoteOptions | null>(null);
159
+ const loading = ref(false);
160
+ const saving = ref(false);
161
+ const error = ref("");
162
+
163
+ const activeLang = ref("fr-FR");
164
+ const form = reactive<{ title: string; summary: string; sections: NoteSection[] }>({
165
+ title: "",
166
+ summary: "",
167
+ sections: [],
168
+ });
169
+
170
+ /** Ce que chaque langue porte en cours d'edition, gardé d'un onglet a l'autre. */
171
+ const drafts = reactive<Record<string, { title: string; summary: string; sections: NoteSection[] }>>({});
172
+ /** L'etat charge, pour ne renvoyer que les langues touchees. */
173
+ const loaded = reactive<Record<string, string>>({});
174
+
175
+ const categoryLabel = ref<string | undefined>(undefined);
176
+ const statusLabel = ref<string | undefined>(undefined);
177
+ const subjects = ref<string[]>([]);
178
+ const sources = ref<string[]>([]);
179
+
180
+ const categoryLabels = computed(() =>
181
+ (options.value?.categories ?? []).map((item) => (isFr.value ? item.fr : item.en))
182
+ );
183
+ const statusLabels = computed(() => STATUSES.map((item) => (isFr.value ? item.fr : item.en)));
184
+
185
+ const subjectOptions = computed(() =>
186
+ (options.value?.subjects ?? []).map((item) => ({ label: item.path, value: item.id }))
187
+ );
188
+ const sourceOptions = computed(() =>
189
+ (options.value?.sources ?? []).map((item) => ({ label: item.label, value: item.uuid }))
190
+ );
191
+
192
+ const activeTranslation = computed<AdminNoteTranslation | undefined>(() =>
193
+ note.value?.translations.find((item) => item.languages_code === activeLang.value)
194
+ );
195
+
196
+ const fingerprint = (draft: { title: string; summary: string; sections: NoteSection[] }) =>
197
+ JSON.stringify([draft.title, draft.summary, draft.sections]);
198
+
199
+ function loadDraft(code: string) {
200
+ const draft = drafts[code];
201
+ form.title = draft?.title ?? "";
202
+ form.summary = draft?.summary ?? "";
203
+ form.sections = draft?.sections ?? [];
204
+ }
205
+
206
+ function keepDraft(code: string) {
207
+ drafts[code] = {
208
+ title: form.title,
209
+ summary: form.summary,
210
+ sections: form.sections,
211
+ };
212
+ }
213
+
214
+ async function load() {
215
+ loading.value = true;
216
+ error.value = "";
217
+ try {
218
+ const [row, lists] = await Promise.all([
219
+ $fetch<AdminNote>(endpoints.noteEndpoint, { query: { id: props.noteId } }),
220
+ options.value
221
+ ? Promise.resolve(options.value)
222
+ : $fetch<NoteOptions>(endpoints.optionsEndpoint),
223
+ ]);
224
+
225
+ note.value = row;
226
+ options.value = lists;
227
+
228
+ for (const translation of row.translations) {
229
+ const draft = {
230
+ title: translation.title,
231
+ summary: translation.summary,
232
+ sections: translation.sections,
233
+ };
234
+ drafts[translation.languages_code] = draft;
235
+ loaded[translation.languages_code] = fingerprint(draft);
236
+ }
237
+
238
+ // La langue du rail d'abord : c'est celle qu'on vient de relire.
239
+ activeLang.value = isFr.value ? "fr-FR" : "en-US";
240
+ loadDraft(activeLang.value);
241
+
242
+ const category = (options.value?.categories ?? []).find((item) => item.id === row.category);
243
+ categoryLabel.value = category ? (isFr.value ? category.fr : category.en) : undefined;
244
+ const status = STATUSES.find((item) => item.value === row.status);
245
+ statusLabel.value = status ? (isFr.value ? status.fr : status.en) : undefined;
246
+
247
+ subjects.value = row.subjects.map((link) => link.subjectId);
248
+ sources.value = row.sources.map((link) => link.sourceUuid);
249
+ } catch (err: any) {
250
+ error.value = err?.data?.message || err?.message || "Load failed";
251
+ } finally {
252
+ loading.value = false;
253
+ }
254
+ }
255
+
256
+ watch(open, (value) => {
257
+ if (value) load();
258
+ });
259
+
260
+ watch(activeLang, (next, previous) => {
261
+ // Pendant le chargement, la langue est posee par `load`, qui remplit les
262
+ // brouillons lui-meme : garder le formulaire a ce moment ecraserait la
263
+ // traduction qu'on vient de lire avec un formulaire encore vide.
264
+ if (loading.value) return;
265
+ if (previous) keepDraft(previous);
266
+ loadDraft(next);
267
+ });
268
+
269
+ const notify = useOipNotify();
270
+ const endpoints = useAppConfig().oip.panels.Notes;
271
+
272
+ async function save() {
273
+ if (!note.value) return;
274
+ keepDraft(activeLang.value);
275
+
276
+ saving.value = true;
277
+ error.value = "";
278
+
279
+ const translations = note.value.translations
280
+ .filter((translation) => {
281
+ const draft = drafts[translation.languages_code];
282
+ return draft && fingerprint(draft) !== loaded[translation.languages_code];
283
+ })
284
+ .map((translation) => {
285
+ const draft = drafts[translation.languages_code]!;
286
+ return {
287
+ ...(translation.id ? { id: translation.id } : {}),
288
+ languages_code: translation.languages_code,
289
+ title: draft.title,
290
+ summary: draft.summary,
291
+ sections: draft.sections,
292
+ };
293
+ });
294
+
295
+ const category = (options.value?.categories ?? []).find(
296
+ (item) => (isFr.value ? item.fr : item.en) === categoryLabel.value
297
+ );
298
+ const status = STATUSES.find(
299
+ (item) => (isFr.value ? item.fr : item.en) === statusLabel.value
300
+ );
301
+
302
+ try {
303
+ await $fetch(endpoints.endpoint, {
304
+ method: "PATCH",
305
+ body: {
306
+ id: note.value.id,
307
+ ...(status ? { status: status.value } : {}),
308
+ ...(category ? { category: category.id } : {}),
309
+ ...(translations.length ? { translations } : {}),
310
+ subjects: subjects.value,
311
+ sources: sources.value,
312
+ },
313
+ });
314
+ notify(isFr.value ? "Note enregistrée" : "Note saved");
315
+ open.value = false;
316
+ emit("saved");
317
+ } catch (err: any) {
318
+ error.value = err?.data?.message || err?.message || "Save failed";
319
+ } finally {
320
+ saving.value = false;
321
+ }
322
+ }
323
+ </script>
324
+
325
+ <style scoped>
326
+ .note_edit_title {
327
+ margin: 0;
328
+ color: var(--text-primary);
329
+ font-family: var(--font-serif);
330
+ font-size: var(--font-18);
331
+ font-weight: var(--weight-bold);
332
+ }
333
+
334
+ .note_edit_tabs {
335
+ padding: 0 var(--space-4);
336
+ }
337
+
338
+ .note_edit_body {
339
+ display: flex;
340
+ flex-direction: column;
341
+ gap: var(--space-20);
342
+ padding: var(--space-4) 0;
343
+ }
344
+
345
+ .note_edit_section {
346
+ display: flex;
347
+ flex-direction: column;
348
+ gap: var(--space-12);
349
+ }
350
+
351
+ .note_edit_row {
352
+ display: flex;
353
+ gap: var(--space-12);
354
+ }
355
+
356
+ .note_edit_row > * {
357
+ flex: 1;
358
+ min-width: 0;
359
+ }
360
+
361
+ .note_edit_eyebrow {
362
+ margin: 0;
363
+ color: var(--text-muted);
364
+ font-size: var(--font-11);
365
+ font-weight: var(--weight-bold);
366
+ letter-spacing: .06em;
367
+ text-transform: uppercase;
368
+ }
369
+
370
+ .note_edit_hint {
371
+ margin: 0;
372
+ color: var(--text-secondary);
373
+ font-size: var(--font-12);
374
+ line-height: 1.5;
375
+ }
376
+
377
+ .note_edit_error {
378
+ margin: 0;
379
+ color: var(--color-danger);
380
+ font-size: var(--font-13);
381
+ }
382
+ </style>