@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,79 @@
1
+ <template>
2
+ <NodeViewWrapper
3
+ as="div"
4
+ class="my-2 rounded-md border border-neutral-300 dark:border-neutral-700"
5
+ :class="{ 'ring-2 ring-neutral-400': selected }"
6
+ >
7
+ <div class="flex items-center gap-3 p-3">
8
+ <UIcon name="iconoir:page" class="size-6 shrink-0 text-neutral-500" />
9
+ <div class="min-w-0 flex-1">
10
+ <p class="truncate text-sm font-medium">{{ title || 'Fichier' }}</p>
11
+ <p v-if="resolving" class="text-2xs text-neutral-400">résolution…</p>
12
+ </div>
13
+ <UButton
14
+ v-if="isPdf && src"
15
+ :icon="preview ? 'iconoir:eye-closed' : 'iconoir:eye'"
16
+ variant="ghost"
17
+ color="neutral"
18
+ size="xs"
19
+ :aria-label="preview ? 'Masquer l’aperçu' : 'Aperçu du PDF'"
20
+ @click="preview = !preview"
21
+ />
22
+ <UButton
23
+ v-if="src"
24
+ :to="src"
25
+ target="_blank"
26
+ icon="iconoir:open-new-window"
27
+ variant="ghost"
28
+ color="neutral"
29
+ size="xs"
30
+ aria-label="Ouvrir le fichier"
31
+ />
32
+ </div>
33
+ <!-- Native browser PDF viewer: no bundled viewer library. -->
34
+ <embed
35
+ v-if="preview && isPdf && src"
36
+ :src="src"
37
+ type="application/pdf"
38
+ class="h-96 w-full rounded-b-md"
39
+ >
40
+ </NodeViewWrapper>
41
+ </template>
42
+
43
+ <script setup>
44
+ // Media resolves on mount through the extension's `fetch` option. Resolved
45
+ // values only rewrite attrs when they actually differ, so displaying a
46
+ // document never mutates it.
47
+ import { computed, onMounted, ref } from 'vue'
48
+ import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
49
+
50
+ const props = defineProps(nodeViewProps)
51
+ const resolving = ref(false)
52
+ const resolved = ref(null)
53
+ const preview = ref(false)
54
+
55
+ const title = computed(() => resolved.value?.title ?? props.node.attrs.title)
56
+ const src = computed(() => resolved.value?.src ?? props.node.attrs.src)
57
+ const isPdf = computed(() =>
58
+ /\.pdf($|\?)/i.test(src.value ?? '') || /\.pdf$/i.test(title.value ?? ''),
59
+ )
60
+
61
+ onMounted(async () => {
62
+ const { id } = props.node.attrs
63
+ const fetch = props.extension.options.fetch
64
+ if (!id || !fetch) return
65
+ resolving.value = true
66
+ try {
67
+ const media = await fetch({ id })
68
+ resolved.value = media
69
+ const next = {}
70
+ if (media?.src && media.src !== props.node.attrs.src) next.src = media.src
71
+ if (media?.title && media.title !== props.node.attrs.title) next.title = media.title
72
+ if (Object.keys(next).length) props.updateAttributes(next)
73
+ } catch {
74
+ // Stored attrs remain displayed; resolution retries on next mount.
75
+ } finally {
76
+ resolving.value = false
77
+ }
78
+ })
79
+ </script>
@@ -0,0 +1,56 @@
1
+ <template>
2
+ <NodeViewWrapper
3
+ as="figure"
4
+ class="my-2"
5
+ :class="{ 'ring-2 ring-neutral-400': selected }"
6
+ >
7
+ <img
8
+ v-if="src"
9
+ :src="src"
10
+ :alt="node.attrs.alt || ''"
11
+ :title="title || undefined"
12
+ class="max-w-full rounded-md"
13
+ draggable="false"
14
+ >
15
+ <div
16
+ v-else
17
+ class="flex items-center gap-3 rounded-md border border-dashed border-neutral-300 p-6 text-neutral-400"
18
+ >
19
+ <UIcon name="iconoir:media-image" class="size-6" />
20
+ <span class="text-sm">{{ resolving ? 'résolution…' : 'Image indisponible' }}</span>
21
+ </div>
22
+ </NodeViewWrapper>
23
+ </template>
24
+
25
+ <script setup>
26
+ // Same resolution contract as FileView: fetch on mount, attrs rewritten
27
+ // only when values actually differ.
28
+ import { computed, onMounted, ref } from 'vue'
29
+ import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
30
+
31
+ const props = defineProps(nodeViewProps)
32
+ const resolving = ref(false)
33
+ const resolved = ref(null)
34
+
35
+ const title = computed(() => resolved.value?.title ?? props.node.attrs.title)
36
+ const src = computed(() => resolved.value?.src ?? props.node.attrs.src)
37
+
38
+ onMounted(async () => {
39
+ const { id } = props.node.attrs
40
+ const fetch = props.extension.options.fetch
41
+ if (!id || !fetch) return
42
+ resolving.value = true
43
+ try {
44
+ const media = await fetch({ id })
45
+ resolved.value = media
46
+ const next = {}
47
+ if (media?.src && media.src !== props.node.attrs.src) next.src = media.src
48
+ if (media?.title && media.title !== props.node.attrs.title) next.title = media.title
49
+ if (Object.keys(next).length) props.updateAttributes(next)
50
+ } catch {
51
+ // Stored attrs remain displayed; resolution retries on next mount.
52
+ } finally {
53
+ resolving.value = false
54
+ }
55
+ })
56
+ </script>
@@ -0,0 +1,71 @@
1
+ <template>
2
+ <NodeViewWrapper as="div" class="my-2">
3
+ <UFileUpload
4
+ v-model="file"
5
+ :accept="node.attrs.accept || undefined"
6
+ :label="node.attrs.accept ? 'Téléversez une image' : 'Téléversez un fichier'"
7
+ description="Ou glissez-le directement dans l'éditeur"
8
+ :preview="false"
9
+ class="min-h-32"
10
+ >
11
+ <template #leading>
12
+ <UAvatar
13
+ :icon="loading ? 'iconoir:refresh' : 'iconoir:page'"
14
+ size="xl"
15
+ :ui="{ icon: [loading && 'animate-spin'] }"
16
+ />
17
+ </template>
18
+ </UFileUpload>
19
+ </NodeViewWrapper>
20
+ </template>
21
+
22
+ <script setup>
23
+ // Uploads the picked file then swaps this placeholder for the real node.
24
+ import { ref, watch } from 'vue'
25
+ import { NodeViewWrapper, nodeViewProps } from '@tiptap/vue-3'
26
+
27
+ const props = defineProps(nodeViewProps)
28
+ const toast = useToast()
29
+ const file = ref(null)
30
+ const loading = ref(false)
31
+
32
+ /**
33
+ * Where this slot sits RIGHT NOW, sized from the node the document actually
34
+ * holds. An upload takes seconds: a position read before it, or a size taken
35
+ * from the captured `props.node`, describes a document that has moved on.
36
+ * Null when the slot is gone — then there is nothing left to replace.
37
+ */
38
+ const range = () => {
39
+ const pos = props.getPos()
40
+ if (typeof pos !== 'number') return null
41
+ const node = props.editor.state.doc.nodeAt(pos)
42
+ return node ? { from: pos, to: pos + node.nodeSize } : null
43
+ }
44
+
45
+ watch(file, async (picked) => {
46
+ if (!picked || loading.value) return
47
+ loading.value = true
48
+ try {
49
+ const upload = props.extension.options.upload
50
+ if (!upload) throw new Error('no upload handler')
51
+ const { id, src, title } = await upload(picked)
52
+ const at = range()
53
+ if (!at) return
54
+ // One replacement rather than a delete then an insert: the second half of
55
+ // a two-step edit addresses a document the first half has already shifted.
56
+ props.editor.chain().focus().insertContentAt(at, {
57
+ type: picked.type.startsWith('image/') ? 'image' : 'file',
58
+ attrs: { id, src, title },
59
+ }).run()
60
+ } catch (error) {
61
+ // The cause reaches the console: an author reporting a failed upload
62
+ // should not be the only source of truth about it.
63
+ console.error('[upload]', picked.name, error)
64
+ toast.add({ title: `Téléversement échoué : ${picked.name}`, color: 'error' })
65
+ const at = range()
66
+ if (at) props.editor.chain().deleteRange(at).run()
67
+ } finally {
68
+ loading.value = false
69
+ }
70
+ })
71
+ </script>
@@ -0,0 +1,21 @@
1
+ <template>
2
+ <div class="min-h-screen">
3
+ <!-- <NuxtLoadingIndicator /> -->
4
+ <primary-navigation />
5
+ <headings-collection />
6
+ <main class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:max-w-7xl lg:px-8 h-full">
7
+ <section>
8
+ <slot />
9
+ </section>
10
+ </main>
11
+ <form-modal />
12
+ <notifications-list />
13
+ </div>
14
+ </template>
15
+
16
+ <script setup>
17
+ import primaryNavigation from '~/components/layout/navigations/primary'
18
+ import headingsCollection from '~/components/layout/headings/headings-collection'
19
+ import formModal from '~/components/forms/layouts/modal'
20
+ import notificationsList from '~/components/layout/notifications/notifications-list'
21
+ </script>
@@ -0,0 +1,10 @@
1
+ <template>
2
+ <div class="min-w-screen min-h-screen">
3
+ <slot />
4
+ <notifications-list />
5
+ </div>
6
+ </template>
7
+
8
+ <script setup>
9
+ import notificationsList from '~/components/layout/notifications/notifications-list'
10
+ </script>
@@ -0,0 +1,21 @@
1
+ <template>
2
+ <div class="min-h-screen">
3
+ <!-- <NuxtLoadingIndicator /> -->
4
+ <primary-navigation />
5
+ <headings-record />
6
+ <main class="py-8 max-w-3xl mx-auto px-4 sm:px-6 lg:max-w-7xl lg:px-8 h-full">
7
+ <section>
8
+ <slot />
9
+ </section>
10
+ </main>
11
+ <form-modal />
12
+ <notifications-list />
13
+ </div>
14
+ </template>
15
+
16
+ <script setup>
17
+ import primaryNavigation from '~/components/layout/navigations/primary'
18
+ import headingsRecord from '~/components/layout/headings/headings-record'
19
+ import formModal from '~/components/forms/layouts/modal'
20
+ import notificationsList from '~/components/layout/notifications/notifications-list'
21
+ </script>
@@ -0,0 +1,16 @@
1
+ // Auth guard. Public: login ('/'), password and invitation flows, the
2
+ // external-session callback, and any page declaring `auth: false`.
3
+ const PUBLIC_PREFIXES = ['/password/', '/welcome/', '/auth/callback']
4
+
5
+ export default defineNuxtRouteMiddleware((to) => {
6
+ if (to.meta.auth === false) return
7
+
8
+ const session = useSession()
9
+ if (session.token.value) {
10
+ if (to.path === '/') return navigateTo('/overview', { replace: true })
11
+ return
12
+ }
13
+
14
+ if (to.path === '/' || PUBLIC_PREFIXES.some((p) => to.path.startsWith(p))) return
15
+ return navigateTo('/', { replace: true })
16
+ })
@@ -0,0 +1,21 @@
1
+ import { useWorkspaceStore } from '~/store/layout/workspace'
2
+
3
+ // Single-tenant (upgrade-admin-nuxt4) : remplace les middlewares
4
+ // 01.list-workspaces / 02.set-workspace — plus de résolution par segment de
5
+ // route, l'instance EST l'organisation. Charge la collectivité une fois
6
+ // après authentification et pose les permissions du rôle.
7
+ export default defineNuxtRouteMiddleware(async () => {
8
+ const session = useSession()
9
+ if (!session.isAuthenticated.value) return
10
+
11
+ const workspaceStore = useWorkspaceStore()
12
+ workspaceStore.updateAbilities()
13
+
14
+ if (!workspaceStore.workspaceId) {
15
+ const $models = useModels()
16
+ if (!$models.Organization.repo.all().length) {
17
+ await $models.Organization.list()
18
+ }
19
+ await workspaceStore.updateOrganization($models.Organization.repo.where().first())
20
+ }
21
+ })
@@ -0,0 +1,11 @@
1
+ export default defineNuxtRouteMiddleware(async (to) => {
2
+ const { can } = useAbility()
3
+
4
+ if (to.meta.permissions?.length) {
5
+ console.log('[midd.] - verifying member permissions')
6
+ if (to.meta.permissions.some((permission) => !can(permission))) {
7
+ return abortNavigation()
8
+ }
9
+ return
10
+ }
11
+ })
@@ -0,0 +1,18 @@
1
+ // @review: c'est quoi le prefetch ? Ca me semble overengineered ça, pourquoi on gère pas ça dans le useAsyncData de nuxt js plutot ?
2
+ export default defineNuxtRouteMiddleware(async (to) => {
3
+ const session = useSession()
4
+ const $models = useModels()
5
+
6
+ if (session.isAuthenticated.value && to.meta.preFetch?.length) {
7
+ console.log('[midd.] - prefetch page', to.params.collection, to.params.record)
8
+ if (to.params.record && to.params.record !== 'new') {
9
+ const [model] = to.meta.preFetch
10
+ await $models[model].read(to.params.record, to.params.collection)
11
+ return
12
+ }
13
+ // Use collection name in case of collection's records pages
14
+ await Promise.all(
15
+ to.meta.preFetch.map(model => $models[model].list(to.params.collection, undefined, { limit: 50 }))
16
+ )
17
+ }
18
+ })
@@ -0,0 +1,25 @@
1
+ import { useHeadingsStore } from '~/store/layout/headings'
2
+
3
+ // @review: meme remarque ça me semble overengineered ça, pourquoi c'est pas gérer directement au niveau, des pages ça a un interet ?
4
+ export default defineNuxtRouteMiddleware((to, from) => {
5
+ const session = useSession()
6
+ if (session.isAuthenticated.value) {
7
+ console.log('[midd.] - update headings', to.params.collection)
8
+ const headingsStore = useHeadingsStore()
9
+ const { Collection } = useModels()
10
+
11
+ // If we are currently on a collection page, we retrieve the headings directly from collection definitions
12
+ if (to.params.collection) {
13
+ const currentCollection = Collection.repo.where('slug', to.params.collection).first()
14
+ // Collection inconnue (ex : URL restaurée vers une collection supprimée) :
15
+ // on renvoie à l'accueil du workspace plutôt que de planter l'app.
16
+ if (!currentCollection) {
17
+ return navigateTo('/overview')
18
+ }
19
+ Object.assign(to.meta, { headings: currentCollection.headings })
20
+ }
21
+
22
+ // Parse page meta headings
23
+ headingsStore.updateHeadings({ ...(to.meta.headings || {}) })
24
+ }
25
+ })
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Pont legacy ↔ Commun partagé par les modèles : cache des définitions de
3
+ * collections, conversion attributes[] ↔ fields[] (même table que le
4
+ * migrateur packages/legacy-migrate) et conversion des VALEURS de champs.
5
+ *
6
+ * Contrat de valeurs (iso legacy Mongo, où tout le complexe était stringifié) :
7
+ * - rich-text : STRING côté widgets (input-wysiwyg) ↔ OBJET côté serveur
8
+ * - steps[].content : STRING côté widgets ↔ OBJET côté serveur
9
+ * - tout le reste (media/relation ids, dates, nombres, json/schedules/location)
10
+ * circule tel quel.
11
+ */
12
+
13
+ // ── Mapping composants de formulaire ↔ types de champs Commun ──────────────
14
+ export const COMPONENT_TO_FIELD_TYPE = {
15
+ 'input-text': 'text',
16
+ 'input-textarea': 'text',
17
+ 'input-text-area': 'text',
18
+ 'input-slug': 'text',
19
+ 'input-number': 'number',
20
+ 'input-toggle': 'boolean',
21
+ 'input-checkbox': 'boolean',
22
+ 'input-date': 'date',
23
+ 'input-datetime': 'date',
24
+ 'input-wysiwyg': 'rich-text',
25
+ 'input-files-media': 'media',
26
+ 'input-file-media': 'media',
27
+ 'input-location': 'json',
28
+ 'select-enum': 'select',
29
+ 'select-record': 'relation',
30
+ 'handler-records': 'relation',
31
+ 'handler-schedules': 'json',
32
+ 'array-of-steps': 'steps',
33
+ 'handler-steps': 'steps'
34
+ }
35
+
36
+ // Composant par défaut pour AFFICHER un champ Commun dans l'éditeur de
37
+ // collections (perte assumée : text ne distingue plus input-text/textarea).
38
+ export const FIELD_TYPE_TO_COMPONENT = {
39
+ text: 'input-text',
40
+ 'rich-text': 'input-wysiwyg',
41
+ number: 'input-number',
42
+ boolean: 'input-toggle',
43
+ date: 'input-date',
44
+ media: 'input-files-media',
45
+ relation: 'select-record',
46
+ select: 'select-enum',
47
+ steps: 'array-of-steps',
48
+ json: 'input-text'
49
+ }
50
+
51
+ // ── Cache module de l'annuaire des membres (affichage « modifié par ») ─────
52
+ let _usersById = null
53
+
54
+ export async function getUsersById(trpc) {
55
+ if (!_usersById) {
56
+ try {
57
+ const directory = await trpc.users.directory.query()
58
+ _usersById = new Map(directory.map((user) => [user.id, user]))
59
+ } catch {
60
+ // Annuaire indisponible (session expirée…) : affichage sans nom.
61
+ return new Map()
62
+ }
63
+ }
64
+ return _usersById
65
+ }
66
+
67
+ /** id utilisateur Commun → objet user legacy attendu par avatar-with-text. */
68
+ export function userRefToLegacy(usersById, id) {
69
+ if (!id) return {}
70
+ const user = usersById.get(id)
71
+ return user ? { _id: id, firstName: user.name, lastName: '' } : {}
72
+ }
73
+
74
+ // ── Cache module des définitions (rafraîchi par Collection.list/mutations) ──
75
+ let _definitions = null
76
+
77
+ export async function getDefinitions(trpc) {
78
+ if (!_definitions) {
79
+ const definitions = await trpc.collections.list.query()
80
+ _definitions = new Map(definitions.map((definition) => [definition.slug, definition]))
81
+ }
82
+ return _definitions
83
+ }
84
+
85
+ export function invalidateDefinitions() {
86
+ _definitions = null
87
+ }
88
+
89
+ export async function getDefinition(trpc, slug) {
90
+ const definitions = await getDefinitions(trpc)
91
+ const definition = definitions.get(slug)
92
+ if (!definition) throw new Error(`E_UNKNOWN_COLLECTION: ${slug}`)
93
+ return definition
94
+ }
95
+
96
+ // ── Conversion des valeurs de champs ────────────────────────────────────────
97
+ function tryParse(raw) {
98
+ try {
99
+ return JSON.parse(raw)
100
+ } catch {
101
+ // Convention migrateur : un contenu non-JSON est encapsulé.
102
+ return { type: 'doc', legacyHtml: String(raw) }
103
+ }
104
+ }
105
+
106
+ /** Valeur serveur (objets) → valeur widget (stringifiée si rich-text/steps). */
107
+ export function toClientValue(field, value) {
108
+ if (value == null) return value
109
+ switch (field?.type) {
110
+ case 'rich-text':
111
+ return typeof value === 'string' ? value : JSON.stringify(value)
112
+ case 'steps':
113
+ return (Array.isArray(value) ? value : []).map((step) => ({
114
+ ...step,
115
+ content: typeof step?.content === 'string'
116
+ ? step.content
117
+ : JSON.stringify(step?.content ?? {})
118
+ }))
119
+ default:
120
+ return value
121
+ }
122
+ }
123
+
124
+ /** Valeur widget → valeur serveur (parse rich-text/steps, boolean normalisé). */
125
+ export function toServerValue(field, value) {
126
+ if (value == null) return value
127
+ switch (field?.type) {
128
+ case 'rich-text':
129
+ return typeof value === 'string' ? tryParse(value) : value
130
+ case 'steps':
131
+ return (Array.isArray(value) ? value : []).map((step) => ({
132
+ ...step,
133
+ content: typeof step?.content === 'string' ? tryParse(step.content) : step?.content
134
+ }))
135
+ case 'boolean':
136
+ return Boolean(value)
137
+ default:
138
+ return value
139
+ }
140
+ }
141
+
142
+ // ── Conversion des définitions attributes[] ↔ fields[] ─────────────────────
143
+ /** FieldDefinition Commun → attribut legacy (édition de collections). */
144
+ export function fieldToAttribute(field) {
145
+ return {
146
+ name: field.name,
147
+ component: FIELD_TYPE_TO_COMPONENT[field.type] ?? 'input-text',
148
+ required: Boolean(field.required),
149
+ options: { hidden: Boolean(field.hidden) },
150
+ headings: { label: field.label ?? field.name },
151
+ ...(field.options?.length ? { enum: field.options } : {}),
152
+ ...(field.target ? { componentOptions: { collection: field.target } } : {})
153
+ }
154
+ }
155
+
156
+ /** Attribut legacy → FieldDefinition Commun (null si non mappable). */
157
+ export function attributeToField(attribute) {
158
+ const name = String(attribute.name ?? '')
159
+ // title/name/slug sont des COLONNES d'entrée, pas des champs (iso migrateur).
160
+ if (['title', 'name', 'slug'].includes(name)) return null
161
+ const component = String(attribute.component ?? attribute.type ?? '')
162
+ const type = COMPONENT_TO_FIELD_TYPE[component]
163
+ if (!type) return null
164
+
165
+ const options = Array.isArray(attribute.enum)
166
+ ? attribute.enum.map(String)
167
+ : Array.isArray(attribute.componentOptions?.options)
168
+ ? attribute.componentOptions.options.map(String)
169
+ : undefined
170
+
171
+ const field = {
172
+ name: name.replace(/[^a-zA-Z0-9_]/g, '_').replace(/^_+/, '') || 'field',
173
+ label: String(attribute.headings?.label ?? name),
174
+ type,
175
+ required: Boolean(attribute.required),
176
+ hidden: Boolean(attribute.options?.hidden ?? attribute.componentOptions?.hidden),
177
+ ...(type === 'select' && options?.length ? { options } : {}),
178
+ ...(type === 'relation'
179
+ ? { target: String(attribute.componentOptions?.collection ?? 'unknown') }
180
+ : {})
181
+ }
182
+ // Un select sans options ne passe pas la validation serveur — dégradé en texte.
183
+ if (type === 'select' && !options?.length) return { ...field, type: 'text' }
184
+ return field
185
+ }
186
+
187
+ // ── Projections d'entités Commun → enregistrements legacy ──────────────────
188
+ /** Entry Commun → record legacy aplati par onRetrieve côté composants. */
189
+ export function entryToRecord(definition, entry, usersById = new Map()) {
190
+ const fieldsByName = new Map(definition.fields.map((field) => [field.name, field]))
191
+ return {
192
+ _id: entry.id,
193
+ title: entry.title,
194
+ slug: entry.slug,
195
+ path: '',
196
+ status: entry.status,
197
+ relatedCollection: definition.slug,
198
+ records: entry.related ?? [],
199
+ attributes: Object.entries(entry.data ?? {}).map(([name, value]) => ({
200
+ name,
201
+ value: toClientValue(fieldsByName.get(name), value)
202
+ })),
203
+ createdBy: userRefToLegacy(usersById, entry.createdBy),
204
+ updatedBy: userRefToLegacy(usersById, entry.updatedBy),
205
+ publishedAt: entry.publishedAt ?? '',
206
+ createdAt: entry.createdAt,
207
+ updatedAt: entry.updatedAt
208
+ }
209
+ }
210
+
211
+ /** CollectionDefinition Commun → collection legacy. */
212
+ export function definitionToCollection(definition) {
213
+ return {
214
+ _id: definition.id,
215
+ name: definition.name,
216
+ slug: definition.slug,
217
+ description: definition.description ?? '',
218
+ headings: definition.headings ?? {},
219
+ display: definition.display ?? {},
220
+ editor: definition.editor ?? {},
221
+ attributes: (definition.fields ?? []).map(fieldToAttribute),
222
+ organization: '',
223
+ createdBy: {},
224
+ updatedBy: {},
225
+ createdAt: definition.createdAt,
226
+ updatedAt: definition.updatedAt
227
+ }
228
+ }
229
+
230
+ /** Media Commun (objects signés) → media legacy. */
231
+ export function mediaToLegacy(media) {
232
+ return {
233
+ _id: media.id,
234
+ originalName: media.filename,
235
+ mime: media.mime,
236
+ objects: media.objects ?? {},
237
+ organization: '',
238
+ createdBy: '',
239
+ updatedBy: '',
240
+ createdAt: media.createdAt,
241
+ updatedAt: media.updatedAt
242
+ }
243
+ }
@@ -0,0 +1,34 @@
1
+ import { useRepo } from 'pinia-orm'
2
+ import { useWorkspaceStore } from '~/store/layout/workspace'
3
+
4
+ const HOOK_DEFAULT = (val) => val
5
+
6
+ /**
7
+ * Couche d'accès au monolithe Commun : chaque modèle implémente ses méthodes
8
+ * via le client tRPC (plan admin). L'interface publique legacy est conservée
9
+ * (read/list/create/update/remove + repo pinia-orm + hooks onRetrieve/onSave)
10
+ * pour ne pas toucher aux pages/composants.
11
+ */
12
+ export default class ModelAPI {
13
+
14
+ constructor(model, baseURL) {
15
+ const { entity, editForms, onRetrieve, onSave } = model
16
+ this.baseURL = baseURL
17
+ this.entity = entity
18
+ this.editForms = editForms?.()
19
+ this.onRetrieve = onRetrieve || HOOK_DEFAULT
20
+ this.onSave = onSave || HOOK_DEFAULT
21
+ this.repo = useRepo(model)
22
+ this.workspace = useWorkspaceStore()
23
+ this.trpc = useTrpc()
24
+ }
25
+
26
+ // Chaque modèle surcharge ce dont il a besoin — iso convention legacy
27
+ // (Entrance/Media jetaient déjà E_UNKNOWN_ENDPOINT sur les verbes absents).
28
+ async read() { throw new Error('E_UNKNOWN_ENDPOINT') }
29
+ async list() { throw new Error('E_UNKNOWN_ENDPOINT') }
30
+ async create() { throw new Error('E_UNKNOWN_ENDPOINT') }
31
+ async update() { throw new Error('E_UNKNOWN_ENDPOINT') }
32
+ async remove() { throw new Error('E_UNKNOWN_ENDPOINT') }
33
+
34
+ }