@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
@@ -0,0 +1,69 @@
1
+ <template>
2
+ <!-- An instant is formatted in the server's timezone at render time and in the
3
+ reader's at read time, so its text and `title` legitimately differ and Vue is
4
+ told to accept it. A plain date carries no time, so nothing about it varies —
5
+ exempting it would hide a real bug instead of a legitimate difference. Vue only
6
+ honours `data-allow-mismatch` on an element whose children are plain text,
7
+ never on a text node coming from a slot: hence two static elements instead of a
8
+ `<component :is>`, which would pass the text through a slot. -->
9
+ <time
10
+ v-if="datetime"
11
+ :datetime
12
+ :title
13
+ :data-allow-mismatch="allowMismatch"
14
+ >{{ formatted }}</time>
15
+ <!-- No date to format: nothing here depends on the timezone or on "now". -->
16
+ <span v-else>{{ formatted }}</span>
17
+ </template>
18
+
19
+ <script setup lang="ts">
20
+ import { computed } from 'vue'
21
+ import { PLAIN_DATE, parseDateValue, useFormatDate } from '../functions/dates'
22
+
23
+ const props = withDefaults(defineProps<{
24
+ date: Date | string | null | undefined
25
+ /**
26
+ * `date`: the full date. `relative`: "3 days ago" while the date is less than a
27
+ * month old, the full date beyond. `from-now`: always relative, however old.
28
+ */
29
+ format?: 'date' | 'relative' | 'from-now'
30
+ options?: Intl.DateTimeFormatOptions
31
+ }>(), {
32
+ format: 'date',
33
+ options: () => ({}),
34
+ })
35
+
36
+ const { formatDate, formatFromNow, formatRelativeIfRecentDate } = useFormatDate()
37
+
38
+ const plainDate = computed(() => typeof props.date === 'string' ? props.date.match(PLAIN_DATE) : null)
39
+
40
+ const parsed = computed(() => parseDateValue(props.date))
41
+
42
+ // Announce the precision the value carries, never more: a month stays a month.
43
+ const datetime = computed(() => {
44
+ if (!parsed.value) return undefined
45
+ return plainDate.value ? plainDate.value[0] : parsed.value.toISOString()
46
+ })
47
+
48
+ // Only what genuinely differs between server and browser is exempted: an instant, and
49
+ // the relative forms, which are measured against "now".
50
+ const allowMismatch = computed(() => !plainDate.value || props.format !== 'date'
51
+ ? 'text,attribute'
52
+ : undefined)
53
+
54
+ const formatted = computed(() => {
55
+ if (props.format === 'relative') return formatRelativeIfRecentDate(parsed.value, props.options)
56
+ if (props.format === 'from-now') return formatFromNow(parsed.value)
57
+ return formatDate(parsed.value, props.options)
58
+ })
59
+
60
+ // Hovering an instant gives the exact moment, which the displayed form usually omits:
61
+ // the relative forms drop it entirely, and most styles drop the time. A plain date has
62
+ // no moment to reveal — a `title` there would be one we made up, and a form that already
63
+ // spells the moment out needs no tooltip repeating it word for word.
64
+ const title = computed(() => {
65
+ if (!parsed.value || plainDate.value) return undefined
66
+ const exact = formatDate(parsed.value, { dateStyle: 'long', timeStyle: 'short' })
67
+ return exact === formatted.value ? undefined : exact
68
+ })
69
+ </script>
@@ -1,8 +1,11 @@
1
1
  <template>
2
2
  <div class="relative">
3
3
  <div :class="{ 'opacity-50 min-h-64': loading }">
4
+ <!-- Keep the previous content while reloading, dimmed under the loader:
5
+ dropping it collapses the page height, which makes the browser clamp
6
+ the scroll and throws the reader back to the top. -->
4
7
  <slot
5
- v-if="!loading && !hasError && data"
8
+ v-if="!hasError && data"
6
9
  :data="data"
7
10
  />
8
11
  <slot
@@ -28,12 +28,15 @@ import { computed } from 'vue'
28
28
 
29
29
  const props = withDefaults(defineProps<{
30
30
  articleClass?: string | string[] | Record<string, boolean>
31
- mediaSize?: 'sm' | 'lg'
31
+ mediaSize?: 'sm' | 'lg' | 'xl'
32
32
  }>(), {
33
33
  mediaSize: 'sm',
34
34
  })
35
35
 
36
36
  const mediaContainerClass = computed(() => {
37
+ if (props.mediaSize === 'xl') {
38
+ return 'w-[250px] h-[190px]'
39
+ }
37
40
  if (props.mediaSize === 'lg') {
38
41
  return 'w-[225px] h-[120px]'
39
42
  }
@@ -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()