@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.
Files changed (132) hide show
  1. package/README.md +41 -0
  2. package/app.config.ts +10 -0
  3. package/app.vue +13 -0
  4. package/assets/css/main.css +13 -0
  5. package/components/data/display/row/data-avatar.vue +23 -0
  6. package/components/data/display/row/data-badge.vue +34 -0
  7. package/components/data/display/row/data-color.vue +17 -0
  8. package/components/data/display/row/data-display-badge-publish-status.vue +29 -0
  9. package/components/data/display/row/data-label.vue +37 -0
  10. package/components/data/display/row/data-last-update.vue +45 -0
  11. package/components/data/display/row/data-spacer.vue +4 -0
  12. package/components/data/display/row/data-summary.vue +44 -0
  13. package/components/data/display/row/data-text.vue +14 -0
  14. package/components/data/display/row/data-toggle.vue +16 -0
  15. package/components/data/display/row.vue +111 -0
  16. package/components/data/lists/data-list.vue +85 -0
  17. package/components/data/lists/data-lists-table.vue +155 -0
  18. package/components/elements/avatars/avatar-circular.vue +68 -0
  19. package/components/elements/avatars/avatar-with-text.vue +47 -0
  20. package/components/elements/badges/badge.vue +85 -0
  21. package/components/elements/buttons/primary.vue +32 -0
  22. package/components/elements/buttons/secondary.vue +35 -0
  23. package/components/elements/buttons/tertiary.vue +25 -0
  24. package/components/elements/dropdowns/dots.vue +75 -0
  25. package/components/elements/media/image.vue +82 -0
  26. package/components/elements/notifications/default.vue +90 -0
  27. package/components/forms/handlers/handler-collections.vue +189 -0
  28. package/components/forms/handlers/handler-publication-status.vue +193 -0
  29. package/components/forms/handlers/handler-records.vue +138 -0
  30. package/components/forms/handlers/handler-roles.vue +176 -0
  31. package/components/forms/handlers/handler-schedules.vue +218 -0
  32. package/components/forms/handlers/handler-steps.vue +147 -0
  33. package/components/forms/handlers/handler-tokens.vue +79 -0
  34. package/components/forms/inputs/input-color-picker.vue +159 -0
  35. package/components/forms/inputs/input-editor.vue +363 -0
  36. package/components/forms/inputs/input-files-media.vue +150 -0
  37. package/components/forms/inputs/input-files.vue +98 -0
  38. package/components/forms/inputs/input-json.vue +65 -0
  39. package/components/forms/inputs/input-location.vue +179 -0
  40. package/components/forms/inputs/input-text-area.vue +65 -0
  41. package/components/forms/inputs/input-text.vue +122 -0
  42. package/components/forms/inputs/input-toggle.vue +125 -0
  43. package/components/forms/inputs/media-section/media-editor.vue +95 -0
  44. package/components/forms/layouts/form.vue +203 -0
  45. package/components/forms/layouts/layout.vue +47 -0
  46. package/components/forms/layouts/modal.vue +96 -0
  47. package/components/forms/layouts/navigation.vue +48 -0
  48. package/components/forms/layouts/section.vue +118 -0
  49. package/components/forms/selects/select-collection-enum.vue +195 -0
  50. package/components/forms/selects/select-date.vue +245 -0
  51. package/components/forms/selects/select-enum.vue +186 -0
  52. package/components/forms/selects/select-record.vue +274 -0
  53. package/components/layout/headings/headings-collection.vue +29 -0
  54. package/components/layout/headings/headings-record.vue +33 -0
  55. package/components/layout/navigations/primary/dropdown-publish.vue +147 -0
  56. package/components/layout/navigations/primary.vue +132 -0
  57. package/components/layout/notifications/notifications-list.vue +19 -0
  58. package/composables/core/domains/use-definitions.ts +57 -0
  59. package/composables/core/domains/use-entries.ts +62 -0
  60. package/composables/core/domains/use-media.ts +82 -0
  61. package/composables/core/use-core.ts +12 -0
  62. package/composables/use-ability.js +38 -0
  63. package/composables/use-models.js +27 -0
  64. package/composables/use-session.ts +80 -0
  65. package/composables/use-timer.js +62 -0
  66. package/composables/use-trpc.js +17 -0
  67. package/editor/index.ts +44 -0
  68. package/editor/views/CalloutView.vue +22 -0
  69. package/editor/views/DetailsContentView.vue +11 -0
  70. package/editor/views/DetailsSummaryView.vue +11 -0
  71. package/editor/views/DetailsView.vue +72 -0
  72. package/editor/views/EmbedView.vue +53 -0
  73. package/editor/views/FileView.vue +79 -0
  74. package/editor/views/ImageView.vue +56 -0
  75. package/editor/views/UploadView.vue +71 -0
  76. package/layouts/default.vue +21 -0
  77. package/layouts/entrance.vue +10 -0
  78. package/layouts/record.vue +21 -0
  79. package/middleware/00.auth.global.js +16 -0
  80. package/middleware/01.instance.global.js +21 -0
  81. package/middleware/03.verify-permissions.global.js +11 -0
  82. package/middleware/04.prefetch-page.global.js +18 -0
  83. package/middleware/05.headings-page.global.js +25 -0
  84. package/models/_commun.js +243 -0
  85. package/models/_factory.js +34 -0
  86. package/models/collection.js +87 -0
  87. package/models/entrance.js +42 -0
  88. package/models/media.js +82 -0
  89. package/models/organization.js +92 -0
  90. package/models/record.js +165 -0
  91. package/models/role.js +66 -0
  92. package/models/token.js +56 -0
  93. package/models/user.js +97 -0
  94. package/nuxt.config.ts +151 -0
  95. package/package.json +71 -0
  96. package/pages/[collection]/[record]/index.vue +88 -0
  97. package/pages/[collection]/index.vue +55 -0
  98. package/pages/_lab/editor.vue +149 -0
  99. package/pages/auth/callback.vue +31 -0
  100. package/pages/index/index.vue +85 -0
  101. package/pages/index/password/define/[token].vue +79 -0
  102. package/pages/index/password/recover/index.vue +72 -0
  103. package/pages/index/welcome/[token].vue +90 -0
  104. package/pages/index.vue +44 -0
  105. package/pages/management/index.vue +15 -0
  106. package/pages/management/members.vue +176 -0
  107. package/pages/management/organizations.vue +192 -0
  108. package/pages/management/roles.vue +183 -0
  109. package/pages/overview.vue +15 -0
  110. package/pages/settings.vue +197 -0
  111. package/plugins/01.session.client.ts +5 -0
  112. package/plugins/02.vue-query.ts +25 -0
  113. package/public/favicon.ico +0 -0
  114. package/public/logo-black.svg +1 -0
  115. package/public/logo-white.svg +1 -0
  116. package/server/api/trpc/[...trpc].ts +28 -0
  117. package/server/api/v1/content/deployment.get.ts +45 -0
  118. package/server/api/v1/content/records.get.ts +12 -0
  119. package/server/middleware/0.security.ts +19 -0
  120. package/server/middleware/1.cors.ts +31 -0
  121. package/server/middleware/2.session.ts +20 -0
  122. package/server/middleware/3.api-token.ts +20 -0
  123. package/server/plugins/core.ts +8 -0
  124. package/server/routes/_tasks/[name].post.ts +20 -0
  125. package/server/routes/health.get.ts +6 -0
  126. package/server/tasks/deploy.ts +26 -0
  127. package/server/types.d.ts +8 -0
  128. package/server/utils/core.ts +18 -0
  129. package/store/layout/headings.js +21 -0
  130. package/store/layout/modal.js +80 -0
  131. package/store/layout/notifications.js +52 -0
  132. 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>