@datagouv/components-next 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/README.md +7 -6
  2. package/dist/{Control-ZFh5ta_U.js → Control-B9WQxlpJ.js} +1 -1
  3. package/dist/{Datafair.client-rf4T1IkA.js → Datafair.client-D_6yzJUG.js} +9 -9
  4. package/dist/{Event-DSQcW7OF.js → Event-iXDrp-fy.js} +11 -11
  5. package/dist/{Image-BijNEG0p.js → Image-mNMBOofs.js} +5 -5
  6. package/dist/ImagePreview.client-B0iPhFm3.js +36 -0
  7. package/dist/{JsonPreview.client-dzar6iuh.js → JsonPreview.client-DSl1kiwC.js} +5 -5
  8. package/dist/{Map-BUtPf5GN.js → Map-BfqKMw0S.js} +1306 -1166
  9. package/dist/{MapContainer.client-D-MoRNhG.js → MapContainer.client-BSfpk_rT.js} +15 -15
  10. package/dist/{OSM-D4MTdBtk.js → OSM-CcrUhItr.js} +15 -23
  11. package/dist/{PdfPreview.client-DoDYLmJD.js → PdfPreview.client-BANFeM6K.js} +441 -434
  12. package/dist/Pmtiles.client-DH9ZrsZR.js +26333 -0
  13. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-I8EZCgzp.js +60 -0
  14. package/dist/{ScaleLine-hJQIqcZm.js → ScaleLine-VGQ7nkMS.js} +2 -2
  15. package/dist/{Tile-Dcl7oIVu.js → Tile-CSpn0Ca8.js} +153 -156
  16. package/dist/{TileImage-BJeHipMX.js → TileImage-CpbaEFwd.js} +143 -131
  17. package/dist/{View-xp_P_OHw.js → View-BYBpt-AU.js} +167 -117
  18. package/dist/{XmlPreview.client-cOhwff6P.js → XmlPreview.client-KCB5mwVf.js} +5 -5
  19. package/dist/{common-BjQlan3k.js → common-BoeTUnDZ.js} +26 -26
  20. package/dist/components-next.css +1 -52
  21. package/dist/components-next.js +201 -170
  22. package/dist/components.css +2 -2
  23. package/dist/{index-NofRBuyf.js → index-DHWzX5vK.js} +5999 -5472
  24. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-pVQfonBI.js} +162 -164
  25. package/dist/{main-Iz1ZCL6k.js → main-BDcYkhOc.js} +36671 -29402
  26. package/dist/{proj-CsNo9yH1.js → proj-DxGbobp7.js} +312 -296
  27. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  28. package/dist/{tilecoord-A0fLnBZr.js → tilecoord-DvX7WWiA.js} +264 -255
  29. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  30. package/dist/{vue3-xml-viewer.common-tVI9uXUz.js → vue3-xml-viewer.common-Bdcn54ST.js} +1 -1
  31. package/package.json +40 -39
  32. package/src/components/ActivityList/ActivityList.vue +21 -12
  33. package/src/components/AppLink.vue +3 -0
  34. package/src/components/BannerAction.vue +6 -2
  35. package/src/components/Chart/ChartViewer.vue +111 -17
  36. package/src/components/Chart/ChartViewerWrapper.vue +28 -16
  37. package/src/components/ChartCard.vue +112 -0
  38. package/src/components/CopyButton.vue +6 -2
  39. package/src/components/DataserviceCard.vue +23 -6
  40. package/src/components/DataserviceQuality.vue +82 -0
  41. package/src/components/DataserviceQualityInline.vue +31 -0
  42. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  43. package/src/components/DatasetCard.vue +17 -6
  44. package/src/components/DatasetInformation/DatasetTemporalitySection.vue +7 -4
  45. package/src/components/DatasetQuality.vue +11 -11
  46. package/src/components/DatasetQualityInline.vue +2 -2
  47. package/src/components/DatasetQualityTooltipContent.vue +1 -1
  48. package/src/components/DiscussionMessageCard.vue +14 -15
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/FormattedDate.vue +69 -0
  51. package/src/components/LoadingBlock.vue +4 -1
  52. package/src/components/ObjectCard.vue +4 -1
  53. package/src/components/ObjectCardOwner.vue +13 -1
  54. package/src/components/Pagination.vue +18 -58
  55. package/src/components/Placeholder.vue +3 -2
  56. package/src/components/PostCard.vue +13 -15
  57. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  58. package/src/components/ResourceAccordion/Downloads.vue +19 -7
  59. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  60. package/src/components/ResourceAccordion/MapContainer.client.vue +1 -8
  61. package/src/components/ResourceAccordion/Metadata.vue +21 -5
  62. package/src/components/ResourceAccordion/PdfPreview.client.vue +36 -12
  63. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  64. package/src/components/ResourceAccordion/Pmtiles.client.vue +30 -19
  65. package/src/components/ResourceAccordion/Preview.vue +8 -4
  66. package/src/components/ResourceAccordion/PreviewWrapper.vue +17 -10
  67. package/src/components/ResourceAccordion/ResourceAccordion.vue +31 -11
  68. package/src/components/ResourceExplorer/ResourceExplorer.vue +105 -210
  69. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +79 -0
  70. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +104 -71
  71. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +140 -184
  72. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  73. package/src/components/ResourceExplorer/ResourceSelector.vue +24 -86
  74. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +124 -0
  75. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +96 -0
  76. package/src/components/ResourceListItem.vue +168 -0
  77. package/src/components/ReuseCard.vue +18 -6
  78. package/src/components/ReuseDetails.vue +2 -2
  79. package/src/components/ReuseHorizontalCard.vue +4 -3
  80. package/src/components/SchemaCard.vue +10 -1
  81. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  82. package/src/components/Search/GlobalSearch.vue +28 -10
  83. package/src/components/Search/SearchInput.vue +1 -1
  84. package/src/components/Search/Sidemenu.vue +4 -3
  85. package/src/components/SmallChart.vue +11 -10
  86. package/src/components/StatBox.vue +13 -5
  87. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  88. package/src/components/TabularExplorer/TabularCell.vue +7 -8
  89. package/src/components/TabularExplorer/TabularCellPopover.vue +21 -5
  90. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  91. package/src/components/TabularExplorer/TabularDateField.vue +41 -0
  92. package/src/components/TabularExplorer/TabularDateFilter.vue +403 -0
  93. package/src/components/TabularExplorer/TabularExplorer.vue +127 -780
  94. package/src/components/TabularExplorer/TabularFilterContent.vue +48 -38
  95. package/src/components/TabularExplorer/TabularFilterPopover.vue +27 -32
  96. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  97. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  98. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  99. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  100. package/src/components/TabularExplorer/TabularTable.vue +484 -0
  101. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  102. package/src/components/TabularExplorer/types.ts +40 -28
  103. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  104. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  105. package/src/components/TopicCard.vue +15 -14
  106. package/src/composables/useActiveDescendant.ts +5 -0
  107. package/src/composables/useDatasetResources.ts +164 -0
  108. package/src/composables/useHasTabularData.ts +2 -1
  109. package/src/composables/useResizable.ts +46 -0
  110. package/src/composables/useResourceCapabilities.ts +48 -40
  111. package/src/composables/useSearchFilter.ts +7 -5
  112. package/src/composables/useTranslation.ts +24 -9
  113. package/src/config.ts +1 -0
  114. package/src/functions/api.ts +13 -7
  115. package/src/functions/charts.ts +32 -1
  116. package/src/functions/dates.ts +26 -7
  117. package/src/functions/helpers.ts +15 -0
  118. package/src/functions/metrics.ts +57 -56
  119. package/src/functions/owned.ts +21 -0
  120. package/src/functions/paginate.ts +46 -0
  121. package/src/functions/resourceCapabilities.ts +34 -0
  122. package/src/functions/resources.ts +88 -0
  123. package/src/functions/schemas.ts +27 -0
  124. package/src/functions/scroll.ts +20 -0
  125. package/src/functions/tabular.ts +222 -5
  126. package/src/functions/tabularApi.ts +1 -43
  127. package/src/main.ts +32 -6
  128. package/src/types/dataservices.ts +15 -0
  129. package/src/types/organizations.ts +10 -0
  130. package/src/types/resources.ts +0 -1
  131. package/src/types/reuses.ts +12 -1
  132. package/src/types/search.ts +2 -2
  133. package/src/types/visualizations.ts +5 -0
  134. package/dist/Pmtiles.client-Dzm01Zfm.js +0 -25185
  135. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-BRNYswg3.js +0 -61
  136. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  137. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  138. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -1,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,195 +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_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 PAGE_SIZE = 10
98
- const url = computed(() => `/api/2/datasets/${props.dataset.id}/resources/`)
99
-
100
- // Resource ID from URL query
101
- const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
102
-
103
- // Fetch resources for each type
104
- const mainParams = computed(() => ({
105
- type: 'main' as const,
106
- page_size: PAGE_SIZE,
107
- q: searchDebounced.value || undefined,
108
- }))
109
- const documentationParams = computed(() => ({
110
- type: 'documentation' as const,
111
- page_size: PAGE_SIZE,
112
- q: searchDebounced.value || undefined,
113
- }))
114
- const updateParams = computed(() => ({
115
- type: 'update' as const,
116
- page_size: PAGE_SIZE,
117
- q: searchDebounced.value || undefined,
118
- }))
119
- const apiParams = computed(() => ({
120
- type: 'api' as const,
121
- page_size: PAGE_SIZE,
122
- q: searchDebounced.value || undefined,
123
- }))
124
- const codeParams = computed(() => ({
125
- type: 'code' as const,
126
- page_size: PAGE_SIZE,
127
- q: searchDebounced.value || undefined,
128
- }))
129
- const otherParams = computed(() => ({
130
- type: 'other' as const,
131
- page_size: PAGE_SIZE,
132
- q: searchDebounced.value || undefined,
133
- }))
134
-
135
- const { data: mainData, status: mainStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: mainParams })
136
- const { data: documentationData, status: documentationStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: documentationParams, server: false })
137
- const { data: updateData, status: updateStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: updateParams, server: false })
138
- const { data: apiData, status: apiStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: apiParams, server: false })
139
- const { data: codeData, status: codeStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: codeParams, server: false })
140
- const { data: otherData, status: otherStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: otherParams, server: false })
141
-
142
- const rawResourcesByTypes = [
143
- { data: mainData, status: mainStatus },
144
- { data: documentationData, status: documentationStatus },
145
- { data: updateData, status: updateStatus },
146
- { data: apiData, status: apiStatus },
147
- { data: codeData, status: codeStatus },
148
- { data: otherData, status: otherStatus },
149
- ]
150
-
151
- // Evaluated once at setup (before any search) — never changes afterwards
152
- const hasAnyResources = computed(() => {
153
- return props.dataset.resources.total > 0
154
- })
155
-
156
- const extraResourcesByType: Ref<Resource[]>[] = RESOURCE_TYPE.map(() => ref<Resource[]>([]))
157
- const pageByType: Ref<number>[] = RESOURCE_TYPE.map(() => ref(1))
158
-
159
- watch(searchDebounced, () => {
160
- for (let i = 0; i < RESOURCE_TYPE.length; i++) {
161
- extraResourcesByType[i]!.value = []
162
- pageByType[i]!.value = 1
163
- }
164
- })
165
-
166
- // Separate useFetch for loadMore, initialized at setup time with immediate: false
167
- // so that it doesn't fetch until execute() is called from the event handler.
168
- const loadMoreType = ref<ResourceType>('main')
169
- const loadMorePage = ref(1)
170
- const loadMoreParams = computed(() => ({
171
- type: loadMoreType.value,
172
- page_size: PAGE_SIZE,
173
- page: loadMorePage.value,
174
- q: searchDebounced.value || undefined,
175
- }))
176
- const { data: loadMoreData, execute: executeLoadMore } = await useFetch<PaginatedArray<Resource>>(url, {
177
- params: loadMoreParams,
178
- immediate: false,
179
- watch: false,
180
- })
181
-
182
- const loadMore = async (type: ResourceType) => {
183
- const index = RESOURCE_TYPE.indexOf(type)
184
- if (index === -1) return
185
- const pageRef = pageByType[index]!
186
- const extraRef = extraResourcesByType[index]!
187
- pageRef.value++
188
-
189
- loadMoreType.value = type
190
- loadMorePage.value = pageRef.value
191
- await executeLoadMore()
192
-
193
- if (loadMoreData.value) {
194
- extraRef.value = [...extraRef.value, ...loadMoreData.value.data]
195
- }
196
- }
197
-
198
- const allResources = computed<ResourceGroup[]>(() => {
199
- return RESOURCE_TYPE
200
- .map((type, index) => {
201
- const rawData = rawResourcesByTypes[index]!
202
- const extraData = extraResourcesByType[index]!
203
- return {
204
- type: type as ResourceType,
205
- total: rawData.data.value?.total ?? 0,
206
- items: [...(rawData.data.value?.data ?? []), ...extraData.value],
207
- }
208
- })
209
- .filter(group => group.items.length > 0)
210
- })
211
-
212
- const flatResources = computed(() =>
213
- allResources.value.flatMap(g => g.items),
214
- )
215
-
216
- // Fetch resource by ID if specified in URL (for SSR)
217
- const initialResourceId = resourceIdQuery.value
218
- const { data: fetchedResource } = initialResourceId
219
- ? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
220
- : { data: ref(null) }
221
-
222
- // Initial selection (synchronous for SSR hydration)
223
- function getInitialResource(): Resource | null {
224
- const resourceId = resourceIdQuery.value
225
- if (resourceId) {
226
- // First check in already loaded resources
227
- const existing = flatResources.value.find(r => r.id === resourceId)
228
- if (existing) return existing
229
- // Use fetched resource if available
230
- if (fetchedResource.value) return fetchedResource.value
231
- }
232
- // Default to first resource
233
- return flatResources.value[0] ?? null
234
- }
235
-
236
- const selectedResource = ref<Resource | null>(getInitialResource())
237
-
238
- function updateSearch(newSearch: string) {
239
- search.value = newSearch
240
- flush()
241
- }
242
-
243
- const selectResource = (resource: Resource) => {
244
- selectedResource.value = resource
245
- router.replace({
246
- query: { ...router.currentRoute.value.query, resource_id: resource.id },
247
- })
248
- }
249
149
 
250
- // Update selection when resources change (e.g., after client-side fetch completes)
251
- watch(flatResources, () => {
252
- if (selectedResource.value) return
253
- const firstResource = flatResources.value[0]
254
- if (firstResource) {
255
- selectedResource.value = firstResource
256
- }
150
+ const resourceTo = (resource: Resource): RouteLocationRaw => ({
151
+ query: { ...route.query, resource_id: resource.id },
257
152
  })
258
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>
@@ -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>