@datagouv/components-next 1.1.2 → 1.3.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 (62) hide show
  1. package/README.md +7 -6
  2. package/dist/{Control-ZFh5ta_U.js → Control-b-43UT2B.js} +10 -10
  3. package/dist/{Datafair.client-8bXp6UeQ.js → Datafair.client-BrjISlaZ.js} +4 -4
  4. package/dist/{Event-DSQcW7OF.js → Event-py_AAwJY.js} +18 -18
  5. package/dist/{Image-BijNEG0p.js → Image-BHQYZWzH.js} +23 -23
  6. package/dist/{JsonPreview.client-BxuoPK_w.js → JsonPreview.client-DivFAnbN.js} +5 -5
  7. package/dist/{Map-BUtPf5GN.js → Map-CtP7leHl.js} +1313 -1173
  8. package/dist/{MapContainer.client-CTz0wmJG.js → MapContainer.client-Cq8tNLZZ.js} +17 -17
  9. package/dist/{OSM-D4MTdBtk.js → OSM-gdDSIsDA.js} +15 -23
  10. package/dist/{PdfPreview.client-DpVreUSl.js → PdfPreview.client-BNBgLsK2.js} +6 -10
  11. package/dist/Pmtiles.client-CQi5Rw8W.js +26123 -0
  12. package/dist/{PreviewWrapper.vue_vue_type_script_setup_true_lang-stmU5qEB.js → PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js} +2 -2
  13. package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-hG-FhNqy.js} +2 -2
  14. package/dist/{Tile-Dcl7oIVu.js → Tile-D6rFonpT.js} +163 -166
  15. package/dist/{TileImage-BJeHipMX.js → TileImage-cfrEgz5y.js} +131 -119
  16. package/dist/{View-xp_P_OHw.js → View-vp7vDb9q.js} +169 -119
  17. package/dist/{XmlPreview.client-DAOs89cR.js → XmlPreview.client-XCsNvnsZ.js} +5 -5
  18. package/dist/{common-BjQlan3k.js → common-C8efyyuc.js} +49 -49
  19. package/dist/components-next.css +1 -52
  20. package/dist/components-next.js +166 -157
  21. package/dist/components.css +2 -2
  22. package/dist/{index-JqjPja1u.js → index-CXUamc8Z.js} +6382 -6057
  23. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-BRKv9Ksv.js} +162 -164
  24. package/dist/{main-D4WQMky0.js → main-DKu5SOAl.js} +48480 -97188
  25. package/dist/{proj-CsNo9yH1.js → proj-CsjVeojN.js} +322 -306
  26. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  27. package/dist/{tilecoord-A0fLnBZr.js → tilecoord-CPcQJnrF.js} +270 -261
  28. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  29. package/dist/{vue3-xml-viewer.common-BGsoNyZe.js → vue3-xml-viewer.common-BIaEIFYG.js} +1 -1
  30. package/package.json +42 -35
  31. package/src/chart.ts +5 -0
  32. package/src/components/ActivityList/ActivityList.vue +3 -0
  33. package/src/components/DatasetCard.vue +6 -4
  34. package/src/components/ObjectCardHeader.vue +1 -1
  35. package/src/components/RadioInput.vue +7 -2
  36. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  37. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  38. package/src/components/ResourceAccordion/Pmtiles.client.vue +3 -1
  39. package/src/components/ResourceAccordion/ResourceAccordion.vue +7 -104
  40. package/src/components/ResourceExplorer/ResourceExplorer.vue +10 -64
  41. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +30 -136
  42. package/src/components/ResourceExplorer/ResourceSelector.vue +113 -0
  43. package/src/components/Search/GlobalSearch.vue +27 -6
  44. package/src/components/TabularExplorer/TabularCellPopover.vue +2 -2
  45. package/src/components/TabularExplorer/TabularExplorer.vue +404 -163
  46. package/src/components/TabularExplorer/TabularFilterContent.vue +15 -13
  47. package/src/components/TabularExplorer/TabularFilterPopover.vue +21 -26
  48. package/src/components/TabularExplorer/types.ts +9 -9
  49. package/src/composables/useHasTabularData.ts +7 -0
  50. package/src/composables/useResourceCapabilities.ts +1 -1
  51. package/src/composables/useSearchFilter.ts +7 -5
  52. package/src/composables/useStableQueryParams.ts +7 -3
  53. package/src/composables/useTabularProfile.ts +70 -0
  54. package/src/functions/activities.ts +3 -3
  55. package/src/functions/api.ts +13 -7
  56. package/src/functions/resources.ts +5 -0
  57. package/src/functions/tabular.ts +103 -1
  58. package/src/main.ts +12 -6
  59. package/src/types/resources.ts +0 -1
  60. package/src/types/search.ts +11 -0
  61. package/src/types/ui.ts +1 -1
  62. package/dist/Pmtiles.client-BwmHo3T8.js +0 -25185
@@ -265,91 +265,13 @@
265
265
  <div
266
266
  v-if="tab.key === 'downloads'"
267
267
  >
268
- <dl class="fr-pl-0">
269
- <dt
270
- v-if="resource.format === 'url'"
271
- class="font-bold fr-text--sm fr-mb-0"
272
- >
273
- {{ t("URL d'origine") }}
274
- </dt>
275
- <dt
276
- v-else
277
- class="font-bold fr-text--sm fr-mb-0"
278
- >
279
- {{ t('Format original') }}
280
- </dt>
281
- <dd class="text-sm pl-0 mb-4 text-gray-medium h-8 flex flex-wrap items-center">
282
- <span v-if="resource.format === 'url'">
283
- <a
284
- :href="resource.latest"
285
- class="fr-link no-icon-after"
286
- rel="ugc nofollow noopener"
287
- target="_blank"
288
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
289
- >
290
- <component
291
- :is="config.textClamp"
292
- v-if="config && config.textClamp"
293
- :auto-resize="true"
294
- :max-lines="1"
295
- :text="resource.url"
296
- >
297
- <template #after>
298
- <span class="fr-ml-1v fr-icon-external-link-line fr-icon--sm" />
299
- </template>
300
- </component>
301
- </a>
302
- </span>
303
- <span v-else>
304
- <span class="text-datagouv fr-icon-download-line fr-icon--sm fr-mr-1v fr-mt-1v" />
305
- <a
306
- :href="resource.latest"
307
- class="fr-link"
308
- rel="ugc nofollow noopener"
309
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${resource.format}`)"
310
- >
311
- <span>{{ t('Format {format}', { format: resource.format }) }}<span v-if="resourceFilesize"> - {{ filesize(resourceFilesize) }}</span></span>
312
- </a>
313
- </span>
314
- <CopyButton
315
- :label="t('Copier le lien')"
316
- :copied-label="t('Lien copié !')"
317
- :text="resource.latest"
318
- class="relative"
319
- />
320
- </dd>
321
- <template v-if="generatedFormats.length">
322
- <dt class="font-bold fr-text--sm fr-mb-0">
323
- {{ t('Formats générés automatiquement par {platform} (dernière mise à jour {date})', { platform: config.name, date: conversionsLastUpdate }) }}
324
- </dt>
325
- <dd
326
- v-for="generatedFormat in generatedFormats"
327
- :key="generatedFormat.format"
328
- class="text-sm pl-0 mb-4 text-gray-medium h-8 flex flex-wrap items-center"
329
- >
330
- <span>
331
- <span class="text-datagouv fr-icon-download-line fr-icon--sm fr-mr-1v fr-mt-1v" />
332
- <a
333
- :href="generatedFormat.url"
334
- class="fr-link"
335
- rel="ugc nofollow noopener"
336
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${generatedFormat.format}`)"
337
- >
338
- <span>{{ t('Format {format}', { format: generatedFormat.format }) }}<span v-if="generatedFormat.size"> - {{ filesize(generatedFormat.size) }}</span></span>
339
- </a>
340
- </span>
341
- <CopyButton
342
- :label="t('Copier le lien')"
343
- :copied-label="t('Lien copié !')"
344
- :text="generatedFormat.url"
345
- class="relative"
346
- />
347
- </dd>
348
- </template>
349
- </dl>
268
+ <Downloads
269
+ :resource="resource"
270
+ :dataset="dataset"
271
+ />
350
272
  </div>
351
273
  <div
352
- v-if="tab.key === 'swagger'"
274
+ v-if="tab.key === 'api'"
353
275
  >
354
276
  <div class="fr-mb-4w">
355
277
  <p>{{ t("Cette API est générée automatiquement par {platform} à partir du fichier.", { platform: config.name }) }}</p>
@@ -396,11 +318,11 @@ import SchemaBadge from './SchemaBadge.vue'
396
318
  import ResourceIcon from './ResourceIcon.vue'
397
319
  import EditButton from './EditButton.vue'
398
320
  import DataStructure from './DataStructure.vue'
321
+ import Downloads from './Downloads.vue'
399
322
  import Preview from './Preview.vue'
400
323
  import { isOrganizationCertified } from '../../functions/organizations'
401
324
  import OpenApiViewer from '../OpenApiViewer/OpenApiViewer.vue'
402
325
 
403
- const GENERATED_FORMATS = ['parquet', 'pmtiles', 'geojson']
404
326
  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']
405
327
 
406
328
  const props = withDefaults(defineProps<{
@@ -460,24 +382,6 @@ const ogcService = computed(() => detectOgcService(props.resource))
460
382
 
461
383
  const ogcWms = computed(() => ogcService.value === 'wms')
462
384
 
463
- const generatedFormats = computed(() => {
464
- const formats = GENERATED_FORMATS
465
- .filter(format => `analysis:parsing:${format}_url` in props.resource.extras)
466
- .map(format => ({
467
- url: props.resource.extras[`analysis:parsing:${format}_url`] as string,
468
- size: props.resource.extras[`analysis:parsing:${format}_size`] as number | undefined,
469
- format: format,
470
- }))
471
- if ('analysis:parsing:parsing_table' in props.resource.extras) {
472
- formats.push({
473
- url: `${config.tabularApiUrl}/api/resources/${props.resource.id}/data/json/`,
474
- size: undefined,
475
- format: 'json',
476
- })
477
- }
478
- return formats
479
- })
480
-
481
385
  const open = ref(props.expandedOnMount)
482
386
  const toggle = () => {
483
387
  open.value = !open.value
@@ -513,7 +417,7 @@ const tabsOptions = computed(() => {
513
417
  options.push({ key: 'downloads', label: t('Téléchargements') })
514
418
 
515
419
  if (hasTabularData.value) {
516
- options.push({ key: 'swagger', label: t('Swagger') })
420
+ options.push({ key: 'api', label: t('API') })
517
421
  }
518
422
 
519
423
  return options
@@ -540,7 +444,6 @@ const communityResource = computed<CommunityResource | null>(() => {
540
444
  const owner = computed(() => communityResource.value ? getOwnerName(communityResource.value) : null)
541
445
 
542
446
  const lastUpdate = props.resource.last_modified
543
- const conversionsLastUpdate = computed(() => formatRelativeIfRecentDate(props.resource.extras['analysis:parsing:finished_at'] as string | undefined))
544
447
  const availabilityChecked = props.resource.extras && 'check:available' in props.resource.extras
545
448
  const resourceFilesize = computed(() => getResourceFilesize(props.resource))
546
449
 
@@ -2,24 +2,13 @@
2
2
  <div v-if="allResources.length || hasAnyResources">
3
3
  <div class="flex gap-6">
4
4
  <div class="flex-1 min-w-0">
5
- <div
6
- v-if="dataset.resources.total > 1"
7
- class="md:hidden flex justify-end mb-3"
8
- >
9
- <BrandedButton
10
- size="xs"
11
- color="secondary"
12
- :icon="RiListUnordered"
13
- @click="mobileSidebarOpen = true"
14
- >
15
- {{ t('Ressources ({count})', { count: dataset.resources.total }) }}
16
- </BrandedButton>
17
- </div>
18
5
  <ResourceExplorerViewer
19
6
  v-if="selectedResource && allResources.length"
20
7
  :key="selectedResource.id"
21
8
  :dataset
22
9
  :resource="selectedResource"
10
+ :resources="flatResources"
11
+ @select="selectResource"
23
12
  />
24
13
  <div
25
14
  v-else-if="search"
@@ -56,25 +45,6 @@
56
45
  />
57
46
  </div>
58
47
  </div>
59
-
60
- <!-- Mobile sidebar panel -->
61
- <dialog
62
- ref="mobileSidebarDialog"
63
- class="mobile-sidebar md:hidden fixed inset-0 m-0 ml-auto p-0 h-dvh max-h-dvh w-80 max-w-[85vw] bg-white shadow-lg overflow-y-auto overscroll-contain backdrop:bg-black/30"
64
- @close="mobileSidebarOpen = false"
65
- @click.self="closeMobileSidebar"
66
- >
67
- <ResourceExplorerSidebar
68
- :resources="allResources"
69
- :selected-resource-id="selectedResource?.id ?? null"
70
- :collapsed="false"
71
- :search
72
- @select="selectResource"
73
- @load-more="loadMore"
74
- @update:collapsed="closeMobileSidebar"
75
- @update:search="updateSearch($event)"
76
- />
77
- </dialog>
78
48
  </div>
79
49
  <div
80
50
  v-else
@@ -101,13 +71,12 @@ import { useComponentsConfig } from '../../config'
101
71
  import { useTranslation } from '../../composables/useTranslation'
102
72
  import { useDebouncedRef } from '../../composables/useDebouncedRef'
103
73
  import { useFetch } from '../../functions/api'
104
- import { RESOURCE_TYPE } from '../../functions/resources'
74
+ import { RESOURCE_EXPLORER_PAGE_SIZE, RESOURCE_TYPE } from '../../functions/resources'
105
75
  import type { PaginatedArray } from '../../types/api'
106
76
  import type { DatasetV2 } from '../../types/datasets'
107
77
  import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
108
78
  import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
109
79
  import ResourceExplorerViewer from './ResourceExplorerViewer.vue'
110
- import { RiListUnordered } from '@remixicon/vue'
111
80
  import BrandedButton from '../BrandedButton.vue'
112
81
 
113
82
  const props = withDefaults(defineProps<{
@@ -125,7 +94,6 @@ const sidebarCollapsed = ref(false)
125
94
  const search = ref('')
126
95
  const { debounced: searchDebounced, flush } = useDebouncedRef(search, config.searchDebounce ?? 300)
127
96
 
128
- const PAGE_SIZE = 10
129
97
  const url = computed(() => `/api/2/datasets/${props.dataset.id}/resources/`)
130
98
 
131
99
  // Resource ID from URL query
@@ -134,32 +102,32 @@ const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
134
102
  // Fetch resources for each type
135
103
  const mainParams = computed(() => ({
136
104
  type: 'main' as const,
137
- page_size: PAGE_SIZE,
105
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
138
106
  q: searchDebounced.value || undefined,
139
107
  }))
140
108
  const documentationParams = computed(() => ({
141
109
  type: 'documentation' as const,
142
- page_size: PAGE_SIZE,
110
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
143
111
  q: searchDebounced.value || undefined,
144
112
  }))
145
113
  const updateParams = computed(() => ({
146
114
  type: 'update' as const,
147
- page_size: PAGE_SIZE,
115
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
148
116
  q: searchDebounced.value || undefined,
149
117
  }))
150
118
  const apiParams = computed(() => ({
151
119
  type: 'api' as const,
152
- page_size: PAGE_SIZE,
120
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
153
121
  q: searchDebounced.value || undefined,
154
122
  }))
155
123
  const codeParams = computed(() => ({
156
124
  type: 'code' as const,
157
- page_size: PAGE_SIZE,
125
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
158
126
  q: searchDebounced.value || undefined,
159
127
  }))
160
128
  const otherParams = computed(() => ({
161
129
  type: 'other' as const,
162
- page_size: PAGE_SIZE,
130
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
163
131
  q: searchDebounced.value || undefined,
164
132
  }))
165
133
 
@@ -200,7 +168,7 @@ const loadMoreType = ref<ResourceType>('main')
200
168
  const loadMorePage = ref(1)
201
169
  const loadMoreParams = computed(() => ({
202
170
  type: loadMoreType.value,
203
- page_size: PAGE_SIZE,
171
+ page_size: RESOURCE_EXPLORER_PAGE_SIZE,
204
172
  page: loadMorePage.value,
205
173
  q: searchDebounced.value || undefined,
206
174
  }))
@@ -246,21 +214,6 @@ const flatResources = computed(() =>
246
214
 
247
215
  // Fetch resource by ID if specified in URL (for SSR)
248
216
  const initialResourceId = resourceIdQuery.value
249
- const mobileSidebarOpen = ref(false)
250
- const mobileSidebarDialog = ref<HTMLDialogElement | null>(null)
251
-
252
- watch(mobileSidebarOpen, (open) => {
253
- if (open) {
254
- mobileSidebarDialog.value?.showModal()
255
- }
256
- else {
257
- mobileSidebarDialog.value?.close()
258
- }
259
- })
260
-
261
- function closeMobileSidebar() {
262
- mobileSidebarOpen.value = false
263
- }
264
217
  const { data: fetchedResource } = initialResourceId
265
218
  ? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
266
219
  : { data: ref(null) }
@@ -288,7 +241,6 @@ function updateSearch(newSearch: string) {
288
241
 
289
242
  const selectResource = (resource: Resource) => {
290
243
  selectedResource.value = resource
291
- mobileSidebarOpen.value = false
292
244
  router.replace({
293
245
  query: { ...router.currentRoute.value.query, resource_id: resource.id },
294
246
  })
@@ -303,9 +255,3 @@ watch(flatResources, () => {
303
255
  }
304
256
  })
305
257
  </script>
306
-
307
- <style>
308
- html:has(dialog.mobile-sidebar[open]) {
309
- overflow: hidden;
310
- }
311
- </style>
@@ -2,7 +2,7 @@
2
2
  <div class="border border-gray-default">
3
3
  <header class="p-4 flex flex-wrap md:flex-nowrap gap-4 items-center justify-between">
4
4
  <div>
5
- <div class="flex items-center mb-1">
5
+ <div class="flex items-center gap-1 mb-1">
6
6
  <h3 class="m-0 flex items-baseline text-base font-bold leading-tight">
7
7
  <ResourceIcon
8
8
  :resource
@@ -10,6 +10,12 @@
10
10
  />
11
11
  <span class="line-clamp-2">{{ resource.title || t('Fichier sans nom') }}</span>
12
12
  </h3>
13
+ <ResourceSelector
14
+ v-if="resources && resources.length > 1"
15
+ :resources
16
+ :selected-id="resource.id"
17
+ @select="emit('select', $event)"
18
+ />
13
19
  <CopyButton
14
20
  :label="t('Copier le lien')"
15
21
  :copied-label="t('Lien copié !')"
@@ -139,9 +145,9 @@
139
145
  v-else-if="hasOpenAPIPreview"
140
146
  :url="resource.extras['apidocUrl'] as string"
141
147
  />
142
- <Preview
148
+ <TabularExplorer
143
149
  v-else-if="hasTabularData"
144
- :resource="resource"
150
+ :resource-id="resource.id"
145
151
  />
146
152
  <PreviewUnavailable v-else>
147
153
  <!-- "File too large to download" is the only analysis:error value from hydra for now -->
@@ -174,135 +180,20 @@
174
180
  <Metadata :resource />
175
181
  </div>
176
182
  <div v-if="tab.key === 'downloads'">
177
- <dl class="fr-pl-0">
178
- <dt
179
- v-if="resource.format === 'url'"
180
- class="font-bold fr-text--sm fr-mb-0"
181
- >
182
- {{ t("URL d'origine") }}
183
- </dt>
184
- <dt
185
- v-else
186
- class="font-bold fr-text--sm fr-mb-0"
187
- >
188
- {{ t('Format original') }}
189
- </dt>
190
- <dd class="text-sm pl-0 mb-4 text-gray-medium h-8 flex flex-wrap items-center">
191
- <span
192
- v-if="resource.format === 'url'"
193
- class="inline-flex items-center max-w-full"
194
- >
195
- <a
196
- :href="resource.latest"
197
- class="fr-link no-icon-after truncate"
198
- rel="ugc nofollow noopener"
199
- target="_blank"
200
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
201
- >
202
- {{ resource.url }}
203
- </a>
204
- <span class="fr-ml-1v fr-icon-external-link-line fr-icon--sm shrink-0" />
205
- </span>
206
- <span v-else>
207
- <span class="text-datagouv fr-icon-download-line fr-icon--sm fr-mr-1v fr-mt-1v" />
208
- <a
209
- :href="resource.latest"
210
- class="fr-link"
211
- rel="ugc nofollow noopener"
212
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${resource.format}`)"
213
- >
214
- <span>{{ t('Format {format}', { format: resource.format }) }}<span v-if="resourceFilesize"> - {{ filesize(resourceFilesize) }}</span></span>
215
- </a>
216
- </span>
217
- <CopyButton
218
- :label="t('Copier le lien')"
219
- :copied-label="t('Lien copié !')"
220
- :text="resource.latest"
221
- class="relative"
222
- />
223
- </dd>
224
- <template v-if="generatedFormats.length">
225
- <dt class="font-bold fr-text--sm fr-mb-0">
226
- {{ t('Formats générés automatiquement par {platform} (dernière mise à jour {date})', { platform: config.name, date: conversionsLastUpdate }) }}
227
- </dt>
228
- <dd
229
- v-for="generatedFormat in generatedFormats"
230
- :key="generatedFormat.format"
231
- class="text-sm pl-0 mb-4 text-gray-medium h-8 flex flex-wrap items-center"
232
- >
233
- <span>
234
- <span class="text-datagouv fr-icon-download-line fr-icon--sm fr-mr-1v fr-mt-1v" />
235
- <a
236
- :href="generatedFormat.url"
237
- class="fr-link"
238
- rel="ugc nofollow noopener"
239
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${generatedFormat.format}`)"
240
- >
241
- <span>{{ t('Format {format}', { format: generatedFormat.format }) }}<span v-if="generatedFormat.size"> - {{ filesize(generatedFormat.size) }}</span></span>
242
- </a>
243
- </span>
244
- <CopyButton
245
- :label="t('Copier le lien')"
246
- :copied-label="t('Lien copié !')"
247
- :text="generatedFormat.url"
248
- class="relative"
249
- />
250
- </dd>
251
- </template>
252
- <template v-if="wfsFormats.length">
253
- <dt class="font-bold fr-text--sm fr-mb-0">
254
- <div class="flex gap-1 items-center">
255
- {{ t('Formats exportés depuis le service WFS') }}
256
- <span v-if="defaultWfsProjection"> ({{ t('projection {crs}', { crs: defaultWfsProjection }) }})</span>
257
- <Tooltip>
258
- <RiInformationLine
259
- class="flex-none size-4"
260
- :aria-label="t(`Le lien de téléchargement interroge directement le flux WFS distant. Le nombre de features téléchargées peut être limité.`)"
261
- aria-hidden="true"
262
- />
263
- <template #tooltip>
264
- <p class="text-sm font-normal mb-0">
265
- {{ t(`Le lien de téléchargement interroge directement le flux WFS distant.`) }}
266
- </p>
267
- <p class="text-sm font-normal mb-0">
268
- {{ t(`Le nombre de features téléchargées peut être limité.`) }}
269
- </p>
270
- </template>
271
- </Tooltip>
272
- </div>
273
- </dt>
274
- <dd
275
- v-for="wfsFormat in wfsFormats"
276
- :key="wfsFormat.format"
277
- class="text-sm pl-0 mb-4 text-gray-medium h-8 flex flex-wrap items-center"
278
- >
279
- <span>
280
- <span class="text-datagouv fr-icon-download-line fr-icon--sm fr-mr-1v fr-mt-1v" />
281
- <a
282
- :href="wfsFormat.url"
283
- class="fr-link"
284
- rel="ugc nofollow noopener"
285
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${wfsFormat.format}`)"
286
- >
287
- <span>{{ t('Format {format}', { format: wfsFormat.format }) }}</span>
288
- </a>
289
- </span>
290
- <CopyButton
291
- :label="t('Copier le lien')"
292
- :copied-label="t('Lien copié !')"
293
- :text="wfsFormat.url"
294
- class="relative"
295
- />
296
- </dd>
297
- </template>
298
- </dl>
183
+ <Downloads
184
+ :resource="resource"
185
+ :dataset="dataset"
186
+ />
299
187
  </div>
300
- <div v-if="tab.key === 'swagger'">
188
+ <div v-if="tab.key === 'api'">
301
189
  <div class="fr-mb-4w">
302
190
  <p>{{ t("Cette API est générée automatiquement par {platform} à partir du fichier.", { platform: config.name }) }}</p>
303
191
  <p>{{ t("- Si le fichier est modifié, l'API sera mise à jour et sa structure pourra changer.") }}</p>
304
192
  <p>{{ t("- Si le fichier est supprimé, l'API sera également supprimée.") }}</p>
305
193
  <p>{{ t("Pour des usages pérennes, prévoyez que cette API dépend directement du fichier source.") }}</p>
194
+ <p v-if="config.tabularApiUrl">
195
+ {{ t("L'URL de base de l'API est {url}", { url: config.tabularApiUrl }) }}
196
+ </p>
306
197
  </div>
307
198
  <OpenApiViewer
308
199
  v-if="hasTabularData"
@@ -318,7 +209,7 @@
318
209
 
319
210
  <script setup lang="ts">
320
211
  import { computed, defineAsyncComponent } from 'vue'
321
- import { RiDownloadLine, RiFileCopyLine, RiFileWarningLine, RiInformationLine, RiSubtractLine } from '@remixicon/vue'
212
+ import { RiDownloadLine, RiFileCopyLine, RiFileWarningLine, RiSubtractLine } from '@remixicon/vue'
322
213
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
323
214
  import { toast } from 'vue-sonner'
324
215
  import BrandedButton from '../BrandedButton.vue'
@@ -331,11 +222,12 @@ import TabList from '../Tabs/TabList.vue'
331
222
  import Tab from '../Tabs/Tab.vue'
332
223
  import TabPanels from '../Tabs/TabPanels.vue'
333
224
  import TabPanel from '../Tabs/TabPanel.vue'
334
- import Tooltip from '../Tooltip.vue'
335
- import Preview from '../ResourceAccordion/Preview.vue'
225
+ import TabularExplorer from '../TabularExplorer/TabularExplorer.vue'
336
226
  import DataStructure from '../ResourceAccordion/DataStructure.vue'
227
+ import Downloads from '../ResourceAccordion/Downloads.vue'
337
228
  import Metadata from '../ResourceAccordion/Metadata.vue'
338
229
  import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
230
+ import ResourceSelector from './ResourceSelector.vue'
339
231
  import { filesize, summarize } from '../../functions/helpers'
340
232
  import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
341
233
  import { trackEvent } from '../../functions/matomo'
@@ -343,6 +235,7 @@ import { useComponentsConfig } from '../../config'
343
235
  import { useFormatDate } from '../../functions/dates'
344
236
  import { useTranslation } from '../../composables/useTranslation'
345
237
  import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
238
+ import { provideTabularProfile } from '../../composables/useTabularProfile'
346
239
  import type { Resource } from '../../types/resources'
347
240
  import type { Dataset, DatasetV2 } from '../../types/datasets'
348
241
 
@@ -368,6 +261,11 @@ const Pmtiles = defineAsyncComponent(() =>
368
261
  const props = defineProps<{
369
262
  dataset: Dataset | DatasetV2
370
263
  resource: Resource
264
+ resources?: Resource[]
265
+ }>()
266
+
267
+ const emit = defineEmits<{
268
+ select: [resource: Resource]
371
269
  }>()
372
270
 
373
271
  const { t } = useTranslation()
@@ -381,13 +279,13 @@ const {
381
279
  hasOpenAPIPreview,
382
280
  ogcService,
383
281
  ogcWms,
384
- generatedFormats,
385
- wfsFormats,
386
- defaultWfsProjection,
387
282
  isResourceUrl,
388
283
  tabsOptions,
389
284
  } = useResourceCapabilities(() => props.resource, () => props.dataset)
390
285
 
286
+ // Share the tabular profile fetch between TabularExplorer and DataStructure tabs.
287
+ await provideTabularProfile(() => props.resource.id)
288
+
391
289
  const resourceFilesize = computed(() => getResourceFilesize(props.resource))
392
290
  const resourceExternalUrl = computed(() => getResourceExternalUrl(props.dataset, props.resource))
393
291
 
@@ -401,10 +299,6 @@ const downloadButtonTitle = computed(() => {
401
299
  return t('Télécharger le fichier en {format}', { format: format.value })
402
300
  })
403
301
 
404
- const conversionsLastUpdate = computed(() =>
405
- formatRelativeIfRecentDate(props.resource.extras['analysis:parsing:finished_at'] as string | undefined),
406
- )
407
-
408
302
  const copyResourceUrl = async () => {
409
303
  try {
410
304
  await navigator.clipboard.writeText(props.resource.url)
@@ -0,0 +1,113 @@
1
+ <template>
2
+ <Popover
3
+ v-slot="{ open, close }"
4
+ class="relative inline-block"
5
+ >
6
+ <slot
7
+ name="trigger"
8
+ :open="open"
9
+ >
10
+ <PopoverButton
11
+ class="inline-flex items-center justify-center size-6 rounded text-gray-plain hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-new-primary"
12
+ :aria-label="t('Choisir une autre ressource')"
13
+ >
14
+ <RiArrowDownSLine
15
+ class="size-4"
16
+ :class="{ 'rotate-180': open }"
17
+ aria-hidden="true"
18
+ />
19
+ </PopoverButton>
20
+ </slot>
21
+ <PopoverPanel
22
+ :class="searchable
23
+ ? 'absolute left-0 top-full z-50 mt-2 w-96 max-w-[calc(100vw-2rem)] bg-white border border-gray-default rounded shadow-lg p-3 space-y-2'
24
+ : 'absolute left-0 top-full z-50 mt-1 w-80 max-h-96 overflow-auto bg-white border border-gray-default rounded shadow-lg p-1'"
25
+ >
26
+ <input
27
+ v-if="searchable"
28
+ v-model="searchQuery"
29
+ type="search"
30
+ class="w-full border border-gray-default rounded px-2.5 py-1.5 text-sm"
31
+ :placeholder="t('Rechercher dans les ressources…')"
32
+ >
33
+ <ul
34
+ v-if="filteredResources.length > 0"
35
+ class="list-none p-0 m-0 space-y-0.5 max-h-80 overflow-y-auto"
36
+ >
37
+ <li
38
+ v-for="r in filteredResources"
39
+ :key="r.id"
40
+ >
41
+ <button
42
+ v-if="!isDisabled?.(r)"
43
+ type="button"
44
+ class="flex items-center gap-1.5 w-full text-left px-2 py-1.5 rounded text-sm hover:bg-gray-100 focus:outline-none focus-visible:bg-gray-100"
45
+ :class="{ 'font-bold bg-blue-50 text-new-primary': r.id === selectedId }"
46
+ @click="emit('select', r); close()"
47
+ >
48
+ <ResourceIcon
49
+ :resource="r"
50
+ class="size-3.5 shrink-0"
51
+ />
52
+ <span class="truncate">{{ r.title || t('Fichier sans nom') }}</span>
53
+ <span
54
+ v-if="r.format"
55
+ class="ml-auto text-xs text-gray-medium uppercase shrink-0"
56
+ >
57
+ {{ r.format }}
58
+ </span>
59
+ </button>
60
+ <div
61
+ v-else
62
+ class="flex items-center gap-1.5 px-2 py-1.5 rounded text-sm text-gray-medium cursor-not-allowed"
63
+ :title="disabledTitle"
64
+ >
65
+ <ResourceIcon
66
+ :resource="r"
67
+ class="size-3.5 shrink-0 opacity-50"
68
+ />
69
+ <span class="truncate opacity-70">{{ r.title || t('Fichier sans nom') }}</span>
70
+ </div>
71
+ </li>
72
+ </ul>
73
+ <p
74
+ v-else
75
+ class="text-sm text-gray-medium italic mb-0 px-2 py-2"
76
+ >
77
+ {{ t('Aucune ressource correspondante') }}
78
+ </p>
79
+ </PopoverPanel>
80
+ </Popover>
81
+ </template>
82
+
83
+ <script setup lang="ts">
84
+ import { computed, ref } from 'vue'
85
+ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
86
+ import { RiArrowDownSLine } from '@remixicon/vue'
87
+ import { useTranslation } from '../../composables/useTranslation'
88
+ import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
89
+ import type { Resource } from '../../types/resources'
90
+
91
+ const props = defineProps<{
92
+ resources: Resource[]
93
+ selectedId: string
94
+ searchable?: boolean
95
+ isDisabled?: (resource: Resource) => boolean
96
+ disabledTitle?: string
97
+ }>()
98
+
99
+ const emit = defineEmits<{
100
+ select: [resource: Resource]
101
+ }>()
102
+
103
+ const { t } = useTranslation()
104
+
105
+ const searchQuery = ref('')
106
+
107
+ const filteredResources = computed(() => {
108
+ if (!props.searchable) return props.resources
109
+ const q = searchQuery.value.trim().toLowerCase()
110
+ if (!q) return props.resources
111
+ return props.resources.filter(r => (r.title ?? '').toLowerCase().includes(q))
112
+ })
113
+ </script>