@xleddyl/nuxt-cms 0.2.1 → 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 (110) 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 +320 -33
  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/BrandMark.vue +2 -3
  20. package/dist/runtime/app/components/cms/ConfirmModal.vue +14 -1
  21. package/dist/runtime/app/components/cms/EntryForm.vue +9 -4
  22. package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +2 -0
  23. package/dist/runtime/app/components/cms/FieldInput.vue +18 -1
  24. package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +2 -0
  25. package/dist/runtime/app/components/cms/MediaGallery.vue +89 -11
  26. package/dist/runtime/app/components/cms/Modal.vue +0 -2
  27. package/dist/runtime/app/components/cms/SettingsEntry.vue +3 -2
  28. package/dist/runtime/app/components/cms/SettingsModal.vue +2 -1
  29. package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +2 -0
  30. package/dist/runtime/app/components/cms/StatusBadge.vue +9 -3
  31. package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +2 -0
  32. package/dist/runtime/app/components/cms/VisualEditor.d.vue.ts +37 -0
  33. package/dist/runtime/app/components/cms/VisualEditor.vue +533 -0
  34. package/dist/runtime/app/components/cms/VisualEditor.vue.d.ts +37 -0
  35. package/dist/runtime/app/composables/cms-confirm.d.ts +16 -7
  36. package/dist/runtime/app/composables/cms-confirm.js +1 -0
  37. package/dist/runtime/app/composables/cms-entry-disabled.d.ts +7 -1
  38. package/dist/runtime/app/composables/cms-entry-disabled.js +15 -0
  39. package/dist/runtime/app/composables/cms-entry.d.ts +7 -1
  40. package/dist/runtime/app/composables/cms-entry.js +51 -1
  41. package/dist/runtime/app/composables/cms-media-library.d.ts +1 -2
  42. package/dist/runtime/app/composables/cms-media-library.js +12 -19
  43. package/dist/runtime/app/composables/cms-runtime.d.ts +1 -0
  44. package/dist/runtime/app/composables/cms-settings.js +8 -2
  45. package/dist/runtime/app/layouts/cms-admin.vue +5 -0
  46. package/dist/runtime/app/layouts/cms-editor.d.vue.ts +13 -0
  47. package/dist/runtime/app/layouts/cms-editor.vue +14 -0
  48. package/dist/runtime/app/layouts/cms-editor.vue.d.ts +13 -0
  49. package/dist/runtime/app/pages/admin-collection.vue +33 -4
  50. package/dist/runtime/app/pages/admin-content.d.vue.ts +3 -0
  51. package/dist/runtime/app/pages/admin-content.vue +284 -0
  52. package/dist/runtime/app/pages/admin-content.vue.d.ts +3 -0
  53. package/dist/runtime/app/pages/admin-preview.d.vue.ts +3 -0
  54. package/dist/runtime/app/pages/admin-preview.vue +132 -0
  55. package/dist/runtime/app/pages/admin-preview.vue.d.ts +3 -0
  56. package/dist/runtime/app/preview/PreviewOverlay.d.vue.ts +15 -0
  57. package/dist/runtime/app/preview/PreviewOverlay.vue +362 -0
  58. package/dist/runtime/app/preview/PreviewOverlay.vue.d.ts +15 -0
  59. package/dist/runtime/app/utils/ui.d.ts +0 -2
  60. package/dist/runtime/app/utils/ui.js +1 -0
  61. package/dist/runtime/assets/main.css +1 -1
  62. package/dist/runtime/seed.js +11 -1
  63. package/dist/runtime/server/api/collection.get.js +3 -2
  64. package/dist/runtime/server/api/collection.post.js +24 -15
  65. package/dist/runtime/server/api/collection.put.js +12 -12
  66. package/dist/runtime/server/api/item.delete.js +2 -1
  67. package/dist/runtime/server/api/item.put.js +16 -14
  68. package/dist/runtime/server/api/media-folder.patch.js +10 -16
  69. package/dist/runtime/server/api/media-move.post.js +12 -8
  70. package/dist/runtime/server/api/media.delete.js +1 -1
  71. package/dist/runtime/server/api/media.post.js +2 -1
  72. package/dist/runtime/server/api/media.put.js +2 -1
  73. package/dist/runtime/server/plugins/migrate-sqlite.js +2 -2
  74. package/dist/runtime/server/routes/auth/login.post.js +28 -22
  75. package/dist/runtime/server/utils/db-d1.d.ts +0 -1
  76. package/dist/runtime/server/utils/db-d1.js +3 -4
  77. package/dist/runtime/server/utils/db-libsql.d.ts +0 -1
  78. package/dist/runtime/server/utils/db-libsql.js +3 -4
  79. package/dist/runtime/server/utils/db-postgres.d.ts +0 -1
  80. package/dist/runtime/server/utils/db-postgres.js +1 -4
  81. package/dist/runtime/server/utils/db-sqlite.d.ts +0 -1
  82. package/dist/runtime/server/utils/db-sqlite.js +4 -10
  83. package/dist/runtime/server/utils/graphql.js +25 -9
  84. package/dist/runtime/server/utils/libsql-migrations.d.ts +1 -0
  85. package/dist/runtime/server/utils/libsql-migrations.js +1 -1
  86. package/dist/runtime/server/utils/media-folders.js +14 -23
  87. package/dist/runtime/server/utils/media.js +2 -0
  88. package/dist/runtime/server/utils/migrate.d.ts +1 -0
  89. package/dist/runtime/server/utils/migrate.js +32 -0
  90. package/dist/runtime/server/utils/registry.d.ts +4 -0
  91. package/dist/runtime/server/utils/registry.js +16 -1
  92. package/dist/runtime/server/utils/relations.d.ts +1 -1
  93. package/dist/runtime/server/utils/relations.js +6 -9
  94. package/dist/runtime/server/utils/timestamps.d.ts +12 -0
  95. package/dist/runtime/server/utils/timestamps.js +48 -0
  96. package/dist/runtime/server/utils/users.js +3 -2
  97. package/dist/runtime/server/utils/visibility.d.ts +4 -0
  98. package/dist/runtime/server/utils/visibility.js +12 -0
  99. package/dist/runtime/server/utils/write-statements.d.ts +33 -0
  100. package/dist/runtime/server/utils/write-statements.js +75 -0
  101. package/dist/runtime/shared/graphql-sdl.js +4 -1
  102. package/dist/runtime/shared/index.d.ts +73 -5
  103. package/dist/runtime/shared/index.js +146 -0
  104. package/dist/runtime/shared/preview.d.ts +85 -0
  105. package/dist/runtime/shared/preview.js +219 -0
  106. package/dist/runtime/shared/timestamps.d.ts +4 -0
  107. package/dist/runtime/shared/timestamps.js +24 -0
  108. package/dist/runtime/shared/validation.d.ts +2 -0
  109. package/dist/runtime/shared/validation.js +15 -2
  110. package/package.json +1 -1
@@ -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);
@@ -71,5 +71,4 @@ export declare function useCmsMediaLibrary(): {
71
71
  usage: (keys: string[]) => Promise<Record<string, MediaUsage[]> | null>;
72
72
  };
73
73
  export type CmsMediaLibrary = ReturnType<typeof useCmsMediaLibrary>;
74
- export declare function usageSummary(usage: Record<string, MediaUsage[]> | null, keys: string[]): string;
75
- export declare function usageLink(usage: MediaUsage): string;
74
+ export declare function usageLink(usage: Pick<MediaUsage, 'collection' | 'id'>): string;
@@ -9,6 +9,7 @@ import { errorMessage } from "../utils/ui.js";
9
9
  import { invalidateCmsMediaKeys } from "./cms-media-keys.js";
10
10
  import { useCmsToast } from "./cms-toast.js";
11
11
  const endpoint = "/api/cms/admin/media";
12
+ const USAGE_CHUNK = 1e3;
12
13
  export function useCmsMediaLibrary() {
13
14
  const toast = useCmsToast();
14
15
  const items = ref([]);
@@ -106,13 +107,18 @@ export function useCmsMediaLibrary() {
106
107
  });
107
108
  }
108
109
  async function usage(keys) {
109
- if (!keys.length) return {};
110
+ const unique = [...new Set(keys)];
111
+ if (!unique.length) return {};
110
112
  try {
111
- const result = await $fetch(`${endpoint}/usage`, {
112
- method: "POST",
113
- body: { keys }
114
- });
115
- return result.usage;
113
+ const merged = {};
114
+ for (let index = 0; index < unique.length; index += USAGE_CHUNK) {
115
+ const result = await $fetch(
116
+ `${endpoint}/usage`,
117
+ { method: "POST", body: { keys: unique.slice(index, index + USAGE_CHUNK) } }
118
+ );
119
+ Object.assign(merged, result.usage);
120
+ }
121
+ return merged;
116
122
  } catch {
117
123
  return null;
118
124
  }
@@ -136,19 +142,6 @@ export function useCmsMediaLibrary() {
136
142
  usage
137
143
  };
138
144
  }
139
- export function usageSummary(usage, keys) {
140
- if (!usage) return "";
141
- const entries = /* @__PURE__ */ new Set();
142
- let used = 0;
143
- for (const key of keys) {
144
- const list = usage[key] ?? [];
145
- if (list.length) used++;
146
- for (const item of list) entries.add(`${item.collection}:${item.id ?? ""}`);
147
- }
148
- if (!used) return "";
149
- const files = keys.length === 1 ? "It is" : `${used} of them are`;
150
- return `${files} used in ${entries.size} entr${entries.size === 1 ? "y" : "ies"}, which will keep a broken link.`;
151
- }
152
145
  export function usageLink(usage) {
153
146
  return usage.id === null ? `/cms/${usage.collection}` : `/cms/${usage.collection}/${usage.id}`;
154
147
  }
@@ -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;