@datagouv/components-next 0.0.1
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/README.md +150 -0
- package/assets/main.css +136 -0
- package/assets/placeholders/author.png +0 -0
- package/assets/placeholders/dataset.png +0 -0
- package/assets/placeholders/news.png +0 -0
- package/assets/placeholders/organization.png +0 -0
- package/assets/placeholders/reuse.png +0 -0
- package/assets/tailwind.config.js +24 -0
- package/dist/components.css +2 -0
- package/dist/locales/de.js +155 -0
- package/dist/locales/en.js +155 -0
- package/dist/locales/es.js +155 -0
- package/dist/locales/fr.js +155 -0
- package/dist/locales/it.js +155 -0
- package/dist/locales/pt.js +155 -0
- package/dist/locales/sr.js +155 -0
- package/package.json +72 -0
- package/src/components/AppLink.vue +51 -0
- package/src/components/Avatar.vue +27 -0
- package/src/components/AvatarWithName.vue +26 -0
- package/src/components/BannerAction.vue +39 -0
- package/src/components/BrandedButton.vue +170 -0
- package/src/components/CopyButton.vue +84 -0
- package/src/components/DataserviceCard.vue +184 -0
- package/src/components/DatasetCard.vue +198 -0
- package/src/components/DatasetInformationPanel.vue +210 -0
- package/src/components/DatasetQuality.vue +68 -0
- package/src/components/DatasetQualityInline.vue +32 -0
- package/src/components/DatasetQualityItem.vue +32 -0
- package/src/components/DatasetQualityItemWarning.vue +21 -0
- package/src/components/DatasetQualityScore.vue +35 -0
- package/src/components/DatasetQualityTooltipContent.vue +79 -0
- package/src/components/DescriptionDetails.vue +23 -0
- package/src/components/DescriptionList/DescriptionDetails.stories.ts +43 -0
- package/src/components/DescriptionList/DescriptionList.stories.ts +47 -0
- package/src/components/DescriptionList/DescriptionTerm.stories.ts +28 -0
- package/src/components/DescriptionList.vue +8 -0
- package/src/components/DescriptionTerm.vue +8 -0
- package/src/components/ExtraAccordion.vue +78 -0
- package/src/components/Icons/Archive.vue +21 -0
- package/src/components/Icons/Code.vue +21 -0
- package/src/components/Icons/Documentation.vue +21 -0
- package/src/components/Icons/File.vue +21 -0
- package/src/components/Icons/Image.vue +7 -0
- package/src/components/Icons/Link.vue +21 -0
- package/src/components/Icons/Table.vue +21 -0
- package/src/components/OrganizationCard.vue +68 -0
- package/src/components/OrganizationNameWithCertificate.vue +45 -0
- package/src/components/OwnerType.vue +43 -0
- package/src/components/OwnerTypeIcon.vue +18 -0
- package/src/components/Pagination.vue +205 -0
- package/src/components/Placeholder.vue +29 -0
- package/src/components/ReadMore.vue +107 -0
- package/src/components/ResourceAccordion/DataStructure.vue +87 -0
- package/src/components/ResourceAccordion/EditButton.vue +34 -0
- package/src/components/ResourceAccordion/Metadata.vue +171 -0
- package/src/components/ResourceAccordion/Preview.vue +229 -0
- package/src/components/ResourceAccordion/PreviewLoader.vue +148 -0
- package/src/components/ResourceAccordion/ResourceAccordion.vue +484 -0
- package/src/components/ResourceAccordion/ResourceIcon.vue +16 -0
- package/src/components/ResourceAccordion/SchemaBadge.vue +148 -0
- package/src/components/ResourceAccordion/SchemaLoader.vue +30 -0
- package/src/components/ResourceAccordion/Swagger.vue +46 -0
- package/src/components/ResourceAccordion/france.svg +1 -0
- package/src/components/ReuseCard.vue +106 -0
- package/src/components/ReuseDetails.vue +45 -0
- package/src/components/SimpleBanner.vue +24 -0
- package/src/components/SmallChart.vue +149 -0
- package/src/components/StatBox.vue +100 -0
- package/src/components/Tabs/Tab.vue +62 -0
- package/src/components/Tabs/TabGroup.vue +20 -0
- package/src/components/Tabs/TabList.vue +15 -0
- package/src/components/Tabs/TabPanel.vue +7 -0
- package/src/components/Tabs/TabPanels.vue +7 -0
- package/src/components/Toggletip.vue +62 -0
- package/src/components/ToggletipButton.vue +14 -0
- package/src/composables/useActiveDescendant.ts +103 -0
- package/src/composables/useReuseType.ts +14 -0
- package/src/config.ts +33 -0
- package/src/functions/api.ts +96 -0
- package/src/functions/api.types.ts +41 -0
- package/src/functions/config.ts +12 -0
- package/src/functions/datasets.ts +24 -0
- package/src/functions/dates.ts +85 -0
- package/src/functions/helpers.ts +38 -0
- package/src/functions/markdown.ts +47 -0
- package/src/functions/matomo.ts +3 -0
- package/src/functions/organizations.ts +85 -0
- package/src/functions/owned.ts +11 -0
- package/src/functions/resources.ts +99 -0
- package/src/functions/reuses.ts +28 -0
- package/src/functions/schemas.ts +96 -0
- package/src/functions/tabularApi.ts +27 -0
- package/src/functions/users.ts +7 -0
- package/src/locales/de.json +154 -0
- package/src/locales/en.json +154 -0
- package/src/locales/es.json +154 -0
- package/src/locales/fr.json +154 -0
- package/src/locales/it.json +154 -0
- package/src/locales/pt.json +154 -0
- package/src/locales/sr.json +154 -0
- package/src/main.ts +147 -0
- package/src/types/badges.ts +5 -0
- package/src/types/contact_point.ts +7 -0
- package/src/types/dataservices.ts +68 -0
- package/src/types/datasets.ts +80 -0
- package/src/types/frequency.ts +6 -0
- package/src/types/granularity.ts +6 -0
- package/src/types/harvest.ts +3 -0
- package/src/types/keyboard.ts +1 -0
- package/src/types/licenses.ts +9 -0
- package/src/types/organizations.ts +41 -0
- package/src/types/owned.ts +9 -0
- package/src/types/resources.ts +37 -0
- package/src/types/reuses.ts +49 -0
- package/src/types/site.ts +23 -0
- package/src/types/topics.ts +20 -0
- package/src/types/ui.ts +3 -0
- package/src/types/users.ts +10 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<Popover
|
|
3
|
+
v-slot="{ open }"
|
|
4
|
+
class="relative text-gray-title"
|
|
5
|
+
:focus="true"
|
|
6
|
+
>
|
|
7
|
+
<PopoverButton
|
|
8
|
+
v-bind="$attrs"
|
|
9
|
+
ref="button"
|
|
10
|
+
:as="ToggletipButton"
|
|
11
|
+
>
|
|
12
|
+
<slot />
|
|
13
|
+
</PopoverButton>
|
|
14
|
+
<component
|
|
15
|
+
:is="teleportId ? Teleport : 'div'"
|
|
16
|
+
v-if="open"
|
|
17
|
+
:to="`#${teleportId}`"
|
|
18
|
+
:defer="teleportId ? true : undefined"
|
|
19
|
+
>
|
|
20
|
+
<PopoverPanel
|
|
21
|
+
ref="toggletip"
|
|
22
|
+
v-slot="{ close }"
|
|
23
|
+
class="toggletip"
|
|
24
|
+
:class="{
|
|
25
|
+
'p-0': noMargin,
|
|
26
|
+
'left-0': position === 'right',
|
|
27
|
+
'ml-6 top-24': teleportId,
|
|
28
|
+
}"
|
|
29
|
+
>
|
|
30
|
+
<slot
|
|
31
|
+
name="toggletip"
|
|
32
|
+
:close
|
|
33
|
+
/>
|
|
34
|
+
</PopoverPanel>
|
|
35
|
+
</component>
|
|
36
|
+
</Popover>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup lang="ts">
|
|
40
|
+
import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
|
|
41
|
+
import { Teleport } from 'vue'
|
|
42
|
+
import ToggletipButton from './ToggletipButton.vue'
|
|
43
|
+
|
|
44
|
+
withDefaults(defineProps<{
|
|
45
|
+
noMargin?: boolean
|
|
46
|
+
position?: 'left' | 'right'
|
|
47
|
+
teleportId?: string
|
|
48
|
+
}>(), {
|
|
49
|
+
noMargin: false,
|
|
50
|
+
position: 'left',
|
|
51
|
+
})
|
|
52
|
+
defineOptions({ inheritAttrs: false })
|
|
53
|
+
</script>
|
|
54
|
+
|
|
55
|
+
<style scoped>
|
|
56
|
+
.z-10 {
|
|
57
|
+
z-index: 10;
|
|
58
|
+
}
|
|
59
|
+
.left-0 {
|
|
60
|
+
left: 0;
|
|
61
|
+
}
|
|
62
|
+
</style>
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<BrandedButton
|
|
3
|
+
color="secondary-softer"
|
|
4
|
+
icon-only
|
|
5
|
+
:icon="RiInformationLine"
|
|
6
|
+
size="xs"
|
|
7
|
+
keep-margins-even-without-borders
|
|
8
|
+
/>
|
|
9
|
+
</template>
|
|
10
|
+
|
|
11
|
+
<script setup lang="ts">
|
|
12
|
+
import { RiInformationLine } from '@remixicon/vue'
|
|
13
|
+
import BrandedButton from './BrandedButton.vue'
|
|
14
|
+
</script>
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { computed, ref, toValue, type MaybeRefOrGetter } from 'vue'
|
|
2
|
+
import type { CommonKeyboardKeys } from '../types/keyboard'
|
|
3
|
+
|
|
4
|
+
export type Option = {
|
|
5
|
+
id: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export default function useActiveDescendant<T extends Option>(options: MaybeRefOrGetter<Array<T>>, direction: 'horizontal' | 'vertical') {
|
|
9
|
+
const active = ref<string | undefined>()
|
|
10
|
+
|
|
11
|
+
const activeOption = computed<T | undefined>(() => toValue(options).find(option => option.id === active.value))
|
|
12
|
+
|
|
13
|
+
function isActive(id: string | undefined) {
|
|
14
|
+
return active.value === id
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function activate(id?: string) {
|
|
18
|
+
if (id === undefined) {
|
|
19
|
+
return activateAtPosition(0)
|
|
20
|
+
}
|
|
21
|
+
active.value = id
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function focusOut() {
|
|
25
|
+
active.value = undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function activateAtPosition(position: number) {
|
|
29
|
+
const optionsList = toValue(options)
|
|
30
|
+
if (optionsList[position]) {
|
|
31
|
+
activate(optionsList[position].id)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function activateNextOption() {
|
|
36
|
+
let activePosition = 0
|
|
37
|
+
if (active.value) {
|
|
38
|
+
activePosition = toValue(options).findIndex(option => option.id === active.value)
|
|
39
|
+
activePosition++
|
|
40
|
+
if (activePosition === toValue(options).length) {
|
|
41
|
+
activePosition = 0
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
activateAtPosition(activePosition)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function activatePreviousOption() {
|
|
48
|
+
const lastOptionPosition = toValue(options).length - 1
|
|
49
|
+
let activePosition = lastOptionPosition
|
|
50
|
+
if (active.value) {
|
|
51
|
+
activePosition = toValue(options).findIndex(option => option.id === active.value)
|
|
52
|
+
activePosition--
|
|
53
|
+
if (activePosition < 0) {
|
|
54
|
+
activePosition = lastOptionPosition
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
activateAtPosition(activePosition)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function handleKeyPressForActiveDescendant(key: KeyboardEvent, alreadyMovedDown = false) {
|
|
61
|
+
switch (key.key as CommonKeyboardKeys) {
|
|
62
|
+
case 'ArrowDown':
|
|
63
|
+
if (direction === 'vertical' && !alreadyMovedDown && !key.altKey) {
|
|
64
|
+
activateNextOption()
|
|
65
|
+
}
|
|
66
|
+
break
|
|
67
|
+
case 'ArrowUp':
|
|
68
|
+
if (direction === 'vertical') {
|
|
69
|
+
activatePreviousOption()
|
|
70
|
+
}
|
|
71
|
+
break
|
|
72
|
+
case 'ArrowLeft':
|
|
73
|
+
if (direction === 'horizontal') {
|
|
74
|
+
activatePreviousOption()
|
|
75
|
+
}
|
|
76
|
+
break
|
|
77
|
+
case 'ArrowRight':
|
|
78
|
+
if (direction === 'horizontal' && !alreadyMovedDown && !key.altKey) {
|
|
79
|
+
activateNextOption()
|
|
80
|
+
}
|
|
81
|
+
break
|
|
82
|
+
case 'Home':
|
|
83
|
+
case 'End':
|
|
84
|
+
case 'Escape':
|
|
85
|
+
focusOut()
|
|
86
|
+
break
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const ALREADY_MOVED_DOWN = true
|
|
91
|
+
const NOT_MOVED_YET = false
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
activate,
|
|
95
|
+
active,
|
|
96
|
+
activeOption,
|
|
97
|
+
isActive,
|
|
98
|
+
focusOut,
|
|
99
|
+
handleKeyPressForActiveDescendant,
|
|
100
|
+
ALREADY_MOVED_DOWN,
|
|
101
|
+
NOT_MOVED_YET,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { computedAsync } from '@vueuse/core'
|
|
2
|
+
import { toValue, type MaybeRefOrGetter } from 'vue'
|
|
3
|
+
import { fetchReuseTypes, getType } from '../functions/reuses'
|
|
4
|
+
|
|
5
|
+
export function useReuseType(id: MaybeRefOrGetter<string>) {
|
|
6
|
+
const label = computedAsync(async () => {
|
|
7
|
+
const idValue = toValue(id)
|
|
8
|
+
const types = await fetchReuseTypes()
|
|
9
|
+
return getType(types, idValue)
|
|
10
|
+
}, '')
|
|
11
|
+
return {
|
|
12
|
+
label,
|
|
13
|
+
}
|
|
14
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { inject, type Component, type InjectionKey } from 'vue'
|
|
2
|
+
import type { UseFetchFunction } from './functions/api.types'
|
|
3
|
+
|
|
4
|
+
export type PluginConfig = {
|
|
5
|
+
name: string // Name of the application (ex: data.gouv.fr)
|
|
6
|
+
baseUrl: string
|
|
7
|
+
apiBase: string
|
|
8
|
+
devApiKey?: string | null
|
|
9
|
+
staticUrl: string
|
|
10
|
+
datasetQualityGuideUrl?: string
|
|
11
|
+
schemaValidataUrl: string
|
|
12
|
+
schemaDocumentationUrl: string
|
|
13
|
+
tabularApiUrl?: string
|
|
14
|
+
tabularApiPageSize?: number
|
|
15
|
+
tabularAllowRemote?: boolean
|
|
16
|
+
tabularApiDataserviceId?: string
|
|
17
|
+
customUseFetch?: UseFetchFunction | null
|
|
18
|
+
textClamp?: string | Component | null
|
|
19
|
+
appLink?: Component | null
|
|
20
|
+
i18n?: {
|
|
21
|
+
global: {
|
|
22
|
+
mergeLocaleMessage: (locale: string, messages: unknown) => void
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const configKey = Symbol() as InjectionKey<PluginConfig>
|
|
28
|
+
|
|
29
|
+
export function useComponentsConfig(): PluginConfig {
|
|
30
|
+
const config = inject(configKey)
|
|
31
|
+
if (!config) throw new Error('Calling `useComponentsConfig` outside @datagouv/components')
|
|
32
|
+
return config
|
|
33
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import { ref, toValue, watchEffect, type ComputedRef, type Ref } from 'vue'
|
|
2
|
+
import { ofetch } from 'ofetch'
|
|
3
|
+
import { useI18n } from 'vue-i18n'
|
|
4
|
+
import { useComponentsConfig } from '../config'
|
|
5
|
+
import type { AsyncData, AsyncDataExecuteOptions, AsyncDataRequestStatus, UseFetchOptions } from './api.types'
|
|
6
|
+
|
|
7
|
+
export async function useFetch<DataT, ErrorT = never>(
|
|
8
|
+
url: string | Request | Ref<string | Request> | ComputedRef<string | null> | (() => string | Request),
|
|
9
|
+
options?: UseFetchOptions<DataT>,
|
|
10
|
+
): Promise<AsyncData<DataT, ErrorT>> {
|
|
11
|
+
const config = useComponentsConfig()
|
|
12
|
+
|
|
13
|
+
const { t, locale } = useI18n()
|
|
14
|
+
|
|
15
|
+
if (config.customUseFetch) {
|
|
16
|
+
return await config.customUseFetch(url, options)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const data: Ref<DataT | null> = ref(null)
|
|
20
|
+
const error: Ref<ErrorT | null> = ref(null)
|
|
21
|
+
const status = ref<AsyncDataRequestStatus>('idle')
|
|
22
|
+
|
|
23
|
+
const execute = async (opts?: AsyncDataExecuteOptions) => {
|
|
24
|
+
const urlValue = toValue(url)
|
|
25
|
+
if (!urlValue) return
|
|
26
|
+
status.value = 'pending'
|
|
27
|
+
try {
|
|
28
|
+
data.value = await ofetch(urlValue, {
|
|
29
|
+
baseURL: config.apiBase,
|
|
30
|
+
onRequest({ options }) {
|
|
31
|
+
options.headers.set('Content-Type', 'application/json')
|
|
32
|
+
options.headers.set('Accept', 'application/json')
|
|
33
|
+
options.credentials = 'include'
|
|
34
|
+
if (config.devApiKey) {
|
|
35
|
+
options.headers.set('X-API-KEY', config.devApiKey)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (locale.value) {
|
|
39
|
+
if (!options.params) {
|
|
40
|
+
options.params = {}
|
|
41
|
+
}
|
|
42
|
+
options.params['lang'] = locale.value
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
async onResponseError({ response }) {
|
|
46
|
+
// TODO redirect to login outside Nuxt?
|
|
47
|
+
// if (response.status === 401) {
|
|
48
|
+
// await nuxtApp.runWithContext(() => navigateTo(localePath('/login')))
|
|
49
|
+
// }
|
|
50
|
+
|
|
51
|
+
let message
|
|
52
|
+
try {
|
|
53
|
+
if ('error' in response._data) {
|
|
54
|
+
message = response._data.error
|
|
55
|
+
}
|
|
56
|
+
else if ('message' in response._data) {
|
|
57
|
+
message = response._data.message
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch (e) {
|
|
61
|
+
console.error(e)
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
63
|
+
message = t('The API returned an unexpected error')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// TODO Toast outside Nuxt
|
|
67
|
+
// toast.error(message)
|
|
68
|
+
},
|
|
69
|
+
...options,
|
|
70
|
+
})
|
|
71
|
+
status.value = 'success'
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
error.value = e as ErrorT
|
|
75
|
+
status.value = 'error'
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
watchEffect(async () => {
|
|
80
|
+
await execute()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
data,
|
|
85
|
+
refresh: async (opts?: AsyncDataExecuteOptions) => {
|
|
86
|
+
execute()
|
|
87
|
+
},
|
|
88
|
+
execute,
|
|
89
|
+
clear: () => {
|
|
90
|
+
data.value = null
|
|
91
|
+
status.value = 'idle'
|
|
92
|
+
},
|
|
93
|
+
error,
|
|
94
|
+
status,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { ComputedRef, Ref, WatchSource } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type UseFetchOptions<DataT> = {
|
|
4
|
+
key?: string
|
|
5
|
+
method?: string
|
|
6
|
+
query?: SearchParams
|
|
7
|
+
params?: SearchParams
|
|
8
|
+
body?: RequestInit['body'] | Record<string, any>
|
|
9
|
+
headers?: Record<string, string> | [key: string, value: string][] | Headers
|
|
10
|
+
baseURL?: string
|
|
11
|
+
server?: boolean
|
|
12
|
+
lazy?: boolean
|
|
13
|
+
immediate?: boolean
|
|
14
|
+
getCachedData?: (key: string, nuxtApp: any) => DataT
|
|
15
|
+
deep?: boolean
|
|
16
|
+
dedupe?: 'cancel' | 'defer'
|
|
17
|
+
default?: () => DataT
|
|
18
|
+
transform?: (input: DataT) => DataT | Promise<DataT>
|
|
19
|
+
pick?: string[]
|
|
20
|
+
watch?: WatchSource[] | false
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type AsyncData<DataT, ErrorT> = {
|
|
24
|
+
data: Ref<DataT | null>
|
|
25
|
+
refresh: (opts?: AsyncDataExecuteOptions) => Promise<void>
|
|
26
|
+
execute: (opts?: AsyncDataExecuteOptions) => Promise<void>
|
|
27
|
+
clear: () => void
|
|
28
|
+
error: Ref<ErrorT | null>
|
|
29
|
+
status: Ref<AsyncDataRequestStatus>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AsyncDataExecuteOptions {
|
|
33
|
+
dedupe?: 'cancel' | 'defer'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type AsyncDataRequestStatus = 'idle' | 'pending' | 'success' | 'error'
|
|
37
|
+
|
|
38
|
+
export type UseFetchFunction = (<DataT, ErrorT>(
|
|
39
|
+
url: string | Request | Ref<string | Request> | ComputedRef<string | null> | (() => string | Request),
|
|
40
|
+
options?: UseFetchOptions<DataT>
|
|
41
|
+
) => Promise<AsyncData<DataT, ErrorT>>)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { ref } from 'vue'
|
|
2
|
+
|
|
3
|
+
export type ComponentsConfig = {
|
|
4
|
+
staticUrl: string
|
|
5
|
+
baseApiUrl: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const config = ref<ComponentsConfig | null>(null)
|
|
9
|
+
|
|
10
|
+
export function setConfig(newConfig: ComponentsConfig) {
|
|
11
|
+
config.value = newConfig
|
|
12
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useComponentsConfig } from '../config'
|
|
2
|
+
import type { Dataset, DatasetV2 } from '../types/datasets'
|
|
3
|
+
import type { CommunityResource, Resource } from '../types/resources'
|
|
4
|
+
|
|
5
|
+
function constructUrl(baseUrl: string, path: string): string {
|
|
6
|
+
const url = new URL(baseUrl)
|
|
7
|
+
url.pathname = `${url.pathname}${path}`
|
|
8
|
+
return url.toString()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export default function getDatasetOEmbedHtml(type: string, id: string): string {
|
|
12
|
+
const config = useComponentsConfig()
|
|
13
|
+
|
|
14
|
+
const staticUrl = constructUrl(config.staticUrl, 'oembed.js')
|
|
15
|
+
return `<div data-udata-${type}="${id}"></div><script data-udata="${config.baseUrl}" src="${staticUrl}" async defer></script>`
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isCommunityResource(resource: Resource | CommunityResource): boolean {
|
|
19
|
+
return 'organization' in resource || 'owner' in resource
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>, resource: Resource | CommunityResource): string {
|
|
23
|
+
return `${dataset.page}#/${isCommunityResource(resource) ? 'community-resources' : 'resources'}/${resource.id}`
|
|
24
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useI18n } from 'vue-i18n'
|
|
2
|
+
|
|
3
|
+
const SECONDS_IN_A_DAY = 3600 * 24
|
|
4
|
+
|
|
5
|
+
export function formatDate(date: Date | string | null, options: Intl.DateTimeFormatOptions = {}) {
|
|
6
|
+
if (!date) {
|
|
7
|
+
return ''
|
|
8
|
+
}
|
|
9
|
+
date = new Date(date)
|
|
10
|
+
if (!('dateStyle' in options)) {
|
|
11
|
+
options.dateStyle = 'long'
|
|
12
|
+
}
|
|
13
|
+
const { locale } = useI18n()
|
|
14
|
+
return new Intl.DateTimeFormat(locale.value, options).format(date)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format date as relative from now.
|
|
19
|
+
* It displays "today" or Intl.RelativeTimeFormat content, based on date.
|
|
20
|
+
*/
|
|
21
|
+
export const formatFromNow = (date: Date | string | null) => {
|
|
22
|
+
if (!date) {
|
|
23
|
+
return ''
|
|
24
|
+
}
|
|
25
|
+
const { t, locale } = useI18n()
|
|
26
|
+
if (!('RelativeTimeFormat' in Intl)) {
|
|
27
|
+
return t('on {date}', { date: formatDate(date) })
|
|
28
|
+
}
|
|
29
|
+
const today = new Date()
|
|
30
|
+
today.setHours(0)
|
|
31
|
+
today.setMinutes(0)
|
|
32
|
+
today.setSeconds(0)
|
|
33
|
+
const dateWithoutTime = new Date(date)
|
|
34
|
+
dateWithoutTime.setHours(0)
|
|
35
|
+
dateWithoutTime.setMinutes(0)
|
|
36
|
+
dateWithoutTime.setSeconds(0)
|
|
37
|
+
// Get the diff in second between today and the provided date
|
|
38
|
+
const diff = Math.round((dateWithoutTime.getTime() - today.getTime()) / 1000)
|
|
39
|
+
const units: Array<{ unit: Intl.RelativeTimeFormatUnit, seconds: number, changeAfter: number }> = [
|
|
40
|
+
{
|
|
41
|
+
unit: 'day',
|
|
42
|
+
seconds: SECONDS_IN_A_DAY,
|
|
43
|
+
changeAfter: 30,
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
unit: 'month',
|
|
47
|
+
seconds: SECONDS_IN_A_DAY * 30,
|
|
48
|
+
changeAfter: 12,
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
unit: 'year',
|
|
52
|
+
seconds: SECONDS_IN_A_DAY * 365,
|
|
53
|
+
changeAfter: Infinity,
|
|
54
|
+
},
|
|
55
|
+
]
|
|
56
|
+
const correctUnit = units.find((unit) => {
|
|
57
|
+
const diffInUnit = Math.abs(diff / unit.seconds)
|
|
58
|
+
return diffInUnit < unit.changeAfter
|
|
59
|
+
})!
|
|
60
|
+
return new Intl.RelativeTimeFormat(locale.value, { numeric: 'auto' }).format(Math.round(diff / correctUnit?.seconds), correctUnit?.unit)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format date relative form now if date is less than a month ago.
|
|
65
|
+
* Otherwise, show a formatted date.
|
|
66
|
+
*/
|
|
67
|
+
export const formatRelativeIfRecentDate = (date: Date | string | null, options: Intl.DateTimeFormatOptions = {}) => {
|
|
68
|
+
if (!date) {
|
|
69
|
+
return ''
|
|
70
|
+
}
|
|
71
|
+
const { t } = useI18n()
|
|
72
|
+
const today = new Date()
|
|
73
|
+
today.setHours(0)
|
|
74
|
+
today.setMinutes(0)
|
|
75
|
+
today.setSeconds(0)
|
|
76
|
+
const dateWithoutTime = new Date(date)
|
|
77
|
+
dateWithoutTime.setHours(0)
|
|
78
|
+
dateWithoutTime.setMinutes(0)
|
|
79
|
+
dateWithoutTime.setSeconds(0)
|
|
80
|
+
const diff = Math.abs(dateWithoutTime.getTime() - today.getTime())
|
|
81
|
+
if (Math.round(diff / (SECONDS_IN_A_DAY * 30)) >= 1) {
|
|
82
|
+
return t('on {date}', { date: formatDate(date, options) })
|
|
83
|
+
}
|
|
84
|
+
return formatFromNow(date)
|
|
85
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { useI18n } from 'vue-i18n'
|
|
2
|
+
|
|
3
|
+
export const filesize = (val: number) => {
|
|
4
|
+
const { t } = useI18n()
|
|
5
|
+
const suffix = t('B')
|
|
6
|
+
const units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']
|
|
7
|
+
for (const unit of units) {
|
|
8
|
+
if (Math.abs(val) < 1024.0) {
|
|
9
|
+
return `${val.toFixed(1)}${unit}${suffix}`
|
|
10
|
+
}
|
|
11
|
+
val /= 1024.0
|
|
12
|
+
}
|
|
13
|
+
return `${val.toFixed(1)}Y${suffix}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const summarize = (val: number, fractionDigits = 0) => {
|
|
17
|
+
const toFixedIfNotZero = (value: number) => {
|
|
18
|
+
const asString = value.toFixed(fractionDigits)
|
|
19
|
+
if (!asString.includes('.')) {
|
|
20
|
+
return asString
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Remove trailing "0" to not show "1.0" but only "1"
|
|
24
|
+
return asString.replace(/0+$/, '').replace(/\.$/, '')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (!val) {
|
|
28
|
+
return '0'
|
|
29
|
+
}
|
|
30
|
+
const units = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z']
|
|
31
|
+
for (const unit of units) {
|
|
32
|
+
if (Math.abs(val) < 1000.0) {
|
|
33
|
+
return `${toFixedIfNotZero(val)}${unit}`
|
|
34
|
+
}
|
|
35
|
+
val /= 1000.0
|
|
36
|
+
}
|
|
37
|
+
return `${toFixedIfNotZero(val)}Y`
|
|
38
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import markdownit from 'markdown-it'
|
|
2
|
+
import { remark } from 'remark'
|
|
3
|
+
import remarkGfm from 'remark-gfm'
|
|
4
|
+
import strip from 'strip-markdown'
|
|
5
|
+
|
|
6
|
+
const markdownParser = markdownit({
|
|
7
|
+
html: false,
|
|
8
|
+
linkify: true,
|
|
9
|
+
typographer: true,
|
|
10
|
+
breaks: true,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
// Disable mail linkification
|
|
14
|
+
markdownParser.linkify.add('mailto:', null)
|
|
15
|
+
|
|
16
|
+
markdownParser.use(function (md) {
|
|
17
|
+
md.renderer.rules.link_open = function (tokens, idx, options, _env, self) {
|
|
18
|
+
const link_open = tokens[idx]
|
|
19
|
+
link_open.attrs?.push(['rel', 'ugc nofollow noopener'])
|
|
20
|
+
return self.renderToken(tokens, idx, options)
|
|
21
|
+
}
|
|
22
|
+
// Render ~~<text>~~ as del tag
|
|
23
|
+
md.renderer.rules.s_open = function (tokens, idx, options, _env, self) {
|
|
24
|
+
const s_open = tokens[idx]
|
|
25
|
+
s_open.type = 'del_open'
|
|
26
|
+
s_open.tag = 'del'
|
|
27
|
+
return self.renderToken(tokens, idx, options)
|
|
28
|
+
}
|
|
29
|
+
md.renderer.rules.s_close = function (tokens, idx, options, _env, self) {
|
|
30
|
+
const s_close = tokens[idx]
|
|
31
|
+
s_close.type = 'del_close'
|
|
32
|
+
s_close.tag = 'del'
|
|
33
|
+
return self.renderToken(tokens, idx, options)
|
|
34
|
+
}
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
export function markdown(text: string): string {
|
|
38
|
+
return markdownParser.render(text).trim()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function removeMarkdown(text: string) {
|
|
42
|
+
const file = await remark()
|
|
43
|
+
.use(remarkGfm)
|
|
44
|
+
.use(strip)
|
|
45
|
+
.process(text)
|
|
46
|
+
return String(file)
|
|
47
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { useI18n } from 'vue-i18n'
|
|
2
|
+
import type { Component } from 'vue'
|
|
3
|
+
import { RiBankLine, RiBuilding2Line, RiCommunityLine, RiGovernmentLine, RiUserLine } from '@remixicon/vue'
|
|
4
|
+
import type { Organization } from '../types/organizations'
|
|
5
|
+
|
|
6
|
+
export const CERTIFIED = 'certified'
|
|
7
|
+
export const PUBLIC_SERVICE = 'public-service'
|
|
8
|
+
export const ASSOCIATION = 'association'
|
|
9
|
+
export const COMPANY = 'company'
|
|
10
|
+
export const LOCAL_AUTHORITY = 'local-authority'
|
|
11
|
+
export const OTHER = 'other'
|
|
12
|
+
export const USER = 'user'
|
|
13
|
+
|
|
14
|
+
export type OrganizationTypes = typeof PUBLIC_SERVICE | typeof ASSOCIATION | typeof COMPANY | typeof LOCAL_AUTHORITY | typeof OTHER
|
|
15
|
+
|
|
16
|
+
export type UserType = typeof USER
|
|
17
|
+
|
|
18
|
+
export function isType(organization: Organization, type: OrganizationTypes) {
|
|
19
|
+
return hasBadge(organization, type)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function hasBadge(organization: Organization, kind: string) {
|
|
23
|
+
return organization.badges.some(badge => badge.kind === kind)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function getOrganizationTypes(): Array<{ type: OrganizationTypes | UserType, label: string, icon: Component | null }> {
|
|
27
|
+
const { t } = useI18n()
|
|
28
|
+
return [{
|
|
29
|
+
type: PUBLIC_SERVICE,
|
|
30
|
+
label: t('Public service'),
|
|
31
|
+
icon: RiBankLine,
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
type: LOCAL_AUTHORITY,
|
|
35
|
+
label: t('Local authority'),
|
|
36
|
+
icon: RiGovernmentLine,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: ASSOCIATION,
|
|
40
|
+
label: t('Association'),
|
|
41
|
+
icon: RiCommunityLine,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
type: COMPANY,
|
|
45
|
+
label: t('Company'),
|
|
46
|
+
icon: RiBuilding2Line,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
type: OTHER,
|
|
50
|
+
label: t('Other'),
|
|
51
|
+
icon: null,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: USER,
|
|
55
|
+
label: t('User'),
|
|
56
|
+
icon: RiUserLine,
|
|
57
|
+
}]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function findOrganizationType(searched: OrganizationTypes | UserType) {
|
|
61
|
+
return getOrganizationTypes().find(type => type.type === searched)!
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getOrganizationType(organization: Organization): OrganizationTypes {
|
|
65
|
+
if (isType(organization, LOCAL_AUTHORITY)) {
|
|
66
|
+
return LOCAL_AUTHORITY
|
|
67
|
+
}
|
|
68
|
+
else if (isType(organization, PUBLIC_SERVICE)) {
|
|
69
|
+
return PUBLIC_SERVICE
|
|
70
|
+
}
|
|
71
|
+
else if (isType(organization, ASSOCIATION)) {
|
|
72
|
+
return ASSOCIATION
|
|
73
|
+
}
|
|
74
|
+
else if (isType(organization, COMPANY)) {
|
|
75
|
+
return COMPANY
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
return OTHER
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function isOrganizationCertified(organization: Organization | null): boolean {
|
|
83
|
+
if (!organization) return false
|
|
84
|
+
return hasBadge(organization, CERTIFIED) && (isType(organization, PUBLIC_SERVICE) || isType(organization, LOCAL_AUTHORITY))
|
|
85
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Owned } from '../types/owned'
|
|
2
|
+
|
|
3
|
+
export function getOwnerName(owned: Owned): string {
|
|
4
|
+
if (owned.organization) {
|
|
5
|
+
return owned.organization.name
|
|
6
|
+
}
|
|
7
|
+
else if (owned.owner) {
|
|
8
|
+
return `${owned.owner.first_name} ${owned.owner.last_name}`
|
|
9
|
+
}
|
|
10
|
+
return '' // Not supposed to exist but it does...
|
|
11
|
+
}
|