@datagouv/components-next 1.0.2-dev.18 → 1.0.2-dev.180

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 (151) hide show
  1. package/README.md +7 -6
  2. package/assets/main.css +4 -0
  3. package/dist/{Control-DuZJdKV_.js → Control-B9WQxlpJ.js} +1 -1
  4. package/dist/{Datafair.client-Ds3VQrRi.js → Datafair.client-CaxMyNif.js} +9 -9
  5. package/dist/{Event--kp8kMdJ.js → Event-iXDrp-fy.js} +31 -31
  6. package/dist/{Image-34hvypZI.js → Image-mNMBOofs.js} +9 -9
  7. package/dist/ImagePreview.client-CPmCyisP.js +36 -0
  8. package/dist/JsonPreview.client-CKOU-LBs.js +40 -0
  9. package/dist/{Map-BjUnLyj8.js → Map-BfqKMw0S.js} +1739 -1599
  10. package/dist/{MapContainer.client-B2BijMx5.js → MapContainer.client-IGYJWP5m.js} +41 -42
  11. package/dist/{OSM-s40W6sQ2.js → OSM-CcrUhItr.js} +15 -23
  12. package/dist/{PdfPreview.client-DJBMzrlC.js → PdfPreview.client-csUebU3A.js} +835 -856
  13. package/dist/Pmtiles.client-CLq7UHBg.js +26333 -0
  14. package/dist/PreviewWrapper.vue_vue_type_script_setup_true_lang-DeoHZXW1.js +60 -0
  15. package/dist/{ScaleLine-KW-nXqp3.js → ScaleLine-VGQ7nkMS.js} +2 -2
  16. package/dist/{Tile-DbNFNPfU.js → Tile-CSpn0Ca8.js} +160 -163
  17. package/dist/{TileImage-BsXBxMtq.js → TileImage-CpbaEFwd.js} +143 -131
  18. package/dist/{View-BR92hTWP.js → View-BYBpt-AU.js} +483 -422
  19. package/dist/XmlPreview.client-C1toAbQ0.js +34 -0
  20. package/dist/{common-PJfpC179.js → common-BoeTUnDZ.js} +52 -52
  21. package/dist/components-next.css +1 -52
  22. package/dist/components-next.js +193 -149
  23. package/dist/components.css +2 -2
  24. package/dist/{index-BFQX7Ccz.js → index-CaUlhl3k.js} +21842 -15810
  25. package/dist/{leaflet-src-7m1mB8LI.js → leaflet-src-ByYGrf8H.js} +185 -187
  26. package/dist/{main-vqhkVhVS.js → main-DD4ieUAo.js} +41500 -73223
  27. package/dist/{proj-DsetBcW7.js → proj-DxGbobp7.js} +662 -626
  28. package/dist/{text-clamp.esm-B5kW_XMt.js → text-clamp.esm-DVseBqZZ.js} +4 -4
  29. package/dist/{tilecoord-Db24Px13.js → tilecoord-DvX7WWiA.js} +273 -264
  30. package/dist/{vue3-json-viewer-BXwup7nO.js → vue3-json-viewer-CJLQFzXZ.js} +10 -16
  31. package/dist/{vue3-xml-viewer.common-BcNJo35o.js → vue3-xml-viewer.common-B1KPK4L4.js} +16 -16
  32. package/package.json +47 -38
  33. package/src/chart.ts +5 -0
  34. package/src/components/ActivityList/ActivityList.vue +3 -2
  35. package/src/components/AppLink.vue +3 -0
  36. package/src/components/BannerAction.vue +6 -2
  37. package/src/components/Chart/ChartViewer.vue +320 -0
  38. package/src/components/Chart/ChartViewerWrapper.vue +182 -0
  39. package/src/components/CopyButton.vue +6 -2
  40. package/src/components/DataserviceCard.vue +9 -0
  41. package/src/components/DataserviceQuality.vue +82 -0
  42. package/src/components/DataserviceQualityInline.vue +31 -0
  43. package/src/components/DataserviceQualityTooltipContent.vue +79 -0
  44. package/src/components/DatasetCard.vue +9 -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/Form/Listbox.vue +101 -0
  49. package/src/components/Form/SchemaSelect.vue +2 -0
  50. package/src/components/InfiniteLoader.vue +53 -0
  51. package/src/components/ObjectCardHeader.vue +11 -4
  52. package/src/components/OpenApiViewer/ContentTypeSelect.vue +48 -0
  53. package/src/components/OpenApiViewer/EndpointRequest.vue +164 -0
  54. package/src/components/OpenApiViewer/EndpointResponses.vue +149 -0
  55. package/src/components/OpenApiViewer/OpenApiViewer.vue +308 -0
  56. package/src/components/OpenApiViewer/SchemaPanel.vue +53 -0
  57. package/src/components/OpenApiViewer/SchemaTree.vue +77 -0
  58. package/src/components/OpenApiViewer/openapi.ts +150 -0
  59. package/src/components/OrganizationNameWithCertificate.vue +3 -2
  60. package/src/components/Pagination.vue +8 -5
  61. package/src/components/RadioInput.vue +7 -2
  62. package/src/components/ResourceAccordion/DataStructure.vue +11 -33
  63. package/src/components/ResourceAccordion/Datafair.client.vue +1 -1
  64. package/src/components/ResourceAccordion/Downloads.vue +160 -0
  65. package/src/components/ResourceAccordion/ImagePreview.client.vue +48 -0
  66. package/src/components/ResourceAccordion/JsonPreview.client.vue +23 -104
  67. package/src/components/ResourceAccordion/MapContainer.client.vue +2 -11
  68. package/src/components/ResourceAccordion/Metadata.vue +1 -2
  69. package/src/components/ResourceAccordion/PdfPreview.client.vue +58 -97
  70. package/src/components/ResourceAccordion/PdfSkeleton.vue +44 -0
  71. package/src/components/ResourceAccordion/Pmtiles.client.vue +28 -13
  72. package/src/components/ResourceAccordion/Preview.vue +11 -11
  73. package/src/components/ResourceAccordion/PreviewWrapper.vue +89 -0
  74. package/src/components/ResourceAccordion/ResourceAccordion.vue +23 -114
  75. package/src/components/ResourceAccordion/XmlPreview.client.vue +16 -98
  76. package/src/components/ResourceExplorer/ResourceExplorer.vue +102 -210
  77. package/src/components/ResourceExplorer/ResourceExplorerHeader.vue +74 -0
  78. package/src/components/ResourceExplorer/ResourceExplorerSidebar.vue +103 -71
  79. package/src/components/ResourceExplorer/ResourceExplorerViewer.vue +143 -291
  80. package/src/components/ResourceExplorer/ResourceMainAction.vue +240 -0
  81. package/src/components/ResourceExplorer/ResourceSelector.vue +51 -0
  82. package/src/components/ResourceExplorer/ResourceViewerHeader.vue +115 -0
  83. package/src/components/ResourceExplorer/ResourceViewerSkeleton.vue +94 -0
  84. package/src/components/ResourceListItem.vue +154 -0
  85. package/src/components/ReuseCard.vue +14 -6
  86. package/src/components/ReuseDetails.vue +2 -2
  87. package/src/components/ReuseHorizontalCard.vue +2 -2
  88. package/src/components/SchemaCard.vue +10 -1
  89. package/src/components/Search/Filter/AccessTypeFilter.vue +2 -2
  90. package/src/components/Search/GlobalSearch.vue +219 -117
  91. package/src/components/Search/SearchInput.vue +5 -4
  92. package/src/components/TabularExplorer/TabularActiveFilters.vue +79 -0
  93. package/src/components/TabularExplorer/TabularCell.vue +50 -0
  94. package/src/components/TabularExplorer/TabularCellPopover.vue +170 -0
  95. package/src/components/TabularExplorer/TabularColumnsMenu.vue +112 -0
  96. package/src/components/TabularExplorer/TabularExplorer.vue +299 -0
  97. package/src/components/TabularExplorer/TabularFilterContent.vue +362 -0
  98. package/src/components/TabularExplorer/TabularFilterPopover.vue +106 -0
  99. package/src/components/TabularExplorer/TabularMobileFilterButton.vue +21 -0
  100. package/src/components/TabularExplorer/TabularMobileFilters.vue +150 -0
  101. package/src/components/TabularExplorer/TabularRowsInfo.vue +31 -0
  102. package/src/components/TabularExplorer/TabularSkeleton.vue +92 -0
  103. package/src/components/TabularExplorer/TabularTable.vue +481 -0
  104. package/src/components/TabularExplorer/TabularToolbar.vue +25 -0
  105. package/src/components/TabularExplorer/types.ts +81 -0
  106. package/src/components/TabularExplorer/useColumnMetadata.ts +98 -0
  107. package/src/components/TabularExplorer/useTabularContext.ts +82 -0
  108. package/src/composables/useActiveDescendant.ts +5 -0
  109. package/src/composables/useDatasetResources.ts +164 -0
  110. package/src/composables/useHasTabularData.ts +15 -1
  111. package/src/composables/useMetrics.ts +1 -1
  112. package/src/composables/useResizable.ts +46 -0
  113. package/src/composables/useResourceCapabilities.ts +47 -21
  114. package/src/composables/useSearchFilter.ts +120 -0
  115. package/src/composables/useStableQueryParams.ts +38 -6
  116. package/src/composables/useTabularProfile.ts +70 -0
  117. package/src/config.ts +21 -3
  118. package/src/functions/activities.ts +3 -3
  119. package/src/functions/api.ts +20 -42
  120. package/src/functions/api.types.ts +1 -0
  121. package/src/functions/charts.ts +99 -0
  122. package/src/functions/datasets.ts +0 -17
  123. package/src/functions/dates.ts +3 -2
  124. package/src/functions/metrics.ts +6 -4
  125. package/src/functions/resources.ts +136 -1
  126. package/src/functions/schemas.ts +27 -0
  127. package/src/functions/tabular.ts +214 -0
  128. package/src/functions/tabularApi.ts +96 -11
  129. package/src/main.ts +117 -14
  130. package/src/types/dataservices.ts +17 -0
  131. package/src/types/organizations.ts +10 -0
  132. package/src/types/pages.ts +0 -5
  133. package/src/types/posts.ts +2 -2
  134. package/src/types/reports.ts +5 -1
  135. package/src/types/resources.ts +0 -1
  136. package/src/types/reuses.ts +12 -1
  137. package/src/types/search.ts +63 -1
  138. package/src/types/site.ts +5 -3
  139. package/src/types/ui.ts +2 -0
  140. package/src/types/users.ts +2 -1
  141. package/src/types/visualizations.ts +93 -0
  142. package/assets/swagger-themes/newspaper.css +0 -1670
  143. package/dist/JsonPreview.client-CYxmz0yV.js +0 -72
  144. package/dist/Pmtiles.client-C_hvRDuy.js +0 -25185
  145. package/dist/Swagger.client-B4y0hXME.js +0 -4
  146. package/dist/XmlPreview.client-LETEfNBH.js +0 -64
  147. package/src/components/ResourceAccordion/Swagger.client.vue +0 -48
  148. package/src/functions/pagination.ts +0 -9
  149. /package/src/components/{DatasetQualityItem.vue → QualityItem.vue} +0 -0
  150. /package/src/components/{DatasetQualityItemWarning.vue → QualityItemWarning.vue} +0 -0
  151. /package/src/components/{DatasetQualityScore.vue → QualityScore.vue} +0 -0
@@ -1,45 +1,82 @@
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"
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">
18
+ <ResourceExplorerSidebar
19
+ :groups
20
+ :selected-resource-id="selectedResource?.id ?? null"
21
+ :collapsed="sidebarCollapsed"
22
+ :search
23
+ :loading-type="loadingType"
24
+ :resource-to="resourceTo"
25
+ replace
26
+ @load-more="loadMore"
27
+ @update:collapsed="sidebarCollapsed = $event"
28
+ @update:search="updateSearch($event)"
10
29
  />
30
+ </div>
31
+ <div
32
+ class="flex-1 min-w-0"
33
+ :class="fullscreen ? 'flex flex-col' : ''"
34
+ >
35
+ <!-- Suspense shows the skeleton fallback while the (async) viewer resolves
36
+ its data on each resource switch. `timeout` keeps the previous resource
37
+ visible for a beat so a near-instant switch doesn't flash a skeleton. -->
38
+ <Suspense
39
+ v-if="selectedResource"
40
+ :timeout="200"
41
+ >
42
+ <ResourceExplorerViewer
43
+ :key="selectedResource.id"
44
+ :dataset
45
+ :resource="selectedResource"
46
+ :resources="flatResources"
47
+ :resource-to="resourceTo"
48
+ :explore-to="exploreTo"
49
+ replace
50
+ :fullscreen
51
+ />
52
+ <template #fallback>
53
+ <ResourceViewerSkeleton
54
+ :resource="selectedResource"
55
+ :dataset
56
+ :resources="flatResources"
57
+ :resource-to="resourceTo"
58
+ :explore-to="exploreTo"
59
+ replace
60
+ :fullscreen
61
+ />
62
+ </template>
63
+ </Suspense>
64
+ <!-- The selection never falls back to nothing once a resource has been shown,
65
+ so having none means the groups aren't in yet: only `main` is fetched on
66
+ the server, the other types arrive on hydration. -->
11
67
  <div
12
- v-else-if="search"
13
- class="flex flex-col items-center py-12"
68
+ v-else
69
+ class="animate-pulse-placeholder p-4"
70
+ :class="fullscreen ? 'min-h-0 flex-1' : ''"
71
+ role="status"
72
+ :aria-label="t('Chargement des ressources…')"
14
73
  >
15
- <slot name="no-results-image">
16
- <img
17
- :src="noResultsImage"
18
- class="h-20"
19
- alt=""
20
- >
21
- </slot>
22
- <p class="fr-text--bold fr-my-3v">
23
- {{ t('Pas de résultats pour « {q} »', { q: search }) }}
24
- </p>
25
- <BrandedButton
26
- color="primary"
27
- @click="updateSearch('')"
28
- >
29
- {{ t('Réinitialiser la recherche') }}
30
- </BrandedButton>
74
+ <div
75
+ class="w-full rounded bg-gray-200"
76
+ :class="fullscreen ? 'h-full' : 'h-96'"
77
+ />
31
78
  </div>
32
79
  </div>
33
- <ResourceExplorerSidebar
34
- :resources="allResources"
35
- :selected-resource-id="selectedResource?.id ?? null"
36
- :collapsed="sidebarCollapsed"
37
- :search
38
- @select="selectResource"
39
- @load-more="loadMore"
40
- @update:collapsed="sidebarCollapsed = $event"
41
- @update:search="updateSearch($event)"
42
- />
43
80
  </div>
44
81
  </div>
45
82
  <div
@@ -60,195 +97,50 @@
60
97
  </template>
61
98
 
62
99
  <script setup lang="ts">
63
- import { computed, ref, watch, type Ref } from 'vue'
64
- import { useRouter } from 'vue-router'
65
- import { useRouteQuery } from '@vueuse/router'
66
- import { useComponentsConfig } from '../../config'
100
+ import { ref } from 'vue'
101
+ import { useRoute } from 'vue-router'
102
+ import type { RouteLocationRaw } from 'vue-router'
67
103
  import { useTranslation } from '../../composables/useTranslation'
68
- import { useDebouncedRef } from '../../composables/useDebouncedRef'
69
- import { useFetch } from '../../functions/api'
70
- import { RESOURCE_TYPE } from '../../functions/resources'
71
- import type { PaginatedArray } from '../../types/api'
104
+ import { useDatasetResources } from '../../composables/useDatasetResources'
72
105
  import type { DatasetV2 } from '../../types/datasets'
73
- import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
106
+ import type { Resource } from '../../types/resources'
74
107
  import ResourceExplorerSidebar from './ResourceExplorerSidebar.vue'
75
108
  import ResourceExplorerViewer from './ResourceExplorerViewer.vue'
76
- import BrandedButton from '../BrandedButton.vue'
109
+ import ResourceExplorerHeader from './ResourceExplorerHeader.vue'
110
+ import ResourceViewerSkeleton from './ResourceViewerSkeleton.vue'
77
111
 
78
112
  const props = withDefaults(defineProps<{
79
113
  dataset: DatasetV2
80
114
  noResultsImage?: string
115
+ // Fullscreen mode: dataset context bar (org / title / date + download + exit), the
116
+ // viewer fills the height and hides its inline actions (shown in the context bar).
117
+ fullscreen?: boolean
118
+ exitTo?: RouteLocationRaw
119
+ // Inline mode only: link builder for the "Explorer" button in the viewer header
120
+ // that opens the fullscreen explorer on the current resource.
121
+ exploreTo?: (resource: Resource) => string
81
122
  }>(), {
82
123
  noResultsImage: '',
124
+ fullscreen: false,
83
125
  })
84
126
 
85
127
  const { t } = useTranslation()
86
- const router = useRouter()
87
- const config = useComponentsConfig()
128
+ const route = useRoute()
129
+
130
+ const {
131
+ groups,
132
+ flatResources,
133
+ hasAnyResources,
134
+ selectedResource,
135
+ loadMore,
136
+ loadingType,
137
+ search,
138
+ updateSearch,
139
+ } = await useDatasetResources(() => props.dataset)
88
140
 
89
141
  const sidebarCollapsed = ref(false)
90
- const search = ref('')
91
- const { debounced: searchDebounced, flush } = useDebouncedRef(search, config.searchDebounce ?? 300)
92
-
93
- const PAGE_SIZE = 10
94
- const url = computed(() => `/api/2/datasets/${props.dataset.id}/resources/`)
95
-
96
- // Resource ID from URL query
97
- const resourceIdQuery = useRouteQuery<string | undefined>('resource_id')
98
-
99
- // Fetch resources for each type
100
- const mainParams = computed(() => ({
101
- type: 'main' as const,
102
- page_size: PAGE_SIZE,
103
- q: searchDebounced.value || undefined,
104
- }))
105
- const documentationParams = computed(() => ({
106
- type: 'documentation' as const,
107
- page_size: PAGE_SIZE,
108
- q: searchDebounced.value || undefined,
109
- }))
110
- const updateParams = computed(() => ({
111
- type: 'update' as const,
112
- page_size: PAGE_SIZE,
113
- q: searchDebounced.value || undefined,
114
- }))
115
- const apiParams = computed(() => ({
116
- type: 'api' as const,
117
- page_size: PAGE_SIZE,
118
- q: searchDebounced.value || undefined,
119
- }))
120
- const codeParams = computed(() => ({
121
- type: 'code' as const,
122
- page_size: PAGE_SIZE,
123
- q: searchDebounced.value || undefined,
124
- }))
125
- const otherParams = computed(() => ({
126
- type: 'other' as const,
127
- page_size: PAGE_SIZE,
128
- q: searchDebounced.value || undefined,
129
- }))
130
-
131
- const { data: mainData, status: mainStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: mainParams })
132
- const { data: documentationData, status: documentationStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: documentationParams, server: false })
133
- const { data: updateData, status: updateStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: updateParams, server: false })
134
- const { data: apiData, status: apiStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: apiParams, server: false })
135
- const { data: codeData, status: codeStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: codeParams, server: false })
136
- const { data: otherData, status: otherStatus } = await useFetch<PaginatedArray<Resource>>(url, { params: otherParams, server: false })
137
-
138
- const rawResourcesByTypes = [
139
- { data: mainData, status: mainStatus },
140
- { data: documentationData, status: documentationStatus },
141
- { data: updateData, status: updateStatus },
142
- { data: apiData, status: apiStatus },
143
- { data: codeData, status: codeStatus },
144
- { data: otherData, status: otherStatus },
145
- ]
146
-
147
- // Evaluated once at setup (before any search) — never changes afterwards
148
- const hasAnyResources = computed(() => {
149
- return props.dataset.resources.total > 0
150
- })
151
-
152
- const extraResourcesByType: Ref<Resource[]>[] = RESOURCE_TYPE.map(() => ref<Resource[]>([]))
153
- const pageByType: Ref<number>[] = RESOURCE_TYPE.map(() => ref(1))
154
-
155
- watch(searchDebounced, () => {
156
- for (let i = 0; i < RESOURCE_TYPE.length; i++) {
157
- extraResourcesByType[i]!.value = []
158
- pageByType[i]!.value = 1
159
- }
160
- })
161
-
162
- // Separate useFetch for loadMore, initialized at setup time with immediate: false
163
- // so that it doesn't fetch until execute() is called from the event handler.
164
- const loadMoreType = ref<ResourceType>('main')
165
- const loadMorePage = ref(1)
166
- const loadMoreParams = computed(() => ({
167
- type: loadMoreType.value,
168
- page_size: PAGE_SIZE,
169
- page: loadMorePage.value,
170
- q: searchDebounced.value || undefined,
171
- }))
172
- const { data: loadMoreData, execute: executeLoadMore } = await useFetch<PaginatedArray<Resource>>(url, {
173
- params: loadMoreParams,
174
- immediate: false,
175
- watch: false,
176
- })
177
-
178
- const loadMore = async (type: ResourceType) => {
179
- const index = RESOURCE_TYPE.indexOf(type)
180
- if (index === -1) return
181
- const pageRef = pageByType[index]!
182
- const extraRef = extraResourcesByType[index]!
183
- pageRef.value++
184
-
185
- loadMoreType.value = type
186
- loadMorePage.value = pageRef.value
187
- await executeLoadMore()
188
-
189
- if (loadMoreData.value) {
190
- extraRef.value = [...extraRef.value, ...loadMoreData.value.data]
191
- }
192
- }
193
-
194
- const allResources = computed<ResourceGroup[]>(() => {
195
- return RESOURCE_TYPE
196
- .map((type, index) => {
197
- const rawData = rawResourcesByTypes[index]!
198
- const extraData = extraResourcesByType[index]!
199
- return {
200
- type: type as ResourceType,
201
- total: rawData.data.value?.total ?? 0,
202
- items: [...(rawData.data.value?.data ?? []), ...extraData.value],
203
- }
204
- })
205
- .filter(group => group.items.length > 0)
206
- })
207
-
208
- const flatResources = computed(() =>
209
- allResources.value.flatMap(g => g.items),
210
- )
211
-
212
- // Fetch resource by ID if specified in URL (for SSR)
213
- const initialResourceId = resourceIdQuery.value
214
- const { data: fetchedResource } = initialResourceId
215
- ? await useFetch<Resource>(`/api/1/datasets/${props.dataset.id}/resources/${initialResourceId}/`)
216
- : { data: ref(null) }
217
-
218
- // Initial selection (synchronous for SSR hydration)
219
- function getInitialResource(): Resource | null {
220
- const resourceId = resourceIdQuery.value
221
- if (resourceId) {
222
- // First check in already loaded resources
223
- const existing = flatResources.value.find(r => r.id === resourceId)
224
- if (existing) return existing
225
- // Use fetched resource if available
226
- if (fetchedResource.value) return fetchedResource.value
227
- }
228
- // Default to first resource
229
- return flatResources.value[0] ?? null
230
- }
231
-
232
- const selectedResource = ref<Resource | null>(getInitialResource())
233
-
234
- function updateSearch(newSearch: string) {
235
- search.value = newSearch
236
- flush()
237
- }
238
-
239
- const selectResource = (resource: Resource) => {
240
- selectedResource.value = resource
241
- router.replace({
242
- query: { ...router.currentRoute.value.query, resource_id: resource.id },
243
- })
244
- }
245
142
 
246
- // Update selection when resources change (e.g., after client-side fetch completes)
247
- watch(flatResources, () => {
248
- if (selectedResource.value) return
249
- const firstResource = flatResources.value[0]
250
- if (firstResource) {
251
- selectedResource.value = firstResource
252
- }
143
+ const resourceTo = (resource: Resource): RouteLocationRaw => ({
144
+ query: { ...route.query, resource_id: resource.id },
253
145
  })
254
146
  </script>
@@ -0,0 +1,74 @@
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
+ <span class="hidden truncate text-gray-medium sm:inline">
22
+ {{ t('mis à jour le {date}', { date: formatDate(dataset.last_update) }) }}
23
+ </span>
24
+ </template>
25
+ </div>
26
+ </div>
27
+ <div class="flex shrink-0 items-center gap-2">
28
+ <ResourceMainAction
29
+ v-if="resource"
30
+ :resource="resource"
31
+ :dataset="dataset"
32
+ />
33
+ <BrandedButton
34
+ v-if="exitTo"
35
+ :href="exitTo"
36
+ :icon="RiFullscreenExitLine"
37
+ color="secondary"
38
+ size="xs"
39
+ icon-only
40
+ :title="t('Quitter le plein écran')"
41
+ >
42
+ {{ t('Quitter le plein écran') }}
43
+ </BrandedButton>
44
+ </div>
45
+ </div>
46
+ </template>
47
+
48
+ <script setup lang="ts">
49
+ import { computed } from 'vue'
50
+ import { RiFullscreenExitLine } from '@remixicon/vue'
51
+ import type { RouteLocationRaw } from 'vue-router'
52
+ import Avatar from '../Avatar.vue'
53
+ import BrandedButton from '../BrandedButton.vue'
54
+ import OrganizationLogo from '../OrganizationLogo.vue'
55
+ import ResourceMainAction from './ResourceMainAction.vue'
56
+ import { getOwnerName } from '../../functions/owned'
57
+ import { useTranslation } from '../../composables/useTranslation'
58
+ import { useFormatDate } from '../../functions/dates'
59
+ import type { Dataset, DatasetV2 } from '../../types/datasets'
60
+ import type { Resource } from '../../types/resources'
61
+
62
+ const props = defineProps<{
63
+ dataset: Dataset | DatasetV2
64
+ resource: Resource | null
65
+ exitTo?: RouteLocationRaw
66
+ }>()
67
+
68
+ const { t } = useTranslation()
69
+ const { formatDate } = useFormatDate()
70
+
71
+ // Plain producer name, matching the prototype: dark text, no link, no certificate
72
+ // badge — unlike ObjectCardOwner which renders a styled link.
73
+ const producerName = computed(() => getOwnerName(props.dataset))
74
+ </script>
@@ -1,116 +1,148 @@
1
1
  <template>
2
2
  <aside
3
- v-if="!collapsed"
4
- class="w-72 shrink-0 pl-4"
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` }"
5
6
  >
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>
7
+ <div
8
+ class="flex h-14 items-center border-b border-gray-default bg-gray-some px-3"
9
+ :class="collapsed ? 'justify-center' : 'justify-between'"
10
+ >
11
+ <span
12
+ v-if="!collapsed"
13
+ class="text-[13px] font-medium text-gray-title"
14
+ >{{ t('Ressources') }}</span>
10
15
  <button
11
16
  type="button"
12
- :title="t('Masquer le panneau')"
13
- class="p-1 hover:bg-gray-100 rounded"
14
- @click="$emit('update:collapsed', true)"
17
+ :title="collapsed ? t('Afficher le panneau des ressources') : t('Masquer le panneau')"
18
+ class="flex size-6 items-center justify-center rounded text-gray-plain hover:bg-gray-100"
19
+ @click="$emit('update:collapsed', !collapsed)"
15
20
  >
16
- <RiArrowRightSLine class="size-5" />
21
+ <component
22
+ :is="collapsed ? RiSidebarUnfoldLine : RiSidebarFoldLine"
23
+ class="size-5"
24
+ />
17
25
  </button>
18
26
  </div>
19
27
 
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>
28
+ <!-- min-w keeps the content laid out at the sidebar's min width so it doesn't
29
+ reflow (badges wrapping, section titles on 2 lines) while the width animates
30
+ open from the collapsed state — the aside's overflow-hidden clips the rest. -->
31
+ <!-- Extra bottom padding so the last item can scroll clear of the browser's
32
+ hover URL bar (Firefox shows it bottom-left). -->
33
+ <div
34
+ v-if="!collapsed"
35
+ class="flex min-h-0 min-w-[260px] flex-1 flex-col gap-3 overflow-y-auto p-2 pb-16"
36
+ >
37
+ <label class="flex h-8 items-center gap-1 rounded border border-gray-default bg-gray-some px-2">
38
+ <RiSearchLine class="size-3.5 shrink-0 text-gray-medium" />
39
+ <span class="sr-only">{{ t('Rechercher') }}</span>
40
+ <input
41
+ :value="search"
42
+ type="search"
43
+ class="min-w-0 flex-1 bg-transparent text-[13px] outline-none placeholder:text-gray-medium"
44
+ :placeholder="t('Rechercher une ressource')"
45
+ @input="$emit('update:search', ($event.target as HTMLInputElement).value)"
46
+ >
47
+ </label>
34
48
 
35
- <div class="space-y-4 overflow-y-auto">
36
- <div
37
- v-for="group in resources"
49
+ <section
50
+ v-for="group in groups"
38
51
  :key="group.type"
52
+ class="space-y-0.5"
39
53
  >
40
- <div class="text-xs text-gray-plain font-bold uppercase mb-1.5 border-b border-gray-default pb-1">
54
+ <p class="px-1 py-2 text-[12px] font-medium leading-3 text-gray-medium">
41
55
  {{ 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>
56
+ </p>
57
+ <ResourceListItem
58
+ v-for="resource in group.items"
59
+ :key="resource.id"
60
+ :resource
61
+ :to="resourceTo(resource)"
62
+ :replace
63
+ :selected="resource.id === selectedResourceId"
64
+ />
64
65
  <button
65
66
  v-if="group.items.length < group.total"
66
67
  type="button"
67
- class="w-full text-left px-2 py-1.5 text-sm text-blue-default hover:underline"
68
+ :disabled="loadingType === group.type"
69
+ 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
70
  @click="$emit('load-more', group.type)"
69
71
  >
72
+ <RiLoader5Line
73
+ v-if="loadingType === group.type"
74
+ class="size-3.5 shrink-0 animate-spin"
75
+ />
70
76
  {{ t('Charger plus…') }}
71
77
  </button>
78
+ </section>
79
+
80
+ <!-- The search lives here, so its outcome does too: the viewer only says that
81
+ nothing is selected. -->
82
+ <div
83
+ v-if="search && !groups.length"
84
+ class="flex flex-col gap-2 px-1 py-2"
85
+ >
86
+ <p class="m-0 text-[13px] leading-snug text-gray-medium">
87
+ {{ t('Pas de résultats pour « {q} »', { q: search }) }}
88
+ </p>
89
+ <BrandedButton
90
+ color="secondary"
91
+ size="xs"
92
+ class="w-full"
93
+ @click="$emit('update:search', '')"
94
+ >
95
+ {{ t('Réinitialiser la recherche') }}
96
+ </BrandedButton>
72
97
  </div>
73
98
  </div>
74
- </aside>
75
99
 
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>
100
+ <!-- Drag the right edge to resize the panel and read longer resource titles.
101
+ A separator rather than a button: it only answers to a drag, and a focusable
102
+ button would announce an action that no key can trigger. Keyboard users fold
103
+ the panel from the header instead. -->
104
+ <div
105
+ v-if="!collapsed"
106
+ role="separator"
107
+ aria-orientation="vertical"
108
+ :title="t('Glisser pour redimensionner')"
109
+ class="absolute right-0 top-0 z-20 h-full w-2 cursor-col-resize touch-none hover:bg-new-primary/10"
110
+ @mousedown="startResize"
111
+ />
112
+ </aside>
89
113
  </template>
90
114
 
91
115
  <script setup lang="ts">
92
- import { useId } from 'vue'
93
- import { RiArrowRightSLine, RiArrowLeftSLine } from '@remixicon/vue'
94
- import ResourceIcon from '../ResourceAccordion/ResourceIcon.vue'
116
+ import type { RouteLocationRaw } from 'vue-router'
117
+ import { RiSidebarFoldLine, RiSidebarUnfoldLine, RiSearchLine, RiLoader5Line } from '@remixicon/vue'
118
+ import BrandedButton from '../BrandedButton.vue'
119
+ import ResourceListItem from '../ResourceListItem.vue'
95
120
  import { getResourceLabel } from '../../functions/resources'
96
121
  import { useTranslation } from '../../composables/useTranslation'
122
+ import { useResizable } from '../../composables/useResizable'
97
123
  import type { Resource, ResourceGroup, ResourceType } from '../../types/resources'
98
124
 
99
125
  const { t } = useTranslation()
100
126
 
101
127
  defineProps<{
102
- resources: ResourceGroup[]
128
+ groups: ResourceGroup[]
103
129
  selectedResourceId: string | null
104
130
  collapsed: boolean
105
131
  search: string
132
+ loadingType: ResourceType | null
133
+ resourceTo: (resource: Resource) => RouteLocationRaw
134
+ replace?: boolean
106
135
  }>()
107
136
 
108
137
  defineEmits<{
109
- 'select': [resource: Resource]
110
138
  'load-more': [type: ResourceType]
111
139
  'update:collapsed': [value: boolean]
112
140
  'update:search': [value: string]
113
141
  }>()
114
142
 
115
- const searchId = useId()
143
+ const { width, resizing, startResize } = useResizable({
144
+ initialWidth: 300,
145
+ minWidth: 260,
146
+ maxWidth: 720,
147
+ })
116
148
  </script>