@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,361 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Panneau « Sources » du rail, servi par le layer OIP (tuiles Sources et
|
|
3
|
+
Tables du groupe Documentation).
|
|
4
|
+
|
|
5
|
+
Vue « sources » : les jeux Directus de l'ecran, la source principale cadree
|
|
6
|
+
et les secondaires en dessous. Les uuids viennent de l'app (panelProps),
|
|
7
|
+
qui sait ce que l'ecran affiche.
|
|
8
|
+
|
|
9
|
+
Vue « tables » : les tablegroups Directus du sujet de l'ecran. Un
|
|
10
|
+
tablegroup se lit d'un coup d'oeil (categorie, description, data packages,
|
|
11
|
+
date) ; seul le detail de ses tables se deplie.
|
|
12
|
+
-->
|
|
13
|
+
<template>
|
|
14
|
+
<div class="src_tabs">
|
|
15
|
+
<div v-if="!showTables" class="src_sources">
|
|
16
|
+
<p class="src_heading">Sources</p>
|
|
17
|
+
<OipSourceGroups
|
|
18
|
+
:uuids="uuids"
|
|
19
|
+
:primary-uuid="primaryUuid"
|
|
20
|
+
:lang="locale === 'en' ? 'en' : 'fr'"
|
|
21
|
+
:editable="superAdmin"
|
|
22
|
+
/>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<div v-else class="tables_panel">
|
|
26
|
+
<div v-if="pending" class="tables_loading">
|
|
27
|
+
<RmSkeleton v-for="i in 2" :key="i" width="100%" :height="64" radius="8px" />
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<template v-else>
|
|
31
|
+
<p v-if="!readable" class="tables_note">
|
|
32
|
+
{{ isFr
|
|
33
|
+
? "Le jeton Directus de l'app ne lit pas les tablegroups : seuls leurs identifiants sont affichés."
|
|
34
|
+
: "The app's Directus token cannot read table groups: only their ids are shown." }}
|
|
35
|
+
</p>
|
|
36
|
+
|
|
37
|
+
<article v-for="g in groups" :key="g.id" class="tg_card">
|
|
38
|
+
<div v-if="g.category || g.status" class="tg_chips">
|
|
39
|
+
<span v-if="g.category" class="tg_chip">{{ g.category }}</span>
|
|
40
|
+
<span v-if="g.status && g.status !== 'published'" class="tg_chip">{{ g.status }}</span>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<h3 class="tg_title">{{ g.title || g.id }}</h3>
|
|
44
|
+
<p v-if="g.short_description" class="tg_description">{{ g.short_description }}</p>
|
|
45
|
+
|
|
46
|
+
<a
|
|
47
|
+
v-if="databankUrl(g)"
|
|
48
|
+
:href="databankUrl(g)"
|
|
49
|
+
target="_blank"
|
|
50
|
+
rel="noopener"
|
|
51
|
+
class="tg_link"
|
|
52
|
+
>
|
|
53
|
+
{{ isFr ? "Voir la page Databank" : "Open in Databank" }}
|
|
54
|
+
<RmIcon name="ArrowSquareOut" :size="12" />
|
|
55
|
+
</a>
|
|
56
|
+
|
|
57
|
+
<div v-if="g.packages.length" class="tg_row">
|
|
58
|
+
<span class="tg_label">Data packages :</span>
|
|
59
|
+
<span v-for="p in g.packages" :key="p.id" class="tg_chip" :title="p.summary || undefined">
|
|
60
|
+
{{ p.name || `#${p.id}` }}
|
|
61
|
+
</span>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div v-if="g.date_updated" class="tg_row">
|
|
65
|
+
<span class="tg_label">{{ isFr ? "Mis à jour :" : "Updated:" }}</span>
|
|
66
|
+
<span class="tg_chip">{{ day(g.date_updated) }}</span>
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<section v-for="d in g.datasets" :key="tableName(d)" class="tg_dataset">
|
|
70
|
+
<button
|
|
71
|
+
type="button"
|
|
72
|
+
class="tg_head"
|
|
73
|
+
:aria-expanded="isOpen(tableName(d))"
|
|
74
|
+
@click="toggle(tableName(d))"
|
|
75
|
+
>
|
|
76
|
+
<span class="tg_chevron">
|
|
77
|
+
<RmIcon :name="isOpen(tableName(d)) ? 'CaretUp' : 'CaretDown'" :size="12" />
|
|
78
|
+
</span>
|
|
79
|
+
<span class="tg_dataset_title">{{ d.nickname || tableName(d) }}</span>
|
|
80
|
+
</button>
|
|
81
|
+
|
|
82
|
+
<div v-if="isOpen(tableName(d))" class="tg_body">
|
|
83
|
+
<div class="tg_row">
|
|
84
|
+
<span class="tg_label">Table :</span>
|
|
85
|
+
<span class="tg_value">{{ tableName(d) }}</span>
|
|
86
|
+
<button
|
|
87
|
+
type="button"
|
|
88
|
+
class="tg_copy"
|
|
89
|
+
:aria-label="isFr ? 'Copier' : 'Copy'"
|
|
90
|
+
:title="isFr ? 'Copier' : 'Copy'"
|
|
91
|
+
@click="copy(tableName(d))"
|
|
92
|
+
>
|
|
93
|
+
<RmIcon name="Copy" :size="12" />
|
|
94
|
+
</button>
|
|
95
|
+
</div>
|
|
96
|
+
<p v-if="d.short_description" class="tg_description">{{ d.short_description }}</p>
|
|
97
|
+
|
|
98
|
+
<div v-if="d.last_update || d.update_frequency" class="tg_row">
|
|
99
|
+
<span class="tg_label">{{ isFr ? "Dernière mise à jour :" : "Last update:" }}</span>
|
|
100
|
+
<span v-if="d.last_update" class="tg_chip">{{ day(d.last_update) }}</span>
|
|
101
|
+
<template v-if="d.update_frequency">
|
|
102
|
+
<span class="tg_text">{{ isFr ? "mise à jour" : "updated" }}</span>
|
|
103
|
+
<span class="tg_chip">{{ d.update_frequency }}</span>
|
|
104
|
+
</template>
|
|
105
|
+
</div>
|
|
106
|
+
|
|
107
|
+
<div v-if="d.start_period || d.end_period" class="tg_row">
|
|
108
|
+
<span class="tg_label">{{ isFr ? "Période :" : "Date period:" }}</span>
|
|
109
|
+
<span class="tg_text">{{ isFr ? "du" : "from" }}</span>
|
|
110
|
+
<span class="tg_chip">{{ day(d.start_period) }}</span>
|
|
111
|
+
<span class="tg_text">{{ isFr ? "au" : "to" }}</span>
|
|
112
|
+
<span class="tg_chip">{{ day(d.end_period) }}</span>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<div v-if="d.location || d.geographical_scope || d.geographical_granularity" class="tg_geo">
|
|
116
|
+
<p class="tg_label">{{ isFr ? "Couverture géographique" : "Geographical info" }}</p>
|
|
117
|
+
<div v-if="d.location" class="tg_row">
|
|
118
|
+
<span class="tg_text">{{ isFr ? "Zone :" : "Location:" }}</span>
|
|
119
|
+
<span class="tg_chip">{{ d.location }}</span>
|
|
120
|
+
</div>
|
|
121
|
+
<div v-if="d.geographical_scope" class="tg_row">
|
|
122
|
+
<span class="tg_text">{{ isFr ? "Périmètre :" : "Geographical scope:" }}</span>
|
|
123
|
+
<span class="tg_chip">{{ d.geographical_scope }}</span>
|
|
124
|
+
</div>
|
|
125
|
+
<div v-if="d.geographical_granularity" class="tg_row">
|
|
126
|
+
<span class="tg_text">{{ isFr ? "Granularité :" : "Geographical granularity:" }}</span>
|
|
127
|
+
<span class="tg_chip">{{ d.geographical_granularity }}</span>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</section>
|
|
132
|
+
</article>
|
|
133
|
+
</template>
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
</template>
|
|
137
|
+
|
|
138
|
+
<script setup lang="ts">
|
|
139
|
+
import type { TableGroup, TableGroupDataset } from "../../types/tableGroups";
|
|
140
|
+
|
|
141
|
+
const props = withDefaults(
|
|
142
|
+
defineProps<{
|
|
143
|
+
appKey?: string;
|
|
144
|
+
superAdmin?: boolean;
|
|
145
|
+
/** `source_uuid` des jeux affiches par l'ecran. */
|
|
146
|
+
uuids?: string[];
|
|
147
|
+
/** Source mise en avant ; absente, la premiere ; vide, liste plate. */
|
|
148
|
+
primaryUuid?: string | null;
|
|
149
|
+
/** Ce que le panneau montre : les sources, ou les tablegroups (tuile Tables). */
|
|
150
|
+
view?: "sources" | "tables";
|
|
151
|
+
/** Droit a l'onglet Tables (licence Databank, admins), connu de l'app. */
|
|
152
|
+
canSeeTables?: boolean;
|
|
153
|
+
}>(),
|
|
154
|
+
{ superAdmin: false, uuids: () => [], primaryUuid: undefined, view: "sources", canSeeTables: false },
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
const { locale } = useI18n();
|
|
158
|
+
const isFr = computed(() => locale.value !== "en");
|
|
159
|
+
|
|
160
|
+
const { groups, pending, readable } = useOipSourceTableGroups(() => props.canSeeTables);
|
|
161
|
+
const showTables = computed(() => props.view === "tables");
|
|
162
|
+
|
|
163
|
+
// Les tables se lisent d'abord par leur nom : le detail se deplie a la demande.
|
|
164
|
+
const opened = ref(new Set<string>());
|
|
165
|
+
const isOpen = (key: string) => opened.value.has(key);
|
|
166
|
+
function toggle(key: string) {
|
|
167
|
+
const next = new Set(opened.value);
|
|
168
|
+
next.has(key) ? next.delete(key) : next.add(key);
|
|
169
|
+
opened.value = next;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const tableName = (d: TableGroupDataset) => (d.schema_name ? `${d.schema_name}.${d.table_name}` : d.table_name);
|
|
173
|
+
|
|
174
|
+
const day = (iso: string | null) =>
|
|
175
|
+
iso ? new Date(iso).toLocaleDateString(isFr.value ? "fr-FR" : "en-US", { day: "numeric", month: "long", year: "numeric" }) : "?";
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* La fiche du tablegroup sur Databank, dans son premier data package :
|
|
179
|
+
* `/package/<package>/tables/<tablegroup>?details=<id package>&table=<id tablegroup>`,
|
|
180
|
+
* les deux segments etant les titres Directus mis en slug.
|
|
181
|
+
*/
|
|
182
|
+
const DATABANK_URL = "https://databank.openinnovationprogram.com";
|
|
183
|
+
const slug = (value: string) =>
|
|
184
|
+
value.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
185
|
+
|
|
186
|
+
function databankUrl(g: TableGroup) {
|
|
187
|
+
const pkg = g.packages.find((p) => p.id && p.name);
|
|
188
|
+
if (!pkg || !g.title) return "";
|
|
189
|
+
return `${DATABANK_URL}/package/${slug(pkg.name)}/tables/${slug(g.title)}?details=${pkg.id}&table=${g.id}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function copy(value: string) {
|
|
193
|
+
navigator.clipboard?.writeText(value).catch(() => {});
|
|
194
|
+
}
|
|
195
|
+
</script>
|
|
196
|
+
|
|
197
|
+
<style scoped>
|
|
198
|
+
.src_sources {
|
|
199
|
+
display: flex;
|
|
200
|
+
flex-direction: column;
|
|
201
|
+
gap: 12px;
|
|
202
|
+
width: 100%;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.src_heading {
|
|
206
|
+
margin: 0;
|
|
207
|
+
color: var(--text-primary);
|
|
208
|
+
font-size: var(--font-16);
|
|
209
|
+
font-weight: var(--weight-semibold);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
.src_tabs {
|
|
213
|
+
align-self: stretch;
|
|
214
|
+
display: flex;
|
|
215
|
+
flex-direction: column;
|
|
216
|
+
gap: 16px;
|
|
217
|
+
width: 100%;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
.tables_panel,
|
|
221
|
+
.tables_loading {
|
|
222
|
+
display: flex;
|
|
223
|
+
flex-direction: column;
|
|
224
|
+
gap: 12px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.tables_note {
|
|
228
|
+
color: var(--text-secondary);
|
|
229
|
+
font-size: 13px;
|
|
230
|
+
font-style: italic;
|
|
231
|
+
line-height: 1.45;
|
|
232
|
+
margin: 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.tg_card {
|
|
236
|
+
background: var(--bg-surface);
|
|
237
|
+
border: 1px solid var(--border-default);
|
|
238
|
+
border-radius: var(--radius-xl, 12px);
|
|
239
|
+
display: flex;
|
|
240
|
+
flex-direction: column;
|
|
241
|
+
gap: 12px;
|
|
242
|
+
padding: 14px 16px;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.tg_chips,
|
|
246
|
+
.tg_row {
|
|
247
|
+
align-items: center;
|
|
248
|
+
display: flex;
|
|
249
|
+
flex-wrap: wrap;
|
|
250
|
+
gap: 6px;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.tg_chip {
|
|
254
|
+
background: var(--bg-surface-3, var(--bg-muted));
|
|
255
|
+
border-radius: 4px;
|
|
256
|
+
color: var(--text-primary);
|
|
257
|
+
font-size: 11px;
|
|
258
|
+
line-height: 1.4;
|
|
259
|
+
padding: 2px 6px;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
.tg_head {
|
|
263
|
+
align-items: center;
|
|
264
|
+
background: none;
|
|
265
|
+
border: 0;
|
|
266
|
+
cursor: pointer;
|
|
267
|
+
display: flex;
|
|
268
|
+
gap: 10px;
|
|
269
|
+
padding: 0;
|
|
270
|
+
text-align: left;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.tg_chevron {
|
|
274
|
+
align-items: center;
|
|
275
|
+
border: 1px solid var(--border-default);
|
|
276
|
+
border-radius: 6px;
|
|
277
|
+
color: var(--color-primary);
|
|
278
|
+
display: inline-flex;
|
|
279
|
+
flex: none;
|
|
280
|
+
height: 24px;
|
|
281
|
+
justify-content: center;
|
|
282
|
+
width: 24px;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
.tg_title {
|
|
286
|
+
color: var(--text-primary);
|
|
287
|
+
font-size: 16px;
|
|
288
|
+
font-weight: 700;
|
|
289
|
+
line-height: 1.3;
|
|
290
|
+
margin: 0;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.tg_body,
|
|
294
|
+
.tg_dataset,
|
|
295
|
+
.tg_geo {
|
|
296
|
+
display: flex;
|
|
297
|
+
flex-direction: column;
|
|
298
|
+
gap: 10px;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
.tg_dataset {
|
|
302
|
+
border-top: 1px solid var(--border-default);
|
|
303
|
+
padding-top: 10px;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
.tg_dataset_title {
|
|
307
|
+
color: var(--text-primary);
|
|
308
|
+
font-size: 13px;
|
|
309
|
+
font-weight: 600;
|
|
310
|
+
word-break: break-all;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.tg_geo {
|
|
314
|
+
gap: 4px;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.tg_description {
|
|
318
|
+
color: var(--text-primary);
|
|
319
|
+
font-size: 13px;
|
|
320
|
+
line-height: 1.5;
|
|
321
|
+
margin: 0;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.tg_label {
|
|
325
|
+
color: var(--text-primary);
|
|
326
|
+
font-size: 13px;
|
|
327
|
+
font-weight: 600;
|
|
328
|
+
margin: 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
.tg_text,
|
|
332
|
+
.tg_value {
|
|
333
|
+
color: var(--text-primary);
|
|
334
|
+
font-size: 13px;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
.tg_value {
|
|
338
|
+
word-break: break-all;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.tg_link {
|
|
342
|
+
align-items: center;
|
|
343
|
+
align-self: flex-start;
|
|
344
|
+
color: var(--color-primary);
|
|
345
|
+
display: inline-flex;
|
|
346
|
+
font-size: 13px;
|
|
347
|
+
font-weight: 600;
|
|
348
|
+
gap: 4px;
|
|
349
|
+
text-decoration: underline;
|
|
350
|
+
text-underline-offset: 3px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.tg_copy {
|
|
354
|
+
background: none;
|
|
355
|
+
border: 0;
|
|
356
|
+
color: var(--color-primary);
|
|
357
|
+
cursor: pointer;
|
|
358
|
+
display: inline-flex;
|
|
359
|
+
padding: 2px;
|
|
360
|
+
}
|
|
361
|
+
</style>
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Panneau « Mes sujets » du rail, servi par le layer OIP (tuile Topics).
|
|
3
|
+
|
|
4
|
+
L'etoile au bout d'une ligne de menu, sur OIP Platform, met un sujet en
|
|
5
|
+
favori. Ce panneau liste ces favoris pour y revenir d'un clic depuis
|
|
6
|
+
n'importe quelle page de l'app. Il est en lecture seule : l'etoile reste sur
|
|
7
|
+
la plateforme, ici on ne fait que rouvrir.
|
|
8
|
+
|
|
9
|
+
Un favori vise soit un sujet (une page, avec son application et son chemin),
|
|
10
|
+
soit une entree de menu (une entree de catalogue sans adresse propre : elle
|
|
11
|
+
ouvre le sujet unique qu'elle porte, sinon le hub de la plateforme).
|
|
12
|
+
-->
|
|
13
|
+
<script setup lang="ts">
|
|
14
|
+
// Passee par OipShell a tous les panneaux ; les favoris n'en dependent pas.
|
|
15
|
+
defineProps<{ appKey?: string; superAdmin?: boolean }>();
|
|
16
|
+
|
|
17
|
+
const { locale } = useI18n();
|
|
18
|
+
const { rows, load, pending, unavailable } = useOipFavorites();
|
|
19
|
+
/** Le hub de la plateforme, la ou s'allume l'etoile. */
|
|
20
|
+
const PLATFORM_URL = useAppConfig().oip.platform.href;
|
|
21
|
+
|
|
22
|
+
onMounted(() => load());
|
|
23
|
+
|
|
24
|
+
/** Meme regle que les sources : la traduction Directus qui commence par la langue. */
|
|
25
|
+
const pick = (list: any[] | undefined) =>
|
|
26
|
+
(list || []).find((item: any) =>
|
|
27
|
+
String(item?.languages_code?.code || item?.languages_code || "").startsWith(locale.value),
|
|
28
|
+
) || (list || [])[0];
|
|
29
|
+
|
|
30
|
+
/** Une description Directus peut porter du balisage ; un sous-titre non. */
|
|
31
|
+
const plain = (value?: string | null) =>
|
|
32
|
+
(value || "").replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
|
|
33
|
+
|
|
34
|
+
/** L'adresse d'un sujet : son application, puis son chemin dedans. */
|
|
35
|
+
function subjectUrl(subject: any): string {
|
|
36
|
+
const base = typeof subject?.app === "object" ? subject.app?.prod_url : null;
|
|
37
|
+
if (!base) return "";
|
|
38
|
+
const path = subject.relative_path || "/";
|
|
39
|
+
return `${base.replace(/\/+$/, "")}${path.startsWith("/") ? path : `/${path}`}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Directus met dans `Emoji` soit un emoji, soit un nom d'icone Phosphor. Le
|
|
44
|
+
* nom est de l'ASCII PascalCase, un emoji jamais : on rend celui des deux
|
|
45
|
+
* qu'on a recu. La fonte Phosphor est deja chargee par le cadre.
|
|
46
|
+
*/
|
|
47
|
+
function iconClass(name?: string): string | null {
|
|
48
|
+
const value = name?.trim();
|
|
49
|
+
if (!value || !/^[A-Za-z][A-Za-z0-9]*$/.test(value)) return null;
|
|
50
|
+
return `ph-${value
|
|
51
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
52
|
+
.replace(/([A-Z])([A-Z][a-z])/g, "$1-$2")
|
|
53
|
+
.toLowerCase()}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Directus ne stocke pas d'emoji de pays, seulement son code ISO-2. Le
|
|
58
|
+
* drapeau s'en deduit : deux indicateurs regionaux (U+1F1E6 + le rang de la
|
|
59
|
+
* lettre). « WW », le code du perimetre Global, n'a pas de drapeau : il prend
|
|
60
|
+
* le globe.
|
|
61
|
+
*/
|
|
62
|
+
function flag(iso?: string | null): string {
|
|
63
|
+
const code = (iso || "").trim().toUpperCase();
|
|
64
|
+
if (code === "WW") return "\u{1F310}";
|
|
65
|
+
if (!/^[A-Z]{2}$/.test(code)) return "";
|
|
66
|
+
return String.fromCodePoint(...[...code].map((letter) => 0x1f1e6 + letter.charCodeAt(0) - 65));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Le pays d'une ligne : son drapeau, puis son nom. */
|
|
70
|
+
function country(node: any): string {
|
|
71
|
+
const label = node?.country_label;
|
|
72
|
+
if (!label) return "";
|
|
73
|
+
const mark = flag(node?.country_iso);
|
|
74
|
+
return mark ? `${mark} ${label}` : label;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface TopicRow {
|
|
78
|
+
key: string;
|
|
79
|
+
emoji: string;
|
|
80
|
+
title: string;
|
|
81
|
+
subtitle: string;
|
|
82
|
+
to: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const topics = computed<TopicRow[]>(() =>
|
|
86
|
+
rows.value
|
|
87
|
+
.map((row) => {
|
|
88
|
+
const subject = row.subject;
|
|
89
|
+
if (subject?.subject_id) {
|
|
90
|
+
const translation = pick(subject.translations);
|
|
91
|
+
return {
|
|
92
|
+
key: `s-${subject.subject_id}`,
|
|
93
|
+
// Les sujets ne portent pas d'icone propre dans Directus.
|
|
94
|
+
emoji: "FileText",
|
|
95
|
+
title: translation?.subject_name ?? "",
|
|
96
|
+
subtitle:
|
|
97
|
+
country(subject.country_iso) || plain(translation?.subject_description),
|
|
98
|
+
to: subjectUrl(subject),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const item = row.menu_item;
|
|
103
|
+
if (!item?.id) return null;
|
|
104
|
+
const subjects = (item.Subjects ?? [])
|
|
105
|
+
.map((entry: any) => entry?.OipSubject_subject_id)
|
|
106
|
+
.filter((entry: any) => !!entry?.subject_id);
|
|
107
|
+
const single = subjects.length === 1 ? subjects[0] : null;
|
|
108
|
+
const translation = pick(item.translations);
|
|
109
|
+
return {
|
|
110
|
+
key: `i-${item.id}`,
|
|
111
|
+
emoji: item.Emoji || "Stack",
|
|
112
|
+
title: translation?.Title || item.Label || "",
|
|
113
|
+
subtitle: country(item.Country) || plain(translation?.Description),
|
|
114
|
+
// Une entree qui mene a plusieurs sujets ne peut pas choisir pour le
|
|
115
|
+
// lecteur : elle le renvoie au hub de la plateforme.
|
|
116
|
+
to: single ? subjectUrl(single) : PLATFORM_URL,
|
|
117
|
+
};
|
|
118
|
+
})
|
|
119
|
+
.filter((row): row is TopicRow => !!row?.title)
|
|
120
|
+
// Un sujet par destination. La meme page peut etre mise en favori deux
|
|
121
|
+
// fois, une fois comme entree de menu, une fois comme sujet.
|
|
122
|
+
.filter((row, index, all) => all.findIndex((other) => other.to === row.to) === index),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
/** En dessous de ce nombre, l'oeil est plus rapide qu'un champ de recherche. */
|
|
126
|
+
const SEARCHABLE_FROM = 6;
|
|
127
|
+
|
|
128
|
+
const search = ref("");
|
|
129
|
+
|
|
130
|
+
const shown = computed(() => {
|
|
131
|
+
const query = search.value.trim().toLowerCase();
|
|
132
|
+
if (!query) return topics.value;
|
|
133
|
+
// Le titre et le pays repondent tous les deux : « allemagne » est une entree
|
|
134
|
+
// aussi bonne que « obesite ».
|
|
135
|
+
return topics.value.filter((row) =>
|
|
136
|
+
`${row.title} ${row.subtitle}`.toLowerCase().includes(query),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Un favori pointant sur cette app se rouvre dans l'onglet courant, les
|
|
142
|
+
* autres dans un nouvel onglet : ils quittent l'application.
|
|
143
|
+
*/
|
|
144
|
+
const origin = ref("");
|
|
145
|
+
onMounted(() => {
|
|
146
|
+
origin.value = window.location.origin;
|
|
147
|
+
});
|
|
148
|
+
const isExternal = (to: string) => !!origin.value && !to.startsWith(origin.value);
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<template>
|
|
152
|
+
<div class="topics">
|
|
153
|
+
<p class="topics_intro">
|
|
154
|
+
{{
|
|
155
|
+
locale === "en"
|
|
156
|
+
? "The topics you starred on OIP Platform, one click away from any page of the app."
|
|
157
|
+
: "Les sujets mis en favori sur OIP Platform, à un clic depuis n'importe quelle page de l'app."
|
|
158
|
+
}}
|
|
159
|
+
</p>
|
|
160
|
+
|
|
161
|
+
<input
|
|
162
|
+
v-if="topics.length >= SEARCHABLE_FROM"
|
|
163
|
+
v-model="search"
|
|
164
|
+
class="topics_search"
|
|
165
|
+
type="search"
|
|
166
|
+
:placeholder="locale === 'en' ? 'Search my topics' : 'Rechercher un sujet'"
|
|
167
|
+
>
|
|
168
|
+
|
|
169
|
+
<p v-if="pending && !topics.length" class="topics_note">
|
|
170
|
+
{{ locale === "en" ? "Loading…" : "Chargement…" }}
|
|
171
|
+
</p>
|
|
172
|
+
|
|
173
|
+
<p v-else-if="unavailable" class="topics_note">
|
|
174
|
+
{{
|
|
175
|
+
locale === "en"
|
|
176
|
+
? "Your topics could not be loaded."
|
|
177
|
+
: "Vos sujets n'ont pas pu être chargés."
|
|
178
|
+
}}
|
|
179
|
+
</p>
|
|
180
|
+
|
|
181
|
+
<ul v-else-if="shown.length" class="topics_list">
|
|
182
|
+
<li v-for="row in shown" :key="row.key">
|
|
183
|
+
<a
|
|
184
|
+
class="topics_row"
|
|
185
|
+
:href="row.to"
|
|
186
|
+
:target="isExternal(row.to) ? '_blank' : undefined"
|
|
187
|
+
:rel="isExternal(row.to) ? 'noopener noreferrer' : undefined"
|
|
188
|
+
>
|
|
189
|
+
<span class="topics_icon">
|
|
190
|
+
<i v-if="iconClass(row.emoji)" class="ph" :class="iconClass(row.emoji)" />
|
|
191
|
+
<span v-else>{{ row.emoji }}</span>
|
|
192
|
+
</span>
|
|
193
|
+
<span class="topics_text">
|
|
194
|
+
<span class="topics_title">{{ row.title }}</span>
|
|
195
|
+
<span v-if="row.subtitle" class="topics_subtitle">{{ row.subtitle }}</span>
|
|
196
|
+
</span>
|
|
197
|
+
<i
|
|
198
|
+
class="ph topics_go"
|
|
199
|
+
:class="isExternal(row.to) ? 'ph-arrow-square-out' : 'ph-arrow-right'"
|
|
200
|
+
/>
|
|
201
|
+
</a>
|
|
202
|
+
</li>
|
|
203
|
+
</ul>
|
|
204
|
+
|
|
205
|
+
<p v-else-if="search.trim()" class="topics_note">
|
|
206
|
+
{{ locale === "en" ? "No topic matches that search." : "Aucun sujet ne correspond." }}
|
|
207
|
+
</p>
|
|
208
|
+
|
|
209
|
+
<p v-else class="topics_note">
|
|
210
|
+
{{
|
|
211
|
+
locale === "en"
|
|
212
|
+
? "No topic starred yet. Open a country menu on OIP Platform and click the star at the end of a row."
|
|
213
|
+
: "Aucun sujet en favori. Ouvrez le menu d'un pays sur OIP Platform et cliquez l'étoile au bout d'une ligne."
|
|
214
|
+
}}
|
|
215
|
+
<a class="topics_link" :href="PLATFORM_URL" target="_blank" rel="noopener noreferrer">
|
|
216
|
+
{{ locale === "en" ? "Open OIP Platform" : "Ouvrir OIP Platform" }}
|
|
217
|
+
</a>
|
|
218
|
+
</p>
|
|
219
|
+
</div>
|
|
220
|
+
</template>
|
|
221
|
+
|
|
222
|
+
<style scoped>
|
|
223
|
+
.topics {
|
|
224
|
+
display: flex;
|
|
225
|
+
flex-direction: column;
|
|
226
|
+
gap: 12px;
|
|
227
|
+
align-self: stretch;
|
|
228
|
+
width: 100%;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
.topics_intro {
|
|
232
|
+
margin: 0;
|
|
233
|
+
color: var(--text-muted);
|
|
234
|
+
font-size: var(--font-12);
|
|
235
|
+
line-height: 1.5;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.topics_search {
|
|
239
|
+
padding: 8px 10px;
|
|
240
|
+
border: 1px solid var(--border-default);
|
|
241
|
+
border-radius: var(--radius-md);
|
|
242
|
+
background: var(--bg-surface);
|
|
243
|
+
color: var(--text-primary);
|
|
244
|
+
font-family: var(--font-sans);
|
|
245
|
+
font-size: var(--font-13);
|
|
246
|
+
outline: none;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
.topics_search:focus {
|
|
250
|
+
border-color: var(--color-secondary);
|
|
251
|
+
box-shadow: 0 0 0 3px rgba(var(--brand-rgb), 0.1);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
.topics_note {
|
|
255
|
+
margin: 0;
|
|
256
|
+
color: var(--text-muted);
|
|
257
|
+
font-size: var(--font-12);
|
|
258
|
+
line-height: 1.5;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
.topics_link {
|
|
262
|
+
color: var(--color-secondary);
|
|
263
|
+
font-weight: var(--weight-semibold);
|
|
264
|
+
text-decoration: none;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.topics_list {
|
|
268
|
+
display: flex;
|
|
269
|
+
flex-direction: column;
|
|
270
|
+
gap: 8px;
|
|
271
|
+
margin: 0;
|
|
272
|
+
padding: 0;
|
|
273
|
+
list-style: none;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.topics_row {
|
|
277
|
+
display: flex;
|
|
278
|
+
align-items: center;
|
|
279
|
+
gap: 10px;
|
|
280
|
+
padding: 10px 12px;
|
|
281
|
+
border: 1px solid var(--border-default);
|
|
282
|
+
border-radius: var(--radius-lg);
|
|
283
|
+
background: var(--bg-surface);
|
|
284
|
+
color: var(--text-primary);
|
|
285
|
+
text-decoration: none;
|
|
286
|
+
transition: border-color 0.15s ease-out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
.topics_row:hover {
|
|
290
|
+
border-color: var(--color-secondary);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.topics_icon {
|
|
294
|
+
display: grid;
|
|
295
|
+
place-items: center;
|
|
296
|
+
flex-shrink: 0;
|
|
297
|
+
width: 30px;
|
|
298
|
+
height: 30px;
|
|
299
|
+
border-radius: var(--radius-md);
|
|
300
|
+
background: var(--bg-surface-2);
|
|
301
|
+
font-size: var(--font-16);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.topics_icon .ph {
|
|
305
|
+
color: var(--color-secondary);
|
|
306
|
+
font-size: 17px;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.topics_text {
|
|
310
|
+
display: flex;
|
|
311
|
+
flex-direction: column;
|
|
312
|
+
gap: 2px;
|
|
313
|
+
flex: 1;
|
|
314
|
+
min-width: 0;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.topics_title {
|
|
318
|
+
font-size: var(--font-13);
|
|
319
|
+
font-weight: var(--weight-semibold);
|
|
320
|
+
/* Un favori est un raccourci, pas un paragraphe. */
|
|
321
|
+
overflow: hidden;
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
text-overflow: ellipsis;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
.topics_subtitle {
|
|
327
|
+
color: var(--text-muted);
|
|
328
|
+
font-size: var(--font-11);
|
|
329
|
+
overflow: hidden;
|
|
330
|
+
white-space: nowrap;
|
|
331
|
+
text-overflow: ellipsis;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
.topics_go {
|
|
335
|
+
flex-shrink: 0;
|
|
336
|
+
color: var(--text-muted);
|
|
337
|
+
font-size: 14px;
|
|
338
|
+
}
|
|
339
|
+
</style>
|