Skip to content

Commit e466315

Browse files
authored
feat(posts): show a side menu of the post headings when enabled on the post (#1285)
- Part of datagouv/data.gouv.fr#2073 - Require opendatateam/udata#4021
1 parent 87581a9 commit e466315

13 files changed

Lines changed: 437 additions & 97 deletions

File tree

‎components/Pages/BlocWithTitleAndSubtitle.vue‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
:placeholder="$t('Titre du bloc')"
99
@update:model-value="bloc.title = $event || null"
1010
/>
11+
<!-- The id is rendered server-side so that the router finds the anchor of the URL on load -->
1112
<h2
1213
v-else-if="bloc.title"
14+
:id="slugify(bloc.title)"
1315
class="text-gray-title text-3xl font-extrabold"
1416
>
1517
{{ bloc.title }}

‎components/Pages/EditoBlocs.vue‎

Lines changed: 134 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -15,101 +15,136 @@
1515
</BrandedButton>
1616
</div>
1717

18-
<div
19-
v-for="(bloc, index) in workingBlocs"
20-
:key="bloc.id"
21-
class="relative"
22-
:class="bloc.class !== 'HeroBloc' && (striped ? 'py-24 odd:bg-gray-some even:bg-white' : 'py-12')"
23-
>
24-
<!-- Add button above the bloc (absolute positioned) -->
25-
<div
26-
v-if="isEditing"
27-
class="absolute -top-4 left-0 right-0 flex items-center justify-center"
28-
>
29-
<AddBlocDropdown
30-
:for-organization="forOrganization"
31-
@new-bloc="workingBlocs.splice(index, 0, $event)"
32-
>
33-
<BrandedButton
34-
color="tertiary"
35-
size="xs"
36-
class="bg-white shadow-sm"
37-
:icon="RiAddLine"
38-
>
39-
{{ $t('Ajouter un bloc') }}
40-
</BrandedButton>
41-
</AddBlocDropdown>
42-
</div>
43-
<!-- Bloc toolbar in edit mode -->
44-
<div
45-
v-if="isEditing"
46-
class="absolute left-4 top-4 flex flex-row gap-2 z-10 min-[1530px]:left-8 min-[1530px]:top-1/2 min-[1530px]:-translate-y-1/2 min-[1530px]:flex-col"
47-
>
48-
<BrandedButton
49-
color="tertiary"
50-
:icon="RiArrowUpLine"
51-
:disabled="index === 0"
52-
:title="$t('Monter')"
53-
@click="moveBloc(index, -1)"
54-
/>
55-
<BrandedButton
56-
color="tertiary"
57-
:icon="RiArrowDownLine"
58-
:disabled="index === workingBlocs.length - 1"
59-
:title="$t('Descendre')"
60-
@click="moveBloc(index, 1)"
61-
/>
62-
<BrandedButton
63-
color="tertiary"
64-
:icon="RiDeleteBinLine"
65-
:title="$t('Supprimer')"
66-
@click="workingBlocs.splice(index, 1)"
67-
/>
68-
</div>
69-
70-
<!-- Full-width blocs (like HeroBloc) -->
18+
<!-- Side menu, outside of the edit mode whose toolbar lives in the side margins: the
19+
full-width blocs opening the page stay above it, the others go next to it -->
20+
<template v-if="sidemenu && !isEditing">
7121
<component
7222
:is="blocsTypes[bloc.class].component"
73-
v-if="blocsTypes[bloc.class] && 'fullWidth' in blocsTypes[bloc.class] && blocsTypes[bloc.class].component"
74-
v-model="(workingBlocs[index] as any)"
75-
:edit="isEditing"
76-
v-bind="bloc.class === 'LinksListBloc' ? { 'main-color': mainColor } : {}"
23+
v-for="bloc in leadingFullWidthBlocs"
24+
:key="bloc.id"
25+
:model-value="(bloc as any)"
26+
:edit="false"
7727
/>
28+
<div class="container md:flex md:gap-8 py-12">
29+
<HeadingsSidemenu
30+
v-if="sidemenuHeadings.length"
31+
:headings="sidemenuHeadings"
32+
class="md:w-1/4 shrink-0"
33+
/>
34+
<div class="flex-1 min-w-0 space-y-12">
35+
<template
36+
v-for="bloc in workingBlocs.slice(leadingFullWidthBlocs.length)"
37+
:key="bloc.id"
38+
>
39+
<component
40+
:is="blocsTypes[bloc.class].component"
41+
v-if="blocsTypes[bloc.class]?.component"
42+
:model-value="(bloc as any)"
43+
:edit="false"
44+
v-bind="bloc.class === 'LinksListBloc' ? { 'main-color': mainColor } : {}"
45+
/>
46+
</template>
47+
</div>
48+
</div>
49+
</template>
7850

79-
<!-- Other blocs use container layout -->
51+
<template v-else>
8052
<div
81-
v-else
82-
class="container"
53+
v-for="(bloc, index) in workingBlocs"
54+
:key="bloc.id"
55+
class="relative"
56+
:class="bloc.class !== 'HeroBloc' && (striped ? 'py-24 odd:bg-gray-some even:bg-white' : 'py-12')"
8357
>
58+
<!-- Add button above the bloc (absolute positioned) -->
59+
<div
60+
v-if="isEditing"
61+
class="absolute -top-4 left-0 right-0 flex items-center justify-center"
62+
>
63+
<AddBlocDropdown
64+
:for-organization="forOrganization"
65+
@new-bloc="workingBlocs.splice(index, 0, $event)"
66+
>
67+
<BrandedButton
68+
color="tertiary"
69+
size="xs"
70+
class="bg-white shadow-sm"
71+
:icon="RiAddLine"
72+
>
73+
{{ $t('Ajouter un bloc') }}
74+
</BrandedButton>
75+
</AddBlocDropdown>
76+
</div>
77+
<!-- Bloc toolbar in edit mode -->
78+
<div
79+
v-if="isEditing"
80+
class="absolute left-4 top-4 flex flex-row gap-2 z-10 min-[1530px]:left-8 min-[1530px]:top-1/2 min-[1530px]:-translate-y-1/2 min-[1530px]:flex-col"
81+
>
82+
<BrandedButton
83+
color="tertiary"
84+
:icon="RiArrowUpLine"
85+
:disabled="index === 0"
86+
:title="$t('Monter')"
87+
@click="moveBloc(index, -1)"
88+
/>
89+
<BrandedButton
90+
color="tertiary"
91+
:icon="RiArrowDownLine"
92+
:disabled="index === workingBlocs.length - 1"
93+
:title="$t('Descendre')"
94+
@click="moveBloc(index, 1)"
95+
/>
96+
<BrandedButton
97+
color="tertiary"
98+
:icon="RiDeleteBinLine"
99+
:title="$t('Supprimer')"
100+
@click="workingBlocs.splice(index, 1)"
101+
/>
102+
</div>
103+
104+
<!-- Full-width blocs (like HeroBloc) -->
84105
<component
85106
:is="blocsTypes[bloc.class].component"
86-
v-if="blocsTypes[bloc.class]?.component"
107+
v-if="blocsTypes[bloc.class] && 'fullWidth' in blocsTypes[bloc.class] && blocsTypes[bloc.class].component"
87108
v-model="(workingBlocs[index] as any)"
88109
:edit="isEditing"
89110
v-bind="bloc.class === 'LinksListBloc' ? { 'main-color': mainColor } : {}"
90111
/>
91-
</div>
92112

93-
<!-- Add button below the last bloc -->
94-
<div
95-
v-if="isEditing && index === workingBlocs.length - 1"
96-
class="absolute -bottom-4 left-0 right-0 flex items-center justify-center"
97-
>
98-
<AddBlocDropdown
99-
:for-organization="forOrganization"
100-
@new-bloc="workingBlocs.push($event)"
113+
<!-- Other blocs use container layout -->
114+
<div
115+
v-else
116+
class="container"
101117
>
102-
<BrandedButton
103-
color="tertiary"
104-
size="xs"
105-
class="bg-white shadow-sm"
106-
:icon="RiAddLine"
118+
<component
119+
:is="blocsTypes[bloc.class].component"
120+
v-if="blocsTypes[bloc.class]?.component"
121+
v-model="(workingBlocs[index] as any)"
122+
:edit="isEditing"
123+
v-bind="bloc.class === 'LinksListBloc' ? { 'main-color': mainColor } : {}"
124+
/>
125+
</div>
126+
127+
<!-- Add button below the last bloc -->
128+
<div
129+
v-if="isEditing && index === workingBlocs.length - 1"
130+
class="absolute -bottom-4 left-0 right-0 flex items-center justify-center"
131+
>
132+
<AddBlocDropdown
133+
:for-organization="forOrganization"
134+
@new-bloc="workingBlocs.push($event)"
107135
>
108-
{{ $t('Ajouter un bloc') }}
109-
</BrandedButton>
110-
</AddBlocDropdown>
136+
<BrandedButton
137+
color="tertiary"
138+
size="xs"
139+
class="bg-white shadow-sm"
140+
:icon="RiAddLine"
141+
>
142+
{{ $t('Ajouter un bloc') }}
143+
</BrandedButton>
144+
</AddBlocDropdown>
145+
</div>
111146
</div>
112-
</div>
147+
</template>
113148

114149
<!-- Empty state. In edit mode it invites adding a first bloc; in view mode it
115150
is only shown when the host opts in via `emptyCtaLabel` (e.g. an org admin
@@ -177,10 +212,12 @@
177212
<script setup lang="ts">
178213
import type { ComponentProps } from 'vue-component-type-helpers'
179214
import * as Sentry from '@sentry/nuxt'
180-
import { BrandedButton } from '@datagouv/components-next'
215+
import { BrandedButton, formatMarkdown } from '@datagouv/components-next'
181216
import { RiAddLine, RiArrowDownLine, RiArrowUpLine, RiDeleteBinLine, RiEdit2Line } from '@remixicon/vue'
182217
import AddBlocDropdown from './AddBlocDropdown.vue'
218+
import HeadingsSidemenu from '~/components/Sidemenu/HeadingsSidemenu.vue'
183219
import type { PageBloc } from '~/types/pages'
220+
import type { Heading } from '~/utils/headings'
184221
import { isMeAdmin } from '~/utils/auth'
185222
186223
const blocsTypes = useBlocsTypes()
@@ -190,6 +227,7 @@ const props = withDefaults(defineProps<{
190227
edit?: boolean
191228
editable?: boolean
192229
striped?: boolean
230+
sidemenu?: boolean
193231
hideEditButton?: boolean
194232
// Trims the "add bloc" menu for organization presentation pages (no Hero, Markdown
195233
// moved into the layout group). See AddBlocDropdown.
@@ -204,6 +242,7 @@ const props = withDefaults(defineProps<{
204242
edit: false,
205243
editable: false,
206244
striped: true,
245+
sidemenu: false,
207246
hideEditButton: false,
208247
forOrganization: false,
209248
mainColor: 'primary',
@@ -238,6 +277,21 @@ watchEffect(() => {
238277
239278
const workingBlocs = computed(() => localBlocs.value ?? props.blocs)
240279
280+
const isFullWidth = (bloc: PageBloc) => 'fullWidth' in (blocsTypes[bloc.class] ?? {})
281+
const leadingFullWidthBlocs = computed(() => {
282+
const index = workingBlocs.value.findIndex(bloc => !isFullWidth(bloc))
283+
return workingBlocs.value.slice(0, index === -1 ? workingBlocs.value.length : index)
284+
})
285+
// The title of each bloc, then the headings of its Markdown, rendered with the same options
286+
// as MarkdownBloc so that their ids are the ones on the page
287+
const sidemenuHeadings = computed<Array<Heading>>(() => workingBlocs.value
288+
.filter(bloc => !isFullWidth(bloc))
289+
.flatMap(bloc => [
290+
...(bloc.title ? [{ id: slugify(bloc.title), label: bloc.title, level: 2 }] : []),
291+
...(bloc.class === 'MarkdownBloc' ? anchorHeadings(formatMarkdown(bloc.content, { minDepth: 2, noHeadings: false })).headings : []),
292+
]),
293+
)
294+
241295
// Check if there are unsaved changes
242296
const hasUnsavedChanges = computed(() => {
243297
return localBlocs.value !== null && JSON.stringify(toRaw(localBlocs.value)) !== JSON.stringify(toRaw(props.blocs))

‎components/Posts/DescribePost.vue‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,14 @@
8686
]"
8787
/>
8888
</div>
89+
<div class="fr-fieldset__element">
90+
<ToggleSwitch
91+
v-model="form.sidemenu"
92+
:label="t('Menu latéral')"
93+
:label-true="t('Un menu à côté du contenu renvoie vers chacun de ses titres.')"
94+
:label-false="t('Pas de menu à côté du contenu.')"
95+
/>
96+
</div>
8997
<div class="fr-fieldset__element">
9098
<TagsSelect
9199
v-model="form.tags"
@@ -145,6 +153,7 @@
145153

146154
<script setup lang="ts">
147155
import { BrandedButton, SearchableSelect } from '@datagouv/components-next'
156+
import ToggleSwitch from '~/components/Form/ToggleSwitch.vue'
148157
import type { PaginatedArray } from '~/types/types'
149158
import type { Post, PostAncestor, PostForm } from '~/types/posts'
150159

‎components/Posts/PostView.vue‎

Lines changed: 31 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
v-if="isFullPageBlocs"
4444
:blocs="post.blocs ?? []"
4545
editable
46+
:sidemenu="post.sidemenu"
4647
:on-save="saveBlocs"
4748
/>
4849

@@ -101,29 +102,33 @@
101102
:blocs="post.blocs ?? []"
102103
editable
103104
hide-edit-button
105+
:sidemenu="post.sidemenu"
104106
:on-save="saveBlocs"
105107
/>
106108
</div>
107-
<div
108-
v-else-if="post.body_type === 'markdown'"
109-
class="container"
110-
>
111-
<MarkdownViewer
112-
:content="post.content"
113-
:min-heading="2"
114-
size="md"
115-
class="max-w-4xl"
116-
/>
117-
</div>
118109
<div
119110
v-else
120111
class="container"
112+
:class="{ 'md:flex md:gap-8': anchoredBody?.headings.length }"
121113
>
122-
<div
123-
class="max-w-4xl"
124-
:class="markdownClasses"
125-
v-html="post.content"
114+
<HeadingsSidemenu
115+
v-if="anchoredBody?.headings.length"
116+
:headings="anchoredBody.headings"
117+
class="md:w-1/4 shrink-0"
126118
/>
119+
<div class="max-w-4xl min-w-0 flex-1">
120+
<MarkdownViewer
121+
v-if="post.body_type === 'markdown'"
122+
:content="post.content"
123+
:min-heading="2"
124+
size="md"
125+
/>
126+
<div
127+
v-else
128+
:class="markdownClasses"
129+
v-html="anchoredBody?.html ?? post.content"
130+
/>
131+
</div>
127132
</div>
128133

129134
<div class="container">
@@ -140,11 +145,12 @@
140145
</template>
141146

142147
<script setup lang="ts">
143-
import { markdownClasses, MarkdownViewer, BrandedButton, toast, FormattedDate, TranslationT } from '@datagouv/components-next'
148+
import { formatMarkdown, markdownClasses, MarkdownViewer, BrandedButton, toast, FormattedDate, TranslationT } from '@datagouv/components-next'
144149
import { RiEdit2Line } from '@remixicon/vue'
145150
import BreadcrumbItem from '~/components/Breadcrumbs/BreadcrumbItem.vue'
146151
import EditButton from '~/components/Buttons/EditButton.vue'
147152
import EditoBlocs from '~/components/Pages/EditoBlocs.vue'
153+
import HeadingsSidemenu from '~/components/Sidemenu/HeadingsSidemenu.vue'
148154
import type { PageBloc } from '~/types/pages'
149155
import type { Post } from '~/types/posts'
150156
@@ -163,6 +169,15 @@ const absoluteUrlToRelative = useAbsoluteUrlToRelative()
163169
164170
const blocsSection = ref<HTMLElement | null>(null)
165171
172+
// Only with the side menu: an HTML body is then rendered again with ids on its headings.
173+
// Markdown goes through the options of the MarkdownViewer below to get the same ids.
174+
const anchoredBody = computed(() => {
175+
if (!props.post.sidemenu || props.post.body_type === 'blocs') return null
176+
return anchorHeadings(props.post.body_type === 'markdown'
177+
? formatMarkdown(props.post.content, { minDepth: 2, noHeadings: false })
178+
: props.post.content)
179+
})
180+
166181
// Enter edit mode and scroll to the blocs, which start below the article image:
167182
// with a tall image the user would otherwise see no visible change on click.
168183
async function editContent() {

0 commit comments

Comments
 (0)