@open-commune/web 0.1.3
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.
- package/README.md +41 -0
- package/app.config.ts +10 -0
- package/app.vue +13 -0
- package/assets/css/main.css +13 -0
- package/components/data/display/row/data-avatar.vue +23 -0
- package/components/data/display/row/data-badge.vue +34 -0
- package/components/data/display/row/data-color.vue +17 -0
- package/components/data/display/row/data-display-badge-publish-status.vue +29 -0
- package/components/data/display/row/data-label.vue +37 -0
- package/components/data/display/row/data-last-update.vue +45 -0
- package/components/data/display/row/data-spacer.vue +4 -0
- package/components/data/display/row/data-summary.vue +44 -0
- package/components/data/display/row/data-text.vue +14 -0
- package/components/data/display/row/data-toggle.vue +16 -0
- package/components/data/display/row.vue +111 -0
- package/components/data/lists/data-list.vue +85 -0
- package/components/data/lists/data-lists-table.vue +155 -0
- package/components/elements/avatars/avatar-circular.vue +68 -0
- package/components/elements/avatars/avatar-with-text.vue +47 -0
- package/components/elements/badges/badge.vue +85 -0
- package/components/elements/buttons/primary.vue +32 -0
- package/components/elements/buttons/secondary.vue +35 -0
- package/components/elements/buttons/tertiary.vue +25 -0
- package/components/elements/dropdowns/dots.vue +75 -0
- package/components/elements/media/image.vue +82 -0
- package/components/elements/notifications/default.vue +90 -0
- package/components/forms/handlers/handler-collections.vue +189 -0
- package/components/forms/handlers/handler-publication-status.vue +193 -0
- package/components/forms/handlers/handler-records.vue +138 -0
- package/components/forms/handlers/handler-roles.vue +176 -0
- package/components/forms/handlers/handler-schedules.vue +218 -0
- package/components/forms/handlers/handler-steps.vue +147 -0
- package/components/forms/handlers/handler-tokens.vue +79 -0
- package/components/forms/inputs/input-color-picker.vue +159 -0
- package/components/forms/inputs/input-editor.vue +363 -0
- package/components/forms/inputs/input-files-media.vue +150 -0
- package/components/forms/inputs/input-files.vue +98 -0
- package/components/forms/inputs/input-json.vue +65 -0
- package/components/forms/inputs/input-location.vue +179 -0
- package/components/forms/inputs/input-text-area.vue +65 -0
- package/components/forms/inputs/input-text.vue +122 -0
- package/components/forms/inputs/input-toggle.vue +125 -0
- package/components/forms/inputs/media-section/media-editor.vue +95 -0
- package/components/forms/layouts/form.vue +203 -0
- package/components/forms/layouts/layout.vue +47 -0
- package/components/forms/layouts/modal.vue +96 -0
- package/components/forms/layouts/navigation.vue +48 -0
- package/components/forms/layouts/section.vue +118 -0
- package/components/forms/selects/select-collection-enum.vue +195 -0
- package/components/forms/selects/select-date.vue +245 -0
- package/components/forms/selects/select-enum.vue +186 -0
- package/components/forms/selects/select-record.vue +274 -0
- package/components/layout/headings/headings-collection.vue +29 -0
- package/components/layout/headings/headings-record.vue +33 -0
- package/components/layout/navigations/primary/dropdown-publish.vue +147 -0
- package/components/layout/navigations/primary.vue +132 -0
- package/components/layout/notifications/notifications-list.vue +19 -0
- package/composables/core/domains/use-definitions.ts +57 -0
- package/composables/core/domains/use-entries.ts +62 -0
- package/composables/core/domains/use-media.ts +82 -0
- package/composables/core/use-core.ts +12 -0
- package/composables/use-ability.js +38 -0
- package/composables/use-models.js +27 -0
- package/composables/use-session.ts +80 -0
- package/composables/use-timer.js +62 -0
- package/composables/use-trpc.js +17 -0
- package/editor/index.ts +44 -0
- package/editor/views/CalloutView.vue +22 -0
- package/editor/views/DetailsContentView.vue +11 -0
- package/editor/views/DetailsSummaryView.vue +11 -0
- package/editor/views/DetailsView.vue +72 -0
- package/editor/views/EmbedView.vue +53 -0
- package/editor/views/FileView.vue +79 -0
- package/editor/views/ImageView.vue +56 -0
- package/editor/views/UploadView.vue +71 -0
- package/layouts/default.vue +21 -0
- package/layouts/entrance.vue +10 -0
- package/layouts/record.vue +21 -0
- package/middleware/00.auth.global.js +16 -0
- package/middleware/01.instance.global.js +21 -0
- package/middleware/03.verify-permissions.global.js +11 -0
- package/middleware/04.prefetch-page.global.js +18 -0
- package/middleware/05.headings-page.global.js +25 -0
- package/models/_commun.js +243 -0
- package/models/_factory.js +34 -0
- package/models/collection.js +87 -0
- package/models/entrance.js +42 -0
- package/models/media.js +82 -0
- package/models/organization.js +92 -0
- package/models/record.js +165 -0
- package/models/role.js +66 -0
- package/models/token.js +56 -0
- package/models/user.js +97 -0
- package/nuxt.config.ts +151 -0
- package/package.json +71 -0
- package/pages/[collection]/[record]/index.vue +88 -0
- package/pages/[collection]/index.vue +55 -0
- package/pages/_lab/editor.vue +149 -0
- package/pages/auth/callback.vue +31 -0
- package/pages/index/index.vue +85 -0
- package/pages/index/password/define/[token].vue +79 -0
- package/pages/index/password/recover/index.vue +72 -0
- package/pages/index/welcome/[token].vue +90 -0
- package/pages/index.vue +44 -0
- package/pages/management/index.vue +15 -0
- package/pages/management/members.vue +176 -0
- package/pages/management/organizations.vue +192 -0
- package/pages/management/roles.vue +183 -0
- package/pages/overview.vue +15 -0
- package/pages/settings.vue +197 -0
- package/plugins/01.session.client.ts +5 -0
- package/plugins/02.vue-query.ts +25 -0
- package/public/favicon.ico +0 -0
- package/public/logo-black.svg +1 -0
- package/public/logo-white.svg +1 -0
- package/server/api/trpc/[...trpc].ts +28 -0
- package/server/api/v1/content/deployment.get.ts +45 -0
- package/server/api/v1/content/records.get.ts +12 -0
- package/server/middleware/0.security.ts +19 -0
- package/server/middleware/1.cors.ts +31 -0
- package/server/middleware/2.session.ts +20 -0
- package/server/middleware/3.api-token.ts +20 -0
- package/server/plugins/core.ts +8 -0
- package/server/routes/_tasks/[name].post.ts +20 -0
- package/server/routes/health.get.ts +6 -0
- package/server/tasks/deploy.ts +26 -0
- package/server/types.d.ts +8 -0
- package/server/utils/core.ts +18 -0
- package/store/layout/headings.js +21 -0
- package/store/layout/modal.js +80 -0
- package/store/layout/notifications.js +52 -0
- package/store/layout/workspace.js +80 -0
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<label v-if="label" :for="name" class="block text-2xs font-light tracking-wider text-gray-500 uppercase">
|
|
4
|
+
{{ label }}
|
|
5
|
+
</label>
|
|
6
|
+
<!-- Fixed toolbar, same layout as the legacy editor: discoverability for
|
|
7
|
+
authors who never hover a handle or type "/". Gestures stay available. -->
|
|
8
|
+
<UEditorToolbar
|
|
9
|
+
v-if="uEditor?.editor"
|
|
10
|
+
:editor="uEditor.editor"
|
|
11
|
+
:items="toolbarItems"
|
|
12
|
+
class="sticky top-2 z-10 flex flex-wrap items-center gap-1 rounded-t-md border border-b-0 border-neutral-200 bg-white px-2 py-1 dark:border-neutral-800 dark:bg-neutral-900"
|
|
13
|
+
:class="{ 'mt-1': !!label }"
|
|
14
|
+
>
|
|
15
|
+
<template #blocks>
|
|
16
|
+
<UDropdownMenu :modal="false" :items="blockMenuInsertItems(uEditor.editor)" :ui="{ content: 'w-64' }">
|
|
17
|
+
<UButton icon="i-lucide-plus" color="neutral" variant="ghost" size="sm" label="Ajouter un bloc" />
|
|
18
|
+
</UDropdownMenu>
|
|
19
|
+
</template>
|
|
20
|
+
<template #link>
|
|
21
|
+
<UPopover v-model:open="toolbarLinkOpen">
|
|
22
|
+
<UButton
|
|
23
|
+
icon="iconoir:link"
|
|
24
|
+
color="neutral"
|
|
25
|
+
variant="ghost"
|
|
26
|
+
size="sm"
|
|
27
|
+
:active="uEditor.editor.isActive('link')"
|
|
28
|
+
aria-label="Lien"
|
|
29
|
+
@click="linkUrl = uEditor.editor.getAttributes('link').href ?? ''"
|
|
30
|
+
/>
|
|
31
|
+
<template #content>
|
|
32
|
+
<form class="flex items-center gap-2 p-2" @submit.prevent="applyLink(uEditor.editor, 'toolbar')">
|
|
33
|
+
<UInput v-model="linkUrl" placeholder="https://…" size="sm" class="w-64" autofocus />
|
|
34
|
+
<UButton type="submit" size="sm" color="neutral" label="OK" />
|
|
35
|
+
<UButton
|
|
36
|
+
v-if="uEditor.editor.isActive('link')"
|
|
37
|
+
size="sm"
|
|
38
|
+
color="neutral"
|
|
39
|
+
variant="ghost"
|
|
40
|
+
icon="iconoir:trash"
|
|
41
|
+
aria-label="Retirer le lien"
|
|
42
|
+
@click="removeLink(uEditor.editor, 'toolbar')"
|
|
43
|
+
/>
|
|
44
|
+
</form>
|
|
45
|
+
</template>
|
|
46
|
+
</UPopover>
|
|
47
|
+
</template>
|
|
48
|
+
</UEditorToolbar>
|
|
49
|
+
<UEditor
|
|
50
|
+
ref="uEditor"
|
|
51
|
+
v-slot="{ editor, handlers }"
|
|
52
|
+
v-model="doc"
|
|
53
|
+
:image="false"
|
|
54
|
+
:mention="false"
|
|
55
|
+
:starter-kit="communStarterKit"
|
|
56
|
+
:extensions="extensions"
|
|
57
|
+
:handlers="customHandlers"
|
|
58
|
+
:enable-content-check="true"
|
|
59
|
+
:on-content-error="onContentError"
|
|
60
|
+
:placeholder="placeHolder || 'Écrivez, tapez « / » pour les commandes…'"
|
|
61
|
+
class="min-h-40 rounded-b-md border border-neutral-200 px-6 py-3 dark:border-neutral-800"
|
|
62
|
+
>
|
|
63
|
+
<!-- Selection bubble: marks, alignment, link. -->
|
|
64
|
+
<UEditorToolbar
|
|
65
|
+
:editor="editor"
|
|
66
|
+
:items="bubbleItems"
|
|
67
|
+
layout="bubble"
|
|
68
|
+
:should-show="({ editor: e, view, state }) => {
|
|
69
|
+
if (e.isActive('image') || e.isActive('file') || e.isActive('embed')) return false
|
|
70
|
+
return view.hasFocus() && !state.selection.empty
|
|
71
|
+
}"
|
|
72
|
+
>
|
|
73
|
+
<template #link>
|
|
74
|
+
<UPopover v-model:open="linkOpen">
|
|
75
|
+
<UButton
|
|
76
|
+
icon="iconoir:link"
|
|
77
|
+
color="neutral"
|
|
78
|
+
variant="ghost"
|
|
79
|
+
size="sm"
|
|
80
|
+
:active="editor.isActive('link')"
|
|
81
|
+
aria-label="Lien"
|
|
82
|
+
@click="linkUrl = editor.getAttributes('link').href ?? ''"
|
|
83
|
+
/>
|
|
84
|
+
<template #content>
|
|
85
|
+
<form class="flex items-center gap-2 p-2" @submit.prevent="applyLink(editor)">
|
|
86
|
+
<UInput v-model="linkUrl" placeholder="https://…" size="sm" class="w-64" autofocus />
|
|
87
|
+
<UButton type="submit" size="sm" color="neutral" label="OK" />
|
|
88
|
+
<UButton
|
|
89
|
+
v-if="editor.isActive('link')"
|
|
90
|
+
size="sm"
|
|
91
|
+
color="neutral"
|
|
92
|
+
variant="ghost"
|
|
93
|
+
icon="iconoir:trash"
|
|
94
|
+
aria-label="Retirer le lien"
|
|
95
|
+
@click="removeLink(editor)"
|
|
96
|
+
/>
|
|
97
|
+
</form>
|
|
98
|
+
</template>
|
|
99
|
+
</UPopover>
|
|
100
|
+
</template>
|
|
101
|
+
</UEditorToolbar>
|
|
102
|
+
|
|
103
|
+
<!-- Slash commands: standard blocks plus ours. -->
|
|
104
|
+
<UEditorSuggestionMenu :editor="editor" :items="slashItems" size="lg" :ui="{ content: 'w-[40rem]' }" />
|
|
105
|
+
|
|
106
|
+
<!-- Block handle: plus opens the slash menu, grip moves blocks. -->
|
|
107
|
+
<UEditorDragHandle
|
|
108
|
+
v-slot="{ ui, onClick }"
|
|
109
|
+
:editor="editor"
|
|
110
|
+
@node-change="selectedNode = $event"
|
|
111
|
+
>
|
|
112
|
+
<UButton
|
|
113
|
+
icon="i-lucide-plus"
|
|
114
|
+
color="neutral"
|
|
115
|
+
variant="ghost"
|
|
116
|
+
size="sm"
|
|
117
|
+
:class="ui.handle()"
|
|
118
|
+
aria-label="Ajouter un bloc"
|
|
119
|
+
@click="(e) => {
|
|
120
|
+
e.stopPropagation()
|
|
121
|
+
const selected = onClick()
|
|
122
|
+
handlers.suggestion?.execute(editor, { pos: selected?.pos }).run()
|
|
123
|
+
}"
|
|
124
|
+
/>
|
|
125
|
+
<UDropdownMenu
|
|
126
|
+
:modal="false"
|
|
127
|
+
:items="blockMenuItems(editor)"
|
|
128
|
+
:content="{ side: 'left' }"
|
|
129
|
+
:ui="{ content: 'w-44' }"
|
|
130
|
+
@update:open="editor.chain().setMeta('lockDragHandle', $event).run()"
|
|
131
|
+
>
|
|
132
|
+
<UButton
|
|
133
|
+
color="neutral"
|
|
134
|
+
variant="ghost"
|
|
135
|
+
size="sm"
|
|
136
|
+
icon="i-lucide-grip-vertical"
|
|
137
|
+
:class="ui.handle()"
|
|
138
|
+
aria-label="Déplacer le bloc"
|
|
139
|
+
/>
|
|
140
|
+
</UDropdownMenu>
|
|
141
|
+
</UEditorDragHandle>
|
|
142
|
+
|
|
143
|
+
</UEditor>
|
|
144
|
+
</div>
|
|
145
|
+
</template>
|
|
146
|
+
|
|
147
|
+
<script setup>
|
|
148
|
+
// Rich-text input on UEditor with the parity extension set and the media
|
|
149
|
+
// contract (shared cache, bounded resolution). Editing surface is
|
|
150
|
+
// Notion-like: selection bubble, slash commands, per-block drag handle.
|
|
151
|
+
//
|
|
152
|
+
// TRANSITIONAL value contract: accepts the ProseMirror object (target) or
|
|
153
|
+
// the legacy stringified JSON, and emits IN THE SAME SHAPE it received —
|
|
154
|
+
// the string adapter dies with models/_commun.
|
|
155
|
+
import { ref, watch } from 'vue'
|
|
156
|
+
import { mapEditorItems } from '@nuxt/ui/utils/editor'
|
|
157
|
+
import { communExtensions, communStarterKit, sanitizeDoc, EMBED_SERVICES } from '~/editor'
|
|
158
|
+
|
|
159
|
+
const $emitters = defineEmits(['change'])
|
|
160
|
+
const $props = defineProps({
|
|
161
|
+
label: { type: String, default: '' },
|
|
162
|
+
name: { type: String, default: 'input' },
|
|
163
|
+
placeHolder: { type: String, default: '' },
|
|
164
|
+
value: { type: [String, Object], default: '' },
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
const { media } = useCore()
|
|
168
|
+
const extensions = communExtensions(media.forEditor())
|
|
169
|
+
const toast = useToast()
|
|
170
|
+
const uEditor = ref()
|
|
171
|
+
|
|
172
|
+
// ── Value contract ──────────────────────────────────────────────────────────
|
|
173
|
+
const receivedAsString = typeof $props.value === 'string'
|
|
174
|
+
// Le noeud `doc` de ProseMirror attend `block+` : AU MOINS un bloc. Un document
|
|
175
|
+
// au contenu vide est donc invalide, et tiptap le refuse avec « Invalid JSON
|
|
176
|
+
// content » — ce que voyait l'auteur en ouvrant l'onglet d'une entree qui vient
|
|
177
|
+
// d'etre creee, ou dont le contenu n'a jamais ete rempli.
|
|
178
|
+
const emptyDoc = () => ({ type: 'doc', content: [{ type: 'paragraph' }] })
|
|
179
|
+
|
|
180
|
+
const parse = (value) => {
|
|
181
|
+
if (!value) return emptyDoc()
|
|
182
|
+
const raw = typeof value === 'string' ? JSON.parse(value) : value
|
|
183
|
+
const doc = sanitizeDoc(raw)
|
|
184
|
+
// `sanitizeDoc` peut vider un document ne contenant que des noeuds texte
|
|
185
|
+
// vides : on repasse alors par le document vide VALIDE.
|
|
186
|
+
return doc?.content?.length ? doc : emptyDoc()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const doc = ref(parse($props.value))
|
|
190
|
+
|
|
191
|
+
// The transient upload slot must never leave the editor.
|
|
192
|
+
const stripTransient = (node) => {
|
|
193
|
+
if (!node?.content) return node
|
|
194
|
+
const content = node.content
|
|
195
|
+
.filter((child) => child.type !== 'imageUpload')
|
|
196
|
+
.map(stripTransient)
|
|
197
|
+
return content.length === node.content.length && content.every((c, i) => c === node.content[i])
|
|
198
|
+
? node
|
|
199
|
+
: { ...node, content }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
watch(doc, (next) => {
|
|
203
|
+
const out = stripTransient(next)
|
|
204
|
+
$emitters('change', receivedAsString ? JSON.stringify(out) : out)
|
|
205
|
+
}, { deep: true })
|
|
206
|
+
|
|
207
|
+
watch(() => $props.value, (next) => {
|
|
208
|
+
const parsed = parse(next)
|
|
209
|
+
// Compared against the STRIPPED document: what we emit comes back without
|
|
210
|
+
// the upload slot, and taking it as a difference would tear that slot out
|
|
211
|
+
// of the editor while its file is still uploading.
|
|
212
|
+
if (JSON.stringify(parsed) !== JSON.stringify(stripTransient(doc.value))) doc.value = parsed
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
function onContentError({ error }) {
|
|
216
|
+
// Never a silent drop: the author is told when content cannot load as-is.
|
|
217
|
+
toast.add({
|
|
218
|
+
title: 'Contenu partiellement illisible',
|
|
219
|
+
description: error?.message ?? String(error),
|
|
220
|
+
color: 'error',
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ── Custom handlers: our blocks, addressable from every menu ────────────────
|
|
225
|
+
const customHandlers = {
|
|
226
|
+
callout: {
|
|
227
|
+
canExecute: (editor) => editor.can().setCallout(),
|
|
228
|
+
execute: (editor) => editor.chain().focus().setCallout({ icon: 'iconoir:megaphone' }),
|
|
229
|
+
isActive: (editor) => editor.isActive('callout'),
|
|
230
|
+
},
|
|
231
|
+
details: {
|
|
232
|
+
canExecute: (editor) => editor.can().setDetails(),
|
|
233
|
+
execute: (editor) => editor.chain().focus().setDetails(),
|
|
234
|
+
isActive: (editor) => editor.isActive('details'),
|
|
235
|
+
},
|
|
236
|
+
...Object.fromEntries(
|
|
237
|
+
Object.entries(EMBED_SERVICES).map(([service, preset]) => [
|
|
238
|
+
`embed-${service}`,
|
|
239
|
+
{
|
|
240
|
+
canExecute: () => true,
|
|
241
|
+
execute: (editor) =>
|
|
242
|
+
editor.chain().focus().setEmbed({
|
|
243
|
+
service,
|
|
244
|
+
icon: preset.icon,
|
|
245
|
+
placeholder: preset.placeholder,
|
|
246
|
+
title: preset.title,
|
|
247
|
+
height: preset.height,
|
|
248
|
+
}),
|
|
249
|
+
isActive: (editor) => editor.isActive('embed', { service }),
|
|
250
|
+
},
|
|
251
|
+
]),
|
|
252
|
+
),
|
|
253
|
+
uploadImage: {
|
|
254
|
+
canExecute: (editor) => editor.can().insertContent({ type: 'imageUpload' }),
|
|
255
|
+
execute: (editor) => editor.chain().focus().insertUploadPlaceholder({ accept: 'image/*' }),
|
|
256
|
+
isActive: (editor) => editor.isActive('imageUpload'),
|
|
257
|
+
},
|
|
258
|
+
uploadFile: {
|
|
259
|
+
canExecute: (editor) => editor.can().insertContent({ type: 'imageUpload' }),
|
|
260
|
+
execute: (editor) => editor.chain().focus().insertUploadPlaceholder(),
|
|
261
|
+
isActive: (editor) => editor.isActive('imageUpload'),
|
|
262
|
+
},
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// ── Menus ───────────────────────────────────────────────────────────────────
|
|
266
|
+
const bubbleItems = [
|
|
267
|
+
[
|
|
268
|
+
{ kind: 'mark', mark: 'bold', icon: 'iconoir:bold-square-outline', tooltip: { text: 'Gras' } },
|
|
269
|
+
{ kind: 'mark', mark: 'italic', icon: 'iconoir:italic-square-outline', tooltip: { text: 'Italique' } },
|
|
270
|
+
],
|
|
271
|
+
[{ slot: 'link', icon: 'iconoir:link' }],
|
|
272
|
+
[
|
|
273
|
+
{ kind: 'textAlign', align: 'justify', icon: 'iconoir:align-justify', tooltip: { text: 'Justifié' } },
|
|
274
|
+
{ kind: 'textAlign', align: 'left', icon: 'iconoir:align-left', tooltip: { text: 'Aligné à gauche' } },
|
|
275
|
+
{ kind: 'textAlign', align: 'center', icon: 'iconoir:align-center', tooltip: { text: 'Centré' } },
|
|
276
|
+
{ kind: 'textAlign', align: 'right', icon: 'iconoir:align-right', tooltip: { text: 'Aligné à droite' } },
|
|
277
|
+
],
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
const slashItems = [
|
|
281
|
+
[
|
|
282
|
+
{ type: 'label', label: 'Blocs de base' },
|
|
283
|
+
{ kind: 'paragraph', label: 'Texte', icon: 'iconoir:text', description: 'Écrivez du texte. Tout simplement' },
|
|
284
|
+
{ kind: 'heading', level: 1, label: 'Titre 1', icon: 'iconoir:text-size', description: 'Titre de grande taille' },
|
|
285
|
+
{ kind: 'heading', level: 2, label: 'Titre 2', icon: 'iconoir:text-size', description: 'Titre de taille moyenne' },
|
|
286
|
+
{ kind: 'heading', level: 3, label: 'Titre 3', icon: 'iconoir:text-size', description: 'Titre de petite taille' },
|
|
287
|
+
{ kind: 'bulletList', label: 'Liste à puces', icon: 'iconoir:list', description: 'Créez une liste à puces' },
|
|
288
|
+
{ kind: 'orderedList', label: 'Liste numérotée', icon: 'iconoir:numbered-list-left', description: 'Créez une liste numérotée' },
|
|
289
|
+
{ kind: 'blockquote', label: 'Citation', icon: 'iconoir:quote-message', description: 'Affichez une citation' },
|
|
290
|
+
{ kind: 'horizontalRule', label: 'Séparateur', icon: 'iconoir:minus', description: 'Séparez deux blocs' },
|
|
291
|
+
],
|
|
292
|
+
[
|
|
293
|
+
{ type: 'label', label: 'Blocs riches' },
|
|
294
|
+
{ kind: 'callout', label: 'Encart', icon: 'iconoir:megaphone', description: 'Mettez une information en avant' },
|
|
295
|
+
{ kind: 'details', label: 'Accordéon', icon: 'lucide:list-collapse', description: 'Contenu repliable' },
|
|
296
|
+
...Object.entries(EMBED_SERVICES).map(([service, preset]) => ({
|
|
297
|
+
kind: `embed-${service}`,
|
|
298
|
+
label: preset.label,
|
|
299
|
+
icon: preset.icon,
|
|
300
|
+
description: `Intégrez : ${preset.placeholder}`,
|
|
301
|
+
})),
|
|
302
|
+
{ kind: 'uploadImage', label: 'Image', icon: 'iconoir:media-image', description: 'Téléversez une image' },
|
|
303
|
+
{ kind: 'uploadFile', label: 'Fichier', icon: 'iconoir:page', description: 'Téléversez un document (PDF…)' },
|
|
304
|
+
],
|
|
305
|
+
]
|
|
306
|
+
|
|
307
|
+
// Fixed toolbar: the legacy composition — block catalog, link, marks, alignment.
|
|
308
|
+
const toolbarItems = [
|
|
309
|
+
[{ slot: 'blocks' }],
|
|
310
|
+
[
|
|
311
|
+
{ kind: 'mark', mark: 'bold', icon: 'iconoir:bold-square-outline', tooltip: { text: 'Gras' } },
|
|
312
|
+
{ kind: 'mark', mark: 'italic', icon: 'iconoir:italic-square-outline', tooltip: { text: 'Italique' } },
|
|
313
|
+
],
|
|
314
|
+
[{ slot: 'link', icon: 'iconoir:link' }],
|
|
315
|
+
[
|
|
316
|
+
{ kind: 'textAlign', align: 'justify', icon: 'iconoir:align-justify', tooltip: { text: 'Justifié' } },
|
|
317
|
+
{ kind: 'textAlign', align: 'left', icon: 'iconoir:align-left', tooltip: { text: 'Aligné à gauche' } },
|
|
318
|
+
{ kind: 'textAlign', align: 'center', icon: 'iconoir:align-center', tooltip: { text: 'Centré' } },
|
|
319
|
+
{ kind: 'textAlign', align: 'right', icon: 'iconoir:align-right', tooltip: { text: 'Aligné à droite' } },
|
|
320
|
+
],
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
const blockMenuInsertItems = (editor) => mapEditorItems(editor, slashItems, customHandlers)
|
|
324
|
+
|
|
325
|
+
const selectedNode = ref()
|
|
326
|
+
|
|
327
|
+
const blockMenuItems = (editor) =>
|
|
328
|
+
mapEditorItems(
|
|
329
|
+
editor,
|
|
330
|
+
[
|
|
331
|
+
[
|
|
332
|
+
{ kind: 'moveUp', pos: selectedNode.value?.pos, label: 'Monter', icon: 'iconoir:arrow-up' },
|
|
333
|
+
{ kind: 'moveDown', pos: selectedNode.value?.pos, label: 'Descendre', icon: 'iconoir:arrow-down' },
|
|
334
|
+
{ kind: 'duplicate', pos: selectedNode.value?.pos, label: 'Dupliquer', icon: 'iconoir:copy' },
|
|
335
|
+
],
|
|
336
|
+
[{ kind: 'delete', pos: selectedNode.value?.pos, label: 'Supprimer', icon: 'iconoir:trash' }],
|
|
337
|
+
],
|
|
338
|
+
customHandlers,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// ── Link popovers (bubble and fixed toolbar hold their own open state) ──────
|
|
342
|
+
const linkOpen = ref(false)
|
|
343
|
+
const toolbarLinkOpen = ref(false)
|
|
344
|
+
const linkUrl = ref('')
|
|
345
|
+
|
|
346
|
+
function applyLink(editor, source) {
|
|
347
|
+
const href = linkUrl.value.trim()
|
|
348
|
+
if (href) {
|
|
349
|
+
editor.chain().focus().extendMarkRange('link').setLink({ href }).run()
|
|
350
|
+
}
|
|
351
|
+
closeLink(source)
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function removeLink(editor, source) {
|
|
355
|
+
editor.chain().focus().extendMarkRange('link').unsetLink().run()
|
|
356
|
+
closeLink(source)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function closeLink(source) {
|
|
360
|
+
if (source === 'toolbar') toolbarLinkOpen.value = false
|
|
361
|
+
else linkOpen.value = false
|
|
362
|
+
}
|
|
363
|
+
</script>
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<label
|
|
4
|
+
:for="name"
|
|
5
|
+
class="block text-2xs font-light tracking-wider text-gray-500 uppercase"
|
|
6
|
+
>
|
|
7
|
+
{{ label }}
|
|
8
|
+
</label>
|
|
9
|
+
<ul
|
|
10
|
+
:id="name"
|
|
11
|
+
:name="name"
|
|
12
|
+
:class="{ 'p-3': (limit !== 1 || !currentMedia.length), 'mt-1': !!label }"
|
|
13
|
+
class="bg-gray-100 rounded-md"
|
|
14
|
+
>
|
|
15
|
+
<!-- <draggable
|
|
16
|
+
:list="value"
|
|
17
|
+
:disabled="readOnly"
|
|
18
|
+
ghost-class="opacity-25"
|
|
19
|
+
@start="dragging = true"
|
|
20
|
+
@end="dragging = false"
|
|
21
|
+
> -->
|
|
22
|
+
<ul role="list" :class="{ 'grid grid-cols-2 grid-flow-row': limit !== 1 }">
|
|
23
|
+
<li
|
|
24
|
+
v-if="!readOnly && (limit === 0 || currentMedia.length < limit)"
|
|
25
|
+
class="relative mt-1 col-span-2"
|
|
26
|
+
>
|
|
27
|
+
<div class="flex w-full justify-center rounded-md border-2 border-dashed border-gray-300 focus:outline-none px-6 pt-5 pb-6">
|
|
28
|
+
<div class="space-y-1 text-center">
|
|
29
|
+
<svg
|
|
30
|
+
class="cursor-pointer mx-auto h-12 w-12 text-gray-400"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
fill="none"
|
|
33
|
+
viewBox="0 0 48 48"
|
|
34
|
+
aria-hidden="true"
|
|
35
|
+
>
|
|
36
|
+
<path
|
|
37
|
+
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
|
38
|
+
stroke-width="2"
|
|
39
|
+
stroke-linecap="round"
|
|
40
|
+
stroke-linejoin="round"
|
|
41
|
+
/>
|
|
42
|
+
</svg>
|
|
43
|
+
<div class="flex text-sm text-gray-600">
|
|
44
|
+
<label
|
|
45
|
+
for="file-upload"
|
|
46
|
+
class="relative cursor-pointer text-indigo-600 rounded-md font-medium focus-within:outline-none focus-within:ring-2 focus-within:ring-indigo-500 focus-within:ring-offset-2 hover:text-indigo-500"
|
|
47
|
+
@click="_uploadMedia"
|
|
48
|
+
>
|
|
49
|
+
<span>Upload a file</span>
|
|
50
|
+
<input-files
|
|
51
|
+
ref="inputMedia"
|
|
52
|
+
class="sr-only"
|
|
53
|
+
:read-only="readOnly"
|
|
54
|
+
@loading="loading = true"
|
|
55
|
+
@loaded="loading = false"
|
|
56
|
+
@change="_handleUploadedFiles"
|
|
57
|
+
/>
|
|
58
|
+
</label>
|
|
59
|
+
<p class="pl-1">
|
|
60
|
+
or drag and drop
|
|
61
|
+
</p>
|
|
62
|
+
</div>
|
|
63
|
+
<p class="text-xs text-gray-500">
|
|
64
|
+
PNG, JPG, GIF up to 10MB
|
|
65
|
+
</p>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
</li>
|
|
69
|
+
<media-card-editor
|
|
70
|
+
v-for="(_id, i) in currentMedia"
|
|
71
|
+
:key="_id"
|
|
72
|
+
:value="_id"
|
|
73
|
+
:read-only="readOnly"
|
|
74
|
+
:full-width="limit === 1"
|
|
75
|
+
:class="{ 'cursor-move':!readOnly, 'row-span-1': limit !== 1 && [1, currentMedia.length - 1].includes(i) }"
|
|
76
|
+
class="row-span-3 h-full"
|
|
77
|
+
@remove="_removeMedia(_id)"
|
|
78
|
+
/>
|
|
79
|
+
</ul>
|
|
80
|
+
<!-- </draggable> -->
|
|
81
|
+
</ul>
|
|
82
|
+
</div>
|
|
83
|
+
</template>
|
|
84
|
+
|
|
85
|
+
<script setup>
|
|
86
|
+
import inputFiles from '~/components/forms/inputs/input-files'
|
|
87
|
+
import mediaCardEditor from '~/components/forms/inputs/media-section/media-editor'
|
|
88
|
+
|
|
89
|
+
// Prepare emitters
|
|
90
|
+
const $emitters = defineEmits(['change'])
|
|
91
|
+
|
|
92
|
+
// Prepare props
|
|
93
|
+
const $props = defineProps({
|
|
94
|
+
readOnly: {
|
|
95
|
+
type: Boolean,
|
|
96
|
+
default: false
|
|
97
|
+
},
|
|
98
|
+
label: {
|
|
99
|
+
type: String,
|
|
100
|
+
default: ''
|
|
101
|
+
},
|
|
102
|
+
name: {
|
|
103
|
+
type: String,
|
|
104
|
+
default: 'input'
|
|
105
|
+
},
|
|
106
|
+
limit: {
|
|
107
|
+
type: Number,
|
|
108
|
+
default: 0
|
|
109
|
+
},
|
|
110
|
+
value: {
|
|
111
|
+
type: [Array, String],
|
|
112
|
+
default: () => []
|
|
113
|
+
}
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
// Prepare reactive data
|
|
117
|
+
const dragging = ref(false)
|
|
118
|
+
const loaded = ref(false)
|
|
119
|
+
const initialValue = ref([])
|
|
120
|
+
const currentMedia = ref([])
|
|
121
|
+
const inputMedia = ref(null)
|
|
122
|
+
|
|
123
|
+
// Prepare watchers
|
|
124
|
+
watch(
|
|
125
|
+
() => $props.value,
|
|
126
|
+
(newValue) => {
|
|
127
|
+
if (!currentMedia.value.length && (newValue && newValue.length)) {
|
|
128
|
+
initialValue.value = Array.isArray(newValue) ? [...newValue] : [newValue]
|
|
129
|
+
currentMedia.value = [...initialValue.value]
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
{ immediate: true }
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
// Prepare methods
|
|
136
|
+
const _uploadMedia = () => {
|
|
137
|
+
inputMedia.value._handleClick()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const _removeMedia = (idToRemove) => {
|
|
141
|
+
initialValue.value = initialValue.value.filter(_id => _id !== idToRemove)
|
|
142
|
+
currentMedia.value = [...initialValue.value]
|
|
143
|
+
$emitters('change', currentMedia.value.map(_id => _id))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const _handleUploadedFiles = (data = []) => {
|
|
147
|
+
currentMedia.value = [...initialValue.value, ...data.map(({ _id }) => _id)]
|
|
148
|
+
$emitters('change', currentMedia.value)
|
|
149
|
+
}
|
|
150
|
+
</script>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<input
|
|
4
|
+
ref="inputFile"
|
|
5
|
+
type="file"
|
|
6
|
+
class="sr-only"
|
|
7
|
+
:accept="mime"
|
|
8
|
+
:multiple="multiple"
|
|
9
|
+
:disabled="readOnly"
|
|
10
|
+
@change="_prepareUpload"
|
|
11
|
+
>
|
|
12
|
+
</div>
|
|
13
|
+
</template>
|
|
14
|
+
|
|
15
|
+
<script setup>
|
|
16
|
+
import axios from 'axios'
|
|
17
|
+
|
|
18
|
+
const { Media } = useModels()
|
|
19
|
+
|
|
20
|
+
// Prepare emitters
|
|
21
|
+
const $emitters = defineEmits(['loading', 'loaded', 'change', 'error'])
|
|
22
|
+
|
|
23
|
+
// Prepare props
|
|
24
|
+
defineProps({
|
|
25
|
+
mime: {
|
|
26
|
+
type: String,
|
|
27
|
+
default: 'image/*,application/pdf'
|
|
28
|
+
},
|
|
29
|
+
multiple: {
|
|
30
|
+
type: Boolean,
|
|
31
|
+
default: false
|
|
32
|
+
},
|
|
33
|
+
readOnly: {
|
|
34
|
+
type: Boolean,
|
|
35
|
+
default: false
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// Prepare reactive data
|
|
40
|
+
const loading = ref(true)
|
|
41
|
+
const files = ref([])
|
|
42
|
+
const inputFile = ref(null)
|
|
43
|
+
|
|
44
|
+
// Prepare methods
|
|
45
|
+
const _handleClick = () => inputFile.value.click()
|
|
46
|
+
|
|
47
|
+
const _createMedia = async ({ name, mime, file }) => {
|
|
48
|
+
// Request for S3 pre-signed url (le nom alimente la clé S3 côté Commun)
|
|
49
|
+
const { _id, url } = await Media.getS3PreSignedUrl({ mime, name })
|
|
50
|
+
|
|
51
|
+
// Put media on amazon S3
|
|
52
|
+
await axios({
|
|
53
|
+
method: 'PUT',
|
|
54
|
+
url,
|
|
55
|
+
data: file,
|
|
56
|
+
headers: { 'Content-Type': mime }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// Inform object has been pushed
|
|
60
|
+
const finalData = await Media.create({ _id, mime, name })
|
|
61
|
+
return finalData
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const _uploadFiles = async () => {
|
|
65
|
+
const filesToUpload = files.value.filter(({ isUploaded }) => !isUploaded)
|
|
66
|
+
if (filesToUpload && filesToUpload.length) {
|
|
67
|
+
let i = (filesToUpload.length - 1)
|
|
68
|
+
while (i >= 0) {
|
|
69
|
+
const { file } = filesToUpload[i]
|
|
70
|
+
const { name, type: mime } = file
|
|
71
|
+
const mediaRecord = await _createMedia({ mime, name, file })
|
|
72
|
+
files.value.pop()
|
|
73
|
+
files.value.unshift({ ...mediaRecord, isUploaded: true })
|
|
74
|
+
i--
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const _prepareUpload = async () => {
|
|
80
|
+
$emitters('loading')
|
|
81
|
+
loading.value = true
|
|
82
|
+
try {
|
|
83
|
+
const list = Array.from(inputFile.value.files)
|
|
84
|
+
list.forEach(file => files.value.push({ file, isUploaded: false }))
|
|
85
|
+
await _uploadFiles()
|
|
86
|
+
$emitters('change', files.value)
|
|
87
|
+
} catch (err) {
|
|
88
|
+
$emitters('error', err)
|
|
89
|
+
} finally {
|
|
90
|
+
loading.value = false
|
|
91
|
+
$emitters('loaded')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
defineExpose({
|
|
96
|
+
_handleClick
|
|
97
|
+
})
|
|
98
|
+
</script>
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div>
|
|
3
|
+
<label
|
|
4
|
+
:for="name"
|
|
5
|
+
class="block text-2xs font-light tracking-wider text-gray-500 uppercase"
|
|
6
|
+
>
|
|
7
|
+
{{ label }}
|
|
8
|
+
</label>
|
|
9
|
+
<textarea
|
|
10
|
+
:id="name"
|
|
11
|
+
v-model="data"
|
|
12
|
+
:name="name"
|
|
13
|
+
:class="{ 'mt-1':!!label }"
|
|
14
|
+
:placeholder="placeHolder"
|
|
15
|
+
:disabled="readOnly"
|
|
16
|
+
rows="3"
|
|
17
|
+
class="block w-full rounded-md py-2 px-3 shadow-xs border border-gray-200 focus:outline-none focus:ring-gray-50 focus:border-black sm:text-sm disabled:cursor-not-allowed disabled:bg-gray-50 disabled:text-gray-500 disabled:ring-gray-200"
|
|
18
|
+
/>
|
|
19
|
+
</div>
|
|
20
|
+
</template>
|
|
21
|
+
|
|
22
|
+
<script setup>
|
|
23
|
+
// Prepare $emitters
|
|
24
|
+
const $emitters = defineEmits(['keyup.enter', 'change'])
|
|
25
|
+
|
|
26
|
+
// Prepare $props
|
|
27
|
+
const $props = defineProps({
|
|
28
|
+
label: {
|
|
29
|
+
type: String,
|
|
30
|
+
default: ''
|
|
31
|
+
},
|
|
32
|
+
name: {
|
|
33
|
+
type: String,
|
|
34
|
+
default: 'input'
|
|
35
|
+
},
|
|
36
|
+
placeHolder: {
|
|
37
|
+
type: String,
|
|
38
|
+
default: ''
|
|
39
|
+
},
|
|
40
|
+
value: {
|
|
41
|
+
type: [Object, Array],
|
|
42
|
+
default: () => ({})
|
|
43
|
+
},
|
|
44
|
+
readOnly: {
|
|
45
|
+
type: Boolean,
|
|
46
|
+
default: false
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Prepare reactive data
|
|
51
|
+
const data = ref('')
|
|
52
|
+
|
|
53
|
+
// Prepare watchers
|
|
54
|
+
watch(
|
|
55
|
+
() => $props.value,
|
|
56
|
+
(newValue) => {
|
|
57
|
+
data.value = JSON.stringify(newValue, null, 2)
|
|
58
|
+
},
|
|
59
|
+
{ immediate: true }
|
|
60
|
+
)
|
|
61
|
+
watch(
|
|
62
|
+
data,
|
|
63
|
+
(newValue) => $emitters('change', JSON.parse(newValue))
|
|
64
|
+
)
|
|
65
|
+
</script>
|