@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
@@ -1,116 +1,149 @@
1
1
  <template>
2
2
  <aside
3
- v-if="!collapsed"
4
- class="w-full md:w-72 shrink-0 p-4 md:pr-0"
3
+ class="relative flex shrink-0 flex-col overflow-hidden border-r border-gray-default bg-white"
4
+ :class="resizing ? '' : 'transition-[width] duration-200'"
5
+ :style="{ width: collapsed ? '48px' : `${width}px` }"
6
+ :aria-label="t('Ressources')"
5
7
  >
6
- <div class="flex items-center justify-between mb-3">
7
- <h3 class="text-sm font-bold uppercase mb-0">
8
- {{ t('Ressources') }}
9
- </h3>
8
+ <div
9
+ class="flex h-14 items-center border-b border-gray-default bg-gray-some px-3"
10
+ :class="collapsed ? 'justify-center' : 'justify-between'"
11
+ >
12
+ <span
13
+ v-if="!collapsed"
14
+ class="text-[13px] font-medium text-gray-title"
15
+ >{{ t('Ressources') }}</span>
10
16
  <button
11
17
  type="button"
12
- :title="t('Masquer le panneau')"
13
- class="p-1 hover:bg-gray-100 rounded"
14
- @click="$emit('update:collapsed', true)"
18
+ :title="collapsed ? t('Afficher le panneau des ressources') : t('Masquer le panneau')"
19
+ class="flex size-6 items-center justify-center rounded text-gray-plain hover:bg-gray-100"
20
+ @click="$emit('update:collapsed', !collapsed)"
15
21
  >
16
- <RiArrowRightSLine class="size-5" />
22
+ <component
23
+ :is="collapsed ? RiSidebarUnfoldLine : RiSidebarFoldLine"
24
+ class="size-5"
25
+ />
17
26
  </button>
18
27
  </div>
19
28
 
20
- <div class="mb-3">
21
- <label
22
- :for="searchId"
23
- class="sr-only"
24
- >{{ t('Rechercher') }}</label>
25
- <input
26
- :id="searchId"
27
- :value="search"
28
- type="search"
29
- class="w-full border border-gray-default rounded px-2.5 py-1.5 text-sm"
30
- :placeholder="t('Rechercher')"
31
- @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
32
- >
33
- </div>
29
+ <!-- min-w keeps the content laid out at the sidebar's min width so it doesn't
30
+ reflow (badges wrapping, section titles on 2 lines) while the width animates
31
+ open from the collapsed state — the aside's overflow-hidden clips the rest. -->
32
+ <!-- Extra bottom padding so the last item can scroll clear of the browser's
33
+ hover URL bar (Firefox shows it bottom-left). -->
34
+ <div
35
+ v-if="!collapsed"
36
+ class="flex min-h-0 min-w-[260px] flex-1 flex-col gap-3 overflow-y-auto p-2 pb-16"
37
+ >
38
+ <label class="flex h-8 items-center gap-1 rounded border border-gray-default bg-gray-some px-2">
39
+ <RiSearchLine class="size-3.5 shrink-0 text-gray-medium" />
40
+ <span class="sr-only">{{ t('Rechercher') }}</span>
41
+ <input
42
+ :value="search"
43
+ type="search"
44
+ class="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-gray-medium"
45
+ :placeholder="t('Rechercher une ressource')"
46
+ @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
47
+ >
48
+ </label>
34
49
 
35
- <div class="space-y-4 overflow-y-auto md:max-h-[calc(100vh-14rem)]">
36
- <div
37
- v-for="group in resources"
50
+ <section
51
+ v-for="group in groups"
38
52
  :key="group.type"
53
+ class="space-y-0.5"
39
54
  >
40
- <div class="text-xs text-gray-plain font-bold uppercase mb-1.5 border-b border-gray-default pb-1">
55
+ <p class="px-1 py-2 text-[12px] font-medium leading-3 text-gray-medium">
41
56
  {{ getResourceLabel(group.type, group.total) }}
42
- </div>
43
- <ul class="list-none p-0 m-0 space-y-0.5">
44
- <li
45
- v-for="resource in group.items"
46
- :key="resource.id"
47
- >
48
- <button
49
- type="button"
50
- class="w-full text-left px-2 py-1.5 rounded text-sm flex items-center gap-1.5 hover:bg-gray-100"
51
- :class="{
52
- 'font-bold bg-blue-50': resource.id === selectedResourceId,
53
- }"
54
- @click="$emit('select', resource)"
55
- >
56
- <ResourceIcon
57
- :resource
58
- class="size-3.5 shrink-0"
59
- />
60
- <span class="truncate">{{ resource.title || t('Fichier sans nom') }}</span>
61
- </button>
62
- </li>
63
- </ul>
57
+ </p>
58
+ <ResourceListItem
59
+ v-for="resource in group.items"
60
+ :key="resource.id"
61
+ :resource
62
+ :to="resourceTo(resource)"
63
+ :replace
64
+ :selected="resource.id === selectedResourceId"
65
+ />
64
66
  <button
65
67
  v-if="group.items.length < group.total"
66
68
  type="button"
67
- class="w-full text-left px-2 py-1.5 text-sm text-blue-default hover:underline"
69
+ :disabled="loadingType === group.type"
70
+ class="flex w-full items-center gap-1 px-1 py-1 text-left text-[13px] text-blue-default hover:underline disabled:cursor-default disabled:no-underline"
68
71
  @click="$emit('load-more', group.type)"
69
72
  >
73
+ <RiLoader5Line
74
+ v-if="loadingType === group.type"
75
+ class="size-3.5 shrink-0 animate-spin"
76
+ />
70
77
  {{ t('Charger plus…') }}
71
78
  </button>
79
+ </section>
80
+
81
+ <!-- The search lives here, so its outcome does too: the viewer only says that
82
+ nothing is selected. -->
83
+ <div
84
+ v-if="search && !groups.length"
85
+ class="flex flex-col gap-2 px-1 py-2"
86
+ >
87
+ <p class="m-0 text-[13px] leading-snug text-gray-medium">
88
+ {{ t('Pas de résultats pour « {q} »', { q: search }) }}
89
+ </p>
90
+ <BrandedButton
91
+ color="secondary"
92
+ size="xs"
93
+ class="w-full"
94
+ @click="$emit('update:search', '')"
95
+ >
96
+ {{ t('Réinitialiser la recherche') }}
97
+ </BrandedButton>
72
98
  </div>
73
99
  </div>
74
- </aside>
75
100
 
76
- <div
77
- v-else
78
- class="shrink-0 flex items-start pt-1"
79
- >
80
- <button
81
- type="button"
82
- :title="t('Afficher le panneau des ressources')"
83
- class="p-1 hover:bg-gray-100 rounded border border-gray-default"
84
- @click="$emit('update:collapsed', false)"
85
- >
86
- <RiArrowLeftSLine class="size-5" />
87
- </button>
88
- </div>
101
+ <!-- Drag the right edge to resize the panel and read longer resource titles.
102
+ A separator rather than a button: it only answers to a drag, and a focusable
103
+ button would announce an action that no key can trigger. Keyboard users fold
104
+ the panel from the header instead. -->
105
+ <div
106
+ v-if="!collapsed"
107
+ role="separator"
108
+ aria-orientation="vertical"
109
+ :title="t('Glisser pour redimensionner')"
110
+ class="absolute right-0 top-0 z-20 h-full w-2 cursor-col-resize touch-none hover:bg-new-primary/10"
111
+ @mousedown="startResize"
112
+ />
113
+ </aside>
89
114
  </template>
90
115
 
91
116
  <script setup lang="ts">
92
- import { useId } from 'vue'
93
- import { RiArrowRightSLine, RiArrowLeftSLine } from '@remixicon/vue'
94
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
117
+ import type { RouteLocationRaw } from 'vue-router'
118
+ import { RiSidebarFoldLine, RiSidebarUnfoldLine, RiSearchLine, RiLoader5Line } from '@remixicon/vue'
119
+ import BrandedButton from '../BrandedButton.vue'
120
+ import ResourceListItem from '../ResourceListItem.vue'
95
121
  import { getResourceLabel } from '../../functions/resources'
96
122
  import { useTranslation } from '../../composables/useTranslation'
123
+ import { useResizable } from '../../composables/useResizable'
97
124
  import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
98
125
 
99
126
  const { t } = useTranslation()
100
127
 
101
128
  defineProps<{
102
- resources: ResourceGroup[]
129
+ groups: ResourceGroup[]
103
130
  selectedResourceId: string | null
104
131
  collapsed: boolean
105
132
  search: string
133
+ loadingType: ResourceType | null
134
+ resourceTo: (resource: Resource) => RouteLocationRaw
135
+ replace?: boolean
106
136
  }>()
107
137
 
108
138
  defineEmits<{
109
- 'select': [resource: Resource]
110
139
  'load-more': [type: ResourceType]
111
140
  'update:collapsed': [value: boolean]
112
141
  'update:search': [value: string]
113
142
  }>()
114
143
 
115
- const searchId = useId()
144
+ const { width, resizing, startResize } = useResizable({
145
+ initialWidth: 300,
146
+ minWidth: 260,
147
+ maxWidth: 720,
148
+ })
116
149
  </script>
@@ -1,102 +1,24 @@
1
1
  <template>
2
- <div class="border border-gray-default">
3
- <header class="p-4 flex flex-wrap md:flex-nowrap gap-4 items-center justify-between">
4
- <div>
5
- <div class="flex items-center gap-1 mb-1">
6
- <h3 class="m-0 flex items-baseline text-base font-bold leading-tight">
7
- <ResourceIcon
8
- :resource
9
- class="size-3.5 mr-1 shrink-0 translate-y-px"
10
- />
11
- <span class="line-clamp-2">{{ resource.title || t('Fichier sans nom') }}</span>
12
- </h3>
13
- <ResourceSelector
14
- v-if="resources && resources.length > 1"
15
- :resources
16
- :selected-id="resource.id"
17
- @select="emit('select', $event)"
18
- />
19
- <CopyButton
20
- :label="t('Copier le lien')"
21
- :copied-label="t('Lien copié !')"
22
- :text="resourceExternalUrl"
23
- class="hidden md:inline-flex"
24
- />
25
- </div>
26
- <div class="text-gray-medium text-xs flex items-center gap-1 flex-wrap">
27
- <SchemaBadge :resource />
28
- <RiSubtractLine
29
- v-if="resource.schema?.name || resource.schema?.url"
30
- aria-hidden="true"
31
- class="size-3 fill-gray-medium"
32
- />
33
- <span>{{ t('mis à jour {date}', { date: formatRelativeIfRecentDate(resource.last_modified) }) }}</span>
34
- <RiSubtractLine
35
- aria-hidden="true"
36
- class="size-3 fill-gray-medium"
37
- />
38
- <template v-if="resource.format">
39
- <span>
40
- {{ resource.format.trim().toLowerCase() }}
41
- <span v-if="resourceFilesize">({{ filesize(resourceFilesize) }})</span>
42
- </span>
43
- <RiSubtractLine
44
- aria-hidden="true"
45
- class="size-3 fill-gray-medium"
46
- />
47
- </template>
48
- <span class="inline-flex items-center">
49
- <RiDownloadLine class="size-3 mr-0.5" />
50
- {{ summarize(resource.metrics.views) }}
51
- </span>
52
- </div>
53
- </div>
54
- <div class="flex items-center gap-2">
55
- <BrandedButton
56
- v-if="isResourceUrl"
57
- :href="resource.latest"
58
- :title="t('Lien du fichier - ouvre une nouvelle fenêtre')"
59
- rel="ugc nofollow noopener"
60
- new-tab
61
- size="xs"
62
- external
63
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
64
- >
65
- {{ t('Visiter') }}
66
- </BrandedButton>
67
- <BrandedButton
68
- v-else-if="ogcService"
69
- :icon="RiFileCopyLine"
70
- color="primary"
71
- size="xs"
72
- @click="copyResourceUrl"
73
- >
74
- {{ t('Copier le lien') }}
75
- </BrandedButton>
76
- <BrandedButton
77
- v-else
78
- :href="resource.latest"
79
- rel="ugc nofollow noopener"
80
- :title="downloadButtonTitle"
81
- download
82
- class="matomo_download"
83
- :icon="unavailable ? RiFileWarningLine : RiDownloadLine"
84
- size="xs"
85
- color="primary"
86
- external
87
- @click="trackEvent('Jeux de données', 'Télécharger un fichier', 'Bouton : télécharger un fichier')"
88
- >
89
- {{ t('Télécharger') }}
90
- </BrandedButton>
91
- </div>
92
- </header>
2
+ <div :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
3
+ <ResourceViewerHeader
4
+ :dataset
5
+ :resource
6
+ :resources
7
+ :resource-to
8
+ :explore-to="exploreTo"
9
+ :resource-external-url="resourceExternalUrl"
10
+ :replace
11
+ :fullscreen
12
+ />
93
13
 
94
- <section class="pb-4">
14
+ <section :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
95
15
  <TabGroup
96
16
  size="sm"
17
+ :default-index="defaultTabIndex"
18
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
97
19
  @change="switchTab"
98
20
  >
99
- <div class="pl-4 pr-4 pb-4">
21
+ <div class="flex shrink-0 items-center border-b border-gray-default p-2">
100
22
  <TabList class="max-w-full overflow-x-auto">
101
23
  <Tab
102
24
  v-for="tab in tabsOptions"
@@ -106,13 +28,16 @@
106
28
  </Tab>
107
29
  </TabList>
108
30
  </div>
109
- <TabPanels>
31
+ <TabPanels :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''">
110
32
  <TabPanel
111
33
  v-for="tab in tabsOptions"
112
34
  :key="tab.key"
113
- class="px-4"
35
+ :class="[tab.key === 'data' || tab.key === 'map' ? '' : 'p-4', fullscreen ? 'flex min-h-0 flex-1 flex-col' : '']"
114
36
  >
115
- <div v-if="tab.key === 'map'">
37
+ <div
38
+ v-if="tab.key === 'map'"
39
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : 'h-[600px]'"
40
+ >
116
41
  <Pmtiles
117
42
  v-if="hasPmtiles"
118
43
  :resource="resource"
@@ -122,47 +47,81 @@
122
47
  v-if="ogcWms"
123
48
  :resource="resource"
124
49
  />
50
+ <PreviewUnavailable v-if="!hasPmtiles && !ogcWms && hasPmtilesError">
51
+ {{ t("La carte n'a pas pu être générée automatiquement pour ce fichier.") }}
52
+ <br>
53
+ <span class="text-gray-medium text-xs">{{ pmtilesError }}</span>
54
+ </PreviewUnavailable>
125
55
  </div>
126
- <div v-if="tab.key === 'data'">
127
- <JsonPreview
128
- v-if="resource.format && resource.format.toLowerCase() === 'json'"
129
- :resource="resource"
130
- />
56
+ <div
57
+ v-if="tab.key === 'data'"
58
+ :class="fullscreen ? 'flex min-h-0 flex-1 flex-col' : ''"
59
+ >
60
+ <!-- Wrapped in Suspense so switching to this tab (or loading its data) shows
61
+ the table skeleton instead of a blank gap while TabularExplorer resolves. -->
62
+ <Suspense
63
+ v-if="previewKind === 'tabular'"
64
+ :timeout="200"
65
+ >
66
+ <TabularExplorer :resource-id="resource.id">
67
+ <TabularToolbar class="shrink-0 border-b border-gray-default p-2" />
68
+ <TabularTable :fill="fullscreen" />
69
+ <TabularMobileFilters />
70
+ </TabularExplorer>
71
+ <template #fallback>
72
+ <TabularSkeleton :fill="fullscreen" />
73
+ </template>
74
+ </Suspense>
75
+
76
+ <!-- PDF is a full-bleed visual preview like the table and the map: it
77
+ owns its own reader backdrop, so it sits outside the padded wrapper. -->
131
78
  <PdfPreview
132
- v-else-if="resource.format && resource.format.toLowerCase() === 'pdf'"
79
+ v-else-if="previewKind === 'pdf'"
133
80
  :resource="resource"
81
+ :fill="fullscreen"
134
82
  />
135
- <XmlPreview
136
- v-else-if="resource.format && resource.format.toLowerCase() === 'xml'"
137
- :resource="resource"
138
- />
139
- <DatafairPreview
140
- v-else-if="hasDatafairPreview"
141
- :resource="resource"
142
- :dataset="dataset"
143
- />
144
- <OpenApiViewer
145
- v-else-if="hasOpenAPIPreview"
146
- :url="resource.extras['apidocUrl'] as string"
147
- />
148
- <TabularExplorer
149
- v-else-if="hasTabularData"
150
- :resource-id="resource.id"
151
- />
152
- <PreviewUnavailable v-else>
153
- <!-- "File too large to download" is the only analysis:error value from hydra for now -->
154
- <template v-if="resource.extras['analysis:error'] === 'File too large to download'">
155
- {{ t("Ce fichier est trop volumineux pour être analysé et prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.") }}
156
- </template>
157
- <template v-else-if="resource.extras['analysis:parsing:error']">
158
- {{ t("L'analyse de ce fichier a rencontré une erreur, l'aperçu n'est pas disponible. Téléchargez-le depuis l'onglet Téléchargements.") }}
159
- <br>
160
- <span class="text-gray-medium text-xs">{{ resource.extras['analysis:parsing:error'] }}</span>
161
- </template>
162
- <template v-else>
163
- {{ t("Ce fichier ne peut pas être prévisualisé. Téléchargez-le depuis l'onglet Téléchargements.") }}
164
- </template>
165
- </PreviewUnavailable>
83
+
84
+ <!-- Text previews stay padded inside the tab panel -->
85
+ <div
86
+ v-else
87
+ class="p-4"
88
+ >
89
+ <JsonPreview
90
+ v-if="previewKind === 'json'"
91
+ :resource="resource"
92
+ />
93
+ <XmlPreview
94
+ v-else-if="previewKind === 'xml'"
95
+ :resource="resource"
96
+ />
97
+ <ImagePreview
98
+ v-else-if="previewKind === 'image'"
99
+ :resource="resource"
100
+ />
101
+ <DatafairPreview
102
+ v-else-if="previewKind === 'datafair'"
103
+ :resource="resource"
104
+ :dataset="dataset"
105
+ />
106
+ <OpenApiViewer
107
+ v-else-if="previewKind === 'openapi'"
108
+ :url="resource.extras['apidocUrl'] as string"
109
+ />
110
+ <PreviewUnavailable v-else>
111
+ <!-- "File too large to download" is the only analysis:error value from hydra for now -->
112
+ <template v-if="resource.extras['analysis:error'] === 'File too large to download'">
113
+ {{ t("Ce fichier est trop volumineux pour être analysé et prévisualisé. Téléchargez-le avec le bouton Télécharger.") }}
114
+ </template>
115
+ <template v-else-if="resource.extras['analysis:parsing:error']">
116
+ {{ t("L'analyse de ce fichier a rencontré une erreur, l'aperçu n'est pas disponible. Téléchargez-le avec le bouton Télécharger.") }}
117
+ <br>
118
+ <span class="text-gray-medium text-xs">{{ resource.extras['analysis:parsing:error'] }}</span>
119
+ </template>
120
+ <template v-else>
121
+ {{ t("Ce fichier ne peut pas être prévisualisé. Téléchargez-le avec le bouton Télécharger.") }}
122
+ </template>
123
+ </PreviewUnavailable>
124
+ </div>
166
125
  </div>
167
126
  <div v-if="tab.key === 'description'">
168
127
  <MarkdownViewer
@@ -179,12 +138,6 @@
179
138
  <div v-if="tab.key === 'metadata'">
180
139
  <Metadata :resource />
181
140
  </div>
182
- <div v-if="tab.key === 'downloads'">
183
- <Downloads
184
- :resource="resource"
185
- :dataset="dataset"
186
- />
187
- </div>
188
141
  <div v-if="tab.key === 'api'">
189
142
  <div class="fr-mb-4w">
190
143
  <p>{{ t("Cette API est générée automatiquement par {platform} à partir du fichier.", { platform: config.name }) }}</p>
@@ -209,13 +162,9 @@
209
162
 
210
163
  <script setup lang="ts">
211
164
  import { computed, defineAsyncComponent } from 'vue'
212
- import { RiDownloadLine, RiFileCopyLine, RiFileWarningLine, RiSubtractLine } from '@remixicon/vue'
165
+ import { useRoute, useRouter } from 'vue-router'
213
166
  import PreviewUnavailable from '../ResourceAccordion/PreviewUnavailable.vue'
214
- import { toast } from 'vue-sonner'
215
- import BrandedButton from '../BrandedButton.vue'
216
- import CopyButton from '../CopyButton.vue'
217
167
  import MarkdownViewer from '../MarkdownViewer.vue'
218
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
219
168
  import OpenApiViewer from '../OpenApiViewer/OpenApiViewer.vue'
220
169
  import TabGroup from '../Tabs/TabGroup.vue'
221
170
  import TabList from '../Tabs/TabList.vue'
@@ -223,19 +172,19 @@ import Tab from '../Tabs/Tab.vue'
223
172
  import TabPanels from '../Tabs/TabPanels.vue'
224
173
  import TabPanel from '../Tabs/TabPanel.vue'
225
174
  import TabularExplorer from '../TabularExplorer/TabularExplorer.vue'
175
+ import TabularToolbar from '../TabularExplorer/TabularToolbar.vue'
176
+ import TabularTable from '../TabularExplorer/TabularTable.vue'
177
+ import TabularMobileFilters from '../TabularExplorer/TabularMobileFilters.vue'
178
+ import TabularSkeleton from '../TabularExplorer/TabularSkeleton.vue'
226
179
  import DataStructure from '../ResourceAccordion/DataStructure.vue'
227
- import Downloads from '../ResourceAccordion/Downloads.vue'
228
180
  import Metadata from '../ResourceAccordion/Metadata.vue'
229
- import SchemaBadge from '../ResourceAccordion/SchemaBadge.vue'
230
- import ResourceSelector from './ResourceSelector.vue'
231
- import { filesize, summarize } from '../../functions/helpers'
232
- import { getResourceFormatIcon, getResourceExternalUrl, getResourceFilesize } from '../../functions/resources'
181
+ import ResourceViewerHeader from './ResourceViewerHeader.vue'
233
182
  import { trackEvent } from '../../functions/matomo'
234
183
  import { useComponentsConfig } from '../../config'
235
- import { useFormatDate } from '../../functions/dates'
236
184
  import { useTranslation } from '../../composables/useTranslation'
237
185
  import { useResourceCapabilities } from '../../composables/useResourceCapabilities'
238
186
  import { provideTabularProfile } from '../../composables/useTabularProfile'
187
+ import type { RouteLocationRaw } from 'vue-router'
239
188
  import type { Resource } from '../../types/resources'
240
189
  import type { Dataset, DatasetV2 } from '../../types/datasets'
241
190
 
@@ -248,6 +197,9 @@ const PdfPreview = defineAsyncComponent(() =>
248
197
  const XmlPreview = defineAsyncComponent(() =>
249
198
  import('../ResourceAccordion/XmlPreview.client.vue'),
250
199
  )
200
+ const ImagePreview = defineAsyncComponent(() =>
201
+ import('../ResourceAccordion/ImagePreview.client.vue'),
202
+ )
251
203
  const DatafairPreview = defineAsyncComponent(() =>
252
204
  import('../ResourceAccordion/Datafair.client.vue'),
253
205
  )
@@ -258,60 +210,61 @@ const Pmtiles = defineAsyncComponent(() =>
258
210
  import('../ResourceAccordion/Pmtiles.client.vue'),
259
211
  )
260
212
 
261
- const props = defineProps<{
213
+ const props = withDefaults(defineProps<{
262
214
  dataset: Dataset | DatasetV2
263
215
  resource: Resource
264
216
  resources?: Resource[]
265
- }>()
266
-
267
- const emit = defineEmits<{
268
- select: [resource: Resource]
269
- }>()
217
+ resourceTo: (resource: Resource) => RouteLocationRaw
218
+ // When provided (inline mode), shows an "Explorer" button next to the download
219
+ // action that opens the fullscreen explorer on the current resource.
220
+ exploreTo?: (resource: Resource) => string
221
+ // Overrides the "Copier le lien" target.
222
+ resourceExternalUrl?: (resource: Resource) => string
223
+ replace?: boolean
224
+ // Fullscreen mode: make the viewer a flex column so the table fills down to the
225
+ // bottom, and hide the inline download/visit/copy actions — they're shown in the
226
+ // dataset context bar above. Inline mode (dataset page) shows them in the header.
227
+ fullscreen?: boolean
228
+ }>(), {
229
+ fullscreen: false,
230
+ })
270
231
 
271
232
  const { t } = useTranslation()
272
233
  const config = useComponentsConfig()
273
- const { formatRelativeIfRecentDate } = useFormatDate()
234
+ const route = useRoute()
235
+ const router = useRouter()
274
236
 
275
237
  const {
238
+ previewKind,
276
239
  hasTabularData,
277
240
  hasPmtiles,
278
- hasDatafairPreview,
279
- hasOpenAPIPreview,
280
- ogcService,
241
+ hasPmtilesError,
242
+ pmtilesError,
281
243
  ogcWms,
282
- isResourceUrl,
283
244
  tabsOptions,
284
245
  } = useResourceCapabilities(() => props.resource, () => props.dataset)
285
246
 
286
247
  // Share the tabular profile fetch between TabularExplorer and DataStructure tabs.
287
- await provideTabularProfile(() => props.resource.id)
288
-
289
- const resourceFilesize = computed(() => getResourceFilesize(props.resource))
290
- const resourceExternalUrl = computed(() => getResourceExternalUrl(props.dataset, props.resource))
248
+ // Only tabular resources have one: asking for the profile of a PDF or an image is a
249
+ // request to the Tabular API that can only fail.
250
+ if (hasTabularData.value) {
251
+ await provideTabularProfile(() => props.resource.id)
252
+ }
291
253
 
292
- const format = computed(() => getResourceFormatIcon(props.resource.format) ? props.resource.format : 'Fichier')
293
- const availabilityChecked = computed(() => props.resource.extras && 'check:available' in props.resource.extras)
294
- const unavailable = computed(() => availabilityChecked.value && props.resource.extras['check:available'] === false)
295
- const downloadButtonTitle = computed(() => {
296
- if (unavailable.value) {
297
- return t('Le robot de {platform} n\'a pas pu accéder à ce fichier - Télécharger le fichier en {format}', { platform: config.name, format: format.value })
298
- }
299
- return t('Télécharger le fichier en {format}', { format: format.value })
254
+ // The active tab lives in the URL so a shared link opens on the same one. Read once
255
+ // at mount (TabGroup only takes an initial index), which is enough: switching resource
256
+ // remounts the viewer, and the tab is preserved when the new resource also has it.
257
+ const defaultTabIndex = computed(() => {
258
+ const index = tabsOptions.value.findIndex(option => option.key === route.query.tab)
259
+ return index === -1 ? 0 : index
300
260
  })
301
261
 
302
- const copyResourceUrl = async () => {
303
- try {
304
- await navigator.clipboard.writeText(props.resource.url)
305
- toast.success(t('Lien copié !'))
306
- }
307
- catch {
308
- toast.error(t('Impossible de copier dans le presse-papier'))
309
- }
310
- }
311
-
312
262
  const switchTab = (index: number) => {
313
263
  const option = tabsOptions.value[index]
314
264
  if (!option) return
265
+ // The first tab is the default: drop `tab` rather than writing it in the URL.
266
+ const { tab: _, ...query } = route.query
267
+ router.replace({ query: index === 0 ? query : { ...query, tab: option.key } })
315
268
  trackEvent('View resource tab', props.resource.id, option.label)
316
269
  if (option.key === 'data') {
317
270
  trackEvent('Show preview', props.resource.id)