@datagouv/components-next 1.3.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 (131) hide show
  1. package/dist/{Control-b-43UT2B.js → Control-B9WQxlpJ.js} +10 -10
  2. package/dist/{Datafair.client-BrjISlaZ.js → Datafair.client-D_6yzJUG.js} +9 -9
  3. package/dist/{Event-py_AAwJY.js → Event-iXDrp-fy.js} +15 -15
  4. package/dist/{Image-BHQYZWzH.js → Image-mNMBOofs.js} +22 -22
  5. package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
  6. package/dist/{JsonPreview.client-DivFAnbN.js → JsonPreview.client-DSl1kiwC.js} +2 -2
  7. package/dist/{Map-CtP7leHl.js → Map-BfqKMw0S.js} +165 -165
  8. package/dist/{MapContainer.client-Cq8tNLZZ.js → MapContainer.client-BSfpk_rT.js} +17 -17
  9. package/dist/{OSM-gdDSIsDA.js → OSM-CcrUhItr.js} +1 -1
  10. package/dist/{PdfPreview.client-BNBgLsK2.js → PdfPreview.client-BANFeM6K.js} +438 -427
  11. package/dist/{Pmtiles.client-CQi5Rw8W.js → Pmtiles.client-DH9ZrsZR.js} +6177 -5967
  12. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
  13. package/dist/{ScaleLine-hG-FhNqy.js → ScaleLine-VGQ7nkMS.js} +2 -2
  14. package/dist/{Tile-D6rFonpT.js → Tile-CSpn0Ca8.js} +47 -47
  15. package/dist/{TileImage-cfrEgz5y.js → TileImage-CpbaEFwd.js} +44 -44
  16. package/dist/{View-vp7vDb9q.js → View-BYBpt-AU.js} +58 -58
  17. package/dist/{XmlPreview.client-XCsNvnsZ.js → XmlPreview.client-KCB5mwVf.js} +3 -3
  18. package/dist/{common-C8efyyuc.js → common-BoeTUnDZ.js} +58 -58
  19. package/dist/components-next.css +1 -1
  20. package/dist/components-next.js +199 -172
  21. package/dist/components.css +1 -1
  22. package/dist/{index-CXUamc8Z.js → index-DHWzX5vK.js} +4668 -4466
  23. package/dist/{leaflet-src-BRKv9Ksv.js → leaflet-src-pVQfonBI.js} +1 -1
  24. package/dist/{main-DKu5SOAl.js → main-BDcYkhOc.js} +41684 -33432
  25. package/dist/{proj-CsjVeojN.js → proj-DxGbobp7.js} +110 -110
  26. package/dist/{tilecoord-CPcQJnrF.js → tilecoord-DvX7WWiA.js} +94 -94
  27. package/dist/{vue3-xml-viewer.common-BIaEIFYG.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
  28. package/package.json +28 -27
  29. package/src/components/ActivityList/ActivityList.vue +21 -12
  30. package/src/components/AppLink.vue +3 -0
  31. package/src/components/BannerAction.vue +6 -2
  32. package/src/components/Chart/ChartViewer.vue +111 -17
  33. package/src/components/Chart/ChartViewerWrapper.vue +28 -16
  34. package/src/components/ChartCard.vue +112 -0
  35. package/src/components/CopyButton.vue +6 -2
  36. package/src/components/DataserviceCard.vue +23 -6
  37. package/src/components/DataserviceQuality.vue +82 -0
  38. package/src/components/DataserviceQualityInline.vue +31 -0
  39. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  40. package/src/components/DatasetCard.vue +17 -6
  41. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  42. package/src/components/DatasetQuality.vue +11 -11
  43. package/src/components/DatasetQualityInline.vue +2 -2
  44. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  45. package/src/components/DiscussionMessageCard.vue +14 -15
  46. package/src/components/Form/SchemaSelect.vue +2 -0
  47. package/src/components/FormattedDate.vue +69 -0
  48. package/src/components/LoadingBlock.vue +4 -1
  49. package/src/components/ObjectCard.vue +4 -1
  50. package/src/components/ObjectCardOwner.vue +13 -1
  51. package/src/components/Pagination.vue +18 -58
  52. package/src/components/Placeholder.vue +3 -2
  53. package/src/components/PostCard.vue +13 -15
  54. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  55. package/src/components/ResourceAccordion/Downloads.vue +19 -7
  56. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  57. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  58. package/src/components/ResourceAccordion/Metadata.vue +21 -5
  59. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  60. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  61. package/src/components/ResourceAccordion/Pmtiles.client.vue +27 -18
  62. package/src/components/ResourceAccordion/Preview.vue +8 -4
  63. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  64. package/src/components/ResourceAccordion/ResourceAccordion.vue +29 -9
  65. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -209
  66. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  67. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  68. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +136 -183
  69. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  70. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  71. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
  72. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
  73. package/src/components/ResourceListItem.vue +168 -0
  74. package/src/components/ReuseCard.vue +18 -6
  75. package/src/components/ReuseDetails.vue +2 -2
  76. package/src/components/ReuseHorizontalCard.vue +4 -3
  77. package/src/components/SchemaCard.vue +10 -1
  78. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  79. package/src/components/Search/GlobalSearch.vue +12 -8
  80. package/src/components/Search/SearchInput.vue +1 -1
  81. package/src/components/Search/Sidemenu.vue +4 -3
  82. package/src/components/SmallChart.vue +11 -10
  83. package/src/components/StatBox.vue +13 -5
  84. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  85. package/src/components/TabularExplorer/TabularCell.vue +7 -8
  86. package/src/components/TabularExplorer/TabularCellPopover.vue +19 -3
  87. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  88. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  89. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  90. package/src/components/TabularExplorer/TabularExplorer.vue +122 -913
  91. package/src/components/TabularExplorer/TabularFilterContent.vue +33 -25
  92. package/src/components/TabularExplorer/TabularFilterPopover.vue +7 -7
  93. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  94. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  95. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  96. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  97. package/src/components/TabularExplorer/TabularTable.vue +484 -0
  98. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  99. package/src/components/TabularExplorer/types.ts +32 -20
  100. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  101. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  102. package/src/components/TopicCard.vue +15 -14
  103. package/src/composables/useActiveDescendant.ts +5 -0
  104. package/src/composables/useDatasetResources.ts +164 -0
  105. package/src/composables/useHasTabularData.ts +2 -1
  106. package/src/composables/useResizable.ts +46 -0
  107. package/src/composables/useResourceCapabilities.ts +47 -39
  108. package/src/composables/useTranslation.ts +24 -9
  109. package/src/config.ts +1 -0
  110. package/src/functions/charts.ts +32 -1
  111. package/src/functions/dates.ts +26 -7
  112. package/src/functions/helpers.ts +15 -0
  113. package/src/functions/metrics.ts +57 -56
  114. package/src/functions/owned.ts +21 -0
  115. package/src/functions/paginate.ts +46 -0
  116. package/src/functions/resourceCapabilities.ts +34 -0
  117. package/src/functions/resources.ts +83 -0
  118. package/src/functions/schemas.ts +27 -0
  119. package/src/functions/scroll.ts +20 -0
  120. package/src/functions/tabular.ts +119 -4
  121. package/src/functions/tabularApi.ts +1 -43
  122. package/src/main.ts +32 -6
  123. package/src/types/dataservices.ts +15 -0
  124. package/src/types/organizations.ts +10 -0
  125. package/src/types/reuses.ts +12 -1
  126. package/src/types/search.ts +2 -2
  127. package/src/types/visualizations.ts +5 -0
  128. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-C3dDzrIr.js +0 -61
  129. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  130. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  131. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -4,6 +4,7 @@
4
4
  class="-mr-0.5 flex-initial truncate"
5
5
  >
6
6
  <AppLink
7
+ v-if="organizationUrl !== null"
7
8
  class="link text-sm overflow-hidden flex items-center relative z-[2] truncate"
8
9
  :to="organizationUrl || organization.page"
9
10
  >
@@ -12,6 +13,16 @@
12
13
  size="sm"
13
14
  />
14
15
  </AppLink>
16
+ <span
17
+ v-else
18
+ class="text-sm overflow-hidden flex items-center truncate"
19
+ >
20
+ <OrganizationNameWithCertificate
21
+ :organization
22
+ size="sm"
23
+ color-class="text-gray-title"
24
+ />
25
+ </span>
15
26
  </div>
16
27
  <div
17
28
  v-else-if="ownerName"
@@ -33,7 +44,8 @@ import OrganizationNameWithCertificate from './OrganizationNameWithCertificate.v
33
44
  const props = defineProps<{
34
45
  organization?: OrganizationReference | null
35
46
  owner?: UserReference | null
36
- organizationUrl?: RouteLocationRaw
47
+ // undefined falls back to organization.page; pass null to render plain, non-linked text
48
+ organizationUrl?: RouteLocationRaw | null
37
49
  }>()
38
50
 
39
51
  const ownerName = computed(() => {
@@ -19,7 +19,7 @@
19
19
  </li>
20
20
  <li>
21
21
  <a
22
- :href="getHref(page - 1)"
22
+ :href="getHref(currentPage - 1)"
23
23
  class="fr-pagination__link fr-pagination__link--prev fr-pagination__link--lg-label"
24
24
  data-testid="previous-page"
25
25
  @click.prevent.stop="previousPage"
@@ -29,10 +29,10 @@
29
29
  </li>
30
30
  <li>
31
31
  <a
32
- :aria-current="page === 1 ? 'page' : undefined"
32
+ :aria-current="currentPage === 1 ? 'page' : undefined"
33
33
  :href="getHref(1)"
34
34
  class="fr-pagination__link"
35
- :class="{ 'fr-hidden fr-unhidden-sm': page > 1 }"
35
+ :class="{ 'fr-hidden fr-unhidden-sm': currentPage > 1 }"
36
36
  :title="t('Page {nb}', { nb: 1 })"
37
37
  :data-testid="1"
38
38
  @click.prevent.stop="onClick(1)"
@@ -47,8 +47,8 @@
47
47
  <a
48
48
  v-if="index"
49
49
  class="fr-pagination__link"
50
- :class="{ 'fr-hidden fr-unhidden-lg': index < page - 1 || index > page + 1 }"
51
- :aria-current="page === index ? 'page' : undefined"
50
+ :class="{ 'fr-hidden fr-unhidden-lg': index < currentPage - 1 || index > currentPage + 1 }"
51
+ :aria-current="currentPage === index ? 'page' : undefined"
52
52
  :href="getHref(index)"
53
53
  :title="t('Page {nb}', { nb: index })"
54
54
  :data-testid="index"
@@ -66,7 +66,7 @@
66
66
  <li>
67
67
  <a
68
68
  class="fr-pagination__link"
69
- :aria-current="page === pageCount ? 'page' : undefined"
69
+ :aria-current="currentPage === pageCount ? 'page' : undefined"
70
70
  :href="getHref(pageCount)"
71
71
  :title="t('Page {nb}', { nb: pageCount })"
72
72
  :data-testid="pageCount"
@@ -78,7 +78,7 @@
78
78
  <li>
79
79
  <a
80
80
  class="fr-pagination__link fr-pagination__link--next fr-pagination__link--lg-label"
81
- :href="getHref(page + 1)"
81
+ :href="getHref(currentPage + 1)"
82
82
  data-testid="next-page"
83
83
  @click.prevent.stop="nextPage"
84
84
  >
@@ -103,6 +103,8 @@
103
103
  import { computed, useTemplateRef } from 'vue'
104
104
  import { useRoute } from 'vue-router'
105
105
  import { useTranslation } from '../composables/useTranslation'
106
+ import { clampPage, getVisiblePages } from '../functions/paginate'
107
+ import { scrollToBlockTop } from '../functions/scroll'
106
108
 
107
109
  type Props = {
108
110
  /**
@@ -128,78 +130,36 @@ const props = withDefaults(defineProps<Props>(), {
128
130
  pageSize: 20,
129
131
  })
130
132
 
131
- const PAGES_AROUND = 3
132
-
133
- function range(size: number, startAt = 1) {
134
- return [...Array(size).keys()].map(i => i + startAt)
135
- }
136
-
137
- function getPages(pageCount: number) {
138
- return range(pageCount)
139
- }
140
-
141
- function getPagesShown(pages: { length: number }, currentPage: number) {
142
- return Math.min(
143
- PAGES_AROUND * 2 + 1,
144
- pages.length - 2,
145
- PAGES_AROUND + currentPage - 1,
146
- PAGES_AROUND + pages.length - currentPage,
147
- )
148
- }
149
-
150
- function getStartPage(currentPage: number) {
151
- return Math.max(
152
- currentPage - PAGES_AROUND, // we want to start 3 pages before the current one
153
- 2, // we don't want to start below page 2
154
- )
155
- }
156
-
157
- function getVisiblePages(currentPage: number, pageCount: number) {
158
- const pages = getPages(pageCount)
159
- const start = getStartPage(currentPage)
160
- if (pageCount <= 2) {
161
- return []
162
- }
163
- const pagination: Array<number | null> = range(getPagesShown(pages, currentPage), start)
164
- if (!pagination.includes(2)) {
165
- pagination.unshift(null)
166
- }
167
- if (!pagination.includes(pageCount - 1)) {
168
- pagination.push(null)
169
- }
170
- return pagination
171
- }
172
-
173
133
  const { t } = useTranslation()
174
134
  const route = useRoute()
175
135
  const pageCount = computed(() => Math.ceil(props.totalResults / props.pageSize))
176
- const visiblePages = computed(() => getVisiblePages(props.page, pageCount.value))
136
+ // The `page` prop often comes straight from a query string, so it is normalised
137
+ // once here and every link below builds on an existing page.
138
+ const currentPage = computed(() => clampPage(props.page, pageCount.value))
139
+ const visiblePages = computed(() => getVisiblePages(currentPage.value, pageCount.value))
177
140
 
178
141
  const nav = useTemplateRef('navRef')
179
142
  function change(index: number) {
180
143
  emit('change', index)
181
144
 
182
- if (!nav.value || !nav.value.parentElement) return
183
-
184
- nav.value.parentElement.style.scrollMarginTop = '100px'
185
- nav.value.parentElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
145
+ scrollToBlockTop(nav.value?.parentElement)
186
146
  }
187
147
 
188
148
  function onClick(index: number) {
189
- if (index !== props.page) {
149
+ if (index !== currentPage.value) {
190
150
  change(index)
191
151
  }
192
152
  }
193
153
 
194
154
  function nextPage() {
195
- const index = props.page + 1
155
+ const index = currentPage.value + 1
196
156
  if (index <= pageCount.value) {
197
157
  change(index)
198
158
  }
199
159
  }
200
160
 
201
161
  function previousPage() {
202
- const index = props.page - 1
162
+ const index = currentPage.value - 1
203
163
  if (index > 0) {
204
164
  change(index)
205
165
  }
@@ -209,7 +169,7 @@ function getHref(forPage: number) {
209
169
  if (forPage < 1 || forPage > pageCount.value) {
210
170
  return undefined
211
171
  }
212
- if (props.page === forPage) {
172
+ if (currentPage.value === forPage) {
213
173
  return undefined
214
174
  }
215
175
  const search = new URLSearchParams(route.query as Record<string, string>)
@@ -10,10 +10,10 @@
10
10
 
11
11
  <script setup lang="ts">
12
12
  import { computed } from 'vue'
13
- import { RiBookShelfLine, RiBuilding2Line, RiDatabase2Line, RiLineChartLine, RiTerminalLine } from '@remixicon/vue'
13
+ import { RiBarChartBoxLine, RiBookShelfLine, RiBuilding2Line, RiDatabase2Line, RiLineChartLine, RiTerminalLine } from '@remixicon/vue'
14
14
 
15
15
  const props = defineProps<{
16
- type: 'Dataset' | 'Dataservice' | 'Reuse' | 'Organization' | 'Topic'
16
+ type: 'Dataset' | 'Dataservice' | 'Reuse' | 'Organization' | 'Topic' | 'Chart'
17
17
  }>()
18
18
 
19
19
  const icon = computed(() => {
@@ -23,6 +23,7 @@ const icon = computed(() => {
23
23
  Reuse: RiLineChartLine,
24
24
  Organization: RiBuilding2Line,
25
25
  Topic: RiBookShelfLine,
26
+ Chart: RiBarChartBoxLine,
26
27
  }[props.type]
27
28
  })
28
29
  </script>
@@ -23,12 +23,20 @@
23
23
 
24
24
  <ObjectCardShortDescription :text="post.headline || post.content" />
25
25
 
26
- <div
26
+ <TranslationT
27
27
  v-if="post.published || post.created_at"
28
+ tag="div"
28
29
  class="text-sm text-gray-medium mt-1"
30
+ keypath="Publié {date}"
29
31
  >
30
- {{ t('Publié {date}', { date: formatDate(post.published || post.created_at) }) }}
31
- </div>
32
+ <template #date>
33
+ <FormattedDate
34
+ :date="post.published || post.created_at"
35
+ format="relative"
36
+ :options="{ dateStyle: 'long', timeStyle: 'short' }"
37
+ />
38
+ </template>
39
+ </TranslationT>
32
40
 
33
41
  <slot />
34
42
  </ObjectCard>
@@ -37,26 +45,16 @@
37
45
  <script setup lang="ts">
38
46
  import { RiArticleLine } from '@remixicon/vue'
39
47
  import type { RouteLocationRaw } from 'vue-router'
40
- import { useFormatDate } from '../functions/dates'
41
- import { useTranslation } from '../composables/useTranslation'
42
48
  import type { Post } from '../types/posts'
43
49
  import Placeholder from './Placeholder.vue'
44
50
  import ObjectCard from './ObjectCard.vue'
45
51
  import ObjectCardHeader from './ObjectCardHeader.vue'
46
52
  import ObjectCardShortDescription from './ObjectCardShortDescription.vue'
53
+ import FormattedDate from './FormattedDate.vue'
54
+ import TranslationT from './TranslationT.vue'
47
55
 
48
56
  defineProps<{
49
57
  post: Post
50
58
  postUrl?: RouteLocationRaw
51
59
  }>()
52
-
53
- const { t } = useTranslation()
54
- const { formatRelativeIfRecentDate } = useFormatDate()
55
-
56
- const formatDate = (dateString: string) => {
57
- return formatRelativeIfRecentDate(dateString, {
58
- dateStyle: 'long',
59
- timeStyle: 'short',
60
- })
61
- }
62
60
  </script>
@@ -10,7 +10,7 @@
10
10
  />
11
11
  </div>
12
12
  <PreviewUnavailable v-else>
13
- {{ t("L'aperçu de ce fichier n'a pas pu être chargé. Téléchargez-le depuis l'onglet Téléchargements.") }}
13
+ {{ t("L'aperçu de ce fichier n'a pas pu être chargé. Téléchargez-le pour le consulter.") }}
14
14
  </PreviewUnavailable>
15
15
  </div>
16
16
  </template>
@@ -47,9 +47,21 @@
47
47
  />
48
48
  </dd>
49
49
  <template v-if="generatedFormats.length">
50
- <dt class="font-bold fr-text--sm fr-mb-0">
51
- {{ t('Formats générés automatiquement par {platform} (dernière mise à jour {date})', { platform: config.name, date: conversionsLastUpdate }) }}
52
- </dt>
50
+ <TranslationT
51
+ tag="dt"
52
+ class="font-bold fr-text--sm fr-mb-0"
53
+ keypath="Formats générés automatiquement par {platform} (dernière mise à jour {date})"
54
+ >
55
+ <template #platform>
56
+ {{ config.name }}
57
+ </template>
58
+ <template #date>
59
+ <FormattedDate
60
+ :date="conversionsFinishedAt"
61
+ format="relative"
62
+ />
63
+ </template>
64
+ </TranslationT>
53
65
  <dd
54
66
  v-for="generatedFormat in generatedFormats"
55
67
  :key="generatedFormat.format"
@@ -132,7 +144,8 @@ import { filesize } from '../../functions/helpers'
132
144
  import { getResourceFilesize } from '../../functions/resources'
133
145
  import { trackEvent } from '../../functions/matomo'
134
146
  import { useComponentsConfig } from '../../config'
135
- import { useFormatDate } from '../../functions/dates'
147
+ import FormattedDate from '../FormattedDate.vue'
148
+ import TranslationT from '../TranslationT.vue'
136
149
  import { useTranslation } from '../../composables/useTranslation'
137
150
  import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
138
151
  import type { Resource } from '../../types/resources'
@@ -145,7 +158,6 @@ const props = defineProps<{
145
158
 
146
159
  const { t } = useTranslation()
147
160
  const config = useComponentsConfig()
148
- const { formatRelativeIfRecentDate } = useFormatDate()
149
161
 
150
162
  const { generatedFormats, wfsFormats, defaultWfsProjection } = useResourceCapabilities(
151
163
  () => props.resource,
@@ -154,7 +166,7 @@ const { generatedFormats, wfsFormats, defaultWfsProjection } = useResourceCapabi
154
166
 
155
167
  const resourceFilesize = computed(() => getResourceFilesize(props.resource))
156
168
 
157
- const conversionsLastUpdate = computed(() =>
158
- formatRelativeIfRecentDate(props.resource.extras['analysis:parsing:finished_at'] as string | undefined),
169
+ const conversionsFinishedAt = computed(() =>
170
+ props.resource.extras['analysis:parsing:finished_at'] as string | undefined,
159
171
  )
160
172
  </script>
@@ -0,0 +1,48 @@
1
+ <template>
2
+ <div class="text-xs">
3
+ <PreviewUnavailable v-if="!isSizeAllowed">
4
+ {{ fileSizeBytes
5
+ ? t("Le fichier {fileType} est trop volumineux pour être prévisualisé. Téléchargez-le pour le consulter.", { fileType })
6
+ : t("La taille du fichier est inconnue, l'aperçu n'est pas disponible. Téléchargez-le pour le consulter.")
7
+ }}
8
+ </PreviewUnavailable>
9
+ <PreviewUnavailable v-else-if="hasError">
10
+ {{ t("L'aperçu de ce fichier n'a pas pu être chargé. Téléchargez-le pour le consulter.") }}
11
+ </PreviewUnavailable>
12
+ <img
13
+ v-else
14
+ :src="resource.url"
15
+ alt=""
16
+ class="block w-full h-auto max-w-[800px]"
17
+ @error="hasError = true"
18
+ >
19
+ </div>
20
+ </template>
21
+
22
+ <script setup lang="ts">
23
+ import { computed, ref } from 'vue'
24
+ import PreviewUnavailable from './PreviewUnavailable.vue'
25
+ import type { Resource } from '../../types/resources'
26
+ import { getResourceFilesize } from '../../functions/resources'
27
+ import { useTranslation } from '../../composables/useTranslation'
28
+ import { useComponentsConfig } from '../../config'
29
+
30
+ const props = defineProps<{
31
+ resource: Resource
32
+ }>()
33
+
34
+ const { t } = useTranslation()
35
+ const config = useComponentsConfig()
36
+
37
+ const hasError = ref(false)
38
+
39
+ const fileType = computed(() => props.resource.format?.toUpperCase() ?? t('Fichier'))
40
+ const fileSizeBytes = computed(() => getResourceFilesize(props.resource))
41
+
42
+ const isSizeAllowed = computed(() => {
43
+ const size = fileSizeBytes.value
44
+ const max = config.maxImagePreviewByteSize
45
+ if (!size || !max) return false
46
+ return size <= max
47
+ })
48
+ </script>
@@ -4,8 +4,8 @@
4
4
  </PreviewUnavailable>
5
5
  <div
6
6
  v-else
7
- id="map"
8
7
  ref="mapRef"
8
+ class="size-full"
9
9
  />
10
10
  </template>
11
11
 
@@ -128,10 +128,3 @@ onMounted(() => {
128
128
  displayMap()
129
129
  })
130
130
  </script>
131
-
132
- <style>
133
- #map {
134
- width: 100%;
135
- height: 500px;
136
- }
137
- </style>
@@ -1,12 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import { computed } from 'vue'
3
+ import { RiInformationLine } from '@remixicon/vue'
3
4
  import type { Resource } from '../../types/resources'
4
5
  import CopyButton from '../CopyButton.vue'
5
6
  import DescriptionDetails from '../DescriptionDetails.vue'
6
7
  import DescriptionTerm from '../DescriptionTerm.vue'
7
- import { useFormatDate } from '../../functions/dates'
8
+ import FormattedDate from '../FormattedDate.vue'
8
9
  import { filesize } from '../../functions/helpers'
9
10
  import ExtraAccordion from '../ExtraAccordion.vue'
11
+ import Toggletip from '../Toggletip.vue'
10
12
  import { getResourceTitleId, getResourceLabel, getResourceFilesize } from '../../functions/resources'
11
13
  import { useTranslation } from '../../composables/useTranslation'
12
14
 
@@ -19,7 +21,6 @@ const resourceTitleId = computed(() => getResourceTitleId(props.resource))
19
21
  const resourceFilesize = computed(() => getResourceFilesize(props.resource))
20
22
 
21
23
  const { t } = useTranslation()
22
- const { formatDate } = useFormatDate()
23
24
  </script>
24
25
 
25
26
  <template>
@@ -42,8 +43,23 @@ const { formatDate } = useFormatDate()
42
43
  </a>
43
44
  </code>
44
45
  </DescriptionDetails>
45
- <DescriptionTerm>
46
+ <DescriptionTerm class="flex items-center">
46
47
  {{ t('URL stable') }}
48
+ <Toggletip
49
+ :styled-button="false"
50
+ button-class="ml-1 border-transparent -outline-offset-2 inline-flex items-center justify-center rounded-sm p-1 text-gray-medium hover:bg-gray-lower transition-colors"
51
+ >
52
+ <RiInformationLine
53
+ class="size-4"
54
+ aria-hidden="true"
55
+ />
56
+ <span class="sr-only">{{ t(`Qu'est-ce qu'une URL stable ?`) }}</span>
57
+ <template #toggletip>
58
+ <p class="fr-text--sm m-0">
59
+ {{ t(`Cette URL redirige toujours vers la dernière version du fichier. L'URL du fichier, elle, change à chaque mise à jour : privilégiez l'URL stable pour partager la ressource ou l'utiliser dans un script.`) }}
60
+ </p>
61
+ </template>
62
+ </Toggletip>
47
63
  <CopyButton
48
64
  :label="t(`Copier l'URL stable`)"
49
65
  :copied-label="t('URL stable copiée !')"
@@ -94,11 +110,11 @@ const { formatDate } = useFormatDate()
94
110
  >
95
111
  <DescriptionTerm>{{ t('Créée le') }}</DescriptionTerm>
96
112
  <DescriptionDetails>
97
- {{ formatDate(resource.created_at) }}
113
+ <FormattedDate :date="resource.created_at" />
98
114
  </DescriptionDetails>
99
115
  <DescriptionTerm>{{ t('Modifiée le') }}</DescriptionTerm>
100
116
  <DescriptionDetails>
101
- {{ formatDate(resource.last_modified) }}
117
+ <FormattedDate :date="resource.last_modified" />
102
118
  </DescriptionDetails>
103
119
  </dl>
104
120
  <dl
@@ -1,23 +1,44 @@
1
1
  <template>
2
2
  <PreviewWrapper
3
- v-slot="{ data }"
4
3
  file-type="PDF"
5
4
  :resource="resource"
6
5
  :max-size="config.maxPdfPreviewByteSize"
7
6
  :load="load"
7
+ :fill="fill"
8
8
  @loaded="renderAllPages"
9
9
  >
10
- <div
11
- ref="containerRef"
12
- class="w-full overflow-y-auto max-h-[80vh] space-y-3"
13
- >
14
- <canvas
15
- v-for="page in (data as PDFDocumentProxy).numPages"
16
- :key="page"
17
- :ref="(el) => setCanvasRef(el as HTMLCanvasElement, page)"
18
- class="w-full"
19
- />
20
- </div>
10
+ <!-- While pdfjs downloads/parses the document, show the A4 skeleton instead of a
11
+ bare "loading" line — it matches the reader framing below seamlessly. -->
12
+ <template #loading>
13
+ <PdfSkeleton :fill="fill" />
14
+ </template>
15
+
16
+ <!-- Cap the page width (A4 never needs more) and center on a light backdrop so
17
+ it reads like a document reader. The width cap applies in both modes since
18
+ the render width follows the container; `fill` only drives the height
19
+ (fullscreen fills down, inline stays capped). The scroll container is a plain
20
+ block (not a flex) so the trailing `pb` is reliably scrollable — flex + overflow
21
+ drops the end padding of flex items. -->
22
+ <template #default="{ data }">
23
+ <div
24
+ class="overflow-y-auto bg-gray-100"
25
+ :class="fill ? 'min-h-0 flex-1' : 'max-h-[80vh]'"
26
+ >
27
+ <div class="flex justify-center p-4 pb-16">
28
+ <div
29
+ ref="containerRef"
30
+ class="w-[800px] max-w-full space-y-3"
31
+ >
32
+ <canvas
33
+ v-for="page in (data as PDFDocumentProxy).numPages"
34
+ :key="page"
35
+ :ref="(el) => setCanvasRef(el as HTMLCanvasElement, page)"
36
+ class="w-full bg-white shadow-sm"
37
+ />
38
+ </div>
39
+ </div>
40
+ </div>
41
+ </template>
21
42
  </PreviewWrapper>
22
43
  </template>
23
44
 
@@ -27,6 +48,7 @@ import * as pdfjsLib from 'pdfjs-dist'
27
48
  import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
28
49
  import type { PDFDocumentProxy } from 'pdfjs-dist'
29
50
  import PreviewWrapper from './PreviewWrapper.vue'
51
+ import PdfSkeleton from './PdfSkeleton.vue'
30
52
  import { useComponentsConfig } from '../../config'
31
53
  import type { Resource } from '../../types/resources'
32
54
 
@@ -34,6 +56,8 @@ pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
34
56
 
35
57
  const props = defineProps<{
36
58
  resource: Resource
59
+ // Fill the available height (fullscreen) instead of the default capped height.
60
+ fill?: boolean
37
61
  }>()
38
62
 
39
63
  const config = useComponentsConfig()
@@ -0,0 +1,44 @@
1
+ <template>
2
+ <!-- An A4 sheet on the reader backdrop with a few pulsing text lines, matching the
3
+ real PdfPreview framing (centered, capped ~800px, gray backdrop). -->
4
+ <div
5
+ class="overflow-hidden bg-gray-100"
6
+ :class="fill ? 'min-h-0 flex-1' : 'max-h-[80vh]'"
7
+ role="status"
8
+ :aria-label="t('Chargement de l\'aperçu…')"
9
+ >
10
+ <div class="flex justify-center p-4">
11
+ <div class="aspect-[210/297] w-[800px] max-w-full bg-white p-10 shadow-sm">
12
+ <div class="animate-pulse-placeholder space-y-6">
13
+ <div class="space-y-3">
14
+ <div class="h-5 w-1/2 rounded bg-gray-200" />
15
+ <div class="h-3 w-full rounded bg-gray-200" />
16
+ <div class="h-3 w-full rounded bg-gray-200" />
17
+ <div class="h-3 w-5/6 rounded bg-gray-200" />
18
+ </div>
19
+ <div class="space-y-3">
20
+ <div class="h-3 w-full rounded bg-gray-200" />
21
+ <div class="h-3 w-11/12 rounded bg-gray-200" />
22
+ <div class="h-3 w-full rounded bg-gray-200" />
23
+ <div class="h-3 w-3/4 rounded bg-gray-200" />
24
+ </div>
25
+ <div class="space-y-3">
26
+ <div class="h-3 w-full rounded bg-gray-200" />
27
+ <div class="h-3 w-5/6 rounded bg-gray-200" />
28
+ </div>
29
+ </div>
30
+ </div>
31
+ </div>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup lang="ts">
36
+ import { useTranslation } from '../../composables/useTranslation'
37
+
38
+ defineProps<{
39
+ // Fill the available height (fullscreen) instead of the default capped height.
40
+ fill?: boolean
41
+ }>()
42
+
43
+ const { t } = useTranslation()
44
+ </script>
@@ -1,15 +1,15 @@
1
1
  <template>
2
- <div>
2
+ <div class="flex h-full flex-col">
3
3
  <PreviewUnavailable v-if="hasError">
4
4
  {{ t("L'aperçu cartographique de ce fichier n'a pas pu être chargé.") }}
5
5
  </PreviewUnavailable>
6
6
  <div
7
7
  v-else
8
- class="-mx-4"
8
+ class="flex min-h-0 flex-1 flex-col"
9
9
  >
10
10
  <div
11
11
  v-if="pmtilesViewerUrl"
12
- class="bg-blue-100 text-datagouv fr-hidden fr-unhidden-md p-4"
12
+ class="bg-blue-100 text-datagouv fr-hidden fr-unhidden-md p-4 shrink-0"
13
13
  >
14
14
  <div class="fr-grid-row fr-grid-row--middle fr-grid-row--gutters">
15
15
  <div
@@ -35,12 +35,17 @@
35
35
  </p>
36
36
  </div>
37
37
  </div>
38
- <div
39
- ref="containerRef"
40
- style="height: 600px;"
41
- />
42
- <div class="fr-px-5v fr-pt-5v">
43
- {{ t("Aperçu de la carte mis à jour le {date}", { date: lastUpdate }) }}
38
+ <div class="relative min-h-0 flex-1">
39
+ <div
40
+ ref="containerRef"
41
+ class="size-full"
42
+ />
43
+ <!-- Freshness caption overlaid inside the map (bottom-left, the corner left
44
+ free by the navigation/geolocate controls top-right and the attribution
45
+ bottom-right) so the map itself can use the whole height. -->
46
+ <div class="pointer-events-none absolute bottom-2 left-2 z-10 rounded bg-white/90 px-2 py-1 text-[12px] leading-4 text-gray-medium shadow-sm">
47
+ {{ t("Aperçu de la carte mis à jour le {date}", { date: lastUpdate }) }}
48
+ </div>
44
49
  </div>
45
50
  </div>
46
51
  </div>
@@ -62,7 +67,7 @@ import styleVector from '../../../assets/json/vector.json'
62
67
  import PreviewUnavailable from './PreviewUnavailable.vue'
63
68
  import { useTranslation } from '../../composables/useTranslation'
64
69
  import franceSvg from './france.svg?raw'
65
- import { getOwnerName, getOwnerPage } from '../../functions/owned'
70
+ import { getMapAttribution } from '../../functions/owned'
66
71
 
67
72
  const props = defineProps<{ resource: Resource, dataset: Dataset | DatasetV2 }>()
68
73
 
@@ -89,11 +94,7 @@ const lastUpdate = computed(() => {
89
94
 
90
95
  const container = useTemplateRef('containerRef')
91
96
 
92
- const attributions = computed(() => {
93
- if (!props.dataset.organization && !props.dataset.owner)
94
- return ''
95
- return `© <a href="${getOwnerPage(props.dataset)}" target="_blank">${getOwnerName(props.dataset)}</a>`
96
- })
97
+ const attributions = computed(() => getMapAttribution(props.dataset))
97
98
 
98
99
  async function displayMap() {
99
100
  await import('maplibre-gl/dist/maplibre-gl.css')
@@ -104,14 +105,22 @@ async function displayMap() {
104
105
  const p = new PMTiles(pmtilesUrl.value)
105
106
  protocol.add(p)
106
107
 
107
- p.getHeader().then((h: { maxZoom: number, centerLon: number, centerLat: number }) => {
108
+ p.getHeader().then((h: { minLon: number, minLat: number, maxLon: number, maxLat: number, centerZoom: number, centerLon: number, centerLat: number }) => {
109
+ // Frame the data's bounding box (carried by the archive header) so we land on
110
+ // the data. Fall back to the header center when the bbox is degenerate — an
111
+ // archive with a single feature, or bounds left unset (all zeroes).
112
+ const hasBounds = h.minLon < h.maxLon && h.minLat < h.maxLat
108
113
  const map = new maplibregl.Map({
109
114
  container: container.value!,
110
115
  // JSON import is inferred too loosely (e.g. center as number[]) to match
111
116
  // maplibre's strict StyleSpecification, so cast through unknown.
112
117
  style: styleVector as unknown as maplibregl.StyleSpecification,
113
- zoom: h.maxZoom - 2,
114
- center: [h.centerLon, h.centerLat],
118
+ ...(hasBounds
119
+ ? {
120
+ bounds: [[h.minLon, h.minLat], [h.maxLon, h.maxLat]] as [[number, number], [number, number]],
121
+ fitBoundsOptions: { padding: 24 },
122
+ }
123
+ : { center: [h.centerLon, h.centerLat] as [number, number], zoom: h.centerZoom }),
115
124
  })
116
125
  map.addControl(new maplibregl.NavigationControl())
117
126
  map.addControl(