@tekkare/romulus 0.1.242 → 0.1.244

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 (45) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/components/AppUpdateToast.vue +157 -0
  3. package/layer/app/components/OipShell.vue +2 -0
  4. package/layer/app/components/auth/SplitLayout.vue +261 -0
  5. package/layer/app/components/layout/alertBrandSync.vue +143 -0
  6. package/layer/app/components/layout/firstVisitVideo.vue +165 -0
  7. package/layer/app/components/layout/tool/adminTemplates.vue +261 -0
  8. package/layer/app/components/layout/tool/alerts.vue +969 -0
  9. package/layer/app/components/layout/tool/export.vue +590 -0
  10. package/layer/app/components/layout/tool/views.vue +933 -0
  11. package/layer/app/components/layout/viewEmptyState.vue +278 -0
  12. package/layer/app/components/layout/viewPill.vue +550 -0
  13. package/layer/app/components/panels/OipPanelProjects.vue +94 -0
  14. package/layer/app/composables/useAdminAccess.ts +86 -0
  15. package/layer/app/composables/useAlertBrandSync.ts +85 -0
  16. package/layer/app/composables/useAlertSource.ts +210 -0
  17. package/layer/app/composables/useAlerts.ts +345 -0
  18. package/layer/app/composables/useExportSource.ts +236 -0
  19. package/layer/app/composables/useHubrEmbedded.ts +13 -0
  20. package/layer/app/composables/usePermalink.ts +108 -0
  21. package/layer/app/composables/useSavedExports.ts +286 -0
  22. package/layer/app/composables/useViewCardActions.ts +500 -0
  23. package/layer/app/composables/useViewEditing.ts +11 -0
  24. package/layer/app/composables/useViewTemplates.ts +369 -0
  25. package/layer/app/composables/useViews.ts +574 -0
  26. package/layer/app/oip-defaults/alerts.ts +34 -0
  27. package/layer/app/oip-defaults/api.ts +18 -0
  28. package/layer/app/oip-defaults/export.ts +23 -0
  29. package/layer/app/oip-defaults/ui.ts +21 -0
  30. package/layer/app/oip-defaults/views.ts +59 -0
  31. package/layer/app/utils/alertFieldLabels.ts +191 -0
  32. package/layer/app/utils/countries.ts +30 -0
  33. package/layer/app/utils/exportPage.ts +418 -0
  34. package/layer/app/utils/headerTabs.ts +15 -0
  35. package/layer/app/utils/hybridRows.ts +22 -0
  36. package/layer/app/utils/oipScopeStore.ts +112 -0
  37. package/layer/app/utils/oipScopes.ts +376 -0
  38. package/layer/app/utils/postLoginRedirect.ts +58 -0
  39. package/layer/app/utils/valueTable.ts +126 -0
  40. package/layer/app/utils/viewPages.ts +30 -0
  41. package/layer/app/utils/viewTemplateCriteria.ts +215 -0
  42. package/layer/app/utils/viewTemplates.ts +116 -0
  43. package/layer/nuxt.config.ts +9 -0
  44. package/layer/server/api/oip/notes.get.ts +1 -1
  45. package/package.json +4 -2
@@ -0,0 +1,165 @@
1
+ <template>
2
+ <aside v-if="visible && video?.src" class="first-visit-video" aria-label="Tutoriel vidéo">
3
+ <div class="first-visit-video__head">
4
+ <div class="first-visit-video__heading">
5
+ <RmIcon name="PlayCircle" :size="18" />
6
+ <strong>{{ lang === "en" ? "Getting started" : "Bien démarrer" }}</strong>
7
+ </div>
8
+ <button
9
+ type="button"
10
+ class="first-visit-video__close"
11
+ :aria-label="lang === 'en' ? 'Close' : 'Fermer'"
12
+ @click="dismiss"
13
+ >
14
+ <RmIcon name="X" :size="16" />
15
+ </button>
16
+ </div>
17
+
18
+ <video
19
+ class="first-visit-video__player"
20
+ :src="video.src"
21
+ autoplay
22
+ controls
23
+ muted
24
+ playsinline
25
+ preload="metadata"
26
+ ></video>
27
+
28
+ <div class="first-visit-video__footer">
29
+ <span>{{ video.title }}</span>
30
+ <button type="button" class="first-visit-video__dismiss" @click="dismiss">
31
+ {{ lang === "en" ? "Got it" : "J’ai compris" }}
32
+ </button>
33
+ </div>
34
+ </aside>
35
+ </template>
36
+
37
+ <script setup lang="ts">
38
+ interface TutorialVideo {
39
+ id: number;
40
+ title: string;
41
+ description: string;
42
+ src: string;
43
+ }
44
+
45
+ const props = defineProps<{
46
+ video: TutorialVideo | null;
47
+ lang: "fr" | "en";
48
+ }>();
49
+
50
+ const STORAGE_KEY = "oip-first-visit-video-seen";
51
+ const visible = ref(false);
52
+
53
+ function hasSeenVideo() {
54
+ try {
55
+ return localStorage.getItem(STORAGE_KEY) === "1";
56
+ } catch {
57
+ return false;
58
+ }
59
+ }
60
+
61
+ function dismiss() {
62
+ visible.value = false;
63
+ try {
64
+ localStorage.setItem(STORAGE_KEY, "1");
65
+ } catch {
66
+ // Le navigateur peut refuser le stockage, la fenêtre reste fermée pour la session.
67
+ }
68
+ }
69
+
70
+ watch(
71
+ () => props.video?.src,
72
+ (src) => {
73
+ if (src && !hasSeenVideo()) visible.value = true;
74
+ },
75
+ { immediate: true },
76
+ );
77
+ </script>
78
+
79
+ <style scoped>
80
+ .first-visit-video {
81
+ position: fixed;
82
+ right: 24px;
83
+ bottom: 24px;
84
+ z-index: 1100;
85
+ width: min(460px, calc(100vw - 32px));
86
+ overflow: hidden;
87
+ border: 1px solid var(--border-default);
88
+ border-radius: var(--radius-xl);
89
+ background: var(--bg-surface);
90
+ box-shadow: 0 18px 48px rgb(15 23 42 / 24%);
91
+ }
92
+
93
+ .first-visit-video__head,
94
+ .first-visit-video__footer {
95
+ align-items: center;
96
+ display: flex;
97
+ gap: var(--space-8);
98
+ justify-content: space-between;
99
+ padding: var(--space-12) var(--space-16);
100
+ }
101
+
102
+ .first-visit-video__head {
103
+ color: var(--color-secondary);
104
+ }
105
+
106
+ .first-visit-video__heading {
107
+ align-items: center;
108
+ display: flex;
109
+ gap: var(--space-8);
110
+ }
111
+
112
+ .first-visit-video__close {
113
+ align-items: center;
114
+ background: transparent;
115
+ border: 0;
116
+ color: var(--text-muted);
117
+ cursor: pointer;
118
+ display: flex;
119
+ justify-content: center;
120
+ padding: var(--space-4);
121
+ }
122
+
123
+ .first-visit-video__close:hover {
124
+ color: var(--text-primary);
125
+ }
126
+
127
+ .first-visit-video__player {
128
+ background: var(--bg-surface-3);
129
+ display: block;
130
+ max-height: 290px;
131
+ width: 100%;
132
+ }
133
+
134
+ .first-visit-video__footer {
135
+ color: var(--text-secondary);
136
+ font-size: var(--font-12);
137
+ }
138
+
139
+ .first-visit-video__footer > span {
140
+ overflow: hidden;
141
+ text-overflow: ellipsis;
142
+ white-space: nowrap;
143
+ }
144
+
145
+ .first-visit-video__dismiss {
146
+ background: transparent;
147
+ border: 0;
148
+ color: var(--color-secondary);
149
+ cursor: pointer;
150
+ flex-shrink: 0;
151
+ font: inherit;
152
+ font-weight: var(--weight-semibold);
153
+ }
154
+
155
+ .first-visit-video__dismiss:hover {
156
+ color: var(--color-primary);
157
+ }
158
+
159
+ @media (max-width: 600px) {
160
+ .first-visit-video {
161
+ bottom: 16px;
162
+ right: 16px;
163
+ }
164
+ }
165
+ </style>
@@ -0,0 +1,261 @@
1
+ <!--
2
+ Onglet « Admin templates » du groupe Vues et alertes (superadmins seuls).
3
+
4
+ L'inventaire des templates publies sur la page, quel que soit l'espace
5
+ vise : qui l'a publie, a qui il est offert, quand il a bouge. Ils vivaient
6
+ dans « Mes vues » de l'administrateur qui les avait publies, melanges a ses
7
+ vues de travail, et ceux de ses collegues n'apparaissaient nulle part.
8
+
9
+ Tout template se gere ici, quel que soit qui l'a publie : changer les
10
+ espaces qui le recoivent, ou le retirer. Retirer ne supprime rien : la vue
11
+ revient dans « Mes vues » de son proprietaire. L'acces d'un template ne
12
+ passe que par ses espaces cibles (l'API ignore le partage nominatif sur un
13
+ template), d'ou un dialogue sans liste de personnes.
14
+ -->
15
+ <script setup lang="ts">
16
+ import { useOipApi } from "#oip-app/api";
17
+ import { oipSnackbar } from "#oip-app/ui";
18
+ import { toTemplate } from "../../../composables/useViewTemplates";
19
+ import { sameViewPage } from "../../../utils/viewPages";
20
+
21
+ const { locale } = useI18n();
22
+ const route = useRoute();
23
+ const views = useViews();
24
+ const elysia = useOipApi();
25
+ const { workspaces, ensureWorkspaces } = useAdminAccess();
26
+
27
+ const page = computed(() => views.current.value?.page ?? route.path);
28
+ const { cardOf, start } = useViewTemplates(() => page.value);
29
+
30
+ type Row = Awaited<ReturnType<typeof elysia.admin.viewTemplates>>["data"][number];
31
+
32
+ const rows = ref<Row[]>([]);
33
+ const loading = ref(false);
34
+
35
+ /**
36
+ * Tout l'inventaire d'un coup, trie par page ici : une vue est rangee sous
37
+ * `route.path`, donc sous `/en/...` pour qui a publie en anglais (cf.
38
+ * `sameViewPage`). Recharge a chaque ouverture : un partage modifie depuis
39
+ * « Mes vues » doit s'y lire au retour.
40
+ */
41
+ async function load() {
42
+ loading.value = true;
43
+ try {
44
+ const [res] = await Promise.all([elysia.admin.viewTemplates(), ensureWorkspaces()]);
45
+ rows.value = res?.data ?? [];
46
+ } catch (error) {
47
+ console.error("[adminTemplates] Inventaire indisponible:", error);
48
+ rows.value = [];
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ }
53
+
54
+ onMounted(load);
55
+
56
+ const pageRows = computed(() => rows.value.filter((row) => sameViewPage(row.page, page.value)));
57
+
58
+ const en = computed(() => locale.value === "en");
59
+
60
+ const dateFormat = computed(
61
+ () => new Intl.DateTimeFormat(en.value ? "en-GB" : "fr-FR", { dateStyle: "medium" }),
62
+ );
63
+
64
+ function workspaceName(id: string) {
65
+ return workspaces.value.find((ws) => ws.workspaceId === id)?.name ?? id;
66
+ }
67
+
68
+ /** Qui a acces : tous les espaces quand aucune cible n'est posee. */
69
+ function accessRow(row: Row) {
70
+ const targets = row.targetWorkspaces ?? [];
71
+ if (!targets.length) {
72
+ return {
73
+ icon: "Globe",
74
+ key: en.value ? "Access" : "Accès",
75
+ value: en.value ? "Every workspace" : "Tous les espaces",
76
+ };
77
+ }
78
+ return {
79
+ icon: "Buildings",
80
+ key: `${en.value ? "Access" : "Accès"} (${targets.length})`,
81
+ pills: targets.map((id) => ({ k: en.value ? "Workspace" : "Espace", v: workspaceName(id) })),
82
+ };
83
+ }
84
+
85
+ function metaOf(row: Row) {
86
+ const meta = [
87
+ {
88
+ icon: "User",
89
+ label: row.isOwner
90
+ ? en.value
91
+ ? "Published by you"
92
+ : "Publié par vous"
93
+ : `${en.value ? "Published by" : "Publié par"} ${row.ownerName ?? "?"}`,
94
+ },
95
+ ];
96
+ const date = new Date(row.updatedAt);
97
+ if (!Number.isNaN(date.getTime())) {
98
+ meta.push({
99
+ icon: "Clock",
100
+ label: `${en.value ? "Updated on" : "Modifié le"} ${dateFormat.value.format(date)}`,
101
+ });
102
+ }
103
+ return meta;
104
+ }
105
+
106
+ function menuOf(row: Row) {
107
+ return [
108
+ { key: "share", label: en.value ? "Edit access" : "Modifier l'accès", icon: "Buildings" },
109
+ {
110
+ key: "withdraw",
111
+ label: en.value ? "Remove from templates" : "Retirer des templates",
112
+ icon: "XCircle",
113
+ danger: true,
114
+ confirm: true,
115
+ confirmText: en.value
116
+ ? `« ${row.title} » will no longer be offered to any workspace. The view stays in its owner's views.`
117
+ : `« ${row.title} » ne sera plus proposé à aucun espace. La vue reste dans les vues de son propriétaire.`,
118
+ },
119
+ ];
120
+ }
121
+
122
+ const cards = computed(() =>
123
+ pageRows.value.map((row) => {
124
+ const card = cardOf(toTemplate(row));
125
+ return {
126
+ ...card,
127
+ id: row.id,
128
+ menu: menuOf(row),
129
+ config: [accessRow(row), ...card.config],
130
+ meta: metaOf(row),
131
+ };
132
+ }),
133
+ );
134
+
135
+ function activate(id: string) {
136
+ const row = rows.value.find((item) => item.id === id);
137
+ if (row) start(toTemplate(row));
138
+ }
139
+
140
+ /** Un mot dans la barre du bas, comme le panneau « Mes vues ». */
141
+ function notify(text: string) {
142
+ oipSnackbar([
143
+ { attribute: "isVisible", value: true },
144
+ { attribute: "text", value: text },
145
+ { attribute: "loader", value: false },
146
+ { attribute: "hasTimeOut", value: true },
147
+ ]);
148
+ }
149
+
150
+ async function update(id: string, body: { isTemplate?: boolean; targetWorkspaces?: string[] }) {
151
+ try {
152
+ await elysia.admin.updateViewTemplate(id, body);
153
+ return true;
154
+ } catch (error) {
155
+ console.error("[adminTemplates] Mise a jour refusee:", error);
156
+ notify(en.value ? "The template could not be updated." : "Le template n'a pas pu être modifié.");
157
+ return false;
158
+ } finally {
159
+ await load();
160
+ }
161
+ }
162
+
163
+ /** Template dont on regle l'acces, ou null. */
164
+ const editingId = ref<string | null>(null);
165
+ const editing = computed(() => rows.value.find((row) => row.id === editingId.value) ?? null);
166
+ const dialogOpen = computed({
167
+ get: () => editing.value !== null,
168
+ set: (open: boolean) => {
169
+ if (!open) editingId.value = null;
170
+ },
171
+ });
172
+
173
+ /**
174
+ * Le dialogue rend toute la portee ; seuls le drapeau et les espaces comptent
175
+ * ici. Choisir autre chose que « template » revient a le retirer : la
176
+ * visibilite et les destinataires restent l'affaire du proprietaire.
177
+ */
178
+ async function confirmAccess(share: { isTemplate?: boolean; targetWorkspaces?: string[] }) {
179
+ const row = editing.value;
180
+ editingId.value = null;
181
+ if (!row) return;
182
+ if (share.isTemplate === false) {
183
+ if (await update(row.id, { isTemplate: false })) {
184
+ notify(en.value ? "Template removed." : "Template retiré.");
185
+ }
186
+ return;
187
+ }
188
+ const targets = share.targetWorkspaces ?? [];
189
+ if (await update(row.id, { targetWorkspaces: targets })) {
190
+ notify(
191
+ targets.length
192
+ ? en.value
193
+ ? `Template offered to ${targets.length} workspace${targets.length > 1 ? "s" : ""}.`
194
+ : `Template proposé à ${targets.length} espace${targets.length > 1 ? "s" : ""}.`
195
+ : en.value
196
+ ? "Template offered to every workspace."
197
+ : "Template proposé à tous les espaces.",
198
+ );
199
+ }
200
+ }
201
+
202
+ async function onAction(key: string, id: string) {
203
+ if (key === "share") editingId.value = id;
204
+ else if (key === "withdraw") {
205
+ if (await update(id, { isTemplate: false })) {
206
+ notify(en.value ? "Template removed." : "Template retiré.");
207
+ }
208
+ }
209
+ }
210
+ </script>
211
+
212
+ <template>
213
+ <div class="full_width">
214
+ <RmSavedViews
215
+ class="full_width"
216
+ :views="cards"
217
+ :menu="[]"
218
+ :renamable="false"
219
+ :starrable="false"
220
+ :loading="loading"
221
+ hide-tip
222
+ save-label=""
223
+ reset-label=""
224
+ dirty-title=""
225
+ :description="
226
+ en
227
+ ? 'Every template published on this page, whatever workspace it is offered to: who published it and who can see it.'
228
+ : 'Tous les templates publiés sur cette page, quel que soit l\'espace visé : qui l\'a publié et qui y a accès.'
229
+ "
230
+ :empty-title="en ? 'No template on this page' : 'Aucun template sur cette page'"
231
+ :empty-text="
232
+ en
233
+ ? 'Publish a view as a template from its Share menu in My views.'
234
+ : 'Publiez une vue comme template depuis son menu Partager, dans Mes vues.'
235
+ "
236
+ empty-action-label=""
237
+ :lang="en ? 'en' : 'fr'"
238
+ @activate="activate"
239
+ @action="onAction"
240
+ />
241
+
242
+ <RmShareDialog
243
+ v-model="dialogOpen"
244
+ :title="editing?.title ?? ''"
245
+ :members="[]"
246
+ can-template
247
+ is-template
248
+ :target-workspaces="editing?.targetWorkspaces ?? []"
249
+ :workspaces="workspaces"
250
+ :lang="en ? 'en' : 'fr'"
251
+ @save="confirmAccess"
252
+ />
253
+ </div>
254
+ </template>
255
+
256
+ <style scoped>
257
+ .full_width {
258
+ align-self: stretch;
259
+ width: 100%;
260
+ }
261
+ </style>