@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.
- package/dist/{Datafair.client-CES67S3v.js → Datafair.client-BU2gspr8.js} +9 -9
- package/dist/ImagePreview.client-BU5QNqG_.js +36 -0
- package/dist/{JsonPreview.client-DtCw5aCA.js → JsonPreview.client-CvOpMCxW.js} +2 -2
- package/dist/{MapContainer.client-Q3TmXpPR.js → MapContainer.client-DNOQ5XTT.js} +9 -9
- package/dist/{PdfPreview.client-BXJzS-Hm.js → PdfPreview.client-DwAvLLq_.js} +438 -427
- package/dist/{Pmtiles.client-DNiYcHH3.js → Pmtiles.client-CgaDrCHk.js} +1341 -1337
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-CrWEJnPl.js +60 -0
- package/dist/{XmlPreview.client-CPfIZl9q.js → XmlPreview.client-B24FIhU7.js} +3 -3
- package/dist/components-next.css +1 -1
- package/dist/components-next.js +144 -138
- package/dist/components.css +1 -1
- package/dist/{index-CXeG6J6C.js → index-Dw1lR0oe.js} +1 -1
- package/dist/{leaflet-src-BwEce6uO.js → leaflet-src-BoMqG6Yx.js} +1 -1
- package/dist/{main-D7EaQcIR.js → main-I9G-L-lg.js} +16280 -15511
- package/dist/{vue3-xml-viewer.common-DznVepCg.js → vue3-xml-viewer.common-PTUJZpH4.js} +1 -1
- package/package.json +1 -1
- package/src/components/AppLink.vue +3 -0
- package/src/components/CopyButton.vue +6 -2
- package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
- package/src/components/ResourceAccordion/ImagePreview.client.vue +3 -3
- package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
- package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
- package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
- package/src/components/ResourceAccordion/Pmtiles.client.vue +25 -12
- package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
- package/src/components/ResourceAccordion/ResourceAccordion.vue +4 -1
- package/src/components/ResourceExplorer/ResourceExplorer.vue +98 -209
- package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
- package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
- package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +123 -187
- package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
- package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
- package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
- package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
- package/src/components/ResourceListItem.vue +154 -0
- package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
- package/src/components/TabularExplorer/TabularCell.vue +2 -4
- package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
- package/src/components/TabularExplorer/TabularExplorer.vue +68 -995
- package/src/components/TabularExplorer/TabularFilterContent.vue +2 -10
- package/src/components/TabularExplorer/TabularFilterPopover.vue +5 -6
- package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
- package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
- package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
- package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
- package/src/components/TabularExplorer/TabularTable.vue +481 -0
- package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
- package/src/components/TabularExplorer/types.ts +0 -5
- package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
- package/src/components/TabularExplorer/useTabularContext.ts +82 -0
- package/src/composables/useDatasetResources.ts +164 -0
- package/src/composables/useResizable.ts +46 -0
- package/src/composables/useResourceCapabilities.ts +20 -7
- package/src/functions/resources.ts +19 -0
- package/src/main.ts +8 -0
- package/dist/ImagePreview.client-c_SJXLpr.js +0 -36
- package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-B3ByRwHK.js +0 -58
|
@@ -1,102 +1,23 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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="
|
|
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="
|
|
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="
|
|
34
|
+
:class="[tab.key === 'data' || tab.key === 'map' ? '' : 'p-4', fullscreen ? 'flex min-h-0 flex-1 flex-col' : '']"
|
|
114
35
|
>
|
|
115
|
-
<div
|
|
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
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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="
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
<
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
280
|
-
|
|
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
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
const
|
|
310
|
-
|
|
311
|
-
|
|
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>
|