@xleddyl/nuxt-cms 0.2.2 → 0.3.0

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