@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
@@ -0,0 +1,98 @@
1
+ import { computed, toValue, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import type { TranslationFunction } from '../../composables/useTranslation'
3
+ import {
4
+ buildFormatConfig,
5
+ buildTypeConfig,
6
+ GENERIC_FORMATS,
7
+ humanizeFormat,
8
+ isFalsy,
9
+ isTruthy,
10
+ resolveColumnType,
11
+ } from '../../functions/tabular'
12
+ import type { ColumnDisplay } from './useTabularContext'
13
+ import type {
14
+ ColumnType,
15
+ TabularColumnProfile,
16
+ TabularProfileResponse,
17
+ } from './types'
18
+
19
+ // Pure derivations of the csv-detective profile: a column's display type, its
20
+ // human-facing label/icon, null ratio and boolean counts.
21
+ // Kept out of TabularExplorer so the fetch/pagination/filter state doesn't drown
22
+ // under a hundred lines of metadata math — and so this logic stays unit-testable
23
+ // in isolation from the component.
24
+ export function useColumnMetadata(
25
+ profileData: Readonly<Ref<TabularProfileResponse | null>>,
26
+ allColumns: MaybeRefOrGetter<string[]>,
27
+ t: TranslationFunction,
28
+ ) {
29
+ const typeConfig = buildTypeConfig(t)
30
+ const formatConfig = buildFormatConfig(t)
31
+
32
+ function getColumnProfile(col: string): TabularColumnProfile | null {
33
+ return profileData.value?.profile?.profile?.[col] ?? null
34
+ }
35
+
36
+ function getColumnTypeFromName(col: string): ColumnType {
37
+ const profile = profileData.value?.profile
38
+ if (!profile) return 'text'
39
+ const colInfo = profile.columns[col]
40
+ if (!colInfo) return 'text'
41
+ return resolveColumnType(colInfo, profile.categorical?.includes(col) ?? false)
42
+ }
43
+
44
+ const columnTypesMap = computed<Record<string, ColumnType>>(() => {
45
+ const map: Record<string, ColumnType> = {}
46
+ for (const col of toValue(allColumns)) {
47
+ map[col] = getColumnTypeFromName(col)
48
+ }
49
+ return map
50
+ })
51
+
52
+ function getColumnType(col: string): ColumnType {
53
+ return columnTypesMap.value[col] ?? 'text'
54
+ }
55
+
56
+ function getNullPercent(col: string): string {
57
+ const colProfile = getColumnProfile(col)
58
+ const total = profileData.value?.profile?.total_lines
59
+ if (!colProfile || !total) return '0%'
60
+ return `${((colProfile.nb_missing_values / total) * 100).toFixed(1)}%`
61
+ }
62
+
63
+ function getTypeConfig(col: string) {
64
+ return typeConfig[getColumnType(col)]
65
+ }
66
+
67
+ // Header display: prefer the rich semantic format detected by csv-detective
68
+ // (code INSEE, UAI, département, SIREN, email…) over the generic display type.
69
+ // Tolerant: generic formats fall back to the type config, and unknown semantic
70
+ // formats are humanized rather than dropped.
71
+ function getColumnDisplay(col: string): ColumnDisplay {
72
+ const format = profileData.value?.profile?.columns?.[col]?.format
73
+ const fallback = getTypeConfig(col)
74
+ if (!format || GENERIC_FORMATS.has(format)) return fallback
75
+ return formatConfig[format] ?? { icon: fallback.icon, label: humanizeFormat(format) }
76
+ }
77
+
78
+ function getBooleanCounts(col: string): { trueCount: number, falseCount: number } {
79
+ const profile = getColumnProfile(col)
80
+ if (!profile?.tops) return { trueCount: 0, falseCount: 0 }
81
+ let trueCount = 0
82
+ let falseCount = 0
83
+ for (const top of profile.tops) {
84
+ if (isTruthy(top.value ?? '')) trueCount += top.count
85
+ else if (isFalsy(top.value ?? '')) falseCount += top.count
86
+ }
87
+ return { trueCount, falseCount }
88
+ }
89
+
90
+ return {
91
+ columnTypesMap,
92
+ getColumnType,
93
+ getColumnProfile,
94
+ getColumnDisplay,
95
+ getNullPercent,
96
+ getBooleanCounts,
97
+ }
98
+ }
@@ -0,0 +1,82 @@
1
+ import type { Component, ComputedRef, InjectionKey, Ref } from 'vue'
2
+ import { inject, provide } from 'vue'
3
+ import type {
4
+ ColumnFilters,
5
+ ColumnType,
6
+ SortConfig,
7
+ TabularColumnProfile,
8
+ TabularDataResponse,
9
+ TabularRow,
10
+ } from './types'
11
+
12
+ export type ActiveFilter = {
13
+ column: string
14
+ label: string
15
+ }
16
+
17
+ export type ColumnDisplay = {
18
+ icon: Component
19
+ label: string
20
+ }
21
+
22
+ // Data + cross-cutting state shared by the tabular explorer's parts (toolbar, header
23
+ // filters, table). Provided by <TabularExplorer>, injected by children so they can be
24
+ // arranged freely without prop-drilling.
25
+ //
26
+ // NOT here — kept local to whoever renders it, because nothing else needs it:
27
+ // - column widths / resize, cell popover, mobile row expansion → TabularTable
28
+ // - mobile filter sheet's expanded column → TabularMobileFilters
29
+ export type TabularContext = {
30
+ // Data & pagination (the parent owns the fetch, children just render it)
31
+ tableData: Ref<TabularDataResponse | null>
32
+ totalLines: ComputedRef<number>
33
+ allRows: Ref<TabularRow[]>
34
+ hasMore: Ref<boolean>
35
+ loadNextPage: () => Promise<void>
36
+ // A sort / filter / search change is refetching while the previous rows are
37
+ // still on screen.
38
+ isRefreshing: ComputedRef<boolean>
39
+
40
+ // Sort & filters
41
+ sort: Ref<SortConfig | null>
42
+ filters: Ref<Record<string, ColumnFilters>>
43
+ activeFilters: ComputedRef<ActiveFilter[]>
44
+ removeFilter: (column: string) => void
45
+ clearAllFilters: () => void
46
+ hasFilterForColumn: (col: string) => boolean
47
+
48
+ // Columns visibility
49
+ allColumns: ComputedRef<string[]>
50
+ visibleColumns: Ref<Set<string>>
51
+ displayedColumns: ComputedRef<string[]>
52
+ hiddenCount: ComputedRef<number>
53
+ toggleColumn: (col: string) => void
54
+ showAllColumns: () => void
55
+ hideAllColumns: () => void
56
+ selectOnlyColumn: (col: string) => void
57
+
58
+ // Mobile filter sheet open/close (button lives in the toolbar, sheet elsewhere)
59
+ mobileFilterOpen: Ref<boolean>
60
+
61
+ // Column metadata helpers (pure derivations of the profile)
62
+ columnTypesMap: ComputedRef<Record<string, ColumnType>>
63
+ getColumnType: (col: string) => ColumnType
64
+ getColumnProfile: (col: string) => TabularColumnProfile | null
65
+ getColumnDisplay: (col: string) => ColumnDisplay
66
+ getNullPercent: (col: string) => string
67
+ getBooleanCounts: (col: string) => { trueCount: number, falseCount: number }
68
+ }
69
+
70
+ const TABULAR_CONTEXT_KEY: InjectionKey<TabularContext> = Symbol('tabular-context')
71
+
72
+ export function provideTabularContext(context: TabularContext) {
73
+ provide(TABULAR_CONTEXT_KEY, context)
74
+ }
75
+
76
+ export function useTabularContext(): TabularContext {
77
+ const context = inject(TABULAR_CONTEXT_KEY)
78
+ if (!context) {
79
+ throw new Error('useTabularContext() must be used inside a <TabularExplorer>')
80
+ }
81
+ return context
82
+ }
@@ -35,18 +35,27 @@
35
35
  <ObjectCardOwner
36
36
  :organization="topic.organization"
37
37
  :owner="topic.owner"
38
+ :organization-url="organizationUrl"
38
39
  />
39
40
  <RiSubtractLine
40
41
  v-if="(topic.organization || topic.owner) && topic.last_modified"
41
42
  aria-hidden="true"
42
43
  class="hidden md:block size-4 flex-none fill-gray-medium"
43
44
  />
44
- <div
45
+ <TranslationT
45
46
  v-if="topic.last_modified"
47
+ tag="div"
46
48
  class="w-full md:w-auto text-gray-medium whitespace-nowrap"
49
+ keypath="Mis à jour {date}"
47
50
  >
48
- {{ t('Mis à jour {date}', { date: formatDate(topic.last_modified) }) }}
49
- </div>
51
+ <template #date>
52
+ <FormattedDate
53
+ :date="topic.last_modified"
54
+ format="relative"
55
+ :options="{ dateStyle: 'long' }"
56
+ />
57
+ </template>
58
+ </TranslationT>
50
59
  </div>
51
60
 
52
61
  <ObjectCardShortDescription :text="topic.description" />
@@ -97,8 +106,6 @@
97
106
  import { RiBookShelfLine, RiDatabase2Line, RiLineChartLine, RiSubtractLine, RiTerminalLine } from '@remixicon/vue'
98
107
  import type { RouteLocationRaw } from 'vue-router'
99
108
  import type { TopicV2 } from '../types/topics'
100
- import { useFormatDate } from '../functions/dates'
101
- import { useTranslation } from '../composables/useTranslation'
102
109
  import ObjectCardHeader from './ObjectCardHeader.vue'
103
110
  import ObjectCardOwner from './ObjectCardOwner.vue'
104
111
  import ObjectCardShortDescription from './ObjectCardShortDescription.vue'
@@ -106,6 +113,8 @@ import OrganizationLogo from './OrganizationLogo.vue'
106
113
  import Avatar from './Avatar.vue'
107
114
  import Placeholder from './Placeholder.vue'
108
115
  import ObjectCard from './ObjectCard.vue'
116
+ import FormattedDate from './FormattedDate.vue'
117
+ import TranslationT from './TranslationT.vue'
109
118
 
110
119
  type TopicWithStats = TopicV2 & {
111
120
  nb_datasets?: number
@@ -116,19 +125,11 @@ type TopicWithStats = TopicV2 & {
116
125
  withDefaults(defineProps<{
117
126
  topic: TopicWithStats
118
127
  topicUrl?: RouteLocationRaw
128
+ organizationUrl?: RouteLocationRaw | null
119
129
  showLogo?: boolean
120
130
  showStats?: boolean
121
131
  }>(), {
122
132
  showLogo: true,
123
133
  showStats: true,
124
134
  })
125
-
126
- const { t } = useTranslation()
127
- const { formatRelativeIfRecentDate } = useFormatDate()
128
-
129
- const formatDate = (dateString: string) => {
130
- return formatRelativeIfRecentDate(dateString, {
131
- dateStyle: 'long',
132
- })
133
- }
134
135
  </script>
@@ -60,23 +60,28 @@ export function useActiveDescendant<T extends Option>(options: MaybeRefOrGetter<
60
60
 
61
61
  function handleKeyPressForActiveDescendant(key: KeyboardEvent, alreadyMovedDown = false) {
62
62
  switch (key.key as CommonKeyboardKeys) {
63
+ // An arrow that moves the active option must not also scroll the page behind it
63
64
  case 'ArrowDown':
64
65
  if (direction === 'vertical' && !alreadyMovedDown && !key.altKey) {
66
+ key.preventDefault()
65
67
  activateNextOption()
66
68
  }
67
69
  break
68
70
  case 'ArrowUp':
69
71
  if (direction === 'vertical') {
72
+ key.preventDefault()
70
73
  activatePreviousOption()
71
74
  }
72
75
  break
73
76
  case 'ArrowLeft':
74
77
  if (direction === 'horizontal') {
78
+ key.preventDefault()
75
79
  activatePreviousOption()
76
80
  }
77
81
  break
78
82
  case 'ArrowRight':
79
83
  if (direction === 'horizontal' && !alreadyMovedDown && !key.altKey) {
84
+ key.preventDefault()
80
85
  activateNextOption()
81
86
  }
82
87
  break
@@ -0,0 +1,164 @@
1
+ import { computed, ref, toValue, watch, type MaybeRefOrGetter, type Ref } from 'vue'
2
+ import { useRouteQuery } from '@vueuse/router'
3
+ import { useComponentsConfig } from '../config'
4
+ import { useDebouncedRef } from './useDebouncedRef'
5
+ import { useFetch } from '../functions/api'
6
+ import { RESOURCE_EXPLORER_PAGE_SIZE, RESOURCE_TYPE } from '../functions/resources'
7
+ import type { PaginatedArray } from '../types/api'
8
+ import type { DatasetV2 } from '../types/datasets'
9
+ import type { Resource, ResourceGroup, ResourceType } from '../types/resources'
10
+
11
+ // Fetches a dataset's resources grouped by type (paginated per type, searchable)
12
+ // and derives the currently selected resource from the `resource_id` query param.
13
+ // Shared by the dataset page and the explore page so the left navigation behaves
14
+ // identically in both.
15
+ export async function useDatasetResources(datasetGetter: MaybeRefOrGetter<DatasetV2>) {
16
+ const config = useComponentsConfig()
17
+
18
+ const search = ref('')
19
+ const { debounced: searchDebounced, flush } = useDebouncedRef(search, config.searchDebounce ?? 300)
20
+
21
+ const datasetId = computed(() => toValue(datasetGetter).id)
22
+ const url = computed(() => `/api/2/datasets/${datasetId.value}/resources/`)
23
+
24
+ const makeParams = (type: ResourceType) => computed(() => ({
25
+ type,
26
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
27
+ q: searchDebounced.value || undefined,
28
+ }))
29
+
30
+ // Evaluated once at setup (before any search) — never changes afterwards
31
+ const hasAnyResources = computed(() => toValue(datasetGetter).resources.total > 0)
32
+
33
+ const extraResourcesByType: Ref<Resource[]>[] = RESOURCE_TYPE.map(() => ref<Resource[]>([]))
34
+ const pageByType: Ref<number>[] = RESOURCE_TYPE.map(() => ref(1))
35
+
36
+ watch(searchDebounced, () => {
37
+ for (let i = 0; i < RESOURCE_TYPE.length; i++) {
38
+ extraResourcesByType[i]!.value = []
39
+ pageByType[i]!.value = 1
40
+ }
41
+ })
42
+
43
+ // Separate useFetch for loadMore, initialized with immediate: false so it doesn't
44
+ // fetch until execute() is called from the event handler.
45
+ const loadMoreType = ref<ResourceType>('main')
46
+ const loadMorePage = ref(1)
47
+ const loadMoreParams = computed(() => ({
48
+ type: loadMoreType.value,
49
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
50
+ page: loadMorePage.value,
51
+ q: searchDebounced.value || undefined,
52
+ }))
53
+
54
+ const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
55
+ const initialResourceId = resourceIdQuery.value
56
+
57
+ // Vue rule: composables must be called synchronously, before any `await` — `inject()`
58
+ // needs the active component instance, which a plain .ts composable loses after its
59
+ // first await (documented in useTabularProfile.ts). The package's `useFetch` calls
60
+ // `useComponentsConfig()` (an inject) on every call, so we kick off every request
61
+ // up-front, then await them together. This is also the idiomatic Nuxt parallel-fetch.
62
+ const typeFetches = RESOURCE_TYPE.map(type =>
63
+ useFetch<PaginatedArray<Resource>>(url, {
64
+ params: makeParams(type),
65
+ // Only main is server-rendered; the rest hydrate client-side to keep SSR light.
66
+ server: type === 'main',
67
+ }),
68
+ )
69
+ const loadMoreFetch = useFetch<PaginatedArray<Resource>>(url, { params: loadMoreParams, immediate: false, watch: false })
70
+ // The resource pointed at by `resource_id` (SSR / deep link) may live beyond the first
71
+ // page of its type group, so it wouldn't be in flatResources — fetch it directly.
72
+ const fetchedResourceFetch = initialResourceId
73
+ ? useFetch<Resource>(`/api/1/datasets/${datasetId.value}/resources/${initialResourceId}/`)
74
+ : null
75
+
76
+ const typeResults = await Promise.all(typeFetches)
77
+ const { data: loadMoreData, execute: executeLoadMore } = await loadMoreFetch
78
+ const fetchedResource = fetchedResourceFetch ? (await fetchedResourceFetch).data : ref<Resource | null>(null)
79
+
80
+ const rawDataByType = typeResults.map(result => result.data)
81
+
82
+ // Which type group is currently loading its next page. A single loadMoreFetch is
83
+ // shared across types, so we track the type here to show the spinner on the right
84
+ // group's "Charger plus…" button.
85
+ const loadingType = ref<ResourceType | null>(null)
86
+
87
+ const loadMore = async (type: ResourceType) => {
88
+ const index = RESOURCE_TYPE.indexOf(type)
89
+ if (index === -1) return
90
+ const pageRef = pageByType[index]!
91
+ const extraRef = extraResourcesByType[index]!
92
+ pageRef.value++
93
+
94
+ loadMoreType.value = type
95
+ loadMorePage.value = pageRef.value
96
+ loadingType.value = type
97
+ try {
98
+ await executeLoadMore()
99
+
100
+ if (loadMoreData.value) {
101
+ extraRef.value = [...extraRef.value, ...loadMoreData.value.data]
102
+ }
103
+ }
104
+ finally {
105
+ loadingType.value = null
106
+ }
107
+ }
108
+
109
+ const groups = computed<ResourceGroup[]>(() => {
110
+ return RESOURCE_TYPE
111
+ .map((type, index) => {
112
+ const rawData = rawDataByType[index]!
113
+ const extraData = extraResourcesByType[index]!
114
+ return {
115
+ type: type as ResourceType,
116
+ total: rawData.value?.total ?? 0,
117
+ items: [...(rawData.value?.data ?? []), ...extraData.value],
118
+ }
119
+ })
120
+ .filter(group => group.items.length > 0)
121
+ })
122
+
123
+ const flatResources = computed(() => groups.value.flatMap(g => g.items))
124
+
125
+ // The URL is the single source of truth for the selection: no ref to keep in
126
+ // sync, navigation is done with plain links that change `resource_id`.
127
+ let lastShown: Resource | null = null
128
+ const remember = (resource: Resource | null) => {
129
+ if (resource) lastShown = resource
130
+ return resource
131
+ }
132
+
133
+ const selectedResource = computed<Resource | null>(() => {
134
+ const wantedId = resourceIdQuery.value
135
+ const fromList = flatResources.value.find(r => r.id === wantedId)
136
+ if (fromList) return remember(fromList)
137
+ // `fetchedResource` answers for `initialResourceId` only: it holds that one
138
+ // resource, so using it for any other id would show something the URL doesn't
139
+ // point at.
140
+ if (wantedId && wantedId === initialResourceId) return remember(fetchedResource.value)
141
+ // A search that matches nothing narrows the navigation, it doesn't unload the
142
+ // viewer — keep showing what we had, which is the dataset's first resource when
143
+ // nothing has been clicked yet.
144
+ return remember(flatResources.value[0] ?? null) ?? lastShown
145
+ })
146
+
147
+ function updateSearch(newSearch: string) {
148
+ search.value = newSearch
149
+ // Only a reset skips the debounce: typing must stay debounced, since each
150
+ // change fires one request per resource type.
151
+ if (!newSearch) flush()
152
+ }
153
+
154
+ return {
155
+ groups,
156
+ flatResources,
157
+ hasAnyResources,
158
+ selectedResource,
159
+ loadMore,
160
+ loadingType,
161
+ search,
162
+ updateSearch,
163
+ }
164
+ }
@@ -1,4 +1,5 @@
1
1
  import { useComponentsConfig } from '../config'
2
+ import { hasTabularParsingError } from '../functions/resources'
2
3
  import type { Resource } from '../types/resources'
3
4
 
4
5
  /**
@@ -20,7 +21,7 @@ export const useHasTabularData = () => {
20
21
  return (
21
22
  config.tabularApiUrl
22
23
  && resource.extras['analysis:parsing:parsing_table']
23
- && !resource.extras['analysis:parsing:error']
24
+ && !hasTabularParsingError(resource)
24
25
  && !sourceUnreachable
25
26
  && (config.tabularAllowRemote || resource.filetype === 'file')
26
27
  )
@@ -0,0 +1,46 @@
1
+ import { ref } from 'vue'
2
+ import { useEventListener } from '@vueuse/core'
3
+
4
+ type UseResizableOptions = {
5
+ initialWidth: number
6
+ minWidth: number
7
+ maxWidth: number
8
+ }
9
+
10
+ // Drag-to-resize a horizontal panel. Returns the reactive width, a `resizing`
11
+ // flag (to disable width transitions while dragging) and a `startResize` handler
12
+ // to bind on a drag handle's `mousedown`. Encapsulates the drag math, clamping and
13
+ // the temporary body cursor / text-selection lock. VueUse has no ready-made panel
14
+ // resize (useResizeObserver only observes size), so we build it on `useEventListener`
15
+ // which handles listener cleanup and SSR for us.
16
+ export function useResizable({ initialWidth, minWidth, maxWidth }: UseResizableOptions) {
17
+ const width = ref(initialWidth)
18
+ const resizing = ref(false)
19
+ let startX = 0
20
+ let startWidth = 0
21
+
22
+ const clamp = (value: number) => Math.min(maxWidth, Math.max(minWidth, Math.round(value)))
23
+
24
+ function startResize(event: MouseEvent) {
25
+ event.preventDefault()
26
+ startX = event.clientX
27
+ startWidth = width.value
28
+ resizing.value = true
29
+ document.body.style.userSelect = 'none'
30
+ document.body.style.cursor = 'col-resize'
31
+ }
32
+
33
+ useEventListener('mousemove', (event: MouseEvent) => {
34
+ if (!resizing.value) return
35
+ width.value = clamp(startWidth + event.clientX - startX)
36
+ })
37
+
38
+ useEventListener('mouseup', () => {
39
+ if (!resizing.value) return
40
+ resizing.value = false
41
+ document.body.style.userSelect = ''
42
+ document.body.style.cursor = ''
43
+ })
44
+
45
+ return { width, resizing, startResize }
46
+ }
@@ -1,16 +1,20 @@
1
- import { computed, toValue, type MaybeRefOrGetter } from 'vue'
1
+ import { computed, type MaybeRefOrGetter, toValue } from 'vue'
2
2
  import { useComponentsConfig } from '../config'
3
- import { useTranslation } from './useTranslation'
4
- import { useHasTabularData } from './useHasTabularData'
5
- import { detectOgcService } from '../functions/resources'
6
3
  import { isOrganizationCertified } from '../functions/organizations'
7
- import type { Resource, WfsMetadata } from '../types/resources'
4
+ import { getGeneratedFormats, getWfsExportFormats } from '../functions/resourceCapabilities'
5
+ import { detectOgcService, getParsingErrorMessage, getParsingErrorStep, isImagePreviewFormat } from '../functions/resources'
8
6
  import type { Dataset, DatasetV2 } from '../types/datasets'
9
- import { getWfsExportFormats } from '../functions/resourceCapabilities'
7
+ import type { Resource, WfsMetadata } from '../types/resources'
8
+ import { useHasTabularData } from './useHasTabularData'
9
+ import { useTranslation } from './useTranslation'
10
10
 
11
- const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
12
11
  const URL_FORMATS = ['url', 'doi', 'www:link', 'www:link-1.0-http--link', 'www:link-1.0-http--partners', 'www:link-1.0-http--related', 'www:link-1.0-http--samples']
13
12
 
13
+ // Which preview the data tab shows. Single source of truth: the viewer renders it and
14
+ // the skeleton picks the matching placeholder shape, so each holding its own copy of
15
+ // this precedence would let the two drift apart.
16
+ export type PreviewKind = 'json' | 'pdf' | 'xml' | 'image' | 'datafair' | 'openapi' | 'tabular' | 'unavailable'
17
+
14
18
  export function useResourceCapabilities(
15
19
  resource: MaybeRefOrGetter<Resource>,
16
20
  dataset: MaybeRefOrGetter<Dataset | DatasetV2>,
@@ -19,11 +23,6 @@ export function useResourceCapabilities(
19
23
  const { t } = useTranslation()
20
24
  const checkTabularData = useHasTabularData()
21
25
 
22
- const hasPreview = computed(() => {
23
- const format = toValue(resource).format?.toLowerCase()
24
- return format === 'json' || format === 'pdf' || format === 'xml'
25
- })
26
-
27
26
  const hasTabularData = computed(() => {
28
27
  const r = toValue(resource)
29
28
  return checkTabularData(r)
@@ -49,24 +48,12 @@ export function useResourceCapabilities(
49
48
  const ogcService = computed(() => detectOgcService(toValue(resource)))
50
49
  const ogcWms = computed(() => ogcService.value === 'wms')
51
50
 
52
- const generatedFormats = computed(() => {
53
- const r = toValue(resource)
54
- const formats = GENERATED_FORMATS
55
- .filter(format => `analysis:parsing:${format}_url` in r.extras)
56
- .map(format => ({
57
- url: r.extras[`analysis:parsing:${format}_url`] as string,
58
- size: r.extras[`analysis:parsing:${format}_size`] as number | undefined,
59
- format,
60
- }))
61
- if ('analysis:parsing:parsing_table' in r.extras) {
62
- formats.push({
63
- url: `${config.tabularApiUrl}/api/resources/${r.id}/data/json/`,
64
- size: undefined,
65
- format: 'json',
66
- })
67
- }
68
- return formats
69
- })
51
+ // The map is built from a pmtiles export; when that export fails we still want
52
+ // to show the Carte tab, but with the reason it could not be generated.
53
+ const hasPmtilesError = computed(() => getParsingErrorStep(toValue(resource)) === 'pmtiles_export')
54
+ const pmtilesError = computed(() => hasPmtilesError.value ? getParsingErrorMessage(toValue(resource)) : null)
55
+
56
+ const generatedFormats = computed(() => getGeneratedFormats(toValue(resource), config.tabularApiUrl))
70
57
 
71
58
  const wfsFormats = computed(() => {
72
59
  return getWfsExportFormats(toValue(resource))
@@ -81,43 +68,64 @@ export function useResourceCapabilities(
81
68
 
82
69
  const isResourceUrl = computed(() => URL_FORMATS.includes(toValue(resource).format))
83
70
 
71
+ // A dedicated preview always wins over the interactive table: a PDF that also
72
+ // carries a parsing table is a PDF first.
73
+ const previewKind = computed<PreviewKind>(() => {
74
+ const format = toValue(resource).format?.toLowerCase()
75
+ if (format === 'json') return 'json'
76
+ if (format === 'pdf') return 'pdf'
77
+ if (format === 'xml') return 'xml'
78
+ if (isImagePreviewFormat(format)) return 'image'
79
+ if (hasDatafairPreview.value) return 'datafair'
80
+ if (hasOpenAPIPreview.value) return 'openapi'
81
+ if (hasTabularData.value) return 'tabular'
82
+ return 'unavailable'
83
+ })
84
+
84
85
  const tabsOptions = computed(() => {
85
86
  const r = toValue(resource)
86
87
  const options = []
87
88
 
88
- if (hasTabularData.value) {
89
- options.push({ key: 'data', label: t('Données') })
89
+ const dataTab = { key: 'data', label: hasTabularData.value ? t('Données') : t('Aperçu') }
90
+ const mapTab = { key: 'map', label: t('Carte') }
91
+ const hasMap = hasPmtiles.value || ogcWms.value
92
+
93
+ // The map is the most visual preview: when it is available, show it first.
94
+ // When its generation failed, keep the data preview first and surface the
95
+ // Carte tab (with its error) right after.
96
+ if (hasMap) {
97
+ options.push(mapTab, dataTab)
98
+ }
99
+ else if (hasPmtilesError.value) {
100
+ options.push(dataTab, mapTab)
90
101
  }
91
102
  else {
92
- options.push({ key: 'data', label: t('Aperçu') })
103
+ options.push(dataTab)
93
104
  }
94
105
 
95
106
  if (hasTabularData.value) {
96
107
  options.push({ key: 'data-structure', label: t('Structure des données') })
97
108
  }
98
109
 
99
- if (hasPmtiles.value || ogcWms.value) {
100
- options.push({ key: 'map', label: t('Carte') })
101
- }
102
-
103
110
  if (r.description) {
104
111
  options.push({ key: 'description', label: t('Description') })
105
112
  }
106
113
 
107
114
  options.push({ key: 'metadata', label: t('Métadonnées') })
108
- options.push({ key: 'downloads', label: t('Téléchargements') })
109
115
 
110
116
  if (hasTabularData.value) {
111
- options.push({ key: 'swagger', label: t('Swagger') })
117
+ options.push({ key: 'api', label: t('API') })
112
118
  }
113
119
 
114
120
  return options
115
121
  })
116
122
 
117
123
  return {
118
- hasPreview,
124
+ previewKind,
119
125
  hasTabularData,
120
126
  hasPmtiles,
127
+ hasPmtilesError,
128
+ pmtilesError,
121
129
  hasDatafairPreview,
122
130
  hasOpenAPIPreview,
123
131
  ogcService,
@@ -94,10 +94,12 @@ export function useSearchFilter(
94
94
  })
95
95
 
96
96
  onScopeDispose(() => {
97
- // Clear the URL param when the scope ends. This mirrors GlobalSearch's
98
- // own `watch(currentType)` logic that drops built-in filters which don't
99
- // apply to the new type: a custom filter's applicability is signalled
100
- // by the consumer via `v-if`, so its unmount is the equivalent signal.
97
+ // Type-scoped filters (typeKeys set) are cleared by GlobalSearch's
98
+ // watch(currentType) instead, so their URL update batches with the other
99
+ // type-change resets via useRouteQuery rather than racing them with the
100
+ // separate router.replace below. Here we only clear filters that apply to
101
+ // all types: their unmount is a consumer signal unrelated to a type switch,
102
+ // which GlobalSearch has no way to detect.
101
103
  //
102
104
  // We cannot use `value.value = defaultValue` here because VueUse's
103
105
  // useRouteQuery registers its own tryOnScopeDispose cleanup that zeroes
@@ -105,7 +107,7 @@ export function useSearchFilter(
105
107
  // The setter then sees `query === v` and early-returns without ever
106
108
  // calling router.replace(). Instead we read the live route.query directly
107
109
  // (which is router state, not affected by that cleanup) and push the update.
108
- if (route.query[urlParam] !== undefined) {
110
+ if (normalizedTypeKeys === undefined && route.query[urlParam] !== undefined) {
109
111
  const { [urlParam]: _removed, ...restQuery } = route.query
110
112
  router.replace({
111
113
  query: defaultValue === undefined ? restQuery : { ...restQuery, [urlParam]: String(defaultValue) },