@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,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
|
+
}
|
package/editor/index.ts
ADDED
|
@@ -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>
|