@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,57 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
2
+ import { buildDataSchema, type FieldDefinition } from '@open-commune/core/collections/utils'
3
+ import { computed, unref, type MaybeRef } from 'vue'
4
+
5
+ /**
6
+ * Collection-definition domain composable — the only data surface pages use.
7
+ * Server state lives in the query cache; writes invalidate the
8
+ * ['definitions'] prefix.
9
+ */
10
+ export default function useDefinitions() {
11
+ const trpc = useTrpc()
12
+ const queryClient = useQueryClient()
13
+ const invalidate = () => queryClient.invalidateQueries({ queryKey: ['definitions'] })
14
+
15
+ const list = () =>
16
+ useQuery({
17
+ queryKey: ['definitions'],
18
+ queryFn: () => trpc.collections.list.query(),
19
+ })
20
+
21
+ const get = (idOrSlug: MaybeRef<string>) =>
22
+ useQuery({
23
+ queryKey: computed(() => ['definitions', unref(idOrSlug)]),
24
+ queryFn: () => trpc.collections.get.query({ idOrSlug: unref(idOrSlug) }),
25
+ })
26
+
27
+ const create = () =>
28
+ useMutation({
29
+ mutationFn: (input: Record<string, unknown>) => trpc.collections.create.mutate(input),
30
+ onSuccess: invalidate,
31
+ })
32
+
33
+ const update = () =>
34
+ useMutation({
35
+ mutationFn: ({ id, data }: { id: string; data: Record<string, unknown> }) =>
36
+ trpc.collections.update.mutate({ id, data }),
37
+ onSuccess: invalidate,
38
+ })
39
+
40
+ const remove = () =>
41
+ useMutation({
42
+ mutationFn: (id: string) => trpc.collections.remove.mutate({ id }),
43
+ onSuccess: invalidate,
44
+ })
45
+
46
+ /**
47
+ * Zod schema of an entry's data, generated from the definition — exactly
48
+ * the rule the server applies. Pass as-is to a UForm `:schema`.
49
+ */
50
+ const dataSchema = (fields: MaybeRef<FieldDefinition[] | undefined>) =>
51
+ computed(() => {
52
+ const resolved = unref(fields)
53
+ return resolved?.length ? buildDataSchema(resolved) : undefined
54
+ })
55
+
56
+ return { list, get, create, update, remove, dataSchema, invalidate }
57
+ }
@@ -0,0 +1,62 @@
1
+ import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
2
+ import { computed, unref, type MaybeRef } from 'vue'
3
+
4
+ /**
5
+ * Entries domain composable. Keys: ['entries', collectionId] for lists,
6
+ * ['entries', 'byId', id] for one entry; a write invalidates its
7
+ * collection's prefix so every list refreshes without hand-kept keys.
8
+ * Rich content travels as ProseMirror OBJECTS end to end.
9
+ */
10
+ export default function useEntries() {
11
+ const trpc = useTrpc()
12
+ const queryClient = useQueryClient()
13
+
14
+ const invalidateCollection = (collectionId: string) => {
15
+ queryClient.invalidateQueries({ queryKey: ['entries', collectionId] })
16
+ queryClient.invalidateQueries({ queryKey: ['entries', 'byId'] })
17
+ }
18
+
19
+ const list = (
20
+ collectionId: MaybeRef<string>,
21
+ options: MaybeRef<{ skip?: number; limit?: number }> = {},
22
+ ) =>
23
+ useQuery({
24
+ queryKey: computed(() => ['entries', unref(collectionId), unref(options)]),
25
+ queryFn: () =>
26
+ trpc.collections.entries.list.query({
27
+ collectionId: unref(collectionId),
28
+ ...unref(options),
29
+ }),
30
+ enabled: computed(() => Boolean(unref(collectionId))),
31
+ })
32
+
33
+ const get = (id: MaybeRef<string>) =>
34
+ useQuery({
35
+ queryKey: computed(() => ['entries', 'byId', unref(id)]),
36
+ queryFn: () => trpc.collections.entries.get.query({ id: unref(id) }),
37
+ enabled: computed(() => Boolean(unref(id))),
38
+ })
39
+
40
+ const create = () =>
41
+ useMutation({
42
+ mutationFn: (input: { collectionId: string; data: Record<string, unknown> }) =>
43
+ trpc.collections.entries.create.mutate(input),
44
+ onSuccess: (_result, { collectionId }) => invalidateCollection(collectionId),
45
+ })
46
+
47
+ const update = () =>
48
+ useMutation({
49
+ mutationFn: (input: { id: string; collectionId: string; data: Record<string, unknown> }) =>
50
+ trpc.collections.entries.update.mutate({ id: input.id, data: input.data }),
51
+ onSuccess: (_result, { collectionId }) => invalidateCollection(collectionId),
52
+ })
53
+
54
+ const remove = () =>
55
+ useMutation({
56
+ mutationFn: (input: { id: string; collectionId: string }) =>
57
+ trpc.collections.entries.remove.mutate({ id: input.id }),
58
+ onSuccess: (_result, { collectionId }) => invalidateCollection(collectionId),
59
+ })
60
+
61
+ return { list, get, create, update, remove, invalidateCollection }
62
+ }
@@ -0,0 +1,82 @@
1
+ import type { CommunEditorMedia } from '@open-commune/editor'
2
+
3
+ /**
4
+ * Media domain composable: tRPC calls plus the direct-to-S3 upload step.
5
+ *
6
+ * Resolution is shared app-wide (module cache) and bounded: a rich document
7
+ * resolves every media on mount, and hundreds of concurrent requests freeze
8
+ * the page. Failures are not cached — they retry on next mount.
9
+ */
10
+
11
+ const FETCH_CONCURRENCY = 6
12
+
13
+ const mediaCache = new Map<string, Promise<{ src?: string; title?: string }>>()
14
+ let inFlight = 0
15
+ const waiters: Array<() => void> = []
16
+
17
+ async function withSlot<T>(task: () => Promise<T>): Promise<T> {
18
+ while (inFlight >= FETCH_CONCURRENCY) {
19
+ await new Promise<void>((resolve) => waiters.push(resolve))
20
+ }
21
+ inFlight += 1
22
+ try {
23
+ return await task()
24
+ } finally {
25
+ inFlight -= 1
26
+ waiters.shift()?.()
27
+ }
28
+ }
29
+
30
+ export default function useMedia() {
31
+ const trpc = useTrpc()
32
+
33
+ /** Presigned browser PUT. The Content-Type must match the presign. */
34
+ const uploadToS3 = async (url: string, file: File) => {
35
+ await $fetch.raw(url, {
36
+ method: 'PUT',
37
+ body: file,
38
+ headers: { 'Content-Type': file.type },
39
+ })
40
+ }
41
+
42
+ /**
43
+ * Full upload flow. The node id is the FINAL media id returned by
44
+ * finalize — the presign key is only a transient identifier.
45
+ */
46
+ const upload = async (file: File) => {
47
+ const filename = file.name || 'fichier'
48
+ const { key, url } = await trpc.media.requestUpload.mutate({
49
+ filename,
50
+ mime: file.type,
51
+ })
52
+ await uploadToS3(url, file)
53
+ const media = await trpc.media.finalize.mutate({ key, filename, mime: file.type })
54
+ const resolved = await trpc.media.get.query({ id: media.id })
55
+ return {
56
+ id: resolved.id,
57
+ src: resolved.objects?.original,
58
+ title: resolved.filename ?? filename,
59
+ }
60
+ }
61
+
62
+ const resolve = async ({ id }: { id: string }) => {
63
+ if (!mediaCache.has(id)) {
64
+ mediaCache.set(
65
+ id,
66
+ withSlot(async () => {
67
+ const media = await trpc.media.get.query({ id })
68
+ return { src: media?.objects?.original, title: media?.filename }
69
+ }).catch((error) => {
70
+ mediaCache.delete(id)
71
+ throw error
72
+ }),
73
+ )
74
+ }
75
+ return mediaCache.get(id)!
76
+ }
77
+
78
+ /** The upload/resolve pair in the shape the editor extensions expect. */
79
+ const forEditor = (): CommunEditorMedia => ({ upload, fetch: resolve })
80
+
81
+ return { upload, resolve, forEditor }
82
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Single facade over the backend domains: pages and components call
3
+ * useCore().<domain>. Transport (use-trpc) and auth (use-session) remain
4
+ * separate concerns at the composables root.
5
+ */
6
+ export default function useCore() {
7
+ return {
8
+ definitions: useDefinitions(),
9
+ entries: useEntries(),
10
+ media: useMedia(),
11
+ }
12
+ }
@@ -0,0 +1,38 @@
1
+ import { defineAbility } from '@casl/ability'
2
+
3
+ // Prepare abilities array
4
+ let _abilities = defineAbility((can) => {
5
+ can('entrance', 'self')
6
+ })
7
+
8
+ export default () => {
9
+ // Prepare reusable methods
10
+ function resetAbilities() {
11
+ return _abilities?.update([])
12
+ }
13
+
14
+ function assignAbilities(permissions) {
15
+ resetAbilities()
16
+ _abilities = defineAbility((can) => {
17
+ permissions.forEach((permission) => {
18
+ const [action, subject] = permission.split(':')
19
+ can(action, subject)
20
+ })
21
+ })
22
+ }
23
+
24
+ function can(permission) {
25
+ const [action, subject, collection] = permission.split(':')
26
+ if (!_abilities?.can(action, subject)) {
27
+ return false
28
+ }
29
+ return true
30
+ }
31
+
32
+ return {
33
+ _abilities,
34
+ resetAbilities,
35
+ assignAbilities,
36
+ can,
37
+ }
38
+ }
@@ -0,0 +1,27 @@
1
+ import Organization from '~/models/organization'
2
+ import Collection from '~/models/collection'
3
+ import Media from '~/models/media'
4
+ import Record from '~/models/record'
5
+ import Role from '~/models/role'
6
+ import Token from '~/models/token'
7
+ import User from '~/models/user'
8
+ import Entrance from '~/models/entrance'
9
+
10
+ // @review on pourrait pas déplacer le dossier models dans le dossier composables pour ajouter plus de clareté ? Aussi cette factory me semble un peu alambiquée, pourquoi a t on besoin de passer le model + api url dans un objet exporté par le model lui meme
11
+ // bref, je pense que qu'il y a de l'overengineering ici aussi non ?
12
+
13
+ // Export models
14
+ export default () => {
15
+ const { public: { apiURL } } = useRuntimeConfig()
16
+
17
+ return {
18
+ Organization: new Organization.api(Organization.model, apiURL),
19
+ Collection: new Collection.api(Collection.model, apiURL),
20
+ Media: new Media.api(Media.model, apiURL),
21
+ Record: new Record.api(Record.model, apiURL),
22
+ Role: new Role.api(Role.model, apiURL),
23
+ Token: new Token.api(Token.model, apiURL),
24
+ User: new User.api(User.model, apiURL),
25
+ Entrance: new Entrance.api(Entrance.model, apiURL)
26
+ }
27
+ }
@@ -0,0 +1,80 @@
1
+ import { computed, ref } from 'vue'
2
+
3
+ /**
4
+ * Bearer-token session over the tRPC auth procedures. No cookies, no
5
+ * credentialed requests; the guard is middleware/00.auth.global.
6
+ */
7
+
8
+ const TOKEN_KEY = 'commun.session.token'
9
+
10
+ const token = ref<string | null>(null)
11
+ const user = ref<Record<string, unknown> | null>(null)
12
+
13
+ export default function useSession() {
14
+ const isAuthenticated = computed(() => Boolean(token.value && user.value))
15
+
16
+ const setToken = (value: string) => {
17
+ token.value = value
18
+ localStorage.setItem(TOKEN_KEY, value)
19
+ }
20
+
21
+ const clear = () => {
22
+ token.value = null
23
+ user.value = null
24
+ localStorage.removeItem(TOKEN_KEY)
25
+ }
26
+
27
+ /** Reloads the user from auth.me; clears the session on a dead token. */
28
+ const refresh = async () => {
29
+ if (!token.value) return null
30
+ try {
31
+ const { user: me } = await useTrpc().auth.me.query()
32
+ user.value = me
33
+ return me
34
+ } catch {
35
+ clear()
36
+ return null
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Boot restore: localStorage first, then — once, transitional — the old
42
+ * auth-module cookie so sessions opened before the refonte survive.
43
+ */
44
+ const restore = async () => {
45
+ let saved = localStorage.getItem(TOKEN_KEY)
46
+ if (!saved) {
47
+ for (const legacy of ['auth.token', 'auth:token']) {
48
+ const cookie = useCookie<string | null>(legacy)
49
+ if (cookie.value) {
50
+ saved = cookie.value
51
+ cookie.value = null
52
+ break
53
+ }
54
+ }
55
+ }
56
+ if (!saved) return
57
+ token.value = saved
58
+ localStorage.setItem(TOKEN_KEY, saved)
59
+ await refresh()
60
+ }
61
+
62
+ const login = async (email: string, password: string) => {
63
+ const result = await useTrpc().auth.login.mutate({ email, password })
64
+ setToken(result.token)
65
+ user.value = result.user
66
+ return result.user
67
+ }
68
+
69
+ /** Best-effort server-side revocation, then local clear. */
70
+ const logout = async () => {
71
+ try {
72
+ await useTrpc().auth.logout.mutate()
73
+ } catch {
74
+ // Dead token or unreachable API: the local clear is enough.
75
+ }
76
+ clear()
77
+ }
78
+
79
+ return { token, user, isAuthenticated, login, logout, setToken, clear, refresh, restore }
80
+ }
@@ -0,0 +1,62 @@
1
+ import { useTimestamp } from '@vueuse/core'
2
+
3
+ // @review: qu'elle est l'utilité de ce truc ? Par ailleurs exporte pas déjà des utils pour la gestion des timeout justement ? Mais il faudrait verifier si ce truc est vraiment utile
4
+ export default (cb, interval, options) => {
5
+ let timer = null
6
+ const { pause: tPause, resume: tResume, timestamp } = useTimestamp({ ...(options || {}), controls: true })
7
+ const startTime = ref(null)
8
+
9
+ const remaining = computed(() => {
10
+ if (!startTime.value) {
11
+ return 0
12
+ }
13
+ return interval - (timestamp.value - startTime.value)
14
+ })
15
+
16
+ function set (...args) {
17
+ timer = setTimeout(() => {
18
+ timer = null
19
+ startTime.value = null
20
+ cb(...args)
21
+ }, remaining.value)
22
+ }
23
+
24
+ function clear () {
25
+ if (timer) {
26
+ clearTimeout(timer)
27
+ timer = null
28
+ }
29
+ }
30
+
31
+ function start () {
32
+ startTime.value = Date.now()
33
+
34
+ set()
35
+ }
36
+
37
+ function stop () {
38
+ clear()
39
+ tPause()
40
+ }
41
+
42
+ function pause () {
43
+ clear()
44
+ tPause()
45
+ }
46
+
47
+ function resume () {
48
+ set()
49
+ tResume()
50
+ startTime.value = (startTime.value || 0) + (Date.now() - timestamp.value)
51
+ }
52
+
53
+ start()
54
+
55
+ return {
56
+ start,
57
+ stop,
58
+ pause,
59
+ resume,
60
+ remaining
61
+ }
62
+ }
@@ -0,0 +1,17 @@
1
+ import { createTRPCClient, httpLink } from '@trpc/client'
2
+
3
+ // tRPC client for the admin plane (/api/trpc). No transformer (none server
4
+ // side); the Authorization header re-reads the session token per request.
5
+ export default () => {
6
+ const { public: { apiURL } } = useRuntimeConfig()
7
+ const session = useSession()
8
+
9
+ return createTRPCClient({
10
+ links: [
11
+ httpLink({
12
+ url: `${apiURL}/api/trpc`,
13
+ headers: () => (session.token.value ? { authorization: `Bearer ${session.token.value}` } : {})
14
+ })
15
+ ]
16
+ })
17
+ }
@@ -0,0 +1,44 @@
1
+ import { VueNodeViewRenderer } from '@tiptap/vue-3'
2
+ import {
3
+ communStarterKit,
4
+ communSchemaExtensions,
5
+ sanitizeDoc,
6
+ EMBED_SERVICES,
7
+ UID_TYPES,
8
+ type CommunEditorMedia,
9
+ } from '@open-commune/editor'
10
+ import CalloutView from './views/CalloutView.vue'
11
+ import FileView from './views/FileView.vue'
12
+ import ImageView from './views/ImageView.vue'
13
+ import EmbedView from './views/EmbedView.vue'
14
+ import DetailsView from './views/DetailsView.vue'
15
+ import DetailsSummaryView from './views/DetailsSummaryView.vue'
16
+ import DetailsContentView from './views/DetailsContentView.vue'
17
+ import UploadView from './views/UploadView.vue'
18
+
19
+ export { communStarterKit, UID_TYPES, sanitizeDoc, EMBED_SERVICES }
20
+ export type { CommunEditorMedia }
21
+
22
+ const VIEWS: Record<string, any> = {
23
+ callout: CalloutView,
24
+ file: FileView,
25
+ image: ImageView,
26
+ embed: EmbedView,
27
+ details: DetailsView,
28
+ detailsSummary: DetailsSummaryView,
29
+ detailsContent: DetailsContentView,
30
+ imageUpload: UploadView,
31
+ }
32
+
33
+ /**
34
+ * Browser entry: the shared extension set (@open-commune/editor) with the admin's
35
+ * Vue node views attached.
36
+ */
37
+ export function communExtensions(media: Partial<CommunEditorMedia> = {}) {
38
+ return communSchemaExtensions(media).map((extension: any) => {
39
+ const view = VIEWS[extension.name]
40
+ return view
41
+ ? extension.extend({ addNodeView: () => VueNodeViewRenderer(view) })
42
+ : extension
43
+ })
44
+ }
@@ -0,0 +1,22 @@
1
+ <template>
2
+ <NodeViewWrapper
3
+ as="aside"
4
+ class="my-2 flex items-start gap-3 rounded-md border border-neutral-300 bg-neutral-50 p-3 dark:border-neutral-700 dark:bg-neutral-900"
5
+ >
6
+ <UIcon
7
+ :name="icon"
8
+ class="mt-0.5 size-5 shrink-0 text-neutral-500"
9
+ />
10
+ <NodeViewContent class="min-w-0 flex-1" />
11
+ </NodeViewWrapper>
12
+ </template>
13
+
14
+ <script setup>
15
+ import { computed } from 'vue'
16
+ import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'
17
+
18
+ const props = defineProps(nodeViewProps)
19
+
20
+ // Stored icons are full Iconify names; UIcon takes them as-is.
21
+ const icon = computed(() => props.node.attrs.icon || 'iconoir:megaphone')
22
+ </script>
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <NodeViewWrapper as="div" data-type="detailsContent">
3
+ <NodeViewContent class="border-t border-neutral-100 px-4 py-3 dark:border-neutral-900" />
4
+ </NodeViewWrapper>
5
+ </template>
6
+
7
+ <script setup>
8
+ import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'
9
+
10
+ defineProps(nodeViewProps)
11
+ </script>
@@ -0,0 +1,11 @@
1
+ <template>
2
+ <NodeViewWrapper as="div">
3
+ <NodeViewContent class="min-h-6 px-4 py-3 text-base" />
4
+ </NodeViewWrapper>
5
+ </template>
6
+
7
+ <script setup>
8
+ import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'
9
+
10
+ defineProps(nodeViewProps)
11
+ </script>
@@ -0,0 +1,72 @@
1
+ <template>
2
+ <NodeViewWrapper
3
+ as="div"
4
+ class="relative my-2 rounded-md border border-neutral-200 dark:border-neutral-800"
5
+ :data-open="open || undefined"
6
+ >
7
+ <button
8
+ type="button"
9
+ contenteditable="false"
10
+ class="absolute top-3 right-3 z-10 flex size-8 cursor-pointer items-center justify-center rounded-full bg-black text-white dark:bg-white dark:text-black"
11
+ :aria-expanded="open"
12
+ aria-label="Déplier ou replier"
13
+ @click="open = !open"
14
+ >
15
+ <UIcon :name="open ? 'iconoir:minus' : 'iconoir:plus'" class="size-4" />
16
+ </button>
17
+ <NodeViewContent
18
+ class="details-body min-w-0 pr-14"
19
+ :class="{ closed: !open }"
20
+ />
21
+ </NodeViewWrapper>
22
+ </template>
23
+
24
+ <script setup>
25
+ // Open state is local editing state, never written to the document. Closed
26
+ // by default; a freshly inserted (empty) accordion opens for writing, and a
27
+ // collapsed one opens when the selection lands in its BODY (redirected paste,
28
+ // programmatic insertion) — the author must see where their content went.
29
+ // A caret in the title line alone does not open it.
30
+ import { ref, onMounted, onBeforeUnmount } from 'vue'
31
+ import { NodeViewWrapper, NodeViewContent, nodeViewProps } from '@tiptap/vue-3'
32
+
33
+ const props = defineProps(nodeViewProps)
34
+ const open = ref(!props.node.textContent?.length)
35
+
36
+ const containsPos = (target) => {
37
+ const pos = props.getPos()
38
+ return typeof pos === 'number' && target > pos && target < pos + props.node.nodeSize
39
+ }
40
+
41
+ const openOnSelectionInBody = () => {
42
+ if (open.value) return
43
+ const summary = props.node.firstChild
44
+ const pos = props.getPos()
45
+ if (!summary || typeof pos !== 'number') return
46
+ const { $head } = props.editor.state.selection
47
+ const bodyFrom = pos + 1 + summary.nodeSize
48
+ if ($head.pos > bodyFrom && $head.pos < pos + props.node.nodeSize - 1) open.value = true
49
+ }
50
+
51
+ const openOnRedirectedPaste = ({ transaction }) => {
52
+ if (open.value) return
53
+ const target = transaction.getMeta('communDetailsOpenAt')
54
+ if (typeof target === 'number' && containsPos(target)) open.value = true
55
+ }
56
+
57
+ onMounted(() => {
58
+ props.editor.on('selectionUpdate', openOnSelectionInBody)
59
+ props.editor.on('transaction', openOnRedirectedPaste)
60
+ })
61
+ onBeforeUnmount(() => {
62
+ props.editor.off('selectionUpdate', openOnSelectionInBody)
63
+ props.editor.off('transaction', openOnRedirectedPaste)
64
+ })
65
+ </script>
66
+
67
+ <style scoped>
68
+ /* Collapsed: only the summary stays visible. */
69
+ .details-body.closed :deep([data-type='detailsContent']) {
70
+ display: none;
71
+ }
72
+ </style>
@@ -0,0 +1,53 @@
1
+ <template>
2
+ <NodeViewWrapper
3
+ as="div"
4
+ class="my-2 rounded-md"
5
+ :class="{ 'ring-2 ring-neutral-400 ring-offset-2': selected }"
6
+ >
7
+ <iframe
8
+ v-if="node.attrs.src"
9
+ :src="node.attrs.src"
10
+ :title="node.attrs.title || node.attrs.service || 'embed'"
11
+ :height="node.attrs.height || 315"
12
+ :frameborder="node.attrs.frameborder"
13
+ :allow="node.attrs.allow"
14
+ :allowfullscreen="node.attrs.allowfullscreen"
15
+ class="w-full rounded-md"
16
+ sandbox="allow-scripts allow-same-origin allow-presentation allow-popups allow-forms"
17
+ />
18
+ <form
19
+ v-else
20
+ contenteditable="false"
21
+ class="flex items-center gap-3 rounded-md border border-dashed border-neutral-300 p-4 text-neutral-400"
22
+ @submit.prevent="applyUrl"
23
+ >
24
+ <UIcon :name="node.attrs.icon || 'iconoir:youtube'" class="size-6 shrink-0" />
25
+ <UInput
26
+ v-model="url"
27
+ :placeholder="node.attrs.placeholder || 'Collez l’url de la vidéo…'"
28
+ class="flex-1"
29
+ size="sm"
30
+ />
31
+ <UButton type="submit" size="sm" color="neutral" variant="soft" :disabled="!url">
32
+ Ajouter
33
+ </UButton>
34
+ </form>
35
+ </NodeViewWrapper>
36
+ </template>
37
+
38
+ <script setup>
39
+ // The sandbox bounds third-party content. Without a source the node shows
40
+ // an URL form the author completes in place.
41
+ import { computed, ref } from 'vue'
42
+ import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
43
+ import { EMBED_SERVICES } from '@open-commune/editor'
44
+
45
+ const props = defineProps(nodeViewProps)
46
+ const url = ref('')
47
+ const service = computed(() => EMBED_SERVICES[props.node.attrs.service] ?? EMBED_SERVICES.video)
48
+
49
+ function applyUrl() {
50
+ if (!url.value) return
51
+ props.updateAttributes({ src: service.value.toSrc(url.value.trim()) })
52
+ }
53
+ </script>