@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.
Files changed (53) hide show
  1. package/dist/module.json +1 -1
  2. package/layer/app/app.config.ts +90 -0
  3. package/layer/app/assets/tekkare_logo.svg +20 -0
  4. package/layer/app/components/OipDirectusSources.vue +192 -0
  5. package/layer/app/components/OipDocumentPreview.vue +158 -0
  6. package/layer/app/components/OipMetadataEditDialog.vue +350 -0
  7. package/layer/app/components/OipNoteEditDialog.vue +382 -0
  8. package/layer/app/components/OipShell.vue +711 -0
  9. package/layer/app/components/OipSourceEditDialog.vue +419 -0
  10. package/layer/app/components/OipSourceGroups.vue +107 -0
  11. package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
  12. package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
  13. package/layer/app/components/panels/OipPanelNotes.vue +99 -0
  14. package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
  15. package/layer/app/components/panels/OipPanelSources.vue +361 -0
  16. package/layer/app/components/panels/OipPanelTopics.vue +339 -0
  17. package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
  18. package/layer/app/composables/useOipCrumbLabel.ts +11 -0
  19. package/layer/app/composables/useOipEsIndices.ts +57 -0
  20. package/layer/app/composables/useOipFavorites.ts +69 -0
  21. package/layer/app/composables/useOipNotes.ts +90 -0
  22. package/layer/app/composables/useOipNotify.ts +21 -0
  23. package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
  24. package/layer/app/layouts/anonymous.vue +6 -0
  25. package/layer/app/layouts/error.vue +38 -0
  26. package/layer/app/notes/categories.ts +46 -0
  27. package/layer/app/notes/types.ts +100 -0
  28. package/layer/app/plugins/oip-es-indices.client.ts +18 -0
  29. package/layer/app/types/metadata.ts +38 -0
  30. package/layer/app/types/oip.ts +60 -0
  31. package/layer/app/types/tableGroups.ts +42 -0
  32. package/layer/app/utils/oipRail.ts +82 -0
  33. package/layer/nuxt.config.ts +32 -0
  34. package/layer/server/api/oip/asset/[id].get.ts +89 -0
  35. package/layer/server/api/oip/documents.get.ts +92 -0
  36. package/layer/server/api/oip/es-indices.get.ts +21 -0
  37. package/layer/server/api/oip/favorites.get.ts +107 -0
  38. package/layer/server/api/oip/metadata.get.ts +109 -0
  39. package/layer/server/api/oip/metadata.patch.ts +163 -0
  40. package/layer/server/api/oip/note-options.get.ts +89 -0
  41. package/layer/server/api/oip/note.get.ts +95 -0
  42. package/layer/server/api/oip/notes.get.ts +173 -0
  43. package/layer/server/api/oip/notes.patch.ts +198 -0
  44. package/layer/server/api/oip/sources.patch.ts +145 -0
  45. package/layer/server/api/oip/sources.post.ts +88 -0
  46. package/layer/server/api/oip/table-groups.get.ts +145 -0
  47. package/layer/server/api/oip/tutorial-video.get.ts +44 -0
  48. package/layer/server/api/romulus/version.get.ts +4 -0
  49. package/layer/server/utils/oipAuth.ts +69 -0
  50. package/layer/server/utils/oipEsIndices.ts +99 -0
  51. package/layer/server/utils/oipGuards.ts +90 -0
  52. package/layer/server/utils/oipNoteMarkup.ts +197 -0
  53. package/package.json +5 -3
@@ -0,0 +1,419 @@
1
+ <template>
2
+ <RmModal
3
+ v-model="open"
4
+ size="lg"
5
+ mode="free"
6
+ :label="isFr ? 'Modifier la source' : 'Edit source'"
7
+ >
8
+ <template #header>
9
+ <h2 class="source_edit_title">{{ isFr ? "Modifier la source" : "Edit source" }}</h2>
10
+ </template>
11
+
12
+ <template #tabs>
13
+ <div class="source_edit_tabs">
14
+ <RmTabBar
15
+ v-model="activeLang"
16
+ :tabs="langTabs"
17
+ :aria-label="isFr ? 'Langue de la fiche' : 'Card language'"
18
+ />
19
+ </div>
20
+ </template>
21
+
22
+ <div class="source_edit_body">
23
+
24
+ <section class="source_edit_section">
25
+ <p class="source_edit_eyebrow">{{ isFr ? "Source" : "Source" }}</p>
26
+
27
+ <div v-if="!activeTranslation" class="source_edit_fallback">
28
+ <p class="source_edit_hint">
29
+ {{ sourceFallback
30
+ ? (isFr
31
+ ? `Rien dans cette langue. La fiche affiche la version ${langLabel(sourceFallback)}.`
32
+ : `Nothing in this language. The card shows the ${langLabel(sourceFallback)} version.`)
33
+ : (isFr
34
+ ? "Rien dans cette langue. Elle sera créée à l'enregistrement."
35
+ : "Nothing in this language. It will be created on save.") }}
36
+ </p>
37
+ <RmButton v-if="sourceFallback" variant="text" @click="copySourceFallback">
38
+ {{ isFr ? "Partir de cette version" : "Start from that version" }}
39
+ </RmButton>
40
+ </div>
41
+
42
+ <RmInput
43
+ v-model="form.source_name"
44
+ :label="isFr ? 'Nom de la source' : 'Source name'"
45
+ />
46
+ <RmRichText
47
+ v-model="form.source_description"
48
+ :label="isFr ? 'Description de la source' : 'Source description'"
49
+ :rows="6"
50
+ :lang="isFr ? 'fr' : 'en'"
51
+ />
52
+ <RmInput
53
+ v-model="form.source_link"
54
+ :label="isFr ? 'Lien' : 'Link'"
55
+ placeholder="https://"
56
+ />
57
+ <RmDropdownSelect
58
+ v-model="frequencyLabel"
59
+ :label="isFr ? 'Fréquence de mise à jour' : 'Update frequency'"
60
+ :options="frequencyLabels"
61
+ :search="false"
62
+ :lang="isFr ? 'fr' : 'en'"
63
+ />
64
+ </section>
65
+
66
+ <section class="source_edit_section">
67
+ <p class="source_edit_eyebrow">{{ isFr ? "Fournisseur" : "Provider" }}</p>
68
+ <p class="source_edit_hint">
69
+ {{ isFr
70
+ ? "Le fournisseur porte toutes ses sources : le corriger ici change le titre de chacune d'elles."
71
+ : "The provider carries all its sources: editing it here changes every one of their titles." }}
72
+ </p>
73
+
74
+ <div v-if="!activeProviderTranslation" class="source_edit_fallback">
75
+ <p class="source_edit_hint">
76
+ {{ providerFallback
77
+ ? (isFr
78
+ ? `Rien dans cette langue. La fiche affiche la version ${langLabel(providerFallback)}.`
79
+ : `Nothing in this language. The card shows the ${langLabel(providerFallback)} version.`)
80
+ : (isFr
81
+ ? "Rien dans cette langue. Elle sera créée à l'enregistrement."
82
+ : "Nothing in this language. It will be created on save.") }}
83
+ </p>
84
+ <RmButton v-if="providerFallback" variant="text" @click="copyProviderFallback">
85
+ {{ isFr ? "Partir de cette version" : "Start from that version" }}
86
+ </RmButton>
87
+ </div>
88
+
89
+ <RmInput
90
+ v-model="form.provider_name"
91
+ :label="isFr ? 'Nom du fournisseur' : 'Provider name'"
92
+ />
93
+ <RmRichText
94
+ v-model="form.provider_description"
95
+ :label="isFr ? 'Description du fournisseur' : 'Provider description'"
96
+ :rows="6"
97
+ :lang="isFr ? 'fr' : 'en'"
98
+ />
99
+ </section>
100
+
101
+ <p v-if="error" class="source_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" @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
+ const sourcesEndpoint = useAppConfig().oip.panels.Sources.endpoint;
117
+ /**
118
+ * Correction d'une fiche de source, reservee aux superadmins.
119
+ *
120
+ * Trois collections Directus derriere le meme formulaire : la fiche (lien,
121
+ * frequence), sa traduction dans la langue choisie (nom, description) et celle
122
+ * du fournisseur. Le rail n'affiche qu'une langue a la fois, mais la correction
123
+ * porte souvent sur la langue qu'on ne lit pas : le selecteur reste.
124
+ *
125
+ * La liste de langues est fermee aux deux langues que cette app sert, le
126
+ * francais et l'anglais. Elle ne vient pas de la table `languages` de Directus,
127
+ * qui porte des codes abimes (un `ja-JP` suivi d'une tabulation) qu'il ne faut
128
+ * pas propager, et ouvrir une langue que l'app n'affiche pas reviendrait a
129
+ * faire ecrire du texte que personne ne lira.
130
+ *
131
+ * Une langue sans traduction n'est pas cachee : le referentiel est presque
132
+ * entierement anglais (741 traductions de source sur 819), et une source
133
+ * internationale n'a souvent que ca. Le formulaire s'ouvre vide et la ligne
134
+ * est creee a l'enregistrement.
135
+ *
136
+ * Les descriptions sont du HTML redactionnel Directus : elles passent par
137
+ * RmRichText, qui les montre mises en forme plutot qu'en balises et en entites
138
+ * (`&eacute;`). Le bouton « HTML » du champ reste la pour les cas tordus.
139
+ */
140
+ const props = defineProps<{ source: any; lang: string }>();
141
+ const emit = defineEmits<{ saved: [] }>();
142
+
143
+ const open = defineModel<boolean>({ default: false });
144
+
145
+ const isFr = computed(() => props.lang === "fr");
146
+
147
+ const FREQUENCIES: Record<string, { fr: string; en: string }> = {
148
+ daily: { fr: "Quotidienne", en: "Daily" },
149
+ weekly: { fr: "Hebdomadaire", en: "Weekly" },
150
+ monthly: { fr: "Mensuelle", en: "Monthly" },
151
+ bimonthly: { fr: "Bimestrielle", en: "Bi-monthly" },
152
+ quarterly: { fr: "Trimestrielle", en: "Quarterly" },
153
+ biannually: { fr: "Semestrielle", en: "Biannually" },
154
+ annually: { fr: "Annuelle", en: "Annually" },
155
+ irregular: { fr: "Irrégulière", en: "Irregular" },
156
+ };
157
+
158
+ /**
159
+ * Anglais et francais d'abord : ce sont les deux langues que les apps servent.
160
+ * Les quatre suivantes sont optionnelles, presentes pour les referentiels qui
161
+ * les portent deja.
162
+ */
163
+ const LANGUAGES = [
164
+ { value: "fr-FR", name: "Français" },
165
+ { value: "en-US", name: "English" },
166
+ ];
167
+
168
+ const translations = computed<any[]>(() =>
169
+ Array.isArray(props.source?.translations) ? props.source.translations : []
170
+ );
171
+
172
+ // Les codes de langue de Directus portent parfois une tabulation finale
173
+ // (`ja-JP\t`) : sans nettoyage, la ligne existante reste introuvable et on en
174
+ // creerait une seconde a cote.
175
+ const langCode = (t: any) => String(t?.languages_code || "").trim();
176
+
177
+ const langTabs = LANGUAGES;
178
+
179
+ const activeLang = ref("");
180
+
181
+ const form = reactive({
182
+ source_name: "",
183
+ source_description: "",
184
+ source_link: "",
185
+ provider_name: "",
186
+ provider_description: "",
187
+ });
188
+
189
+ const frequencyLabels = computed(() =>
190
+ Object.values(FREQUENCIES).map((f) => (isFr.value ? f.fr : f.en))
191
+ );
192
+ const frequencyLabel = ref("");
193
+
194
+ function frequencyKeyOf(label: string) {
195
+ const entry = Object.entries(FREQUENCIES).find(
196
+ ([, f]) => f.fr === label || f.en === label
197
+ );
198
+ return entry?.[0] ?? null;
199
+ }
200
+
201
+ const providerTranslations = computed<any[]>(() =>
202
+ Array.isArray(props.source?.provider?.translations)
203
+ ? props.source.provider.translations
204
+ : []
205
+ );
206
+
207
+ const activeTranslation = computed(() =>
208
+ translations.value.find((t) => langCode(t) === activeLang.value)
209
+ );
210
+
211
+ const activeProviderTranslation = computed(() =>
212
+ providerTranslations.value.find((t) => langCode(t) === activeLang.value)
213
+ );
214
+
215
+ /**
216
+ * Ce que la fiche affiche a la place quand la langue choisie est vide.
217
+ *
218
+ * Le panneau retombe sur la premiere traduction venue, en pratique l'anglais :
219
+ * 5 fournisseurs sur 300 ont une fiche francaise, 27 sources sur 741. Sans le
220
+ * dire, un formulaire vide devant une carte pleine se lit comme une panne.
221
+ */
222
+ function fallbackOf(rows: any[]) {
223
+ return (
224
+ rows.find((t) => langCode(t) === "en-US") ||
225
+ rows.find((t) => langCode(t) !== activeLang.value) ||
226
+ null
227
+ );
228
+ }
229
+
230
+ const sourceFallback = computed(() =>
231
+ activeTranslation.value ? null : fallbackOf(translations.value)
232
+ );
233
+ const providerFallback = computed(() =>
234
+ activeProviderTranslation.value ? null : fallbackOf(providerTranslations.value)
235
+ );
236
+
237
+ /**
238
+ * « la version anglaise » se lit mieux que « la version EN » dans une phrase.
239
+ * Les langues absentes des onglets restent listees : le referentiel est partage
240
+ * avec les autres apps pays, une fiche peut retomber sur une langue qu'on
241
+ * n'edite pas ici, et il vaut mieux la nommer que montrer un code.
242
+ */
243
+ const LANG_NAMES: Record<string, { fr: string; en: string }> = {
244
+ "en-US": { fr: "anglaise", en: "English" },
245
+ "fr-FR": { fr: "française", en: "French" },
246
+ "es-ES": { fr: "espagnole", en: "Spanish" },
247
+ "de-DE": { fr: "allemande", en: "German" },
248
+ "ja-JP": { fr: "japonaise", en: "Japanese" },
249
+ "ko-KR": { fr: "coréenne", en: "Korean" },
250
+ };
251
+
252
+ const langLabel = (translation: any) => {
253
+ const code = langCode(translation);
254
+ const name = LANG_NAMES[code];
255
+ if (!name) return code;
256
+ return isFr.value ? name.fr : name.en;
257
+ };
258
+
259
+ function copySourceFallback() {
260
+ form.source_name = sourceFallback.value?.source_name ?? "";
261
+ form.source_description = sourceFallback.value?.source_description ?? "";
262
+ }
263
+
264
+ function copyProviderFallback() {
265
+ form.provider_name = providerFallback.value?.provider_name ?? "";
266
+ form.provider_description = providerFallback.value?.provider_description ?? "";
267
+ }
268
+
269
+ function loadTranslation() {
270
+ const t = activeTranslation.value;
271
+ form.source_name = t?.source_name ?? "";
272
+ form.source_description = t?.source_description ?? "";
273
+ const pt = activeProviderTranslation.value;
274
+ form.provider_name = pt?.provider_name ?? "";
275
+ form.provider_description = pt?.provider_description ?? "";
276
+ }
277
+
278
+ function reset() {
279
+ // La langue du rail si elle est ouverte a la saisie, l'anglais sinon : c'est
280
+ // celle qui porte presque toutes les fiches.
281
+ activeLang.value =
282
+ LANGUAGES.find((l) => l.value.startsWith(props.lang))?.value || "en-US";
283
+ loadTranslation();
284
+ form.source_link = props.source?.source_link ?? "";
285
+ const freq = String(props.source?.update_frequency || "").trim().toLowerCase();
286
+ frequencyLabel.value = FREQUENCIES[freq]
287
+ ? isFr.value
288
+ ? FREQUENCIES[freq].fr
289
+ : FREQUENCIES[freq].en
290
+ : "";
291
+ error.value = "";
292
+ }
293
+
294
+ watch(open, (value) => value && reset());
295
+ watch(activeLang, loadTranslation);
296
+
297
+ const saving = ref(false);
298
+ const error = ref("");
299
+
300
+ /** Une ligne existante se corrige par son id, une ligne absente se cree. */
301
+ function target(translation: any) {
302
+ return translation?.id
303
+ ? { id: Number(translation.id) }
304
+ : { languages_code: activeLang.value };
305
+ }
306
+
307
+ async function save() {
308
+ saving.value = true;
309
+ error.value = "";
310
+ try {
311
+ await $fetch(sourcesEndpoint, {
312
+ method: "PATCH",
313
+ body: {
314
+ uuid: props.source?.source_uuid,
315
+ source: {
316
+ source_link: form.source_link || null,
317
+ update_frequency: frequencyKeyOf(frequencyLabel.value),
318
+ },
319
+ providerUuid: props.source?.provider?.provider_uuid,
320
+ // Une langue laissee vide n'est pas creee : une ligne sans nom ni
321
+ // description ferait une fiche muette dans le referentiel.
322
+ ...(activeTranslation.value || form.source_name || form.source_description
323
+ ? {
324
+ translation: {
325
+ ...target(activeTranslation.value),
326
+ source_name: form.source_name,
327
+ source_description: form.source_description,
328
+ },
329
+ }
330
+ : {}),
331
+ ...(activeProviderTranslation.value || form.provider_name || form.provider_description
332
+ ? {
333
+ providerTranslation: {
334
+ ...target(activeProviderTranslation.value),
335
+ provider_name: form.provider_name,
336
+ provider_description: form.provider_description,
337
+ },
338
+ }
339
+ : {}),
340
+ },
341
+ });
342
+ open.value = false;
343
+ emit("saved");
344
+ } catch (err: any) {
345
+ error.value =
346
+ err?.data?.message ||
347
+ err?.message ||
348
+ (isFr.value ? "Enregistrement impossible" : "Could not save");
349
+ } finally {
350
+ saving.value = false;
351
+ }
352
+ }
353
+ </script>
354
+
355
+ <style scoped>
356
+ /*
357
+ * RmTabBar est dimensionne pour l'en-tete d'une page de detail : marge basse et
358
+ * largeur bridee pour laisser respirer le contenu autour. Dans une modale, ces
359
+ * marges appartiennent au conteneur, qui pose deja ses propres retraits.
360
+ */
361
+ .source_edit_tabs {
362
+ padding: 0 24px;
363
+ }
364
+
365
+ .source_edit_tabs :deep(.rm-tabbar) {
366
+ max-width: 100%;
367
+ margin-bottom: 0;
368
+ }
369
+
370
+ .source_edit_title {
371
+ font-size: 16px;
372
+ font-weight: 600;
373
+ margin: 0;
374
+ color: var(--text-primary);
375
+ }
376
+
377
+ .source_edit_body {
378
+ display: flex;
379
+ flex-direction: column;
380
+ gap: 24px;
381
+ }
382
+
383
+ .source_edit_section {
384
+ display: flex;
385
+ flex-direction: column;
386
+ gap: 16px;
387
+ }
388
+
389
+ .source_edit_eyebrow {
390
+ margin: 0;
391
+ font-size: 12px;
392
+ font-weight: 600;
393
+ text-transform: uppercase;
394
+ letter-spacing: 0.04em;
395
+ color: var(--text-muted);
396
+ }
397
+
398
+ .source_edit_fallback {
399
+ align-items: baseline;
400
+ background: var(--bg-surface-2);
401
+ border-radius: 8px;
402
+ display: flex;
403
+ gap: 8px;
404
+ justify-content: space-between;
405
+ padding: 8px 12px;
406
+ }
407
+
408
+ .source_edit_hint {
409
+ margin: 0;
410
+ font-size: 12px;
411
+ color: var(--text-muted);
412
+ }
413
+
414
+ .source_edit_error {
415
+ margin: 0;
416
+ font-size: 13px;
417
+ color: var(--color-danger);
418
+ }
419
+ </style>
@@ -0,0 +1,107 @@
1
+ <!--
2
+ OipSourceGroups (layer OIP) - la liste de sources d'un panneau « Sources », en deux
3
+ sections.
4
+
5
+ La liste d'uuids d'un ecran est ordonnee : la premiere source est celle qui
6
+ porte l'ecran, les suivantes ne font que la completer. Les separer, ouvrir la
7
+ principale et la cadrer en couleur evite de chercher laquelle regarder.
8
+
9
+ Quand aucune source n'est designee (`primary-uuid` vide sur une page qui les
10
+ agrege toutes, cf. Company dans Drug Statistics) la liste reste plate : les
11
+ jeux se valent.
12
+ -->
13
+ <template>
14
+ <ClientOnly>
15
+ <template v-if="safeUuids.length">
16
+ <template v-if="resolvedPrimary">
17
+ <section class="src_group src_primary">
18
+ <p class="src_group_title">
19
+ {{ lang === 'fr' ? 'Source principale' : 'Primary source' }}
20
+ </p>
21
+ <OipDirectusSources
22
+ :uuids="[resolvedPrimary]"
23
+ :source-to-open="resolvedPrimary"
24
+ :lang="lang"
25
+ highlight
26
+ :editable="editable"
27
+ />
28
+ </section>
29
+
30
+ <section v-if="resolvedSecondaries.length" class="src_group">
31
+ <p class="src_group_title">
32
+ {{ lang === 'fr' ? 'Sources secondaires' : 'Additional sources' }}
33
+ </p>
34
+ <OipDirectusSources :uuids="resolvedSecondaries" :lang="lang" :editable="editable" />
35
+ </section>
36
+ </template>
37
+
38
+ <OipDirectusSources v-else :uuids="safeUuids" :lang="lang" :editable="editable" />
39
+ </template>
40
+
41
+ <div v-else class="source_placeholder">
42
+ {{ lang === 'fr' ? 'Aucune source pour cette page' : 'No source for this page' }}
43
+ </div>
44
+ </ClientOnly>
45
+ </template>
46
+
47
+ <script setup lang="ts">
48
+ const props = withDefaults(
49
+ defineProps<{
50
+ uuids: string[];
51
+ /**
52
+ * Source mise en avant. Par defaut la premiere de la liste ; une chaine
53
+ * vide explicite rend la liste plate.
54
+ */
55
+ primaryUuid?: string | null;
56
+ lang?: string;
57
+ /** Crayon de correction sur chaque fiche (superadmin). */
58
+ editable?: boolean;
59
+ }>(),
60
+ { primaryUuid: undefined, lang: 'en', editable: false },
61
+ );
62
+
63
+ // Les uuids viennent de tables ecrites a la main : une entree vide sortirait
64
+ // une fiche « Chargement... » qui ne se remplit jamais.
65
+ const safeUuids = computed<string[]>(() =>
66
+ (Array.isArray(props.uuids) ? props.uuids : []).filter(
67
+ (uuid): uuid is string => typeof uuid === 'string' && uuid.length > 0,
68
+ ),
69
+ );
70
+
71
+ const resolvedPrimary = computed(() =>
72
+ props.primaryUuid === undefined ? (safeUuids.value[0] ?? '') : (props.primaryUuid ?? ''),
73
+ );
74
+
75
+ const resolvedSecondaries = computed(() =>
76
+ safeUuids.value.filter((uuid) => uuid !== resolvedPrimary.value),
77
+ );
78
+ </script>
79
+
80
+ <style scoped>
81
+ .src_group + .src_group {
82
+ margin-top: 24px;
83
+ }
84
+
85
+ .src_group_title {
86
+ margin-bottom: 8px;
87
+ color: var(--medium);
88
+ font-size: 11px;
89
+ font-weight: 700;
90
+ letter-spacing: 0.04em;
91
+ text-transform: uppercase;
92
+ }
93
+
94
+ /* La source principale se distingue par son cadre, pas par un traitement de
95
+ texte : le cadre est pose par la prop `highlight` de RmSourceCard, ici il ne
96
+ reste que le titre de section. */
97
+ .src_primary .src_group_title {
98
+ color: var(--primary);
99
+ }
100
+
101
+ .source_placeholder {
102
+ padding: 8px;
103
+ color: var(--dark);
104
+ font-size: 12px;
105
+ line-height: 14px;
106
+ }
107
+ </style>