@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,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,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
|
+
}
|