@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
@@ -90,7 +90,11 @@
90
90
  @change="changePage"
91
91
  />
92
92
  <div class="fr-px-5v">
93
- {{ t("Dernière mise à jour de la prévisualisation : {date}", { date: lastUpdate }) }} —
93
+ <TranslationT keypath="Dernière mise à jour de la prévisualisation : {date}">
94
+ <template #date>
95
+ <FormattedDate :date="parsingFinishedAt" />
96
+ </template>
97
+ </TranslationT> —
94
98
  {{ t('{count} colonnes', columns.length) }} —
95
99
  {{ t('Lignes {count}', rowCount) }}
96
100
  </div>
@@ -103,7 +107,8 @@ import { computed, onMounted, ref } from 'vue'
103
107
  import { RiArrowDownLine, RiArrowUpLine, RiExternalLinkFill } from '@remixicon/vue'
104
108
  import Pagination from '../Pagination.vue'
105
109
  import { getData, type SortConfig } from '../../functions/tabularApi'
106
- import { useFormatDate } from '../../functions/dates'
110
+ import FormattedDate from '../FormattedDate.vue'
111
+ import TranslationT from '../TranslationT.vue'
107
112
  import { trackEvent } from '../../functions/matomo'
108
113
  import type { Resource } from '../../types/resources'
109
114
  import { useComponentsConfig } from '../../config'
@@ -116,7 +121,6 @@ import PreviewLoader from './PreviewLoader.vue'
116
121
  const props = defineProps<{ resource: Resource }>()
117
122
 
118
123
  const { t } = useTranslation()
119
- const { formatDate } = useFormatDate()
120
124
 
121
125
  const rows = ref<Array<Record<string, unknown>>>([])
122
126
  const columns = ref<Array<string>>([])
@@ -196,7 +200,7 @@ function sortByField(col: string) {
196
200
  getTableInfos(currentPage.value, sortConfig.value)
197
201
  };
198
202
 
199
- const lastUpdate = computed(() => formatDate(props.resource.extras['analysis:parsing:finished_at'] as string | undefined))
203
+ const parsingFinishedAt = computed(() => props.resource.extras['analysis:parsing:finished_at'] as string | undefined)
200
204
 
201
205
  onMounted(() => {
202
206
  getTableInfos(currentPage.value)
@@ -1,29 +1,34 @@
1
1
  <template>
2
- <div class="text-xs">
2
+ <div
3
+ class="text-xs"
4
+ :class="fill ? 'flex min-h-0 flex-1 flex-col' : ''"
5
+ >
3
6
  <slot
4
7
  v-if="data !== null"
5
8
  :data="data"
6
9
  />
7
- <div
10
+ <slot
8
11
  v-else-if="loading"
9
- class="text-gray-medium"
12
+ name="loading"
10
13
  >
11
- {{ t("Chargement de l'aperçu {fileType}...", { fileType }) }}
12
- </div>
14
+ <div class="text-gray-medium">
15
+ {{ t("Chargement de l'aperçu {fileType}...", { fileType }) }}
16
+ </div>
17
+ </slot>
13
18
  <PreviewUnavailable v-else-if="!isSizeAllowed">
14
19
  {{ fileSizeBytes
15
- ? t("Le fichier {fileType} est trop volumineux pour être prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.", { fileType })
16
- : t("La taille du fichier est inconnue, l'aperçu n'est pas disponible. Téléchargez-le depuis l'onglet Téléchargements.")
20
+ ? t("Le fichier {fileType} est trop volumineux pour être prévisualisé. Téléchargez-le pour le consulter.", { fileType })
21
+ : t("La taille du fichier est inconnue, l'aperçu n'est pas disponible. Téléchargez-le pour le consulter.")
17
22
  }}
18
23
  </PreviewUnavailable>
19
24
  <PreviewUnavailable v-else-if="corsStatus === 'blocked'">
20
- {{ t("Ce fichier {fileType} ne peut pas être prévisualisé car il est hébergé sur un site distant qui restreint l'accès (CORS). Téléchargez-le depuis l'onglet Téléchargements.", { fileType }) }}
25
+ {{ t("Ce fichier {fileType} ne peut pas être prévisualisé car il est hébergé sur un site distant qui restreint l'accès (CORS). Téléchargez-le pour le consulter.", { fileType }) }}
21
26
  </PreviewUnavailable>
22
27
  <PreviewUnavailable v-else-if="error === 'network'">
23
- {{ t("Ce fichier est hébergé sur un site externe qui ne permet pas la prévisualisation. Téléchargez-le depuis l'onglet Téléchargements.") }}
28
+ {{ t("Ce fichier est hébergé sur un site externe qui ne permet pas la prévisualisation. Téléchargez-le pour le consulter.") }}
24
29
  </PreviewUnavailable>
25
30
  <PreviewUnavailable v-else-if="error">
26
- {{ t("L'aperçu de ce fichier n'a pas pu être chargé. Téléchargez-le depuis l'onglet Téléchargements.") }}
31
+ {{ t("L'aperçu de ce fichier n'a pas pu être chargé. Téléchargez-le pour le consulter.") }}
27
32
  </PreviewUnavailable>
28
33
  </div>
29
34
  </template>
@@ -40,6 +45,8 @@ const props = defineProps<{
40
45
  resource: Resource
41
46
  maxSize: number | undefined
42
47
  load: () => Promise<unknown>
48
+ // Fill the available height (fullscreen) instead of sizing to content.
49
+ fill?: boolean
43
50
  }>()
44
51
 
45
52
  const emit = defineEmits<{
@@ -47,7 +47,7 @@
47
47
  <CopyButton
48
48
  :label="t('Copier le lien')"
49
49
  :copied-label="t('Lien copié !')"
50
- :text="resourceExternalUrl"
50
+ :text="externalUrl"
51
51
  class="z-2"
52
52
  />
53
53
  </div>
@@ -60,7 +60,17 @@
60
60
  aria-hidden="true"
61
61
  class="size-3 fill-gray-medium"
62
62
  />
63
- <span class="text-xs mb-0">{{ t('Mis à jour {date}', { date: formatRelativeIfRecentDate(lastUpdate) }) }}</span>
63
+ <TranslationT
64
+ class="text-xs mb-0"
65
+ keypath="Mis à jour {date}"
66
+ >
67
+ <template #date>
68
+ <FormattedDate
69
+ :date="lastUpdate"
70
+ format="relative"
71
+ />
72
+ </template>
73
+ </TranslationT>
64
74
  <RiSubtractLine
65
75
  aria-hidden="true"
66
76
  class="size-3 fill-gray-medium"
@@ -197,7 +207,10 @@
197
207
  :key="tab.key"
198
208
  class="px-4"
199
209
  >
200
- <div v-if="tab.key === 'map'">
210
+ <div
211
+ v-if="tab.key === 'map'"
212
+ class="-mx-4 h-[600px]"
213
+ >
201
214
  <Pmtiles
202
215
  v-if="hasPmtiles"
203
216
  :resource="resource"
@@ -224,6 +237,10 @@
224
237
  v-else-if="resource.format && resource.format.toLowerCase() === 'xml'"
225
238
  :resource="resource"
226
239
  />
240
+ <ImagePreview
241
+ v-else-if="isImagePreviewFormat(resource.format)"
242
+ :resource="resource"
243
+ />
227
244
  <!-- Show Datafair embedded preview (koumoul) -->
228
245
  <DatafairPreview
229
246
  v-else-if="hasDatafairPreview"
@@ -296,7 +313,8 @@ import { ref, computed, defineAsyncComponent } from 'vue'
296
313
  import { RiDownloadLine, RiFileCopyLine, RiFileWarningLine, RiSubtractLine } from '@remixicon/vue'
297
314
  import OrganizationNameWithCertificate from '../OrganizationNameWithCertificate.vue'
298
315
  import { filesize, summarize } from '../../functions/helpers'
299
- import { useFormatDate } from '../../functions/dates'
316
+ import FormattedDate from '../FormattedDate.vue'
317
+ import TranslationT from '../TranslationT.vue'
300
318
  import MarkdownViewer from '../MarkdownViewer.vue'
301
319
  import type { CommunityResource, Resource } from '../../types/resources'
302
320
  import type { Dataset, DatasetV2 } from '../../types/datasets'
@@ -309,7 +327,7 @@ import { trackEvent } from '../../functions/matomo'
309
327
  import CopyButton from '../CopyButton.vue'
310
328
  import { useComponentsConfig } from '../../config'
311
329
  import { getOwnerName } from '../../functions/owned'
312
- import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
330
+ import { getResourceFormatIcon, getResourceTitleId, detectOgcService, getResourceFilesize, isImagePreviewFormat, resolveResourceExternalUrl } from '../../functions/resources'
313
331
  import BrandedButton from '../BrandedButton.vue'
314
332
  import { useTranslation } from '../../composables/useTranslation'
315
333
  import { useHasTabularData } from '../../composables/useHasTabularData'
@@ -331,6 +349,8 @@ const props = withDefaults(defineProps<{
331
349
  isCommunityResource?: boolean
332
350
  resource: Resource | CommunityResource
333
351
  canEdit?: boolean
352
+ // Overrides the "Copier le lien" target.
353
+ resourceExternalUrl?: (resource: Resource | CommunityResource) => string
334
354
  }>(), {
335
355
  expandedOnMount: false,
336
356
  isCommunityResource: false,
@@ -344,18 +364,18 @@ const Pmtiles = defineAsyncComponent(() => import('./Pmtiles.client.vue'))
344
364
  const JsonPreview = defineAsyncComponent(() => import('./JsonPreview.client.vue'))
345
365
  const PdfPreview = defineAsyncComponent(() => import('./PdfPreview.client.vue'))
346
366
  const XmlPreview = defineAsyncComponent(() => import('./XmlPreview.client.vue'))
367
+ const ImagePreview = defineAsyncComponent(() => import('./ImagePreview.client.vue'))
347
368
  const DatafairPreview = defineAsyncComponent(() => import('./Datafair.client.vue'))
348
369
 
349
370
  const { t } = useTranslation()
350
- const { formatRelativeIfRecentDate } = useFormatDate()
351
371
  const checkTabularData = useHasTabularData()
352
372
 
353
373
  const hasPreview = computed(() => {
354
- // For JSON, PDF, and XML files, show preview.
374
+ // For JSON, PDF, XML, and image files, show preview.
355
375
  // We cannot check for CORS issues here because we cannot use an async component here.
356
376
  // If there is a CORS issue when fetching the file for preview, it will be managed and displayed as an error banner by the preview component.
357
377
  const format = props.resource.format?.toLowerCase()
358
- return format === 'json' || format === 'pdf' || format === 'xml'
378
+ return format === 'json' || format === 'pdf' || format === 'xml' || isImagePreviewFormat(format)
359
379
  })
360
380
 
361
381
  const hasTabularData = computed(() => checkTabularData(props.resource))
@@ -450,7 +470,7 @@ const resourceFilesize = computed(() => getResourceFilesize(props.resource))
450
470
  const unavailable = availabilityChecked && props.resource.extras['check:available'] === false
451
471
  const downloadButtonTitle = unavailable ? t(`Le robot de {certifier} n'a pas pu accéder à ce fichier - Télécharger le fichier en {format}`, { certifier: config.name, format: format.value }) : t(`Télécharger le fichier en {format}`, { format: format.value })
452
472
 
453
- const resourceExternalUrl = computed(() => getResourceExternalUrl(props.dataset, props.resource))
473
+ const externalUrl = computed(() => resolveResourceExternalUrl(props.dataset, props.resource, props.resourceExternalUrl))
454
474
 
455
475
  const resourceContentId = 'resource-' + props.resource.id
456
476
  const resourceHeaderId = 'resource-' + props.resource.id + '-header'
@@ -1,49 +1,87 @@
1
1
  <template>
2
- <div v-if="allResources.length || hasAnyResources">
3
- <div class="flex gap-6">
4
- <div class="flex-1 min-w-0">
5
- <ResourceExplorerViewer
6
- v-if="selectedResource && allResources.length"
7
- :key="selectedResource.id"
8
- :dataset
9
- :resource="selectedResource"
10
- :resources="flatResources"
11
- @select="selectResource"
12
- />
13
- <div
14
- v-else-if="search"
15
- class="flex flex-col items-center py-12"
16
- >
17
- <slot name="no-results-image">
18
- <img
19
- :src="noResultsImage"
20
- class="h-20"
21
- alt=""
22
- >
23
- </slot>
24
- <p class="fr-text--bold fr-my-3v">
25
- {{ t('Pas de résultats pour « {q} »', { q: search }) }}
26
- </p>
27
- <BrandedButton
28
- color="primary"
29
- @click="updateSearch('')"
30
- >
31
- {{ t('Réinitialiser la recherche') }}
32
- </BrandedButton>
33
- </div>
34
- </div>
35
- <div class="hidden md:block">
2
+ <div
3
+ v-if="groups.length || hasAnyResources"
4
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
5
+ >
6
+ <ResourceExplorerHeader
7
+ v-if="fullscreen"
8
+ class="shrink-0"
9
+ :dataset
10
+ :resource="selectedResource"
11
+ :exit-to="exitTo"
12
+ />
13
+ <div
14
+ class="flex"
15
+ :class="fullscreen ? 'min-h-0 flex-1 overflow-hidden' : 'overflow-hidden rounded border border-gray-default'"
16
+ >
17
+ <div class="hidden md:flex">
36
18
  <ResourceExplorerSidebar
37
- :resources="allResources"
19
+ :groups
38
20
  :selected-resource-id="selectedResource?.id ?? null"
39
21
  :collapsed="sidebarCollapsed"
40
22
  :search
41
- @select="selectResource"
23
+ :loading-type="loadingType"
24
+ :resource-to="resourceTo"
25
+ replace
42
26
  @load-more="loadMore"
43
27
  @update:collapsed="sidebarCollapsed = $event"
44
28
  @update:search="updateSearch($event)"
45
29
  />
46
30
  </div>
31
+ <div
32
+ id="resource-explorer-viewer"
33
+ class="flex-1 min-w-0"
34
+ :class="fullscreen ? 'flex flex-col' : ''"
35
+ role="region"
36
+ :aria-label="t('Détail de la ressource')"
37
+ >
38
+ <!-- Suspense shows the skeleton fallback while the (async) viewer resolves
39
+ its data on each resource switch. `timeout` keeps the previous resource
40
+ visible for a beat so a near-instant switch doesn't flash a skeleton. -->
41
+ <Suspense
42
+ v-if="selectedResource"
43
+ :timeout="200"
44
+ >
45
+ <ResourceExplorerViewer
46
+ :key="selectedResource.id"
47
+ :dataset
48
+ :resource="selectedResource"
49
+ :resources="flatResources"
50
+ :resource-to="resourceTo"
51
+ :explore-to="exploreTo"
52
+ :resource-external-url="resourceExternalUrl"
53
+ replace
54
+ :fullscreen
55
+ />
56
+ <template #fallback>
57
+ <ResourceViewerSkeleton
58
+ :resource="selectedResource"
59
+ :dataset
60
+ :resources="flatResources"
61
+ :resource-to="resourceTo"
62
+ :explore-to="exploreTo"
63
+ :resource-external-url="resourceExternalUrl"
64
+ replace
65
+ :fullscreen
66
+ />
67
+ </template>
68
+ </Suspense>
69
+ <!-- The selection never falls back to nothing once a resource has been shown,
70
+ so having none means the groups aren't in yet: only `main` is fetched on
71
+ the server, the other types arrive on hydration. -->
72
+ <div
73
+ v-else
74
+ class="animate-pulse-placeholder p-4"
75
+ :class="fullscreen ? 'min-h-0 flex-1' : ''"
76
+ role="status"
77
+ :aria-label="t('Chargement des ressources…')"
78
+ >
79
+ <div
80
+ class="w-full rounded bg-gray-200"
81
+ :class="fullscreen ? 'h-full' : 'h-96'"
82
+ />
83
+ </div>
84
+ </div>
47
85
  </div>
48
86
  </div>
49
87
  <div
@@ -64,194 +102,52 @@
64
102
  </template>
65
103
 
66
104
  <script setup lang="ts">
67
- import { computed, ref, watch, type Ref } from 'vue'
68
- import { useRouter } from 'vue-router'
69
- import { useRouteQuery } from '@vueuse/router'
70
- import { useComponentsConfig } from '../../config'
105
+ import { ref } from 'vue'
106
+ import { useRoute } from 'vue-router'
107
+ import type { RouteLocationRaw } from 'vue-router'
71
108
  import { useTranslation } from '../../composables/useTranslation'
72
- import { useDebouncedRef } from '../../composables/useDebouncedRef'
73
- import { useFetch } from '../../functions/api'
74
- import { RESOURCE_EXPLORER_PAGE_SIZE, RESOURCE_TYPE } from '../../functions/resources'
75
- import type { PaginatedArray } from '../../types/api'
109
+ import { useDatasetResources } from '../../composables/useDatasetResources'
76
110
  import type { DatasetV2 } from '../../types/datasets'
77
- import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
111
+ import type { Resource } from '../../types/resources'
78
112
  import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
79
113
  import ResourceExplorerViewer from './ResourceExplorerViewer.vue'
80
- import BrandedButton from '../BrandedButton.vue'
114
+ import ResourceExplorerHeader from './ResourceExplorerHeader.vue'
115
+ import ResourceViewerSkeleton from './ResourceViewerSkeleton.vue'
81
116
 
82
117
  const props = withDefaults(defineProps<{
83
118
  dataset: DatasetV2
84
119
  noResultsImage?: string
120
+ // Fullscreen mode: dataset context bar (org / title / date + download + exit), the
121
+ // viewer fills the height and hides its inline actions (shown in the context bar).
122
+ fullscreen?: boolean
123
+ exitTo?: RouteLocationRaw
124
+ // Inline mode only: link builder for the "Explorer" button in the viewer header
125
+ // that opens the fullscreen explorer on the current resource.
126
+ exploreTo?: (resource: Resource) => string
127
+ // Overrides the "Copier le lien" target.
128
+ resourceExternalUrl?: (resource: Resource) => string
85
129
  }>(), {
86
130
  noResultsImage: '',
131
+ fullscreen: false,
87
132
  })
88
133
 
89
134
  const { t } = useTranslation()
90
- const router = useRouter()
91
- const config = useComponentsConfig()
135
+ const route = useRoute()
136
+
137
+ const {
138
+ groups,
139
+ flatResources,
140
+ hasAnyResources,
141
+ selectedResource,
142
+ loadMore,
143
+ loadingType,
144
+ search,
145
+ updateSearch,
146
+ } = await useDatasetResources(() => props.dataset)
92
147
 
93
148
  const sidebarCollapsed = ref(false)
94
- const search = ref('')
95
- const { debounced: searchDebounced, flush } = useDebouncedRef(search, config.searchDebounce ?? 300)
96
-
97
- const url = computed(() => `/api/2/datasets/${props.dataset.id}/resources/`)
98
-
99
- // Resource ID from URL query
100
- const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
101
-
102
- // Fetch resources for each type
103
- const mainParams = computed(() => ({
104
- type: 'main' as const,
105
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
106
- q: searchDebounced.value || undefined,
107
- }))
108
- const documentationParams = computed(() => ({
109
- type: 'documentation' as const,
110
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
111
- q: searchDebounced.value || undefined,
112
- }))
113
- const updateParams = computed(() => ({
114
- type: 'update' as const,
115
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
116
- q: searchDebounced.value || undefined,
117
- }))
118
- const apiParams = computed(() => ({
119
- type: 'api' as const,
120
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
121
- q: searchDebounced.value || undefined,
122
- }))
123
- const codeParams = computed(() => ({
124
- type: 'code' as const,
125
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
126
- q: searchDebounced.value || undefined,
127
- }))
128
- const otherParams = computed(() => ({
129
- type: 'other' as const,
130
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
131
- q: searchDebounced.value || undefined,
132
- }))
133
-
134
- const { data: mainData, status: mainStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: mainParams })
135
- const { data: documentationData, status: documentationStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: documentationParams, server: false })
136
- const { data: updateData, status: updateStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: updateParams, server: false })
137
- const { data: apiData, status: apiStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: apiParams, server: false })
138
- const { data: codeData, status: codeStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: codeParams, server: false })
139
- const { data: otherData, status: otherStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: otherParams, server: false })
140
-
141
- const rawResourcesByTypes = [
142
- { data: mainData, status: mainStatus },
143
- { data: documentationData, status: documentationStatus },
144
- { data: updateData, status: updateStatus },
145
- { data: apiData, status: apiStatus },
146
- { data: codeData, status: codeStatus },
147
- { data: otherData, status: otherStatus },
148
- ]
149
-
150
- // Evaluated once at setup (before any search) — never changes afterwards
151
- const hasAnyResources = computed(() => {
152
- return props.dataset.resources.total > 0
153
- })
154
-
155
- const extraResourcesByType: Ref<Resource[]>[] = RESOURCE_TYPE.map(() => ref<Resource[]>([]))
156
- const pageByType: Ref<number>[] = RESOURCE_TYPE.map(() => ref(1))
157
-
158
- watch(searchDebounced, () => {
159
- for (let i = 0; i < RESOURCE_TYPE.length; i++) {
160
- extraResourcesByType[i]!.value = []
161
- pageByType[i]!.value = 1
162
- }
163
- })
164
-
165
- // Separate useFetch for loadMore, initialized at setup time with immediate: false
166
- // so that it doesn't fetch until execute() is called from the event handler.
167
- const loadMoreType = ref<ResourceType>('main')
168
- const loadMorePage = ref(1)
169
- const loadMoreParams = computed(() => ({
170
- type: loadMoreType.value,
171
- page_size: RESOURCE_EXPLORER_PAGE_SIZE,
172
- page: loadMorePage.value,
173
- q: searchDebounced.value || undefined,
174
- }))
175
- const { data: loadMoreData, execute: executeLoadMore } = await useFetch<PaginatedArray<Resource>>(url, {
176
- params: loadMoreParams,
177
- immediate: false,
178
- watch: false,
179
- })
180
-
181
- const loadMore = async (type: ResourceType) => {
182
- const index = RESOURCE_TYPE.indexOf(type)
183
- if (index === -1) return
184
- const pageRef = pageByType[index]!
185
- const extraRef = extraResourcesByType[index]!
186
- pageRef.value++
187
-
188
- loadMoreType.value = type
189
- loadMorePage.value = pageRef.value
190
- await executeLoadMore()
191
-
192
- if (loadMoreData.value) {
193
- extraRef.value = [...extraRef.value, ...loadMoreData.value.data]
194
- }
195
- }
196
-
197
- const allResources = computed<ResourceGroup[]>(() => {
198
- return RESOURCE_TYPE
199
- .map((type, index) => {
200
- const rawData = rawResourcesByTypes[index]!
201
- const extraData = extraResourcesByType[index]!
202
- return {
203
- type: type as ResourceType,
204
- total: rawData.data.value?.total ?? 0,
205
- items: [...(rawData.data.value?.data ?? []), ...extraData.value],
206
- }
207
- })
208
- .filter(group => group.items.length > 0)
209
- })
210
-
211
- const flatResources = computed(() =>
212
- allResources.value.flatMap(g => g.items),
213
- )
214
-
215
- // Fetch resource by ID if specified in URL (for SSR)
216
- const initialResourceId = resourceIdQuery.value
217
- const { data: fetchedResource } = initialResourceId
218
- ? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
219
- : { data: ref(null) }
220
-
221
- // Initial selection (synchronous for SSR hydration)
222
- function getInitialResource(): Resource | null {
223
- const resourceId = resourceIdQuery.value
224
- if (resourceId) {
225
- // First check in already loaded resources
226
- const existing = flatResources.value.find(r => r.id === resourceId)
227
- if (existing) return existing
228
- // Use fetched resource if available
229
- if (fetchedResource.value) return fetchedResource.value
230
- }
231
- // Default to first resource
232
- return flatResources.value[0] ?? null
233
- }
234
-
235
- const selectedResource = ref<Resource | null>(getInitialResource())
236
-
237
- function updateSearch(newSearch: string) {
238
- search.value = newSearch
239
- flush()
240
- }
241
-
242
- const selectResource = (resource: Resource) => {
243
- selectedResource.value = resource
244
- router.replace({
245
- query: { ...router.currentRoute.value.query, resource_id: resource.id },
246
- })
247
- }
248
149
 
249
- // Update selection when resources change (e.g., after client-side fetch completes)
250
- watch(flatResources, () => {
251
- if (selectedResource.value) return
252
- const firstResource = flatResources.value[0]
253
- if (firstResource) {
254
- selectedResource.value = firstResource
255
- }
150
+ const resourceTo = (resource: Resource): RouteLocationRaw => ({
151
+ query: { ...route.query, resource_id: resource.id },
256
152
  })
257
153
  </script>
@@ -0,0 +1,79 @@
1
+ <template>
2
+ <div class="sticky top-0 z-30 flex h-[58px] shrink-0 items-center justify-between gap-2 border-b border-gray-default bg-gray-some/95 px-3 backdrop-blur-[5px] sm:gap-4">
3
+ <div class="flex min-w-0 items-center gap-2">
4
+ <OrganizationLogo
5
+ v-if="dataset.organization"
6
+ :organization="dataset.organization"
7
+ size-class="size-[34px] shrink-0 rounded-sm border border-gray-default bg-white p-1"
8
+ />
9
+ <Avatar
10
+ v-else-if="dataset.owner"
11
+ :user="dataset.owner"
12
+ :size="34"
13
+ class="shrink-0"
14
+ />
15
+ <div class="flex min-w-0 items-center gap-1 text-[13px] leading-[1.4] sm:text-base">
16
+ <span class="min-w-0 truncate text-gray-title">{{ producerName }}</span>
17
+ <span class="shrink-0 text-gray-title">/</span>
18
+ <span class="min-w-0 truncate font-bold text-gray-title">{{ dataset.title }}</span>
19
+ <template v-if="dataset.last_update">
20
+ <span class="hidden shrink-0 text-gray-medium sm:inline">·</span>
21
+ <TranslationT
22
+ class="hidden truncate text-gray-medium sm:inline"
23
+ keypath="mis à jour le {date}"
24
+ >
25
+ <template #date>
26
+ <FormattedDate :date="dataset.last_update" />
27
+ </template>
28
+ </TranslationT>
29
+ </template>
30
+ </div>
31
+ </div>
32
+ <div class="flex shrink-0 items-center gap-2">
33
+ <ResourceMainAction
34
+ v-if="resource"
35
+ :resource="resource"
36
+ :dataset="dataset"
37
+ />
38
+ <BrandedButton
39
+ v-if="exitTo"
40
+ :href="exitTo"
41
+ :icon="RiFullscreenExitLine"
42
+ color="secondary"
43
+ size="xs"
44
+ icon-only
45
+ :title="t('Quitter le plein écran')"
46
+ >
47
+ {{ t('Quitter le plein écran') }}
48
+ </BrandedButton>
49
+ </div>
50
+ </div>
51
+ </template>
52
+
53
+ <script setup lang="ts">
54
+ import { computed } from 'vue'
55
+ import { RiFullscreenExitLine } from '@remixicon/vue'
56
+ import type { RouteLocationRaw } from 'vue-router'
57
+ import Avatar from '../Avatar.vue'
58
+ import BrandedButton from '../BrandedButton.vue'
59
+ import OrganizationLogo from '../OrganizationLogo.vue'
60
+ import ResourceMainAction from './ResourceMainAction.vue'
61
+ import FormattedDate from '../FormattedDate.vue'
62
+ import TranslationT from '../TranslationT.vue'
63
+ import { getOwnerName } from '../../functions/owned'
64
+ import { useTranslation } from '../../composables/useTranslation'
65
+ import type { Dataset, DatasetV2 } from '../../types/datasets'
66
+ import type { Resource } from '../../types/resources'
67
+
68
+ const props = defineProps<{
69
+ dataset: Dataset | DatasetV2
70
+ resource: Resource | null
71
+ exitTo?: RouteLocationRaw
72
+ }>()
73
+
74
+ const { t } = useTranslation()
75
+
76
+ // Plain producer name, matching the prototype: dark text, no link, no certificate
77
+ // badge — unlike ObjectCardOwner which renders a styled link.
78
+ const producerName = computed(() => getOwnerName(props.dataset))
79
+ </script>