@xleddyl/nuxt-cms 0.2.2 → 0.2.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 (69) hide show
  1. package/README.md +3 -1
  2. package/dist/module.d.mts +4 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +319 -32
  5. package/dist/runtime/app/blocks/CmsBlocks.d.vue.ts +45 -0
  6. package/dist/runtime/app/blocks/CmsBlocks.vue +36 -0
  7. package/dist/runtime/app/blocks/CmsBlocks.vue.d.ts +45 -0
  8. package/dist/runtime/app/blocks/resolve.d.ts +21 -0
  9. package/dist/runtime/app/blocks/resolve.js +37 -0
  10. package/dist/runtime/app/components/cms/BlockOutline.d.vue.ts +17 -0
  11. package/dist/runtime/app/components/cms/BlockOutline.vue +99 -0
  12. package/dist/runtime/app/components/cms/BlockOutline.vue.d.ts +17 -0
  13. package/dist/runtime/app/components/cms/BlockPalette.d.vue.ts +12 -0
  14. package/dist/runtime/app/components/cms/BlockPalette.vue +29 -0
  15. package/dist/runtime/app/components/cms/BlockPalette.vue.d.ts +12 -0
  16. package/dist/runtime/app/components/cms/BlocksField.d.vue.ts +2 -0
  17. package/dist/runtime/app/components/cms/BlocksField.vue +83 -3
  18. package/dist/runtime/app/components/cms/BlocksField.vue.d.ts +2 -0
  19. package/dist/runtime/app/components/cms/EntryForm.vue +9 -4
  20. package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +2 -0
  21. package/dist/runtime/app/components/cms/FieldInput.vue +18 -1
  22. package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +2 -0
  23. package/dist/runtime/app/components/cms/SettingsEntry.vue +3 -2
  24. package/dist/runtime/app/components/cms/SettingsModal.vue +2 -1
  25. package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +2 -0
  26. package/dist/runtime/app/components/cms/StatusBadge.vue +9 -3
  27. package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +2 -0
  28. package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +37 -0
  29. package/dist/runtime/app/components/cms/VisualEditor.vue +533 -0
  30. package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +37 -0
  31. package/dist/runtime/app/composables/cms-entry-disabled.d.ts +7 -1
  32. package/dist/runtime/app/composables/cms-entry-disabled.js +15 -0
  33. package/dist/runtime/app/composables/cms-entry.d.ts +7 -1
  34. package/dist/runtime/app/composables/cms-entry.js +51 -1
  35. package/dist/runtime/app/composables/cms-runtime.d.ts +1 -0
  36. package/dist/runtime/app/composables/cms-settings.js +8 -2
  37. package/dist/runtime/app/layouts/cms-admin.vue +5 -0
  38. package/dist/runtime/app/layouts/cms-editor.d.vue.ts +13 -0
  39. package/dist/runtime/app/layouts/cms-editor.vue +14 -0
  40. package/dist/runtime/app/layouts/cms-editor.vue.d.ts +13 -0
  41. package/dist/runtime/app/pages/admin-collection.vue +33 -4
  42. package/dist/runtime/app/pages/admin-content.d.vue.ts +3 -0
  43. package/dist/runtime/app/pages/admin-content.vue +284 -0
  44. package/dist/runtime/app/pages/admin-content.vue.d.ts +3 -0
  45. package/dist/runtime/app/pages/admin-preview.d.vue.ts +3 -0
  46. package/dist/runtime/app/pages/admin-preview.vue +132 -0
  47. package/dist/runtime/app/pages/admin-preview.vue.d.ts +3 -0
  48. package/dist/runtime/app/preview/PreviewOverlay.d.vue.ts +15 -0
  49. package/dist/runtime/app/preview/PreviewOverlay.vue +362 -0
  50. package/dist/runtime/app/preview/PreviewOverlay.vue.d.ts +15 -0
  51. package/dist/runtime/app/utils/ui.js +1 -0
  52. package/dist/runtime/assets/main.css +1 -1
  53. package/dist/runtime/server/api/collection.get.js +3 -2
  54. package/dist/runtime/server/api/collection.post.js +4 -1
  55. package/dist/runtime/server/api/item.delete.js +2 -1
  56. package/dist/runtime/server/api/item.put.js +2 -1
  57. package/dist/runtime/server/utils/graphql.js +23 -8
  58. package/dist/runtime/server/utils/registry.d.ts +1 -0
  59. package/dist/runtime/server/utils/registry.js +6 -0
  60. package/dist/runtime/server/utils/visibility.d.ts +4 -0
  61. package/dist/runtime/server/utils/visibility.js +12 -0
  62. package/dist/runtime/shared/graphql-sdl.js +4 -1
  63. package/dist/runtime/shared/index.d.ts +59 -5
  64. package/dist/runtime/shared/index.js +133 -0
  65. package/dist/runtime/shared/preview.d.ts +85 -0
  66. package/dist/runtime/shared/preview.js +219 -0
  67. package/dist/runtime/shared/validation.d.ts +2 -0
  68. package/dist/runtime/shared/validation.js +15 -2
  69. package/package.json +1 -1
@@ -14,6 +14,21 @@ export function useCmsCollection(name, options = {}) {
14
14
  async () => fallback ? fallback() : []
15
15
  );
16
16
  }
17
+ export function useCmsContents(name, options = {}) {
18
+ const { locale, filters, sort, limit, offset, key, default: fallback } = options;
19
+ const variables = { locale, filters, sort, limit, offset };
20
+ return useAsyncData(
21
+ key ?? `cms-contents:${String(name)}:${JSON.stringify(variables)}`,
22
+ async () => fallback ? fallback() : []
23
+ );
24
+ }
25
+ export function useCmsContent(name, slug, options = {}) {
26
+ const { locale, key, default: fallback } = options;
27
+ return useAsyncData(
28
+ key ?? `cms-content:${String(name)}:${slug}:${locale ?? ""}`,
29
+ async () => fallback ? fallback() : null
30
+ );
31
+ }
17
32
  export function useCmsPage(path, options = {}) {
18
33
  const { locale, key, default: fallback } = options;
19
34
  return useAsyncData(
@@ -1,5 +1,5 @@
1
1
  import type { AsyncData } from 'nuxt/app';
2
- import type { CmsCollectionName, CmsCollectionTypes, CmsPagePath, CmsPageTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
2
+ import type { CmsCollectionName, CmsCollectionTypes, CmsContentName, CmsContentTypes, CmsPagePath, CmsPageTypes, CmsSingleName, CmsSingleTypes } from '#cms-types';
3
3
  import type { CmsAsyncDataOptions } from './cms-query.js';
4
4
  export interface CmsSortInput<Entry> {
5
5
  field: Extract<keyof Entry, string>;
@@ -18,6 +18,12 @@ export interface CmsCollectionOptions<ResT, DefaultT, Entry> extends CmsAsyncDat
18
18
  limit?: number;
19
19
  offset?: number;
20
20
  }
21
+ export interface CmsContentOptions<ResT, DefaultT> extends CmsAsyncDataOptions<ResT, DefaultT> {
22
+ locale?: string;
23
+ }
24
+ export type CmsContentsOptions<ResT, DefaultT, Entry> = CmsCollectionOptions<ResT, DefaultT, Entry>;
21
25
  export declare function useCmsSingle<K extends CmsSingleName, DefaultT = null>(name: K, options?: CmsSingleOptions<CmsSingleTypes[K] | null, DefaultT>): AsyncData<CmsSingleTypes[K] | DefaultT | null, Error | undefined>;
22
26
  export declare function useCmsCollection<K extends CmsCollectionName, DefaultT = CmsCollectionTypes[K][]>(name: K, options?: CmsCollectionOptions<CmsCollectionTypes[K][], DefaultT, CmsCollectionTypes[K]>): AsyncData<CmsCollectionTypes[K][] | DefaultT, Error | undefined>;
27
+ export declare function useCmsContents<K extends CmsContentName, DefaultT = CmsContentTypes[K][]>(name: K, options?: CmsContentsOptions<CmsContentTypes[K][], DefaultT, CmsContentTypes[K]>): AsyncData<CmsContentTypes[K][] | DefaultT, Error | undefined>;
28
+ export declare function useCmsContent<K extends CmsContentName, DefaultT = null>(name: K, slug: string, options?: CmsContentOptions<CmsContentTypes[K] | null, DefaultT>): AsyncData<CmsContentTypes[K] | DefaultT | null, Error | undefined>;
23
29
  export declare function useCmsPage<P extends CmsPagePath, DefaultT = null>(path: P, options?: CmsPageOptions<CmsPageTypes[P] | null, DefaultT>): AsyncData<CmsPageTypes[P] | DefaultT | null, Error | undefined>;
@@ -1,4 +1,10 @@
1
- import { cmsCollectionQueries, cmsPageQueries, cmsSingleQueries } from "#cms-queries";
1
+ import {
2
+ cmsCollectionQueries,
3
+ cmsContentItemQueries,
4
+ cmsContentQueries,
5
+ cmsPageQueries,
6
+ cmsSingleQueries
7
+ } from "#cms-queries";
2
8
  import { useAsyncData } from "#imports";
3
9
  import { $cmsQuery } from "./cms-query.js";
4
10
  function unknownEntry(name) {
@@ -48,6 +54,50 @@ export function useCmsCollection(name, options = {}) {
48
54
  }
49
55
  );
50
56
  }
57
+ export function useCmsContents(name, options = {}) {
58
+ const {
59
+ locale,
60
+ filters,
61
+ sort,
62
+ limit,
63
+ offset,
64
+ key,
65
+ default: fallback,
66
+ ...asyncDataOptions
67
+ } = options;
68
+ const entryName = String(name);
69
+ const query = cmsContentQueries[entryName];
70
+ const variables = { locale, filters, sort, limit, offset };
71
+ return useAsyncData(
72
+ key ?? `cms-contents:${entryName}:${JSON.stringify(variables)}`,
73
+ async () => {
74
+ if (!query) unknownEntry(entryName);
75
+ const result = await $cmsQuery(query, variables);
76
+ return result?.[entryName] ?? [];
77
+ },
78
+ {
79
+ default: fallback ?? (() => []),
80
+ ...asyncDataOptions
81
+ }
82
+ );
83
+ }
84
+ export function useCmsContent(name, slug, options = {}) {
85
+ const { locale, key, default: fallback, ...asyncDataOptions } = options;
86
+ const entryName = String(name);
87
+ const query = cmsContentItemQueries[entryName];
88
+ return useAsyncData(
89
+ key ?? `cms-content:${entryName}:${slug}:${locale ?? ""}`,
90
+ async () => {
91
+ if (!query) unknownEntry(entryName);
92
+ const result = await $cmsQuery(query, { slug, locale });
93
+ return result?.item ?? null;
94
+ },
95
+ {
96
+ default: fallback ?? (() => null),
97
+ ...asyncDataOptions
98
+ }
99
+ );
100
+ }
51
101
  export function useCmsPage(path, options = {}) {
52
102
  const { locale, key, default: fallback, ...asyncDataOptions } = options;
53
103
  const pagePath = String(path);
@@ -4,4 +4,5 @@ export declare function useCmsRuntime(): {
4
4
  mediaStorage: MediaStorageMode;
5
5
  mediaMaxFileSize: number;
6
6
  i18n: CmsI18n;
7
+ previewPath?: string;
7
8
  };
@@ -1,4 +1,10 @@
1
- import { layoutFromConfig, pageAllFields, resolveFormLayout, resolveListColumns } from "#nuxt-cms";
1
+ import {
2
+ isCollectionKind,
3
+ layoutFromConfig,
4
+ pageAllFields,
5
+ resolveFormLayout,
6
+ resolveListColumns
7
+ } from "#nuxt-cms";
2
8
  import { useFetch, useState } from "#imports";
3
9
  import cmsConfig from "#cms-config";
4
10
  import { cmsApi } from "../utils/api.js";
@@ -45,7 +51,7 @@ export function entryFormLayout(name, settings, fields = entryFields(name)) {
45
51
  }
46
52
  export function listExtraColumns(name) {
47
53
  const entry = entryOf(name);
48
- return entry?.kind === "collection" && entry.drafts ? ["status"] : [];
54
+ return entry && isCollectionKind(entry) && entry.drafts ? ["status"] : [];
49
55
  }
50
56
  export function entryListColumns(name, settings) {
51
57
  const entry = entryOf(name);
@@ -109,6 +109,11 @@ const groups = computed(
109
109
  icon: "document-text",
110
110
  links: links.filter((l) => l.kind === "single")
111
111
  },
112
+ {
113
+ title: "Content",
114
+ icon: "newspaper",
115
+ links: links.filter((l) => l.kind === "content")
116
+ },
112
117
  {
113
118
  title: "Pages",
114
119
  icon: "window",
@@ -0,0 +1,13 @@
1
+ declare var __VLS_1: {};
2
+ type __VLS_Slots = {} & {
3
+ default?: (props: typeof __VLS_1) => any;
4
+ };
5
+ declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
6
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
9
+ type __VLS_WithSlots<T, S> = T & {
10
+ new (): {
11
+ $slots: S;
12
+ };
13
+ };
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <div class="cms-scope cms-canvas" :data-theme="theme">
3
+ <slot />
4
+ <CmsToaster />
5
+ <CmsConfirmModal />
6
+ </div>
7
+ </template>
8
+
9
+ <script setup>
10
+ import { useCmsTheme } from "../composables/cms-theme";
11
+ import { loadCmsSettings } from "../composables/cms-settings";
12
+ const theme = useCmsTheme();
13
+ await loadCmsSettings();
14
+ </script>
@@ -0,0 +1,13 @@
1
+ declare var __VLS_1: {};
2
+ type __VLS_Slots = {} & {
3
+ default?: (props: typeof __VLS_1) => any;
4
+ };
5
+ declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
6
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
7
+ declare const _default: typeof __VLS_export;
8
+ export default _default;
9
+ type __VLS_WithSlots<T, S> = T & {
10
+ new (): {
11
+ $slots: S;
12
+ };
13
+ };
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="cms-page" :class="{ 'is-fill': config.kind === 'collection' && !error }">
2
+ <div class="cms-page" :class="{ 'is-fill': isCollectionKind(config) && !error }">
3
3
  <CmsPageHeader
4
4
  :title="config.label"
5
5
  :icon="kindMeta.icon"
@@ -156,7 +156,11 @@
156
156
  <CmsMediaThumb :value="mediaThumbValue(key, row.original[key])" />
157
157
  </template>
158
158
  <template v-if="drafts" #status-cell="{ row }">
159
- <CmsStatusBadge :published="row.original.status === 'published'" />
159
+ <CmsStatusBadge
160
+ :published="row.original.status === 'published'"
161
+ :scheduled="mounted && isContent && isScheduledContent(row.original)"
162
+ :at="formatDateTime(row.original.publishedAt)"
163
+ />
160
164
  </template>
161
165
  </CmsTable>
162
166
 
@@ -182,6 +186,7 @@
182
186
  <CmsPagination v-model:page="page" :total="total" :items-per-page="PAGE_SIZE" />
183
187
 
184
188
  <CmsEntryDrawer
189
+ v-if="!isContent"
185
190
  v-model:open="drawerOpen"
186
191
  :collection="name"
187
192
  :config="config"
@@ -195,6 +200,8 @@
195
200
 
196
201
  <script setup>
197
202
  import {
203
+ isCollectionKind,
204
+ isScheduledContent,
198
205
  isTranslatableField,
199
206
  isTranslatableMediaField,
200
207
  pageParentPath,
@@ -204,6 +211,7 @@ import {
204
211
  computed,
205
212
  createError,
206
213
  definePageMeta,
214
+ navigateTo,
207
215
  onMounted,
208
216
  ref,
209
217
  useFetch,
@@ -234,12 +242,14 @@ if (!config) {
234
242
  const KIND_META = {
235
243
  collection: { icon: "square-3-stack-3d", description: "Collection of entries." },
236
244
  single: { icon: "document-text", description: "Single entry, edited in place." },
237
- page: { icon: "window", description: "Content for the routes of your app." }
245
+ page: { icon: "window", description: "Content for the routes of your app." },
246
+ content: { icon: "newspaper", description: "Items built from blocks, with scheduling." }
238
247
  };
239
248
  const kindMeta = { ...KIND_META[config.kind], icon: config.icon ?? KIND_META[config.kind].icon };
240
249
  const fieldKeys = Object.keys(config.fields);
241
250
  const mediaKeys = fieldKeys.filter((key) => config.fields[key].type === "media");
242
- const drafts = config.kind === "collection" && !!config.drafts;
251
+ const isContent = config.kind === "content";
252
+ const drafts = isCollectionKind(config) && !!config.drafts;
243
253
  const formKeys = drafts ? [...fieldKeys, "status"] : fieldKeys;
244
254
  const endpoint = `/api/cms/admin/${name}`;
245
255
  const PAGE_SIZE = 25;
@@ -357,6 +367,15 @@ function mediaThumbValue(key, value) {
357
367
  const locale = contentI18n.defaultLocale;
358
368
  return pickTranslatedMedia(value, locale, locale);
359
369
  }
370
+ function formatDateTime(value) {
371
+ if (typeof value !== "string" || !value) return "";
372
+ const date = new Date(value);
373
+ if (Number.isNaN(date.getTime()) || !mounted.value) return value.slice(0, 16).replace("T", " ");
374
+ return `${date.toLocaleDateString()} ${date.toLocaleTimeString([], {
375
+ hour: "2-digit",
376
+ minute: "2-digit"
377
+ })}`;
378
+ }
360
379
  function relationLabel(field, key, id) {
361
380
  const title = relations.value[key]?.[String(id)];
362
381
  if (title == null || title === "") return `#${id}`;
@@ -378,6 +397,8 @@ function displayValue(field, key, value) {
378
397
  return truncate(stripHtml(String(value)));
379
398
  case "json":
380
399
  return truncate(JSON.stringify(value));
400
+ case "datetime":
401
+ return formatDateTime(value);
381
402
  case "blocks":
382
403
  return `${value.length} \u25A4`;
383
404
  case "relation": {
@@ -407,10 +428,18 @@ const columns = computed(
407
428
  const drawerOpen = ref(false);
408
429
  const drawerEntryId = ref(null);
409
430
  function onSelect(_event, row) {
431
+ if (isContent) {
432
+ void navigateTo(`/cms/${name}/${row.original.id}`);
433
+ return;
434
+ }
410
435
  drawerEntryId.value = String(row.original.id);
411
436
  drawerOpen.value = true;
412
437
  }
413
438
  function openCreate() {
439
+ if (isContent) {
440
+ void navigateTo(`/cms/${name}/new`);
441
+ return;
442
+ }
414
443
  drawerEntryId.value = null;
415
444
  drawerOpen.value = true;
416
445
  }
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,284 @@
1
+ <template>
2
+ <CmsVisualEditor
3
+ ref="editor"
4
+ v-model:state="formState"
5
+ :entry-name="name"
6
+ :field-key="CONTENT_BODY_FIELD"
7
+ :fields="config.fields"
8
+ :title="headerTitle"
9
+ content
10
+ >
11
+ <template #start>
12
+ <CmsButton
13
+ icon="arrow-left"
14
+ size="sm"
15
+ variant="ghost"
16
+ color="neutral"
17
+ :aria-label="`Back to ${config.label}`"
18
+ :title="`Back to ${config.label}`"
19
+ @click="goBack"
20
+ />
21
+ </template>
22
+
23
+ <template #status>
24
+ <CmsStatusBadge :published="published" :scheduled="scheduled" :at="scheduledAt" />
25
+ <span v-if="dirty" class="cms-editor-dirty">Unsaved changes</span>
26
+ </template>
27
+
28
+ <template #actions>
29
+ <CmsPopover v-model:open="scheduleOpen" class="cms-editor-schedule">
30
+ <CmsButton
31
+ icon="clock"
32
+ size="sm"
33
+ variant="ghost"
34
+ color="neutral"
35
+ :label="scheduled ? 'Reschedule' : 'Schedule'"
36
+ :disabled="saving"
37
+ aria-haspopup="dialog"
38
+ :aria-expanded="scheduleOpen"
39
+ />
40
+ <template #content>
41
+ <div class="cms-editor-schedule-panel" role="dialog" aria-label="Schedule">
42
+ <CmsFormField
43
+ label="Publish at"
44
+ description="The item stays hidden from the site until this moment."
45
+ >
46
+ <CmsFieldInput v-model="scheduleDraft" :field="publishedAtField" />
47
+ </CmsFormField>
48
+ <div class="cms-actions is-end">
49
+ <CmsButton
50
+ label="Cancel"
51
+ size="sm"
52
+ variant="ghost"
53
+ color="neutral"
54
+ @click="scheduleOpen = false"
55
+ />
56
+ <CmsButton
57
+ label="Schedule"
58
+ size="sm"
59
+ :disabled="!scheduleDraft"
60
+ :loading="saving"
61
+ @click="schedule"
62
+ />
63
+ </div>
64
+ </div>
65
+ </template>
66
+ </CmsPopover>
67
+ <CmsButton
68
+ label="Save"
69
+ size="sm"
70
+ variant="soft"
71
+ color="neutral"
72
+ :loading="saving"
73
+ @click="save()"
74
+ />
75
+ <CmsButton
76
+ :label="published ? 'Unpublish' : 'Publish'"
77
+ size="sm"
78
+ :variant="published ? 'soft' : 'solid'"
79
+ :color="published ? 'neutral' : 'primary'"
80
+ :loading="saving"
81
+ @click="save(published ? 'draft' : 'published')"
82
+ />
83
+ <CmsDropdownMenu
84
+ v-if="!isNew"
85
+ :items="[{ label: 'Delete', onSelect: remove }]"
86
+ :content="{ align: 'end' }"
87
+ >
88
+ <CmsButton
89
+ icon="ellipsis-horizontal"
90
+ size="sm"
91
+ variant="ghost"
92
+ color="neutral"
93
+ aria-label="More actions"
94
+ title="More actions"
95
+ />
96
+ </CmsDropdownMenu>
97
+ </template>
98
+ </CmsVisualEditor>
99
+ </template>
100
+
101
+ <script setup>
102
+ import {
103
+ CONTENT_BODY_FIELD,
104
+ CONTENT_PUBLISHED_AT_FIELD,
105
+ CONTENT_TITLE_FIELD,
106
+ isScheduledContent
107
+ } from "#nuxt-cms";
108
+ import {
109
+ computed,
110
+ createError,
111
+ definePageMeta,
112
+ navigateTo,
113
+ onBeforeRouteLeave,
114
+ onBeforeUnmount,
115
+ onMounted,
116
+ ref,
117
+ useFetch,
118
+ useHead,
119
+ useRoute,
120
+ watch
121
+ } from "#imports";
122
+ import cmsConfig from "#cms-config";
123
+ import { useCmsConfirm } from "../composables/cms-confirm";
124
+ import { useCmsRuntime } from "../composables/cms-runtime";
125
+ import { useCmsToast } from "../composables/cms-toast";
126
+ import { cmsApi } from "../utils/api";
127
+ import { errorMessage } from "../utils/ui";
128
+ definePageMeta({
129
+ layout: "cms-editor",
130
+ middleware: "cms-auth",
131
+ key: (route2) => route2.fullPath
132
+ });
133
+ const route = useRoute();
134
+ const toast = useCmsToast();
135
+ const confirmAction = useCmsConfirm();
136
+ const { i18n } = useCmsRuntime();
137
+ const name = String(route.meta.cmsEntry ?? route.path.split("/")[2] ?? "");
138
+ const config = cmsConfig[name];
139
+ if (!config || config.kind !== "content") {
140
+ throw createError({ statusCode: 404, statusMessage: "Unknown content entry", fatal: true });
141
+ }
142
+ const id = route.params.id;
143
+ const isNew = id === void 0;
144
+ const endpoint = `/api/cms/admin/${name}`;
145
+ const formKeys = [...Object.keys(config.fields), "status"];
146
+ const editor = ref(null);
147
+ function pickFields(row) {
148
+ return Object.fromEntries(formKeys.map((key) => [key, row[key] ?? null]));
149
+ }
150
+ const formState = ref(
151
+ Object.fromEntries(formKeys.map((key) => [key, key === "status" ? "draft" : null]))
152
+ );
153
+ if (!isNew) {
154
+ const { data, error } = await useFetch(`${endpoint}/${id}`);
155
+ if (error.value || !data.value) {
156
+ const statusCode = error.value?.statusCode ?? 404;
157
+ throw createError({
158
+ statusCode,
159
+ statusMessage: statusCode === 404 ? "Entry not found" : "Failed to load entry",
160
+ fatal: true
161
+ });
162
+ }
163
+ formState.value = pickFields(data.value);
164
+ }
165
+ const snapshot = ref(JSON.stringify(formState.value));
166
+ const dirty = computed(() => JSON.stringify(formState.value) !== snapshot.value);
167
+ const titleText = computed(() => {
168
+ const value = formState.value[CONTENT_TITLE_FIELD];
169
+ if (typeof value === "string") return value;
170
+ if (value && typeof value === "object") {
171
+ const values = value;
172
+ return values[i18n.defaultLocale] || Object.values(values).find(Boolean) || "";
173
+ }
174
+ return "";
175
+ });
176
+ const headerTitle = computed(() => titleText.value || (isNew ? "New item" : "Untitled"));
177
+ useHead({ title: computed(() => `${headerTitle.value} \xB7 ${config.label}`) });
178
+ const published = computed(() => formState.value.status === "published");
179
+ const mounted = ref(false);
180
+ const scheduled = computed(() => mounted.value && isScheduledContent(formState.value));
181
+ const scheduledAt = computed(() => {
182
+ const value = formState.value[CONTENT_PUBLISHED_AT_FIELD];
183
+ if (!mounted.value || typeof value !== "string" || !value) return "";
184
+ return new Date(value).toLocaleString();
185
+ });
186
+ const publishedAtField = computed(() => ({
187
+ ...config.fields[CONTENT_PUBLISHED_AT_FIELD],
188
+ required: true
189
+ }));
190
+ const saving = ref(false);
191
+ const scheduleOpen = ref(false);
192
+ const scheduleDraft = ref(null);
193
+ watch(scheduleOpen, (open) => {
194
+ if (!open) return;
195
+ const current = formState.value[CONTENT_PUBLISHED_AT_FIELD];
196
+ const future = typeof current === "string" && new Date(current).getTime() > Date.now();
197
+ scheduleDraft.value = future ? current : new Date(Math.ceil(Date.now() / 36e5) * 36e5 + 36e5).toISOString();
198
+ });
199
+ function onBeforeUnload(event) {
200
+ event.preventDefault();
201
+ }
202
+ watch(dirty, (value) => {
203
+ if (value) window.addEventListener("beforeunload", onBeforeUnload);
204
+ else window.removeEventListener("beforeunload", onBeforeUnload);
205
+ });
206
+ onMounted(() => {
207
+ mounted.value = true;
208
+ });
209
+ onBeforeUnmount(() => window.removeEventListener("beforeunload", onBeforeUnload));
210
+ onBeforeRouteLeave(async () => {
211
+ if (dirty.value && !await confirmAction("You have unsaved changes. Leave anyway?"))
212
+ return false;
213
+ });
214
+ function goBack() {
215
+ navigateTo(`/cms/${name}`);
216
+ }
217
+ const SUCCESS = {
218
+ save: "Saved",
219
+ published: "Published",
220
+ draft: "Moved to drafts",
221
+ scheduled: "Scheduled"
222
+ };
223
+ async function save(status, changes = {}, notice) {
224
+ const before = formState.value;
225
+ formState.value = { ...before, ...changes, ...status ? { status } : {} };
226
+ if (!editor.value?.validate()) {
227
+ formState.value = {
228
+ ...formState.value,
229
+ status: before.status,
230
+ ...pickRevert(before, changes)
231
+ };
232
+ toast.add({ title: "Check the highlighted fields", color: "error" });
233
+ return false;
234
+ }
235
+ saving.value = true;
236
+ try {
237
+ const row = await (isNew ? cmsApi(endpoint, { method: "POST", body: formState.value }) : cmsApi(`${endpoint}/${id}`, {
238
+ method: "PUT",
239
+ body: formState.value
240
+ }));
241
+ formState.value = pickFields(row);
242
+ snapshot.value = JSON.stringify(formState.value);
243
+ toast.add({ title: SUCCESS[notice ?? status ?? "save"] ?? "Saved", color: "success" });
244
+ if (isNew && row.id) await navigateTo(`/cms/${name}/${row.id}`, { replace: true });
245
+ return true;
246
+ } catch (error) {
247
+ formState.value = {
248
+ ...formState.value,
249
+ status: before.status,
250
+ ...pickRevert(before, changes)
251
+ };
252
+ toast.add({ title: "Save failed", description: errorMessage(error), color: "error" });
253
+ return false;
254
+ } finally {
255
+ saving.value = false;
256
+ }
257
+ }
258
+ function pickRevert(before, changes) {
259
+ return Object.fromEntries(Object.keys(changes).map((key) => [key, before[key] ?? null]));
260
+ }
261
+ async function schedule() {
262
+ if (!scheduleDraft.value) return;
263
+ const saved = await save(
264
+ "published",
265
+ { [CONTENT_PUBLISHED_AT_FIELD]: scheduleDraft.value },
266
+ new Date(scheduleDraft.value).getTime() > Date.now() ? "scheduled" : "published"
267
+ );
268
+ if (saved) scheduleOpen.value = false;
269
+ }
270
+ async function remove() {
271
+ if (!await confirmAction("Delete this item?")) return;
272
+ saving.value = true;
273
+ try {
274
+ await cmsApi(`${endpoint}/${id}`, { method: "DELETE" });
275
+ snapshot.value = JSON.stringify(formState.value);
276
+ toast.add({ title: "Deleted", color: "success" });
277
+ goBack();
278
+ } catch (error) {
279
+ toast.add({ title: "Delete failed", description: errorMessage(error), color: "error" });
280
+ } finally {
281
+ saving.value = false;
282
+ }
283
+ }
284
+ </script>
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;
@@ -0,0 +1,3 @@
1
+ declare const __VLS_export: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
2
+ declare const _default: typeof __VLS_export;
3
+ export default _default;