@datagouv/components-next 1.2.0 → 1.4.0

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 (138) hide show
  1. package/README.md +7 -6
  2. package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
  3. package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
  4. package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
  5. package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
  6. package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
  7. package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
  8. package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
  9. package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
  10. package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
  11. package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
  12. package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
  13. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
  14. package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
  15. package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
  16. package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
  17. package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
  18. package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
  19. package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
  20. package/dist/components-next.css +1 -52
  21. package/dist/components-next.js +201 -170
  22. package/dist/components.css +2 -2
  23. package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
  24. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
  25. package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
  26. package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
  27. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  28. package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
  29. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  30. package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
  31. package/package.json +40 -39
  32. package/src/components/ActivityList/ActivityList.vue +21 -12
  33. package/src/components/AppLink.vue +3 -0
  34. package/src/components/BannerAction.vue +6 -2
  35. package/src/components/Chart/ChartViewer.vue +111 -17
  36. package/src/components/Chart/ChartViewerWrapper.vue +28 -16
  37. package/src/components/ChartCard.vue +112 -0
  38. package/src/components/CopyButton.vue +6 -2
  39. package/src/components/DataserviceCard.vue +23 -6
  40. package/src/components/DataserviceQuality.vue +82 -0
  41. package/src/components/DataserviceQualityInline.vue +31 -0
  42. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  43. package/src/components/DatasetCard.vue +17 -6
  44. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/DiscussionMessageCard.vue +14 -15
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/FormattedDate.vue +69 -0
  51. package/src/components/LoadingBlock.vue +4 -1
  52. package/src/components/ObjectCard.vue +4 -1
  53. package/src/components/ObjectCardOwner.vue +13 -1
  54. package/src/components/Pagination.vue +18 -58
  55. package/src/components/Placeholder.vue +3 -2
  56. package/src/components/PostCard.vue +13 -15
  57. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  58. package/src/components/ResourceAccordion/Downloads.vue +19 -7
  59. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  60. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  61. package/src/components/ResourceAccordion/Metadata.vue +21 -5
  62. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  63. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  64. package/src/components/ResourceAccordion/Pmtiles.client.vue +30 -19
  65. package/src/components/ResourceAccordion/Preview.vue +8 -4
  66. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  67. package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
  68. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
  69. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  70. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  71. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +140 -184
  72. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  73. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  74. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
  75. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
  76. package/src/components/ResourceListItem.vue +168 -0
  77. package/src/components/ReuseCard.vue +18 -6
  78. package/src/components/ReuseDetails.vue +2 -2
  79. package/src/components/ReuseHorizontalCard.vue +4 -3
  80. package/src/components/SchemaCard.vue +10 -1
  81. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  82. package/src/components/Search/GlobalSearch.vue +28 -10
  83. package/src/components/Search/SearchInput.vue +1 -1
  84. package/src/components/Search/Sidemenu.vue +4 -3
  85. package/src/components/SmallChart.vue +11 -10
  86. package/src/components/StatBox.vue +13 -5
  87. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  88. package/src/components/TabularExplorer/TabularCell.vue +7 -8
  89. package/src/components/TabularExplorer/TabularCellPopover.vue +21 -5
  90. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  91. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  92. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  93. package/src/components/TabularExplorer/TabularExplorer.vue +127 -780
  94. package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
  95. package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
  96. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  97. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  98. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  99. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  100. package/src/components/TabularExplorer/TabularTable.vue +484 -0
  101. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  102. package/src/components/TabularExplorer/types.ts +40 -28
  103. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  104. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  105. package/src/components/TopicCard.vue +15 -14
  106. package/src/composables/useActiveDescendant.ts +5 -0
  107. package/src/composables/useDatasetResources.ts +164 -0
  108. package/src/composables/useHasTabularData.ts +2 -1
  109. package/src/composables/useResizable.ts +46 -0
  110. package/src/composables/useResourceCapabilities.ts +48 -40
  111. package/src/composables/useSearchFilter.ts +7 -5
  112. package/src/composables/useTranslation.ts +24 -9
  113. package/src/config.ts +1 -0
  114. package/src/functions/api.ts +13 -7
  115. package/src/functions/charts.ts +32 -1
  116. package/src/functions/dates.ts +26 -7
  117. package/src/functions/helpers.ts +15 -0
  118. package/src/functions/metrics.ts +57 -56
  119. package/src/functions/owned.ts +21 -0
  120. package/src/functions/paginate.ts +46 -0
  121. package/src/functions/resourceCapabilities.ts +34 -0
  122. package/src/functions/resources.ts +88 -0
  123. package/src/functions/schemas.ts +27 -0
  124. package/src/functions/scroll.ts +20 -0
  125. package/src/functions/tabular.ts +222 -5
  126. package/src/functions/tabularApi.ts +1 -43
  127. package/src/main.ts +32 -6
  128. package/src/types/dataservices.ts +15 -0
  129. package/src/types/organizations.ts +10 -0
  130. package/src/types/resources.ts +0 -1
  131. package/src/types/reuses.ts +12 -1
  132. package/src/types/search.ts +2 -2
  133. package/src/types/visualizations.ts +5 -0
  134. package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
  135. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +0 -61
  136. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  137. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  138. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -14,24 +14,39 @@ const translationModules = import.meta.glob<Record<string, string>>('../../../lo
14
14
  import: 'default',
15
15
  })
16
16
 
17
+ const LANGUAGE_SUBTAG_REGEX = /^[a-z]{2,3}$/
18
+
19
+ /**
20
+ * Extract the language subtag of an `accept-language` header or of
21
+ * `navigator.language`, and return it only if it is a well-formed one.
22
+ *
23
+ * Both are user-controlled, and the locale is handed over to `Intl` formatters,
24
+ * which throw a `RangeError` on anything that is not a valid language tag. The
25
+ * wildcard `*` is rejected here too: it means "any language", so it must fall
26
+ * back to the next detection step.
27
+ */
28
+ function parseLanguage(value: string | undefined | null): string | null {
29
+ if (!value) {
30
+ return null
31
+ }
32
+ const language = value.split(';')[0]!.split(',')[0]!.split('-')[0]!.toLowerCase()
33
+ return LANGUAGE_SUBTAG_REGEX.test(language) ? language : null
34
+ }
35
+
17
36
  function detectLanguage(): string {
18
37
  // Server-side (Nuxt)
19
38
  try {
20
- const header = useRequestHeader?.('accept-language')
21
- const acceptLanguage = header
22
- if (acceptLanguage) {
23
- const primaryLang = acceptLanguage.split(';')[0]!.split(',')[0]!.split('-')[0]!.toLowerCase()
24
- // Ignore wildcard * language, that should fallback to client side detection or default language
25
- if (primaryLang !== '*') return primaryLang
26
- }
39
+ const language = parseLanguage(useRequestHeader?.('accept-language'))
40
+ if (language) return language
27
41
  }
28
42
  catch {
29
43
  // useRequestHeaders not available, continue to client-side detection
30
44
  }
31
45
 
32
46
  // Client-side
33
- if (typeof window !== 'undefined' && navigator.language) {
34
- return navigator.language.split('-')[0]!.toLowerCase()
47
+ if (typeof window !== 'undefined') {
48
+ const language = parseLanguage(navigator.language)
49
+ if (language) return language
35
50
  }
36
51
 
37
52
  return 'fr'
package/src/config.ts CHANGED
@@ -12,6 +12,7 @@ export type PluginConfig = {
12
12
  datasetQualityGuideUrl?: string
13
13
  maxJsonPreviewCharSize?: number // Maximum size of JSON to preview in characters. JSON preview module is partly collapsed by default so we can have a preview for large files.
14
14
  maxPdfPreviewByteSize?: number // Maximum size of PDF to preview in bytes
15
+ maxImagePreviewByteSize?: number // Maximum size of image to preview in bytes
15
16
  maxXmlPreviewCharSize?: number // Maximum size of XML to preview in characters. XML preview module can NOT be collapsed by default so we should not have a preview for large files.
16
17
  pmtilesViewerBaseUrl?: string | null // Base URL of a pmtiles viewer (ex: https://pmtiles.io/#url=)
17
18
  metricsApiUrl?: string
@@ -26,7 +26,7 @@ export async function useFetch<DataT, ErrorT = never>(
26
26
  const error: Ref<ErrorT | null> = ref(null)
27
27
  const status = ref<AsyncDataRequestStatus>('idle')
28
28
 
29
- const execute = async () => {
29
+ const execute = async (signal?: AbortSignal) => {
30
30
  const urlValue = toValue(url)
31
31
  if (!urlValue) return
32
32
  const params = deepToValue(options?.params)
@@ -36,13 +36,15 @@ export async function useFetch<DataT, ErrorT = never>(
36
36
  data.value = isRaw
37
37
  // Raw targets another data.gouv service (the Tabular API in TabularExplorer) via its own
38
38
  // absolute URL, so it must stay bare ofetch: no datagouv apiBase, no datagouv auth attached.
39
- ? await ofetch<DataT | null>(urlValue, { params: params ?? query })
39
+ ? await ofetch<DataT | null>(urlValue, { params: params ?? query, signal })
40
40
  // The configured `$fetch` carries baseURL + auth + headers (see the `datagouv` plugin install
41
41
  // for the default one). We only forward the URL and params here.
42
- : await config.$fetch<DataT | null>(urlValue, { baseURL: config.apiBase, params: params ?? query })
42
+ : await config.$fetch<DataT | null>(urlValue, { baseURL: config.apiBase, params: params ?? query, signal })
43
43
  status.value = 'success'
44
44
  }
45
45
  catch (e) {
46
+ // Ignore aborted requests — they're superseded by a newer call, not a real error.
47
+ if (signal?.aborted) return
46
48
  error.value = e as ErrorT
47
49
  status.value = 'error'
48
50
  }
@@ -51,14 +53,18 @@ export async function useFetch<DataT, ErrorT = never>(
51
53
  // When server is false, only start watching after mount (client-side only)
52
54
  if (options?.server === false) {
53
55
  onMounted(() => {
54
- watchEffect(async () => {
55
- await execute()
56
+ watchEffect((onCleanup) => {
57
+ const controller = new AbortController()
58
+ onCleanup(() => controller.abort())
59
+ void execute(controller.signal)
56
60
  })
57
61
  })
58
62
  }
59
63
  else {
60
- watchEffect(async () => {
61
- await execute()
64
+ watchEffect((onCleanup) => {
65
+ const controller = new AbortController()
66
+ onCleanup(() => controller.abort())
67
+ void execute(controller.signal)
62
68
  })
63
69
  }
64
70
 
@@ -1,4 +1,6 @@
1
- import type { Chart, ChartForm, ChartForApi, Filter } from '../types/visualizations'
1
+ import type { Chart, ChartForm, ChartForApi, Filter, ColumnDefinition } from '../types/visualizations'
2
+ import type { ColumnType, TabularProfile } from '../components/TabularExplorer/types'
3
+ import { resolveColumnType } from './tabular'
2
4
 
3
5
  export function toChartForm(chart: Chart) {
4
6
  const seriesFilter = chart.series[0]?.filters as Filter | null
@@ -66,3 +68,32 @@ export function toChartApi(chartForm: ChartForm): ChartForApi {
66
68
  extras: chartForm.extras,
67
69
  }
68
70
  }
71
+
72
+ export function buildColumnsFromProfile(profile: { profile: TabularProfile }): Array<ColumnDefinition> {
73
+ return profile.profile.header.map((name) => {
74
+ const colInfo = profile.profile.columns[name]
75
+ const isCategorical = profile.profile.categorical?.includes(name) ?? false
76
+ // The chart stack only branches on 'number' and 'date' (continuous axis,
77
+ // aggregatable series, time formatting): it has no notion of a year column,
78
+ // and on an axis a year is a number.
79
+ const resolvedType = resolveColumnType(colInfo ?? { python_type: 'unknown', format: undefined }, isCategorical)
80
+ const colType = resolvedType === 'year' ? 'number' : resolvedType
81
+ const colProfile = profile.profile.profile?.[name]
82
+ return {
83
+ name,
84
+ type: colType,
85
+ min: normalizeProfileNumber(colProfile?.min, colType),
86
+ max: normalizeProfileNumber(colProfile?.max, colType),
87
+ }
88
+ })
89
+ }
90
+
91
+ function normalizeProfileNumber(value: number | string | undefined, type: ColumnType): number | undefined {
92
+ if (value === undefined || value === null) return undefined
93
+ if (type === 'date' && typeof value === 'string') {
94
+ const timestamp = new Date(value).getTime()
95
+ return Number.isNaN(timestamp) ? undefined : timestamp
96
+ }
97
+ const parsed = Number(value)
98
+ return Number.isNaN(parsed) ? undefined : parsed
99
+ }
@@ -2,6 +2,26 @@ import { useTranslation } from '../composables/useTranslation'
2
2
 
3
3
  const SECONDS_IN_A_DAY = 3600 * 24
4
4
 
5
+ /**
6
+ * `2026`, `2026-04` and `2026-04-24` carry no time of day. `new Date()` reads them as
7
+ * midnight UTC, an instant they never claimed, and then converts it: a reader west of
8
+ * UTC gets the day before — and with it the month, and sometimes the year.
9
+ */
10
+ export const PLAIN_DATE = /^(\d{4})(?:-(\d{2}))?(?:-(\d{2}))?$/
11
+
12
+ /**
13
+ * A date value read as the day it names. A plain date is built in local time, where no
14
+ * conversion can move it; anything else is an instant and keeps being read as one.
15
+ */
16
+ export function parseDateValue(value: Date | string | null | undefined): Date | null {
17
+ if (!value) return null
18
+ const plain = typeof value === 'string' ? value.match(PLAIN_DATE) : null
19
+ const date = plain
20
+ ? new Date(Number(plain[1]), Number(plain[2] ?? 1) - 1, Number(plain[3] ?? 1))
21
+ : new Date(value)
22
+ return isNaN(date.getTime()) ? null : date
23
+ }
24
+
5
25
  export function useFormatDate() {
6
26
  const { t, locale } = useTranslation()
7
27
 
@@ -9,11 +29,9 @@ export function useFormatDate() {
9
29
  if (!date) {
10
30
  return ''
11
31
  }
12
- date = new Date(date)
13
- if (!('dateStyle' in options)) {
14
- options.dateStyle = 'long'
15
- }
16
- return new Intl.DateTimeFormat(locale, options).format(date)
32
+ // An explicit `dateStyle: undefined` overrides the default, which is how callers ask
33
+ // for `year`/`month` components — `dateStyle` and those cannot be combined.
34
+ return new Intl.DateTimeFormat(locale, { dateStyle: 'long', ...options }).format(new Date(date))
17
35
  }
18
36
 
19
37
  /**
@@ -77,8 +95,9 @@ export function useFormatDate() {
77
95
  dateWithoutTime.setHours(0)
78
96
  dateWithoutTime.setMinutes(0)
79
97
  dateWithoutTime.setSeconds(0)
80
- const diff = Math.abs(dateWithoutTime.getTime() - today.getTime())
81
- if (Math.round(diff / (SECONDS_IN_A_DAY * 30)) >= 1) {
98
+ // `getTime()` returns milliseconds, `SECONDS_IN_A_DAY` is in seconds
99
+ const diffInSeconds = Math.abs(dateWithoutTime.getTime() - today.getTime()) / 1000
100
+ if (diffInSeconds >= SECONDS_IN_A_DAY * 30) {
82
101
  return t('le {date}', { date: formatDate(date, options) })
83
102
  }
84
103
  return formatFromNow(date)
@@ -38,6 +38,21 @@ export const summarize = (val: number, fractionDigits = 0) => {
38
38
  return `${toFixedIfNotZero(val)}Y`
39
39
  }
40
40
 
41
+ // Guards any value that ends up in an `href`: only absolute http(s) URLs are allowed.
42
+ // Some of these URLs come from resource extras, which are freely writable by the
43
+ // resource owner, so an unguarded value could carry a `javascript:` or `data:` scheme
44
+ // and execute in the platform origin when a visitor clicks the link.
45
+ export const isSafeHttpUrl = (value: unknown): value is string => {
46
+ if (typeof value !== 'string') return false
47
+ try {
48
+ const { protocol } = new URL(value)
49
+ return protocol === 'http:' || protocol === 'https:'
50
+ }
51
+ catch {
52
+ return false
53
+ }
54
+ }
55
+
41
56
  export const escapeCsvValue = (value: string | number | null | undefined): string => {
42
57
  if (value === null || value === undefined || value === '') {
43
58
  return ''
@@ -31,49 +31,73 @@ export type ReuseMetrics = {
31
31
  visitsTotal: number
32
32
  }
33
33
 
34
+ type MonthlyVisitsRow = { metric_month: string, monthly_visit: number }
35
+ type MonthlyDatasetRow = MonthlyVisitsRow & { monthly_download_resource: number }
36
+ type MonthlyOrganizationRow = {
37
+ metric_month: string
38
+ monthly_download_resource: number
39
+ monthly_visit_dataservice: number
40
+ monthly_visit_dataset: number
41
+ monthly_visit_reuse: number
42
+ }
43
+
44
+ type TotalVisitsRow = { visit: number }
45
+ type TotalDatasetRow = TotalVisitsRow & { download_resource: number }
46
+ type TotalOrganizationRow = {
47
+ download_resource: number
48
+ visit_dataservice: number
49
+ visit_dataset: number
50
+ visit_reuse: number
51
+ }
52
+
53
+ /**
54
+ * The metrics API is a separate service that answers over the network, so a call fails in
55
+ * three ways: the request itself, an error status, or a body that isn't the expected page
56
+ * of rows. Rejecting on the last two turns them into the same failure as the first, which
57
+ * callers already hand to `useAsyncData` — reading `page.data` straight would instead throw
58
+ * a `TypeError` a few lines later, naming a symptom rather than the service.
59
+ */
60
+ async function fetchMetrics<Row>(url: string): Promise<Array<Row>> {
61
+ const response = await fetch(url)
62
+ if (!response.ok) throw new Error(`The metrics API answered ${response.status} for ${url}`)
63
+
64
+ const page: { data?: Array<Row> } = await response.json()
65
+ if (!Array.isArray(page?.data)) throw new Error(`The metrics API answered an unexpected body for ${url}`)
66
+
67
+ return page.data
68
+ }
69
+
34
70
  /**
35
71
  * There is only one metrics API endpoint to get these 3 values.
36
72
  * The rest of the metrics aren't stored yet at the organization level
37
73
  */
38
74
  export async function getOrganizationMetrics(oid: string, metricsApi: string): Promise<OrganizationMetrics> {
39
75
  // Fetching last 12 months
40
- const response = await fetch(`${metricsApi}/api/organizations/data/?organization_id__exact=${oid}&metric_month__sort=desc&page_size=12`)
41
- const page = await response.json()
76
+ const rows = await fetchMetrics<MonthlyOrganizationRow>(`${metricsApi}/api/organizations/data/?organization_id__exact=${oid}&metric_month__sort=desc&page_size=12`)
42
77
 
43
78
  const dataservicesViews: Record<string, number> = {}
44
79
  const datasetsViews: Record<string, number> = {}
45
80
  const downloads: Record<string, number> = {}
46
81
  const reusesViews: Record<string, number> = {}
47
82
 
48
- for (const { metric_month, monthly_download_resource, monthly_visit_dataservice, monthly_visit_dataset, monthly_visit_reuse } of page.data) {
83
+ for (const { metric_month, monthly_download_resource, monthly_visit_dataservice, monthly_visit_dataset, monthly_visit_reuse } of rows) {
49
84
  dataservicesViews[metric_month] = monthly_visit_dataservice
50
85
  datasetsViews[metric_month] = monthly_visit_dataset
51
86
  downloads[metric_month] = monthly_download_resource
52
87
  reusesViews[metric_month] = monthly_visit_reuse
53
88
  }
54
89
  // Fetching totals
55
- const totalResponse = await fetch(`${metricsApi}/api/organizations_total/data/?organization_id__exact=${oid}`)
56
- const totalPage = await totalResponse.json()
57
-
58
- let dataservicesViewsTotal = 0
59
- let datasetsViewsTotal = 0
60
- let downloadsTotal = 0
61
- let reusesViewsTotal = 0
62
- if (page.data[0]) {
63
- dataservicesViewsTotal = totalPage.data[0].visit_dataservice
64
- datasetsViewsTotal = totalPage.data[0].visit_dataset
65
- downloadsTotal = totalPage.data[0].download_resource
66
- reusesViewsTotal = totalPage.data[0].visit_reuse
67
- }
90
+ const total = (await fetchMetrics<TotalOrganizationRow>(`${metricsApi}/api/organizations_total/data/?organization_id__exact=${oid}`))[0]
91
+
68
92
  return {
69
93
  downloads,
70
- downloadsTotal,
94
+ downloadsTotal: total?.download_resource ?? 0,
71
95
  reusesViews,
72
- reusesViewsTotal,
96
+ reusesViewsTotal: total?.visit_reuse ?? 0,
73
97
  dataservicesViews,
74
- dataservicesViewsTotal,
98
+ dataservicesViewsTotal: total?.visit_dataservice ?? 0,
75
99
  datasetsViews,
76
- datasetsViewsTotal,
100
+ datasetsViewsTotal: total?.visit_dataset ?? 0,
77
101
  }
78
102
  }
79
103
 
@@ -89,33 +113,24 @@ export function createOrganizationMetricsUrl(datasetsViews: Record<string, numbe
89
113
 
90
114
  export async function getDatasetMetrics(datasetId: string, metricsApi: string): Promise<DatasetMetrics> {
91
115
  // Fetching last 12 months
92
- const response = await fetch(`${metricsApi}/api/datasets/data/?dataset_id__exact=${datasetId}&metric_month__sort=desc&page_size=12`)
93
- const page = await response.json()
116
+ const rows = await fetchMetrics<MonthlyDatasetRow>(`${metricsApi}/api/datasets/data/?dataset_id__exact=${datasetId}&metric_month__sort=desc&page_size=12`)
94
117
 
95
118
  const visits: Record<string, number> = {}
96
119
  const downloads: Record<string, number> = {}
97
120
 
98
- for (const { metric_month, monthly_visit, monthly_download_resource } of page.data) {
121
+ for (const { metric_month, monthly_visit, monthly_download_resource } of rows) {
99
122
  visits[metric_month] = monthly_visit
100
123
  downloads[metric_month] = monthly_download_resource
101
124
  }
102
125
 
103
126
  // Fetching totals
104
- const totalResponse = await fetch(`${metricsApi}/api/datasets_total/data/?dataset_id__exact=${datasetId}`)
105
- const totalPage = await totalResponse.json()
106
-
107
- let visitsTotal = 0
108
- let downloadsTotal = 0
109
- if (totalPage.data[0]) {
110
- visitsTotal = totalPage.data[0].visit
111
- downloadsTotal = totalPage.data[0].download_resource
112
- }
127
+ const total = (await fetchMetrics<TotalDatasetRow>(`${metricsApi}/api/datasets_total/data/?dataset_id__exact=${datasetId}`))[0]
113
128
 
114
129
  return {
115
130
  visits,
116
- visitsTotal,
131
+ visitsTotal: total?.visit ?? 0,
117
132
  downloads,
118
- downloadsTotal,
133
+ downloadsTotal: total?.download_resource ?? 0,
119
134
  }
120
135
  }
121
136
 
@@ -151,52 +166,38 @@ export async function createDatasetsForOrganizationMetricsUrl(organizationId: st
151
166
 
152
167
  export async function getDataserviceMetrics(dataserviceId: string, metricsApi: string): Promise<DataserviceMetrics> {
153
168
  // Fetching last 12 months
154
- const response = await fetch(`${metricsApi}/api/dataservices/data/?dataservice_id__exact=${dataserviceId}&metric_month__sort=desc&page_size=12`)
155
- const page = await response.json()
169
+ const rows = await fetchMetrics<MonthlyVisitsRow>(`${metricsApi}/api/dataservices/data/?dataservice_id__exact=${dataserviceId}&metric_month__sort=desc&page_size=12`)
156
170
 
157
171
  const visits: Record<string, number> = {}
158
172
 
159
- for (const { metric_month, monthly_visit } of page.data) {
173
+ for (const { metric_month, monthly_visit } of rows) {
160
174
  visits[metric_month] = monthly_visit
161
175
  }
162
176
 
163
177
  // Fetching totals
164
- const totalResponse = await fetch(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`)
165
- const totalPage = await totalResponse.json()
166
-
167
- let visitsTotal = 0
168
- if (totalPage.data[0]) {
169
- visitsTotal = totalPage.data[0].visit
170
- }
178
+ const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/dataservices_total/data/?dataservice_id__exact=${dataserviceId}`))[0]
171
179
 
172
180
  return {
173
181
  visits,
174
- visitsTotal,
182
+ visitsTotal: total?.visit ?? 0,
175
183
  }
176
184
  }
177
185
 
178
186
  export async function getReuseMetrics(reuseId: string, metricsApi: string): Promise<ReuseMetrics> {
179
187
  // Fetching last 12 months
180
- const response = await fetch(`${metricsApi}/api/reuses/data/?reuse_id__exact=${reuseId}&metric_month__sort=desc&page_size=12`)
181
- const page = await response.json()
188
+ const rows = await fetchMetrics<MonthlyVisitsRow>(`${metricsApi}/api/reuses/data/?reuse_id__exact=${reuseId}&metric_month__sort=desc&page_size=12`)
182
189
 
183
190
  const visits: Record<string, number> = {}
184
191
 
185
- for (const { metric_month, monthly_visit } of page.data) {
192
+ for (const { metric_month, monthly_visit } of rows) {
186
193
  visits[metric_month] = monthly_visit
187
194
  }
188
195
 
189
196
  // Fetching totals
190
- const totalResponse = await fetch(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`)
191
- const totalPage = await totalResponse.json()
192
-
193
- let visitsTotal = 0
194
- if (totalPage.data[0]) {
195
- visitsTotal = totalPage.data[0].visit
196
- }
197
+ const total = (await fetchMetrics<TotalVisitsRow>(`${metricsApi}/api/reuses_total/data/?reuse_id__exact=${reuseId}`))[0]
197
198
 
198
199
  return {
199
200
  visits,
200
- visitsTotal,
201
+ visitsTotal: total?.visit ?? 0,
201
202
  }
202
203
  }
@@ -19,3 +19,24 @@ export function getOwnerPage(owned: Owned): string | null {
19
19
  }
20
20
  return null // Not supposed to exist but it does...
21
21
  }
22
+
23
+ function escapeHtml(value: string): string {
24
+ return value
25
+ .replace(/&/g, '&amp;')
26
+ .replace(/</g, '&lt;')
27
+ .replace(/>/g, '&gt;')
28
+ .replace(/"/g, '&quot;')
29
+ .replace(/'/g, '&#39;')
30
+ }
31
+
32
+ export function getMapAttribution(owned: Owned): string {
33
+ const name = getOwnerName(owned)
34
+ const page = getOwnerPage(owned)
35
+ if (!name && !page)
36
+ return ''
37
+ // The owner name is free text any user controls (organization name, or a
38
+ // profile's first/last name). maplibre-gl injects this attribution string
39
+ // into the DOM as HTML, and its own attribution sanitizer is bypassable via
40
+ // mutation XSS, so every interpolated value has to be escaped here.
41
+ return `© <a href="${escapeHtml(page ?? '')}" target="_blank">${escapeHtml(name)}</a>`
42
+ }
@@ -0,0 +1,46 @@
1
+ export const PAGES_AROUND = 3
2
+
3
+ function range(size: number, startAt: number) {
4
+ return [...Array(size).keys()].map(i => i + startAt)
5
+ }
6
+
7
+ /**
8
+ * Brings whatever the caller holds as a current page back to a page that exists.
9
+ * The page number usually comes from a query string, so it can be anything:
10
+ * `?page=abc` (NaN), `?page=-5`, `?page=1.5`, or a stale link to a page far past
11
+ * the last one.
12
+ */
13
+ export function clampPage(page: number, pageCount: number) {
14
+ if (!Number.isFinite(page)) {
15
+ return 1
16
+ }
17
+ return Math.min(Math.max(Math.trunc(page), 1), Math.max(pageCount, 1))
18
+ }
19
+
20
+ /**
21
+ * The middle window of the pagination: the pages around the current one, with
22
+ * `null` marking an ellipsis. The first and last pages are rendered by the
23
+ * component itself and are never part of this window.
24
+ */
25
+ export function getVisiblePages(currentPage: number, pageCount: number) {
26
+ if (pageCount <= 2) {
27
+ return []
28
+ }
29
+
30
+ const page = clampPage(currentPage, pageCount)
31
+ const size = Math.min(
32
+ PAGES_AROUND * 2 + 1,
33
+ pageCount - 2, // the first and last pages are rendered outside of the window
34
+ PAGES_AROUND + page - 1, // don't reach below page 2
35
+ PAGES_AROUND + pageCount - page, // don't reach past the last page
36
+ )
37
+ const pagination: Array<number | null> = range(size, Math.max(page - PAGES_AROUND, 2))
38
+
39
+ if (!pagination.includes(2)) {
40
+ pagination.unshift(null)
41
+ }
42
+ if (!pagination.includes(pageCount - 1)) {
43
+ pagination.push(null)
44
+ }
45
+ return pagination
46
+ }
@@ -1,4 +1,35 @@
1
1
  import type { Resource, WfsMetadata, OgcLayerInfo } from '../types/resources'
2
+ import { isSafeHttpUrl } from './helpers'
3
+
4
+ const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
5
+
6
+ export type GeneratedFormat = {
7
+ url: string
8
+ size: number | undefined
9
+ format: string
10
+ }
11
+
12
+ export function getGeneratedFormats(resource: Pick<Resource, 'extras' | 'id'>, tabularApiUrl: string | undefined): GeneratedFormat[] {
13
+ const formats: GeneratedFormat[] = GENERATED_FORMATS.flatMap((format) => {
14
+ const url = resource.extras[`analysis:parsing:${format}_url`]
15
+ // The URL is read verbatim from owner-writable extras: drop any entry that is not
16
+ // a plain http(s) URL so a `javascript:`/`data:` value never reaches an href.
17
+ if (!isSafeHttpUrl(url)) return []
18
+ return [{
19
+ url,
20
+ size: resource.extras[`analysis:parsing:${format}_size`] as number | undefined,
21
+ format,
22
+ }]
23
+ })
24
+ if ('analysis:parsing:parsing_table' in resource.extras) {
25
+ formats.push({
26
+ url: `${tabularApiUrl}/api/resources/${resource.id}/data/json/`,
27
+ size: undefined,
28
+ format: 'json',
29
+ })
30
+ }
31
+ return formats
32
+ }
2
33
 
3
34
  const WFS_EXPORT_FORMATS = [
4
35
  {
@@ -46,6 +77,9 @@ export function getWfsExportFormats(resource: Pick<Resource, 'extras' | 'url'>)
46
77
  const outputFormats = wfsMetadata.output_formats.map((format: string) => format.toLowerCase())
47
78
  const layer = wfsMetadata.detected_layer
48
79
  if (!layer) return []
80
+ // Every download URL below is built on top of `resource.url`, so its scheme is the one
81
+ // of `resource.url`: guard it once here rather than on each of the built URLs.
82
+ if (!isSafeHttpUrl(resource.url)) return []
49
83
  const formats = WFS_EXPORT_FORMATS.filter(format => outputFormats.includes(format.mimetype.toLowerCase()))
50
84
  .map(format => ({
51
85
  url: buildWfsDownloadUrl(resource.url, wfsMetadata, format, layer),
@@ -84,6 +84,25 @@ export function getResourceFormatIcon(format: string): Component | null {
84
84
  }
85
85
  }
86
86
 
87
+ // Badge colors for the resource icon, keyed on the same format categorization as
88
+ // getResourceFormatIcon so tabular/geo/code/documentation stay consistent with the icon.
89
+ export function getResourceIconColor(format?: string | null): string {
90
+ const icon = format ? getResourceFormatIcon(format) : null
91
+ switch (icon) {
92
+ case Table:
93
+ return 'bg-[#c3fad5] text-[#18753c]'
94
+ case RiMap2Line:
95
+ case RiEarthLine:
96
+ return 'bg-[#e6eefe] text-[#0063cb]'
97
+ case Code:
98
+ return 'bg-[#fce164] text-[#716043]'
99
+ case Documentation:
100
+ return 'bg-[#fee7fc] text-[#6e445a]'
101
+ default:
102
+ return 'bg-gray-100 text-gray-plain'
103
+ }
104
+ }
105
+
87
106
  export function getResourceTitleId(resource: Resource) {
88
107
  return 'resource-' + resource.id + '-title'
89
108
  }
@@ -91,6 +110,11 @@ export function getResourceTitleId(resource: Resource) {
91
110
  export const RESOURCE_TYPE = readonly(['main', 'documentation', 'update', 'api', 'code', 'other'] as const)
92
111
  export type ResourceType = typeof RESOURCE_TYPE[number]
93
112
 
113
+ // Page size used by ResourceExplorer for its per-type resource fetches.
114
+ // Exported so consumers (e.g. the dataset page's `main` prefetch) can build a
115
+ // byte-for-byte identical cache key and let Nuxt dedupe the request.
116
+ export const RESOURCE_EXPLORER_PAGE_SIZE = 10
117
+
94
118
  export const getResourceLabel = (type: ResourceType, count?: number) => {
95
119
  const { t } = useTranslation()
96
120
  switch (type) {
@@ -100,14 +124,29 @@ export const getResourceLabel = (type: ResourceType, count?: number) => {
100
124
  }
101
125
  return t('Fichiers principaux')
102
126
  case 'documentation':
127
+ if (typeof count === 'number') {
128
+ return t('Aucun fichier de documentation | 1 fichier de documentation | {n} fichiers de documentation', count)
129
+ }
103
130
  return t('Documentation')
104
131
  case 'update':
132
+ if (typeof count === 'number') {
133
+ return t('Aucune mise à jour | 1 mise à jour | {n} mises à jour', count)
134
+ }
105
135
  return t('Mise à jour')
106
136
  case 'api':
137
+ if (typeof count === 'number') {
138
+ return t('Aucune API | 1 API | {n} API', count)
139
+ }
107
140
  return t('API')
108
141
  case 'code':
142
+ if (typeof count === 'number') {
143
+ return t('Aucun fichier de code source | 1 fichier de code source | {n} fichiers de code source', count)
144
+ }
109
145
  return t('Code source')
110
146
  case 'other':
147
+ if (typeof count === 'number') {
148
+ return t('Aucun autre fichier | 1 autre fichier | {n} autres fichiers', count)
149
+ }
111
150
  return t('Autre')
112
151
  }
113
152
  }
@@ -140,6 +179,14 @@ export function getResourceExternalUrl(dataset: Dataset | DatasetV2 | Omit<Datas
140
179
  return `${dataset.page}${isCommunityResource(resource) ? '/community-resources' : ''}?resource_id=${resource.id}`
141
180
  }
142
181
 
182
+ export function resolveResourceExternalUrl<R extends Resource | CommunityResource>(
183
+ dataset: Dataset | DatasetV2 | Omit<Dataset, 'resources' | 'community_resources'>,
184
+ resource: R,
185
+ override?: (resource: R) => string,
186
+ ): string {
187
+ return override ? override(resource) : getResourceExternalUrl(dataset, resource)
188
+ }
189
+
143
190
  export function getResourceFilesize(resource: Resource): null | number {
144
191
  if (resource.filesize) return resource.filesize
145
192
  if ('analysis:content-length' in resource.extras) return resource.extras['analysis:content-length'] as number
@@ -147,6 +194,47 @@ export function getResourceFilesize(resource: Resource): null | number {
147
194
  return null
148
195
  }
149
196
 
197
+ const IMAGE_PREVIEW_FORMATS = ['png', 'jpg', 'jpeg', 'jpe', 'gif', 'webp'] as const
198
+
199
+ export function isImagePreviewFormat(format: string | undefined | null): boolean {
200
+ if (!format) return false
201
+ return IMAGE_PREVIEW_FORMATS.includes(format.toLowerCase() as typeof IMAGE_PREVIEW_FORMATS[number])
202
+ }
203
+
204
+ // Parsing errors are stored as `<step>:<message>`, where `<step>` names the
205
+ // analysis step that failed (e.g. `pmtiles_export`, `geojson_export`).
206
+
207
+ /** The analysis step that failed, or null when there is no parsing error. */
208
+ export function getParsingErrorStep(resource: Resource): string | null {
209
+ const error = resource.extras['analysis:parsing:error']
210
+ if (typeof error !== 'string' || !error) return null
211
+ const [step] = error.split(':', 1)
212
+ return step ?? null
213
+ }
214
+
215
+ /** The parsing error message without its `<step>:` prefix, or null when there is none. */
216
+ export function getParsingErrorMessage(resource: Resource): string | null {
217
+ const error = resource.extras['analysis:parsing:error']
218
+ if (typeof error !== 'string' || !error) return null
219
+ const colon = error.indexOf(':')
220
+ return colon === -1 ? error : error.slice(colon + 1)
221
+ }
222
+
223
+ // Geo/format export steps run *after* the tabular table has been built, so a
224
+ // failure here (e.g. a huge geometry timing out the pmtiles export) leaves the
225
+ // tabular data fully usable and must not disable the tabular explorer.
226
+ const NON_TABULAR_PARSING_ERROR_STEPS = ['pmtiles_export', 'geojson_export']
227
+
228
+ /**
229
+ * Whether the resource's parsing error prevents reading its tabular data.
230
+ * Only errors from the tabular parsing itself are fatal; downstream geo/format
231
+ * export failures are not.
232
+ */
233
+ export function hasTabularParsingError(resource: Resource): boolean {
234
+ const step = getParsingErrorStep(resource)
235
+ return step !== null && !NON_TABULAR_PARSING_ERROR_STEPS.includes(step)
236
+ }
237
+
150
238
  type CorsStatus = 'allowed' | 'blocked' | 'unknown'
151
239
 
152
240
  export const getResourceCorsStatus = (resource: Resource): CorsStatus => {