@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
@@ -1,102 +1,24 @@
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
+ :resource-external-url="resourceExternalUrl"
10
+ :replace
11
+ :fullscreen
12
+ />
93
13
 
94
- <section class="pb-4">
14
+ <section :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
95
15
  <TabGroup
96
16
  size="sm"
17
+ :default-index="defaultTabIndex"
18
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
97
19
  @change="switchTab"
98
20
  >
99
- <div class="pl-4 pr-4 pb-4">
21
+ <div class="flex shrink-0 items-center border-b border-gray-default p-2">
100
22
  <TabList class="max-w-full overflow-x-auto">
101
23
  <Tab
102
24
  v-for="tab in tabsOptions"
@@ -106,13 +28,16 @@
106
28
  </Tab>
107
29
  </TabList>
108
30
  </div>
109
- <TabPanels>
31
+ <TabPanels :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
110
32
  <TabPanel
111
33
  v-for="tab in tabsOptions"
112
34
  :key="tab.key"
113
- class="px-4"
35
+ :class="[tab.key === 'data' || tab.key === 'map' ? '' : 'p-4', fullscreen ? 'flex min-h-0 flex-1 flex-col' : '']"
114
36
  >
115
- <div v-if="tab.key === 'map'">
37
+ <div
38
+ v-if="tab.key === 'map'"
39
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : 'h-[600px]'"
40
+ >
116
41
  <Pmtiles
117
42
  v-if="hasPmtiles"
118
43
  :resource="resource"
@@ -122,47 +47,81 @@
122
47
  v-if="ogcWms"
123
48
  :resource="resource"
124
49
  />
50
+ <PreviewUnavailable v-if="!hasPmtiles && !ogcWms && hasPmtilesError">
51
+ {{ t("La carte n'a pas pu être générée automatiquement pour ce fichier.") }}
52
+ <br>
53
+ <span class="text-gray-medium text-xs">{{ pmtilesError }}</span>
54
+ </PreviewUnavailable>
125
55
  </div>
126
- <div v-if="tab.key === 'data'">
127
- <JsonPreview
128
- v-if="resource.format && resource.format.toLowerCase() === 'json'"
129
- :resource="resource"
130
- />
56
+ <div
57
+ v-if="tab.key === 'data'"
58
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
59
+ >
60
+ <!-- Wrapped in Suspense so switching to this tab (or loading its data) shows
61
+ the table skeleton instead of a blank gap while TabularExplorer resolves. -->
62
+ <Suspense
63
+ v-if="previewKind === 'tabular'"
64
+ :timeout="200"
65
+ >
66
+ <TabularExplorer :resource-id="resource.id">
67
+ <TabularToolbar class="shrink-0 border-b border-gray-default p-2" />
68
+ <TabularTable :fill="fullscreen" />
69
+ <TabularMobileFilters />
70
+ </TabularExplorer>
71
+ <template #fallback>
72
+ <TabularSkeleton :fill="fullscreen" />
73
+ </template>
74
+ </Suspense>
75
+
76
+ <!-- PDF is a full-bleed visual preview like the table and the map: it
77
+ owns its own reader backdrop, so it sits outside the padded wrapper. -->
131
78
  <PdfPreview
132
- v-else-if="resource.format && resource.format.toLowerCase() === 'pdf'"
79
+ v-else-if="previewKind === 'pdf'"
133
80
  :resource="resource"
81
+ :fill="fullscreen"
134
82
  />
135
- <XmlPreview
136
- v-else-if="resource.format && resource.format.toLowerCase() === 'xml'"
137
- :resource="resource"
138
- />
139
- <DatafairPreview
140
- v-else-if="hasDatafairPreview"
141
- :resource="resource"
142
- :dataset="dataset"
143
- />
144
- <OpenApiViewer
145
- v-else-if="hasOpenAPIPreview"
146
- :url="resource.extras['apidocUrl'] as string"
147
- />
148
- <TabularExplorer
149
- v-else-if="hasTabularData"
150
- :resource-id="resource.id"
151
- />
152
- <PreviewUnavailable v-else>
153
- <!-- "File too large to download" is the only analysis:error value from hydra for now -->
154
- <template v-if="resource.extras['analysis:error'] === 'File too large to download'">
155
- {{ t("Ce fichier est trop volumineux pour être analysé et prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.") }}
156
- </template>
157
- <template v-else-if="resource.extras['analysis:parsing:error']">
158
- {{ 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.") }}
159
- <br>
160
- <span class="text-gray-medium text-xs">{{ resource.extras['analysis:parsing:error'] }}</span>
161
- </template>
162
- <template v-else>
163
- {{ t("Ce fichier ne peut pas être prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.") }}
164
- </template>
165
- </PreviewUnavailable>
83
+
84
+ <!-- Text previews stay padded inside the tab panel -->
85
+ <div
86
+ v-else
87
+ class="p-4"
88
+ >
89
+ <JsonPreview
90
+ v-if="previewKind === 'json'"
91
+ :resource="resource"
92
+ />
93
+ <XmlPreview
94
+ v-else-if="previewKind === 'xml'"
95
+ :resource="resource"
96
+ />
97
+ <ImagePreview
98
+ v-else-if="previewKind === 'image'"
99
+ :resource="resource"
100
+ />
101
+ <DatafairPreview
102
+ v-else-if="previewKind === 'datafair'"
103
+ :resource="resource"
104
+ :dataset="dataset"
105
+ />
106
+ <OpenApiViewer
107
+ v-else-if="previewKind === 'openapi'"
108
+ :url="resource.extras['apidocUrl'] as string"
109
+ />
110
+ <PreviewUnavailable v-else>
111
+ <!-- "File too large to download" is the only analysis:error value from hydra for now -->
112
+ <template v-if="resource.extras['analysis:error'] === 'File too large to download'">
113
+ {{ t("Ce fichier est trop volumineux pour être analysé et prévisualisé. Téléchargez-le avec le bouton Télécharger.") }}
114
+ </template>
115
+ <template v-else-if="resource.extras['analysis:parsing:error']">
116
+ {{ 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.") }}
117
+ <br>
118
+ <span class="text-gray-medium text-xs">{{ resource.extras['analysis:parsing:error'] }}</span>
119
+ </template>
120
+ <template v-else>
121
+ {{ t("Ce fichier ne peut pas être prévisualisé. Téléchargez-le avec le bouton Télécharger.") }}
122
+ </template>
123
+ </PreviewUnavailable>
124
+ </div>
166
125
  </div>
167
126
  <div v-if="tab.key === 'description'">
168
127
  <MarkdownViewer
@@ -179,18 +138,15 @@
179
138
  <div v-if="tab.key === 'metadata'">
180
139
  <Metadata :resource />
181
140
  </div>
182
- <div v-if="tab.key === 'downloads'">
183
- <Downloads
184
- :resource="resource"
185
- :dataset="dataset"
186
- />
187
- </div>
188
- <div v-if="tab.key === 'swagger'">
141
+ <div v-if="tab.key === 'api'">
189
142
  <div class="fr-mb-4w">
190
143
  <p>{{ t("Cette API est générée automatiquement par {platform} à partir du fichier.", { platform: config.name }) }}</p>
191
144
  <p>{{ t("- Si le fichier est modifié, l'API sera mise à jour et sa structure pourra changer.") }}</p>
192
145
  <p>{{ t("- Si le fichier est supprimé, l'API sera également supprimée.") }}</p>
193
146
  <p>{{ t("Pour des usages pérennes, prévoyez que cette API dépend directement du fichier source.") }}</p>
147
+ <p v-if="config.tabularApiUrl">
148
+ {{ t("L'URL de base de l'API est {url}", { url: config.tabularApiUrl }) }}
149
+ </p>
194
150
  </div>
195
151
  <OpenApiViewer
196
152
  v-if="hasTabularData"
@@ -206,13 +162,9 @@
206
162
 
207
163
  <script setup lang="ts">
208
164
  import { computed, defineAsyncComponent } from 'vue'
209
- import { RiDownloadLine, RiFileCopyLine, RiFileWarningLine, RiSubtractLine } from '@remixicon/vue'
165
+ import { useRoute, useRouter } from 'vue-router'
210
166
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
211
- import { toast } from 'vue-sonner'
212
- import BrandedButton from '../BrandedButton.vue'
213
- import CopyButton from '../CopyButton.vue'
214
167
  import MarkdownViewer from '../MarkdownViewer.vue'
215
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
216
168
  import OpenApiViewer from '../OpenApiViewer/OpenApiViewer.vue'
217
169
  import TabGroup from '../Tabs/TabGroup.vue'
218
170
  import TabList from '../Tabs/TabList.vue'
@@ -220,19 +172,19 @@ import Tab from '../Tabs/Tab.vue'
220
172
  import TabPanels from '../Tabs/TabPanels.vue'
221
173
  import TabPanel from '../Tabs/TabPanel.vue'
222
174
  import TabularExplorer from '../TabularExplorer/TabularExplorer.vue'
175
+ import TabularToolbar from '../TabularExplorer/TabularToolbar.vue'
176
+ import TabularTable from '../TabularExplorer/TabularTable.vue'
177
+ import TabularMobileFilters from '../TabularExplorer/TabularMobileFilters.vue'
178
+ import TabularSkeleton from '../TabularExplorer/TabularSkeleton.vue'
223
179
  import DataStructure from '../ResourceAccordion/DataStructure.vue'
224
- import Downloads from '../ResourceAccordion/Downloads.vue'
225
180
  import Metadata from '../ResourceAccordion/Metadata.vue'
226
- import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
227
- import ResourceSelector from './ResourceSelector.vue'
228
- import { filesize, summarize } from '../../functions/helpers'
229
- import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
181
+ import ResourceViewerHeader from './ResourceViewerHeader.vue'
230
182
  import { trackEvent } from '../../functions/matomo'
231
183
  import { useComponentsConfig } from '../../config'
232
- import { useFormatDate } from '../../functions/dates'
233
184
  import { useTranslation } from '../../composables/useTranslation'
234
185
  import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
235
186
  import { provideTabularProfile } from '../../composables/useTabularProfile'
187
+ import type { RouteLocationRaw } from 'vue-router'
236
188
  import type { Resource } from '../../types/resources'
237
189
  import type { Dataset, DatasetV2 } from '../../types/datasets'
238
190
 
@@ -245,6 +197,9 @@ const PdfPreview = defineAsyncComponent(() =>
245
197
  const XmlPreview = defineAsyncComponent(() =>
246
198
  import('../ResourceAccordion/XmlPreview.client.vue'),
247
199
  )
200
+ const ImagePreview = defineAsyncComponent(() =>
201
+ import('../ResourceAccordion/ImagePreview.client.vue'),
202
+ )
248
203
  const DatafairPreview = defineAsyncComponent(() =>
249
204
  import('../ResourceAccordion/Datafair.client.vue'),
250
205
  )
@@ -255,60 +210,61 @@ const Pmtiles = defineAsyncComponent(() =>
255
210
  import('../ResourceAccordion/Pmtiles.client.vue'),
256
211
  )
257
212
 
258
- const props = defineProps<{
213
+ const props = withDefaults(defineProps<{
259
214
  dataset: Dataset | DatasetV2
260
215
  resource: Resource
261
216
  resources?: Resource[]
262
- }>()
263
-
264
- const emit = defineEmits<{
265
- select: [resource: Resource]
266
- }>()
217
+ resourceTo: (resource: Resource) => RouteLocationRaw
218
+ // When provided (inline mode), shows an "Explorer" button next to the download
219
+ // action that opens the fullscreen explorer on the current resource.
220
+ exploreTo?: (resource: Resource) => string
221
+ // Overrides the "Copier le lien" target.
222
+ resourceExternalUrl?: (resource: Resource) => string
223
+ replace?: boolean
224
+ // Fullscreen mode: make the viewer a flex column so the table fills down to the
225
+ // bottom, and hide the inline download/visit/copy actions — they're shown in the
226
+ // dataset context bar above. Inline mode (dataset page) shows them in the header.
227
+ fullscreen?: boolean
228
+ }>(), {
229
+ fullscreen: false,
230
+ })
267
231
 
268
232
  const { t } = useTranslation()
269
233
  const config = useComponentsConfig()
270
- const { formatRelativeIfRecentDate } = useFormatDate()
234
+ const route = useRoute()
235
+ const router = useRouter()
271
236
 
272
237
  const {
238
+ previewKind,
273
239
  hasTabularData,
274
240
  hasPmtiles,
275
- hasDatafairPreview,
276
- hasOpenAPIPreview,
277
- ogcService,
241
+ hasPmtilesError,
242
+ pmtilesError,
278
243
  ogcWms,
279
- isResourceUrl,
280
244
  tabsOptions,
281
245
  } = useResourceCapabilities(() => props.resource, () => props.dataset)
282
246
 
283
247
  // Share the tabular profile fetch between TabularExplorer and DataStructure tabs.
284
- await provideTabularProfile(() => props.resource.id)
285
-
286
- const resourceFilesize = computed(() => getResourceFilesize(props.resource))
287
- const resourceExternalUrl = computed(() => getResourceExternalUrl(props.dataset, props.resource))
248
+ // Only tabular resources have one: asking for the profile of a PDF or an image is a
249
+ // request to the Tabular API that can only fail.
250
+ if (hasTabularData.value) {
251
+ await provideTabularProfile(() => props.resource.id)
252
+ }
288
253
 
289
- const format = computed(() => getResourceFormatIcon(props.resource.format) ? props.resource.format : 'Fichier')
290
- const availabilityChecked = computed(() => props.resource.extras && 'check:available' in props.resource.extras)
291
- const unavailable = computed(() => availabilityChecked.value && props.resource.extras['check:available'] === false)
292
- const downloadButtonTitle = computed(() => {
293
- if (unavailable.value) {
294
- 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 })
295
- }
296
- return t('Télécharger le fichier en {format}', { format: format.value })
254
+ // The active tab lives in the URL so a shared link opens on the same one. Read once
255
+ // at mount (TabGroup only takes an initial index), which is enough: switching resource
256
+ // remounts the viewer, and the tab is preserved when the new resource also has it.
257
+ const defaultTabIndex = computed(() => {
258
+ const index = tabsOptions.value.findIndex(option => option.key === route.query.tab)
259
+ return index === -1 ? 0 : index
297
260
  })
298
261
 
299
- const copyResourceUrl = async () => {
300
- try {
301
- await navigator.clipboard.writeText(props.resource.url)
302
- toast.success(t('Lien copié !'))
303
- }
304
- catch {
305
- toast.error(t('Impossible de copier dans le presse-papier'))
306
- }
307
- }
308
-
309
262
  const switchTab = (index: number) => {
310
263
  const option = tabsOptions.value[index]
311
264
  if (!option) return
265
+ // The first tab is the default: drop `tab` rather than writing it in the URL.
266
+ const { tab: _, ...query } = route.query
267
+ router.replace({ query: index === 0 ? query : { ...query, tab: option.key } })
312
268
  trackEvent('View resource tab', props.resource.id, option.label)
313
269
  if (option.key === 'data') {
314
270
  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>