@tekkare/romulus 0.1.241 → 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.
- 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/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 +711 -0
- package/layer/app/components/OipSourceEditDialog.vue +419 -0
- package/layer/app/components/OipSourceGroups.vue +107 -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/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/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/oipRail.ts +82 -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 +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>
|