@datagouv/components-next 1.0.2-dev.172 → 1.0.2-dev.174

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 (57) hide show
  1. package/dist/{Datafair.client-CES67S3v.js → Datafair.client-BU2gspr8.js} +9 -9
  2. package/dist/ImagePreview.client-BU5QNqG_.js +36 -0
  3. package/dist/{JsonPreview.client-DtCw5aCA.js → JsonPreview.client-CvOpMCxW.js} +2 -2
  4. package/dist/{MapContainer.client-Q3TmXpPR.js → MapContainer.client-DNOQ5XTT.js} +9 -9
  5. package/dist/{PdfPreview.client-BXJzS-Hm.js → PdfPreview.client-DwAvLLq_.js} +438 -427
  6. package/dist/{Pmtiles.client-DNiYcHH3.js → Pmtiles.client-CgaDrCHk.js} +1341 -1337
  7. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-CrWEJnPl.js +60 -0
  8. package/dist/{XmlPreview.client-CPfIZl9q.js → XmlPreview.client-B24FIhU7.js} +3 -3
  9. package/dist/components-next.css +1 -1
  10. package/dist/components-next.js +144 -138
  11. package/dist/components.css +1 -1
  12. package/dist/{index-CXeG6J6C.js → index-Dw1lR0oe.js} +1 -1
  13. package/dist/{leaflet-src-BwEce6uO.js → leaflet-src-BoMqG6Yx.js} +1 -1
  14. package/dist/{main-D7EaQcIR.js → main-I9G-L-lg.js} +16280 -15511
  15. package/dist/{vue3-xml-viewer.common-DznVepCg.js → vue3-xml-viewer.common-PTUJZpH4.js} +1 -1
  16. package/package.json +1 -1
  17. package/src/components/AppLink.vue +3 -0
  18. package/src/components/CopyButton.vue +6 -2
  19. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  20. package/src/components/ResourceAccordion/ImagePreview.client.vue +3 -3
  21. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  22. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  23. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  24. package/src/components/ResourceAccordion/Pmtiles.client.vue +25 -12
  25. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  26. package/src/components/ResourceAccordion/ResourceAccordion.vue +4 -1
  27. package/src/components/ResourceExplorer/ResourceExplorer.vue +98 -209
  28. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  29. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  30. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +123 -187
  31. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  32. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  33. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  34. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  35. package/src/components/ResourceListItem.vue +154 -0
  36. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  37. package/src/components/TabularExplorer/TabularCell.vue +2 -4
  38. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  39. package/src/components/TabularExplorer/TabularExplorer.vue +68 -995
  40. package/src/components/TabularExplorer/TabularFilterContent.vue +2 -10
  41. package/src/components/TabularExplorer/TabularFilterPopover.vue +5 -6
  42. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  43. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  44. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  45. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  46. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  47. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  48. package/src/components/TabularExplorer/types.ts +0 -5
  49. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  50. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  51. package/src/composables/useDatasetResources.ts +164 -0
  52. package/src/composables/useResizable.ts +46 -0
  53. package/src/composables/useResourceCapabilities.ts +20 -7
  54. package/src/functions/resources.ts +19 -0
  55. package/src/main.ts +8 -0
  56. package/dist/ImagePreview.client-c_SJXLpr.js +0 -36
  57. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B3ByRwHK.js +0 -58
@@ -1,102 +1,23 @@
1
1
  <template>
2
- <div class="border border-gray-default">
3
- <header class="p-4 flex flex-wrap md:flex-nowrap gap-4 items-center justify-between">
4
- <div>
5
- <div class="flex items-center gap-1 mb-1">
6
- <h3 class="m-0 flex items-baseline text-base font-bold leading-tight">
7
- <ResourceIcon
8
- :resource
9
- class="size-3.5 mr-1 shrink-0 translate-y-px"
10
- />
11
- <span class="line-clamp-2">{{ resource.title || t('Fichier sans nom') }}</span>
12
- </h3>
13
- <ResourceSelector
14
- v-if="resources && resources.length > 1"
15
- :resources
16
- :selected-id="resource.id"
17
- @select="emit('select', $event)"
18
- />
19
- <CopyButton
20
- :label="t('Copier le lien')"
21
- :copied-label="t('Lien copié !')"
22
- :text="resourceExternalUrl"
23
- class="hidden md:inline-flex"
24
- />
25
- </div>
26
- <div class="text-gray-medium text-xs flex items-center gap-1 flex-wrap">
27
- <SchemaBadge :resource />
28
- <RiSubtractLine
29
- v-if="resource.schema?.name || resource.schema?.url"
30
- aria-hidden="true"
31
- class="size-3 fill-gray-medium"
32
- />
33
- <span>{{ t('mis à jour {date}', { date: formatRelativeIfRecentDate(resource.last_modified) }) }}</span>
34
- <RiSubtractLine
35
- aria-hidden="true"
36
- class="size-3 fill-gray-medium"
37
- />
38
- <template v-if="resource.format">
39
- <span>
40
- {{ resource.format.trim().toLowerCase() }}
41
- <span v-if="resourceFilesize">({{ filesize(resourceFilesize) }})</span>
42
- </span>
43
- <RiSubtractLine
44
- aria-hidden="true"
45
- class="size-3 fill-gray-medium"
46
- />
47
- </template>
48
- <span class="inline-flex items-center">
49
- <RiDownloadLine class="size-3 mr-0.5" />
50
- {{ summarize(resource.metrics.views) }}
51
- </span>
52
- </div>
53
- </div>
54
- <div class="flex items-center gap-2">
55
- <BrandedButton
56
- v-if="isResourceUrl"
57
- :href="resource.latest"
58
- :title="t('Lien du fichier - ouvre une nouvelle fenêtre')"
59
- rel="ugc nofollow noopener"
60
- new-tab
61
- size="xs"
62
- external
63
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
64
- >
65
- {{ t('Visiter') }}
66
- </BrandedButton>
67
- <BrandedButton
68
- v-else-if="ogcService"
69
- :icon="RiFileCopyLine"
70
- color="primary"
71
- size="xs"
72
- @click="copyResourceUrl"
73
- >
74
- {{ t('Copier le lien') }}
75
- </BrandedButton>
76
- <BrandedButton
77
- v-else
78
- :href="resource.latest"
79
- rel="ugc nofollow noopener"
80
- :title="downloadButtonTitle"
81
- download
82
- class="matomo_download"
83
- :icon="unavailable ? RiFileWarningLine : RiDownloadLine"
84
- size="xs"
85
- color="primary"
86
- external
87
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
88
- >
89
- {{ t('Télécharger') }}
90
- </BrandedButton>
91
- </div>
92
- </header>
2
+ <div :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
3
+ <ResourceViewerHeader
4
+ :dataset
5
+ :resource
6
+ :resources
7
+ :resource-to
8
+ :explore-to="exploreTo"
9
+ :replace
10
+ :fullscreen
11
+ />
93
12
 
94
- <section class="pb-4">
13
+ <section :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
95
14
  <TabGroup
96
15
  size="sm"
16
+ :default-index="defaultTabIndex"
17
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
97
18
  @change="switchTab"
98
19
  >
99
- <div class="pl-4 pr-4 pb-4">
20
+ <div class="flex shrink-0 items-center border-b border-gray-default p-2">
100
21
  <TabList class="max-w-full overflow-x-auto">
101
22
  <Tab
102
23
  v-for="tab in tabsOptions"
@@ -106,13 +27,16 @@
106
27
  </Tab>
107
28
  </TabList>
108
29
  </div>
109
- <TabPanels>
30
+ <TabPanels :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
110
31
  <TabPanel
111
32
  v-for="tab in tabsOptions"
112
33
  :key="tab.key"
113
- class="px-4"
34
+ :class="[tab.key === 'data' || tab.key === 'map' ? '' : 'p-4', fullscreen ? 'flex min-h-0 flex-1 flex-col' : '']"
114
35
  >
115
- <div v-if="tab.key === 'map'">
36
+ <div
37
+ v-if="tab.key === 'map'"
38
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : 'h-[600px]'"
39
+ >
116
40
  <Pmtiles
117
41
  v-if="hasPmtiles"
118
42
  :resource="resource"
@@ -128,50 +52,75 @@
128
52
  <span class="text-gray-medium text-xs">{{ pmtilesError }}</span>
129
53
  </PreviewUnavailable>
130
54
  </div>
131
- <div v-if="tab.key === 'data'">
132
- <JsonPreview
133
- v-if="resource.format && resource.format.toLowerCase() === 'json'"
134
- :resource="resource"
135
- />
55
+ <div
56
+ v-if="tab.key === 'data'"
57
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
58
+ >
59
+ <!-- Wrapped in Suspense so switching to this tab (or loading its data) shows
60
+ the table skeleton instead of a blank gap while TabularExplorer resolves. -->
61
+ <Suspense
62
+ v-if="previewKind === 'tabular'"
63
+ :timeout="200"
64
+ >
65
+ <TabularExplorer :resource-id="resource.id">
66
+ <TabularToolbar class="shrink-0 border-b border-gray-default p-2" />
67
+ <TabularTable :fill="fullscreen" />
68
+ <TabularMobileFilters />
69
+ </TabularExplorer>
70
+ <template #fallback>
71
+ <TabularSkeleton :fill="fullscreen" />
72
+ </template>
73
+ </Suspense>
74
+
75
+ <!-- PDF is a full-bleed visual preview like the table and the map: it
76
+ owns its own reader backdrop, so it sits outside the padded wrapper. -->
136
77
  <PdfPreview
137
- v-else-if="resource.format && resource.format.toLowerCase() === 'pdf'"
138
- :resource="resource"
139
- />
140
- <XmlPreview
141
- v-else-if="resource.format && resource.format.toLowerCase() === 'xml'"
78
+ v-else-if="previewKind === 'pdf'"
142
79
  :resource="resource"
80
+ :fill="fullscreen"
143
81
  />
144
- <ImagePreview
145
- v-else-if="isImagePreviewFormat(resource.format)"
146
- :resource="resource"
147
- />
148
- <DatafairPreview
149
- v-else-if="hasDatafairPreview"
150
- :resource="resource"
151
- :dataset="dataset"
152
- />
153
- <OpenApiViewer
154
- v-else-if="hasOpenAPIPreview"
155
- :url="resource.extras['apidocUrl'] as string"
156
- />
157
- <TabularExplorer
158
- v-else-if="hasTabularData"
159
- :resource-id="resource.id"
160
- />
161
- <PreviewUnavailable v-else>
162
- <!-- "File too large to download" is the only analysis:error value from hydra for now -->
163
- <template v-if="resource.extras['analysis:error'] === 'File too large to download'">
164
- {{ t("Ce fichier est trop volumineux pour être analysé et prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.") }}
165
- </template>
166
- <template v-else-if="resource.extras['analysis:parsing:error']">
167
- {{ t("L'analyse de ce fichier a rencontré une erreur, l'aperçu n'est pas disponible. Téléchargez-le depuis l'onglet Téléchargements.") }}
168
- <br>
169
- <span class="text-gray-medium text-xs">{{ resource.extras['analysis:parsing:error'] }}</span>
170
- </template>
171
- <template v-else>
172
- {{ t("Ce fichier ne peut pas être prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.") }}
173
- </template>
174
- </PreviewUnavailable>
82
+
83
+ <!-- Text previews stay padded inside the tab panel -->
84
+ <div
85
+ v-else
86
+ class="p-4"
87
+ >
88
+ <JsonPreview
89
+ v-if="previewKind === 'json'"
90
+ :resource="resource"
91
+ />
92
+ <XmlPreview
93
+ v-else-if="previewKind === 'xml'"
94
+ :resource="resource"
95
+ />
96
+ <ImagePreview
97
+ v-else-if="previewKind === 'image'"
98
+ :resource="resource"
99
+ />
100
+ <DatafairPreview
101
+ v-else-if="previewKind === 'datafair'"
102
+ :resource="resource"
103
+ :dataset="dataset"
104
+ />
105
+ <OpenApiViewer
106
+ v-else-if="previewKind === 'openapi'"
107
+ :url="resource.extras['apidocUrl'] as string"
108
+ />
109
+ <PreviewUnavailable v-else>
110
+ <!-- "File too large to download" is the only analysis:error value from hydra for now -->
111
+ <template v-if="resource.extras['analysis:error'] === 'File too large to download'">
112
+ {{ t("Ce fichier est trop volumineux pour être analysé et prévisualisé. Téléchargez-le avec le bouton Télécharger.") }}
113
+ </template>
114
+ <template v-else-if="resource.extras['analysis:parsing:error']">
115
+ {{ t("L'analyse de ce fichier a rencontré une erreur, l'aperçu n'est pas disponible. Téléchargez-le avec le bouton Télécharger.") }}
116
+ <br>
117
+ <span class="text-gray-medium text-xs">{{ resource.extras['analysis:parsing:error'] }}</span>
118
+ </template>
119
+ <template v-else>
120
+ {{ t("Ce fichier ne peut pas être prévisualisé. Téléchargez-le avec le bouton Télécharger.") }}
121
+ </template>
122
+ </PreviewUnavailable>
123
+ </div>
175
124
  </div>
176
125
  <div v-if="tab.key === 'description'">
177
126
  <MarkdownViewer
@@ -188,12 +137,6 @@
188
137
  <div v-if="tab.key === 'metadata'">
189
138
  <Metadata :resource />
190
139
  </div>
191
- <div v-if="tab.key === 'downloads'">
192
- <Downloads
193
- :resource="resource"
194
- :dataset="dataset"
195
- />
196
- </div>
197
140
  <div v-if="tab.key === 'api'">
198
141
  <div class="fr-mb-4w">
199
142
  <p>{{ t("Cette API est générée automatiquement par {platform} à partir du fichier.", { platform: config.name }) }}</p>
@@ -218,13 +161,9 @@
218
161
 
219
162
  <script setup lang="ts">
220
163
  import { computed, defineAsyncComponent } from 'vue'
221
- import { RiDownloadLine, RiFileCopyLine, RiFileWarningLine, RiSubtractLine } from '@remixicon/vue'
164
+ import { useRoute, useRouter } from 'vue-router'
222
165
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
223
- import { toast } from 'vue-sonner'
224
- import BrandedButton from '../BrandedButton.vue'
225
- import CopyButton from '../CopyButton.vue'
226
166
  import MarkdownViewer from '../MarkdownViewer.vue'
227
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
228
167
  import OpenApiViewer from '../OpenApiViewer/OpenApiViewer.vue'
229
168
  import TabGroup from '../Tabs/TabGroup.vue'
230
169
  import TabList from '../Tabs/TabList.vue'
@@ -232,19 +171,19 @@ import Tab from '../Tabs/Tab.vue'
232
171
  import TabPanels from '../Tabs/TabPanels.vue'
233
172
  import TabPanel from '../Tabs/TabPanel.vue'
234
173
  import TabularExplorer from '../TabularExplorer/TabularExplorer.vue'
174
+ import TabularToolbar from '../TabularExplorer/TabularToolbar.vue'
175
+ import TabularTable from '../TabularExplorer/TabularTable.vue'
176
+ import TabularMobileFilters from '../TabularExplorer/TabularMobileFilters.vue'
177
+ import TabularSkeleton from '../TabularExplorer/TabularSkeleton.vue'
235
178
  import DataStructure from '../ResourceAccordion/DataStructure.vue'
236
- import Downloads from '../ResourceAccordion/Downloads.vue'
237
179
  import Metadata from '../ResourceAccordion/Metadata.vue'
238
- import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
239
- import ResourceSelector from './ResourceSelector.vue'
240
- import { filesize, summarize } from '../../functions/helpers'
241
- import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize, isImagePreviewFormat } from '../../functions/resources'
180
+ import ResourceViewerHeader from './ResourceViewerHeader.vue'
242
181
  import { trackEvent } from '../../functions/matomo'
243
182
  import { useComponentsConfig } from '../../config'
244
- import { useFormatDate } from '../../functions/dates'
245
183
  import { useTranslation } from '../../composables/useTranslation'
246
184
  import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
247
185
  import { provideTabularProfile } from '../../composables/useTabularProfile'
186
+ import type { RouteLocationRaw } from 'vue-router'
248
187
  import type { Resource } from '../../types/resources'
249
188
  import type { Dataset, DatasetV2 } from '../../types/datasets'
250
189
 
@@ -270,62 +209,59 @@ const Pmtiles = defineAsyncComponent(() =>
270
209
  import('../ResourceAccordion/Pmtiles.client.vue'),
271
210
  )
272
211
 
273
- const props = defineProps<{
212
+ const props = withDefaults(defineProps<{
274
213
  dataset: Dataset | DatasetV2
275
214
  resource: Resource
276
215
  resources?: Resource[]
277
- }>()
278
-
279
- const emit = defineEmits<{
280
- select: [resource: Resource]
281
- }>()
216
+ resourceTo: (resource: Resource) => RouteLocationRaw
217
+ // When provided (inline mode), shows an "Explorer" button next to the download
218
+ // action that opens the fullscreen explorer on the current resource.
219
+ exploreTo?: (resource: Resource) => string
220
+ replace?: boolean
221
+ // Fullscreen mode: make the viewer a flex column so the table fills down to the
222
+ // bottom, and hide the inline download/visit/copy actions — they're shown in the
223
+ // dataset context bar above. Inline mode (dataset page) shows them in the header.
224
+ fullscreen?: boolean
225
+ }>(), {
226
+ fullscreen: false,
227
+ })
282
228
 
283
229
  const { t } = useTranslation()
284
230
  const config = useComponentsConfig()
285
- const { formatRelativeIfRecentDate } = useFormatDate()
231
+ const route = useRoute()
232
+ const router = useRouter()
286
233
 
287
234
  const {
235
+ previewKind,
288
236
  hasTabularData,
289
237
  hasPmtiles,
290
238
  hasPmtilesError,
291
239
  pmtilesError,
292
- hasDatafairPreview,
293
- hasOpenAPIPreview,
294
- ogcService,
295
240
  ogcWms,
296
- isResourceUrl,
297
241
  tabsOptions,
298
242
  } = useResourceCapabilities(() => props.resource, () => props.dataset)
299
243
 
300
244
  // Share the tabular profile fetch between TabularExplorer and DataStructure tabs.
301
- await provideTabularProfile(() => props.resource.id)
302
-
303
- const resourceFilesize = computed(() => getResourceFilesize(props.resource))
304
- const resourceExternalUrl = computed(() => getResourceExternalUrl(props.dataset, props.resource))
245
+ // Only tabular resources have one: asking for the profile of a PDF or an image is a
246
+ // request to the Tabular API that can only fail.
247
+ if (hasTabularData.value) {
248
+ await provideTabularProfile(() => props.resource.id)
249
+ }
305
250
 
306
- const format = computed(() => getResourceFormatIcon(props.resource.format) ? props.resource.format : 'Fichier')
307
- const availabilityChecked = computed(() => props.resource.extras && 'check:available' in props.resource.extras)
308
- const unavailable = computed(() => availabilityChecked.value && props.resource.extras['check:available'] === false)
309
- const downloadButtonTitle = computed(() => {
310
- if (unavailable.value) {
311
- return t('Le robot de {platform} n\'a pas pu accéder à ce fichier - Télécharger le fichier en {format}', { platform: config.name, format: format.value })
312
- }
313
- return t('Télécharger le fichier en {format}', { format: format.value })
251
+ // The active tab lives in the URL so a shared link opens on the same one. Read once
252
+ // at mount (TabGroup only takes an initial index), which is enough: switching resource
253
+ // remounts the viewer, and the tab is preserved when the new resource also has it.
254
+ const defaultTabIndex = computed(() => {
255
+ const index = tabsOptions.value.findIndex(option => option.key === route.query.tab)
256
+ return index === -1 ? 0 : index
314
257
  })
315
258
 
316
- const copyResourceUrl = async () => {
317
- try {
318
- await navigator.clipboard.writeText(props.resource.url)
319
- toast.success(t('Lien copié !'))
320
- }
321
- catch {
322
- toast.error(t('Impossible de copier dans le presse-papier'))
323
- }
324
- }
325
-
326
259
  const switchTab = (index: number) => {
327
260
  const option = tabsOptions.value[index]
328
261
  if (!option) return
262
+ // The first tab is the default: drop `tab` rather than writing it in the URL.
263
+ const { tab: _, ...query } = route.query
264
+ router.replace({ query: index === 0 ? query : { ...query, tab: option.key } })
329
265
  trackEvent('View resource tab', props.resource.id, option.label)
330
266
  if (option.key === 'data') {
331
267
  trackEvent('Show preview', props.resource.id)
@@ -0,0 +1,240 @@
1
+ <template>
2
+ <!-- A resource that is only a link is visited, not downloaded. -->
3
+ <BrandedButton
4
+ v-if="isResourceUrl"
5
+ :href="resource.latest"
6
+ :title="t('Lien du fichier - ouvre une nouvelle fenêtre')"
7
+ rel="ugc nofollow noopener"
8
+ new-tab
9
+ size="xs"
10
+ external
11
+ @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
12
+ >
13
+ {{ t('Visiter') }}
14
+ </BrandedButton>
15
+
16
+ <!-- An OGC endpoint with nothing to export: its URL is the only useful thing to
17
+ hand over, to paste into a GIS. -->
18
+ <BrandedButton
19
+ v-else-if="!groups.length"
20
+ :icon="RiFileCopyLine"
21
+ color="primary"
22
+ size="xs"
23
+ @click="copyServiceUrl"
24
+ >
25
+ {{ t('Copier le lien') }}
26
+ </BrandedButton>
27
+
28
+ <!-- A single downloadable format needs no menu: a plain download button links
29
+ straight to it, matching the previous behaviour. -->
30
+ <BrandedButton
31
+ v-else-if="singleDownload"
32
+ :href="singleDownload.url"
33
+ :icon="RiDownloadLine"
34
+ rel="ugc nofollow noopener"
35
+ download
36
+ external
37
+ class="matomo_download"
38
+ size="xs"
39
+ color="primary"
40
+ @click="trackDownload(singleDownload.format)"
41
+ >
42
+ {{ t('Télécharger') }}
43
+ </BrandedButton>
44
+
45
+ <Popover
46
+ v-else
47
+ ref="anchor"
48
+ class="relative"
49
+ >
50
+ <PopoverButton
51
+ :as="BrandedButton"
52
+ :icon="RiDownloadLine"
53
+ size="xs"
54
+ color="primary"
55
+ aria-haspopup="menu"
56
+ >
57
+ {{ t('Télécharger') }}
58
+ </PopoverButton>
59
+
60
+ <ClientOnly>
61
+ <Teleport to="#tooltips">
62
+ <PopoverPanel
63
+ v-slot="{ close }"
64
+ ref="panel"
65
+ class="absolute z-[800] w-72 overflow-hidden rounded border border-gray-default bg-white shadow-lg"
66
+ :style="floatingStyles"
67
+ >
68
+ <div
69
+ v-for="group in groups"
70
+ :key="group.title"
71
+ >
72
+ <div class="border-b border-gray-default bg-gray-some px-2 py-1.5 text-[12px] font-bold uppercase leading-tight text-gray-title">
73
+ {{ group.title }}
74
+ </div>
75
+ <div
76
+ v-for="item in group.items"
77
+ :key="item.url"
78
+ class="group/dl flex items-center border-b border-gray-default last:border-b-0 hover:bg-gray-some"
79
+ >
80
+ <a
81
+ :href="item.url"
82
+ class="matomo_download flex min-w-0 flex-1 items-center gap-2 py-2 pl-2 pr-1 text-[14px] leading-tight text-gray-title !bg-none !no-underline"
83
+ rel="ugc nofollow noopener"
84
+ download
85
+ @click="trackDownload(item.format); close()"
86
+ >
87
+ <span class="min-w-0 flex-1 truncate">{{ t('Format {format}', { format: item.format }) }}</span>
88
+ <span
89
+ v-if="item.size"
90
+ class="shrink-0 rounded bg-gray-lower px-1 text-[13px] tabular-nums text-gray-medium"
91
+ >{{ filesize(item.size) }}</span>
92
+ </a>
93
+ <CopyButton
94
+ :label="t('Copier le lien')"
95
+ :copied-label="t('Lien copié !')"
96
+ :text="item.url"
97
+ icon-only
98
+ class="mr-1.5 shrink-0 opacity-0 transition-opacity focus-visible:opacity-100 group-hover/dl:opacity-100"
99
+ />
100
+ </div>
101
+ </div>
102
+
103
+ <!-- The exports above are files; the service itself is consumed live by a GIS,
104
+ so its URL stays reachable next to them. -->
105
+ <div v-if="ogcService">
106
+ <div class="border-y border-gray-default bg-gray-some px-2 py-1.5 text-[12px] font-bold uppercase leading-tight text-gray-title">
107
+ {{ t('Service') }}
108
+ </div>
109
+ <button
110
+ type="button"
111
+ class="flex w-full items-center gap-2 px-2 py-2 text-left text-[14px] leading-tight text-gray-title hover:bg-gray-some"
112
+ @click="copyServiceUrl(); close()"
113
+ >
114
+ <RiFileCopyLine
115
+ class="size-4 shrink-0 text-gray-medium"
116
+ aria-hidden="true"
117
+ />
118
+ {{ t('Copier le lien du service') }}
119
+ </button>
120
+ </div>
121
+ </PopoverPanel>
122
+ </Teleport>
123
+ </ClientOnly>
124
+ </Popover>
125
+ </template>
126
+
127
+ <script setup lang="ts">
128
+ import { computed, useTemplateRef } from 'vue'
129
+ import { autoUpdate, flip, shift, useFloating } from '@floating-ui/vue'
130
+ import { Popover, PopoverButton, PopoverPanel } from '@headlessui/vue'
131
+ import { RiDownloadLine, RiFileCopyLine } from '@remixicon/vue'
132
+ import { toast } from 'vue-sonner'
133
+ import BrandedButton from '../BrandedButton.vue'
134
+ import CopyButton from '../CopyButton.vue'
135
+ import ClientOnly from '../ClientOnly.vue'
136
+ import { filesize } from '../../functions/helpers'
137
+ import { getResourceFilesize } from '../../functions/resources'
138
+ import { trackEvent } from '../../functions/matomo'
139
+ import { useTranslation } from '../../composables/useTranslation'
140
+ import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
141
+ import type { Resource } from '../../types/resources'
142
+ import type { Dataset, DatasetV2 } from '../../types/datasets'
143
+
144
+ type DownloadItem = { url: string, format: string, size?: number }
145
+ type DownloadGroup = { title: string, items: DownloadItem[] }
146
+
147
+ // The one action a resource offers, whatever its kind: the two explorer headers mount
148
+ // this so the triage lives in a single place instead of drifting between them.
149
+ const props = defineProps<{
150
+ resource: Resource
151
+ dataset: Dataset | DatasetV2
152
+ }>()
153
+
154
+ const { t } = useTranslation()
155
+
156
+ const {
157
+ generatedFormats,
158
+ wfsFormats,
159
+ defaultWfsProjection,
160
+ isResourceUrl,
161
+ ogcService,
162
+ } = useResourceCapabilities(() => props.resource, () => props.dataset)
163
+
164
+ const groups = computed<DownloadGroup[]>(() => {
165
+ const result: DownloadGroup[] = []
166
+
167
+ // An OGC endpoint is not a file: `latest` points at its GetCapabilities, which is
168
+ // nothing a visitor wants to download. Only its exports below are.
169
+ if (!ogcService.value) {
170
+ result.push({
171
+ title: t('Format original'),
172
+ items: [{
173
+ url: props.resource.latest,
174
+ format: props.resource.format,
175
+ size: getResourceFilesize(props.resource) ?? undefined,
176
+ }],
177
+ })
178
+ }
179
+
180
+ if (generatedFormats.value.length) {
181
+ result.push({
182
+ title: t('Formats générés automatiquement'),
183
+ items: generatedFormats.value.map(format => ({
184
+ url: format.url,
185
+ format: format.format,
186
+ size: format.size,
187
+ })),
188
+ })
189
+ }
190
+
191
+ if (wfsFormats.value.length) {
192
+ const projection = defaultWfsProjection.value
193
+ result.push({
194
+ title: projection
195
+ ? t('Formats exportés depuis le service WFS (projection {crs})', { crs: projection })
196
+ : t('Formats exportés depuis le service WFS'),
197
+ items: wfsFormats.value.map(format => ({
198
+ url: format.url,
199
+ format: format.format,
200
+ })),
201
+ })
202
+ }
203
+
204
+ return result
205
+ })
206
+
207
+ // A single downloadable format needs no menu: expose it directly for the plain button.
208
+ // Never for an OGC service, whose menu also carries the link to the service itself.
209
+ const singleDownload = computed<DownloadItem | null>(() => {
210
+ if (ogcService.value) return null
211
+ if (groups.value.length !== 1) return null
212
+ const items = groups.value[0]!.items
213
+ return items.length === 1 ? items[0]! : null
214
+ })
215
+
216
+ const trackDownload = (format: string) => {
217
+ trackEvent('Jeux de données', 'Télécharger un fichier', `Bouton : format ${format}`)
218
+ }
219
+
220
+ const copyServiceUrl = async () => {
221
+ try {
222
+ await navigator.clipboard.writeText(props.resource.url)
223
+ toast.success(t('Lien copié !'))
224
+ }
225
+ catch {
226
+ toast.error(t('Impossible de copier dans le presse-papier'))
227
+ }
228
+ }
229
+
230
+ // The panel is teleported, so it is positioned against its trigger with floating-ui.
231
+ const anchor = useTemplateRef<InstanceType<typeof Popover>>('anchor')
232
+ const panel = useTemplateRef<InstanceType<typeof PopoverPanel>>('panel')
233
+ const anchorEl = computed(() => anchor.value?.$el as HTMLElement | undefined)
234
+ const panelEl = computed(() => panel.value?.$el as HTMLElement | undefined)
235
+ const { floatingStyles } = useFloating(anchorEl, panelEl, {
236
+ placement: 'bottom-end',
237
+ middleware: [flip(), shift({ padding: 8 })],
238
+ whileElementsMounted: autoUpdate,
239
+ })
240
+ </script>