@xleddyl/nuxt-cms 0.1.22 → 0.1.24

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 (76) hide show
  1. package/README.md +2 -2
  2. package/dist/module.d.mts +2 -0
  3. package/dist/module.json +1 -1
  4. package/dist/module.mjs +20 -3
  5. package/dist/runtime/app/components/cms/BlocksField.vue +2 -9
  6. package/dist/runtime/app/components/cms/Button.vue +4 -2
  7. package/dist/runtime/app/components/cms/ConfirmModal.vue +5 -14
  8. package/dist/runtime/app/components/cms/Drawer.d.vue.ts +30 -0
  9. package/dist/runtime/app/components/cms/Drawer.vue +65 -0
  10. package/dist/runtime/app/components/cms/Drawer.vue.d.ts +30 -0
  11. package/dist/runtime/app/components/cms/DropdownMenu.d.vue.ts +0 -3
  12. package/dist/runtime/app/components/cms/DropdownMenu.vue +2 -7
  13. package/dist/runtime/app/components/cms/DropdownMenu.vue.d.ts +0 -3
  14. package/dist/runtime/app/components/cms/EmptyState.d.vue.ts +1 -0
  15. package/dist/runtime/app/components/cms/EmptyState.vue +6 -7
  16. package/dist/runtime/app/components/cms/EmptyState.vue.d.ts +1 -0
  17. package/dist/runtime/app/components/cms/EntryDrawer.d.vue.ts +21 -0
  18. package/dist/runtime/app/components/cms/EntryDrawer.vue +193 -0
  19. package/dist/runtime/app/components/cms/EntryDrawer.vue.d.ts +21 -0
  20. package/dist/runtime/app/components/cms/EntryForm.vue +19 -7
  21. package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +1 -0
  22. package/dist/runtime/app/components/cms/FieldInput.vue +13 -27
  23. package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +1 -0
  24. package/dist/runtime/app/components/cms/Form.vue +1 -1
  25. package/dist/runtime/app/components/cms/FormField.d.vue.ts +4 -5
  26. package/dist/runtime/app/components/cms/FormField.vue +9 -7
  27. package/dist/runtime/app/components/cms/FormField.vue.d.ts +4 -5
  28. package/dist/runtime/app/components/cms/JsonField.vue +3 -4
  29. package/dist/runtime/app/components/cms/LocaleSwitch.d.vue.ts +14 -0
  30. package/dist/runtime/app/components/cms/LocaleSwitch.vue +29 -0
  31. package/dist/runtime/app/components/cms/LocaleSwitch.vue.d.ts +14 -0
  32. package/dist/runtime/app/components/cms/MediaField.vue +7 -17
  33. package/dist/runtime/app/components/cms/MediaGallery.vue +57 -80
  34. package/dist/runtime/app/components/cms/MediaThumb.d.vue.ts +6 -0
  35. package/dist/runtime/app/components/cms/MediaThumb.vue +40 -0
  36. package/dist/runtime/app/components/cms/MediaThumb.vue.d.ts +6 -0
  37. package/dist/runtime/app/components/cms/Modal.d.vue.ts +1 -4
  38. package/dist/runtime/app/components/cms/Modal.vue +8 -7
  39. package/dist/runtime/app/components/cms/Modal.vue.d.ts +1 -4
  40. package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +1 -1
  41. package/dist/runtime/app/components/cms/PageHeader.vue +6 -6
  42. package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +1 -1
  43. package/dist/runtime/app/components/cms/RelationField.vue +0 -4
  44. package/dist/runtime/app/components/cms/SelectMenu.vue +23 -3
  45. package/dist/runtime/app/components/cms/SlugField.vue +1 -2
  46. package/dist/runtime/app/components/cms/Spinner.d.vue.ts +3 -0
  47. package/dist/runtime/app/components/cms/Spinner.vue +5 -0
  48. package/dist/runtime/app/components/cms/Spinner.vue.d.ts +3 -0
  49. package/dist/runtime/app/components/cms/StatusBadge.d.vue.ts +6 -0
  50. package/dist/runtime/app/components/cms/StatusBadge.vue +11 -0
  51. package/dist/runtime/app/components/cms/StatusBadge.vue.d.ts +6 -0
  52. package/dist/runtime/app/components/cms/Table.d.vue.ts +3 -6
  53. package/dist/runtime/app/components/cms/Table.vue +72 -27
  54. package/dist/runtime/app/components/cms/Table.vue.d.ts +3 -6
  55. package/dist/runtime/app/components/cms/Toaster.vue +4 -4
  56. package/dist/runtime/app/components/cms/TranslatableField.d.vue.ts +1 -0
  57. package/dist/runtime/app/components/cms/TranslatableField.vue +7 -44
  58. package/dist/runtime/app/components/cms/TranslatableField.vue.d.ts +1 -0
  59. package/dist/runtime/app/composables/cms-runtime.d.ts +2 -1
  60. package/dist/runtime/app/layouts/cms-admin.vue +10 -20
  61. package/dist/runtime/app/pages/admin-collection.vue +130 -103
  62. package/dist/runtime/app/pages/admin-entry.vue +6 -24
  63. package/dist/runtime/app/pages/admin-login.vue +12 -31
  64. package/dist/runtime/app/pages/admin-media.vue +8 -2
  65. package/dist/runtime/app/utils/ui.d.ts +0 -7
  66. package/dist/runtime/app/utils/ui.js +0 -5
  67. package/dist/runtime/assets/main.css +1 -1
  68. package/dist/runtime/server/api/collection.get.js +8 -3
  69. package/dist/runtime/server/api/media.post.js +8 -2
  70. package/dist/runtime/server/api/media.put.js +3 -2
  71. package/dist/runtime/server/utils/media.d.ts +4 -1
  72. package/dist/runtime/server/utils/media.js +16 -3
  73. package/dist/runtime/server/utils/relations.d.ts +1 -0
  74. package/dist/runtime/server/utils/relations.js +25 -0
  75. package/dist/runtime/shared/index.d.ts +11 -3
  76. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
4
4
 
5
5
  - **Zero extra infrastructure**: the CMS runs inside your app's Nitro server; you deploy one thing.
6
6
  - **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
7
- - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage), single-admin auth from env credentials.
7
+ - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a read-only local mode), single-admin auth from env credentials.
8
8
  - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination.
9
9
  - **SQLite, Postgres or libSQL/Turso**: a local file database by default, one config line to switch (including remote SQLite over the network).
10
10
 
@@ -78,7 +78,7 @@ Full documentation lives in [`docs/`](docs/README.md):
78
78
  - [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
79
79
  - [Querying content](docs/querying.md) — GraphQL API, `useCms` / `$cmsQuery`, filters, sorting, pagination.
80
80
  - [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
81
- - [Media](docs/media.md) — S3-compatible storage, upload flow, allowed file types.
81
+ - [Media](docs/media.md) — S3-compatible storage or read-only local mode, upload flow, allowed file types.
82
82
 
83
83
  ## LLM guide
84
84
 
package/dist/module.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as _nuxt_schema from '@nuxt/schema';
2
+ import { MediaStorageMode } from '../dist/runtime/shared/index.js';
2
3
 
3
4
  type Dialect = 'sqlite' | 'postgres';
4
5
  type Driver = Dialect | 'libsql';
@@ -17,6 +18,7 @@ interface ModuleOptions {
17
18
  authToken?: string;
18
19
  };
19
20
  media: {
21
+ storage: MediaStorageMode;
20
22
  endpoint: string;
21
23
  region: string;
22
24
  bucket: string;
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.22",
4
+ "version": "0.1.24",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -10,11 +10,12 @@ import { introspectionFromSchema, buildSchema } from 'graphql';
10
10
  import { minifyIntrospection, outputIntrospectionFile } from 'gql.tada/internal';
11
11
  import { createJiti } from 'jiti';
12
12
  import { typeName, blockTypeName, blockUnionName, renderGraphqlSdl } from '../dist/runtime/shared/graphql-sdl.js';
13
- import { isTranslatableField, isMultiSelect } from '../dist/runtime/shared/index.js';
13
+ import { isMultiSelect, isTranslatableField } from '../dist/runtime/shared/index.js';
14
14
 
15
15
  const IDENTIFIER = /^[a-z_]\w*$/i;
16
16
  const RESERVED_ENTRY_KEYS = ["admin", "auth", "login", "media", "graphql", "cms_media"];
17
17
  const RESERVED_COLUMNS = ["id", "status", "created_at", "updated_at"];
18
+ const TITLE_FIELD_TYPES = ["text", "slug", "email", "number", "date", "select"];
18
19
  const RESERVED_TYPE_NAMES = [
19
20
  "Query",
20
21
  "Mutation",
@@ -68,8 +69,20 @@ function validateConfig(config, i18n) {
68
69
  errors.push(`${at}: drafts are only supported on collections`);
69
70
  if (!entry.fields || !Object.keys(entry.fields).length)
70
71
  errors.push(`${at}: fields must not be empty`);
71
- if (entry.titleField && !entry.fields?.[entry.titleField])
72
- errors.push(`${at}: titleField '${entry.titleField}' is not a declared field`);
72
+ if (entry.kind === "collection" && !entry.titleField) {
73
+ errors.push(`${at}: titleField is required on collections`);
74
+ } else if (entry.titleField) {
75
+ const titleField = entry.fields?.[entry.titleField];
76
+ if (!titleField) {
77
+ errors.push(`${at}: titleField '${entry.titleField}' is not a declared field`);
78
+ } else if (!TITLE_FIELD_TYPES.includes(titleField.type) || isMultiSelect(titleField)) {
79
+ errors.push(
80
+ `${at}: titleField '${entry.titleField}' must be one of ${TITLE_FIELD_TYPES.join(
81
+ ", "
82
+ )} (got '${titleField.type}')`
83
+ );
84
+ }
85
+ }
73
86
  const columnNames = /* @__PURE__ */ new Set();
74
87
  for (const [key, field] of Object.entries(entry.fields ?? {})) {
75
88
  const fat = `${at}, field '${key}'`;
@@ -437,6 +450,7 @@ const module$1 = defineNuxtModule({
437
450
  authToken: ""
438
451
  },
439
452
  media: {
453
+ storage: "s3",
440
454
  endpoint: "",
441
455
  region: "auto",
442
456
  bucket: "",
@@ -464,6 +478,7 @@ const module$1 = defineNuxtModule({
464
478
  ]);
465
479
  nuxt.options.runtimeConfig.public.cms = {
466
480
  mediaBaseUrl: options.media.publicBaseUrl,
481
+ mediaStorage: options.media.storage,
467
482
  i18n: options.i18n
468
483
  };
469
484
  logger.info(
@@ -643,6 +658,7 @@ const module$1 = defineNuxtModule({
643
658
  ...existingConfig.graphql ?? {}
644
659
  },
645
660
  media: {
661
+ storage: options.media.storage,
646
662
  endpoint: options.media.endpoint,
647
663
  region: options.media.region,
648
664
  bucket: options.media.bucket,
@@ -654,6 +670,7 @@ const module$1 = defineNuxtModule({
654
670
  };
655
671
  nuxt.options.runtimeConfig.public.cms = {
656
672
  mediaBaseUrl: options.media.publicBaseUrl,
673
+ mediaStorage: options.media.storage,
657
674
  i18n: options.i18n
658
675
  };
659
676
  addTypeTemplate(
@@ -59,7 +59,6 @@
59
59
  :key="blockKey"
60
60
  :label="blockField.label"
61
61
  :required="blockField.required"
62
- :ui="CMS_FIELD_UI"
63
62
  >
64
63
  <CmsFieldInput
65
64
  :model-value="item[blockKey]"
@@ -69,20 +68,14 @@
69
68
  </CmsFormField>
70
69
  </template>
71
70
  </div>
72
- <CmsDropdownMenu :items="addItems">
73
- <CmsButton
74
- label="Add block"
75
- icon="plus"
76
- variant="subtle"
77
- class="self-start rounded-full px-4"
78
- />
71
+ <CmsDropdownMenu :items="addItems" class="self-start">
72
+ <CmsButton label="Add block" icon="plus" variant="subtle" />
79
73
  </CmsDropdownMenu>
80
74
  </div>
81
75
  </template>
82
76
 
83
77
  <script setup>
84
78
  import { computed, ref } from "#imports";
85
- import { CMS_FIELD_UI } from "../../utils/ui";
86
79
  const props = defineProps({
87
80
  field: { type: Object, required: true }
88
81
  });
@@ -8,7 +8,7 @@
8
8
  `cms-btn-${size ?? 'md'}`,
9
9
  `cms-btn-${color ?? 'primary'}`,
10
10
  `cms-btn-${variant ?? 'solid'}`,
11
- { 'is-block': block }
11
+ { 'is-block': block, 'is-icon': iconOnly }
12
12
  ]"
13
13
  >
14
14
  <CmsIcon
@@ -28,7 +28,7 @@
28
28
  </template>
29
29
 
30
30
  <script setup>
31
- import { computed } from "#imports";
31
+ import { computed, useSlots } from "#imports";
32
32
  const props = defineProps({
33
33
  label: { type: String, required: false },
34
34
  type: { type: String, required: false },
@@ -43,4 +43,6 @@ const props = defineProps({
43
43
  block: { type: Boolean, required: false }
44
44
  });
45
45
  const iconName = computed(() => props.loading ? "arrow-path" : props.icon);
46
+ const slots = useSlots();
47
+ const iconOnly = computed(() => !props.label && !props.block && !slots.default);
46
48
  </script>
@@ -2,24 +2,17 @@
2
2
  <CmsModal
3
3
  v-model:open="state.open"
4
4
  :title="state.title ?? 'Are you sure?'"
5
- :ui="ui"
5
+ size="sm"
6
6
  @after:leave="finish(false)"
7
7
  >
8
8
  <template #body>
9
- <div class="flex flex-col gap-5">
10
- <p class="text-sm text-(--ui-text)">{{ state.message }}</p>
11
- <div class="flex justify-end gap-2">
12
- <CmsButton
13
- label="Cancel"
14
- variant="subtle"
15
- color="neutral"
16
- class="rounded-full px-4"
17
- @click="finish(false)"
18
- />
9
+ <div class="cms-form">
10
+ <p class="text-sm">{{ state.message }}</p>
11
+ <div class="cms-actions is-end">
12
+ <CmsButton label="Cancel" variant="subtle" color="neutral" @click="finish(false)" />
19
13
  <CmsButton
20
14
  :label="state.confirmLabel ?? 'Confirm'"
21
15
  color="error"
22
- class="rounded-full px-4"
23
16
  @click="finish(true)"
24
17
  />
25
18
  </div>
@@ -30,9 +23,7 @@
30
23
 
31
24
  <script setup>
32
25
  import { useCmsConfirmState } from "../../composables/cms-confirm";
33
- import { CMS_MODAL_UI } from "../../utils/ui";
34
26
  const state = useCmsConfirmState();
35
- const ui = { ...CMS_MODAL_UI, content: "rounded-2xl sm:max-w-md" };
36
27
  function finish(value) {
37
28
  const resolve = state.value.resolve;
38
29
  if (!resolve) return;
@@ -0,0 +1,30 @@
1
+ type __VLS_Props = {
2
+ title?: string;
3
+ };
4
+ type __VLS_ModelProps = {
5
+ 'open'?: boolean;
6
+ };
7
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
8
+ declare var __VLS_13: {}, __VLS_22: {}, __VLS_24: {};
9
+ type __VLS_Slots = {} & {
10
+ badge?: (props: typeof __VLS_13) => any;
11
+ } & {
12
+ default?: (props: typeof __VLS_22) => any;
13
+ } & {
14
+ footer?: (props: typeof __VLS_24) => any;
15
+ };
16
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
17
+ close: () => any;
18
+ "update:open": (value: boolean) => any;
19
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
20
+ onClose?: (() => any) | undefined;
21
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
22
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
23
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
24
+ declare const _default: typeof __VLS_export;
25
+ export default _default;
26
+ type __VLS_WithSlots<T, S> = T & {
27
+ new (): {
28
+ $slots: S;
29
+ };
30
+ };
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <Transition name="cms-drawer">
4
+ <div v-if="open" class="cms-scope cms-overlay cms-overlay-drawer" @click.self="close">
5
+ <aside class="cms-drawer" role="dialog" aria-modal="true">
6
+ <header class="cms-drawer-header">
7
+ <div class="cms-actions min-w-0">
8
+ <h2 class="cms-title cms-title-sm truncate">
9
+ {{ title }}
10
+ </h2>
11
+ <slot name="badge" />
12
+ </div>
13
+ <CmsButton
14
+ icon="x-mark"
15
+ variant="ghost"
16
+ color="neutral"
17
+ size="sm"
18
+ aria-label="Close"
19
+ @click="close"
20
+ />
21
+ </header>
22
+
23
+ <div class="cms-drawer-body">
24
+ <slot />
25
+ </div>
26
+
27
+ <footer v-if="$slots.footer" class="cms-drawer-footer">
28
+ <slot name="footer" />
29
+ </footer>
30
+ </aside>
31
+ </div>
32
+ </Transition>
33
+ </Teleport>
34
+ </template>
35
+
36
+ <script setup>
37
+ import { onBeforeUnmount, watch } from "#imports";
38
+ defineProps({
39
+ title: { type: String, required: false }
40
+ });
41
+ const open = defineModel("open", { type: Boolean, ...{ default: false } });
42
+ const emit = defineEmits(["close"]);
43
+ function close() {
44
+ emit("close");
45
+ }
46
+ function onKeydown(event) {
47
+ if (event.key === "Escape") close();
48
+ }
49
+ watch(open, (value) => {
50
+ if (import.meta.server) return;
51
+ if (value) {
52
+ document.addEventListener("keydown", onKeydown);
53
+ document.body.style.overflow = "hidden";
54
+ } else {
55
+ document.removeEventListener("keydown", onKeydown);
56
+ document.body.style.overflow = "";
57
+ }
58
+ });
59
+ onBeforeUnmount(() => {
60
+ if (import.meta.client) {
61
+ document.removeEventListener("keydown", onKeydown);
62
+ document.body.style.overflow = "";
63
+ }
64
+ });
65
+ </script>
@@ -0,0 +1,30 @@
1
+ type __VLS_Props = {
2
+ title?: string;
3
+ };
4
+ type __VLS_ModelProps = {
5
+ 'open'?: boolean;
6
+ };
7
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
8
+ declare var __VLS_13: {}, __VLS_22: {}, __VLS_24: {};
9
+ type __VLS_Slots = {} & {
10
+ badge?: (props: typeof __VLS_13) => any;
11
+ } & {
12
+ default?: (props: typeof __VLS_22) => any;
13
+ } & {
14
+ footer?: (props: typeof __VLS_24) => any;
15
+ };
16
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
17
+ close: () => any;
18
+ "update:open": (value: boolean) => any;
19
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
20
+ onClose?: (() => any) | undefined;
21
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
22
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
23
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
24
+ declare const _default: typeof __VLS_export;
25
+ export default _default;
26
+ type __VLS_WithSlots<T, S> = T & {
27
+ new (): {
28
+ $slots: S;
29
+ };
30
+ };
@@ -10,9 +10,6 @@ type __VLS_Props = {
10
10
  content?: {
11
11
  align?: 'start' | 'end';
12
12
  };
13
- ui?: {
14
- content?: string;
15
- };
16
13
  };
17
14
  declare var __VLS_1: {};
18
15
  type __VLS_Slots = {} & {
@@ -3,11 +3,7 @@
3
3
  <div class="contents" @click="open = !open">
4
4
  <slot />
5
5
  </div>
6
- <div
7
- v-if="open"
8
- class="cms-menu"
9
- :class="[content?.align === 'end' && 'align-end', ui?.content]"
10
- >
6
+ <div v-if="open" class="cms-menu" :class="{ 'align-end': content?.align === 'end' }">
11
7
  <button
12
8
  v-for="(item, index) in items"
13
9
  :key="index"
@@ -30,8 +26,7 @@
30
26
  import { onBeforeUnmount, ref, watch } from "#imports";
31
27
  defineProps({
32
28
  items: { type: Array, required: true },
33
- content: { type: Object, required: false },
34
- ui: { type: Object, required: false }
29
+ content: { type: Object, required: false }
35
30
  });
36
31
  const open = ref(false);
37
32
  const root = ref(null);
@@ -10,9 +10,6 @@ type __VLS_Props = {
10
10
  content?: {
11
11
  align?: 'start' | 'end';
12
12
  };
13
- ui?: {
14
- content?: string;
15
- };
16
13
  };
17
14
  declare var __VLS_1: {};
18
15
  type __VLS_Slots = {} & {
@@ -2,6 +2,7 @@ type __VLS_Props = {
2
2
  icon: string;
3
3
  title: string;
4
4
  body?: string;
5
+ fill?: boolean;
5
6
  };
6
7
  declare var __VLS_6: {};
7
8
  type __VLS_Slots = {} & {
@@ -1,10 +1,8 @@
1
1
  <template>
2
- <div class="cms-empty flex flex-col items-center gap-2 p-14 text-center">
3
- <CmsIcon :name="icon" class="mb-1 size-6 text-(--cms-fern)" />
4
- <p class="cms-display text-xl text-(--ui-text-highlighted)">
5
- {{ title }}<span class="text-(--cms-fern)">.</span>
6
- </p>
7
- <p v-if="body" class="max-w-md text-sm text-(--ui-text-muted)">
2
+ <div class="cms-empty" :class="{ 'is-fill': fill }">
3
+ <CmsIcon :name="icon" class="cms-empty-icon size-6" />
4
+ <p class="cms-title cms-title-sm">{{ title }}<span class="cms-accent">.</span></p>
5
+ <p v-if="body" class="cms-subtitle max-w-md">
8
6
  {{ body }}
9
7
  </p>
10
8
  <slot />
@@ -15,6 +13,7 @@
15
13
  defineProps({
16
14
  icon: { type: String, required: true },
17
15
  title: { type: String, required: true },
18
- body: { type: String, required: false }
16
+ body: { type: String, required: false },
17
+ fill: { type: Boolean, required: false }
19
18
  });
20
19
  </script>
@@ -2,6 +2,7 @@ type __VLS_Props = {
2
2
  icon: string;
3
3
  title: string;
4
4
  body?: string;
5
+ fill?: boolean;
5
6
  };
6
7
  declare var __VLS_6: {};
7
8
  type __VLS_Slots = {} & {
@@ -0,0 +1,21 @@
1
+ import type { CmsEntry } from '#nuxt-cms';
2
+ type __VLS_Props = {
3
+ collection: string;
4
+ config: CmsEntry;
5
+ entryId: string | null;
6
+ };
7
+ type __VLS_ModelProps = {
8
+ 'open'?: boolean;
9
+ };
10
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ saved: () => any;
13
+ deleted: () => any;
14
+ "update:open": (value: boolean) => any;
15
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
16
+ onSaved?: (() => any) | undefined;
17
+ onDeleted?: (() => any) | undefined;
18
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
19
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
+ declare const _default: typeof __VLS_export;
21
+ export default _default;
@@ -0,0 +1,193 @@
1
+ <template>
2
+ <CmsDrawer v-model:open="open" :title="title" @close="requestClose">
3
+ <template v-if="drafts && !loading" #badge>
4
+ <CmsStatusBadge :published="published" />
5
+ </template>
6
+
7
+ <CmsSpinner v-if="loading" />
8
+
9
+ <CmsEntryForm
10
+ v-else
11
+ v-model="formState"
12
+ :fields="config.fields"
13
+ :drafts="drafts"
14
+ :form-id="FORM_ID"
15
+ :loading="saving"
16
+ :footer="false"
17
+ @submit="save"
18
+ @error="revertStatus"
19
+ />
20
+
21
+ <template #footer>
22
+ <CmsButton
23
+ v-if="!isNew"
24
+ label="Delete"
25
+ icon="trash"
26
+ variant="ghost"
27
+ color="error"
28
+ @click="remove"
29
+ />
30
+ <span v-else />
31
+ <div class="cms-actions">
32
+ <CmsButton
33
+ v-if="drafts"
34
+ type="submit"
35
+ :form="FORM_ID"
36
+ :label="published ? 'Make draft' : 'Publish'"
37
+ variant="subtle"
38
+ :loading="saving"
39
+ :disabled="loading"
40
+ @click="togglePublished"
41
+ />
42
+ <CmsButton
43
+ type="submit"
44
+ :form="FORM_ID"
45
+ label="Save"
46
+ :loading="saving"
47
+ :disabled="loading"
48
+ />
49
+ </div>
50
+ </template>
51
+ </CmsDrawer>
52
+ </template>
53
+
54
+ <script setup>
55
+ import { isTranslatableField } from "#nuxt-cms";
56
+ import { computed, ref, watch } from "#imports";
57
+ import { useCmsConfirm } from "../../composables/cms-confirm";
58
+ import { useCmsRuntime } from "../../composables/cms-runtime";
59
+ import { useCmsToast } from "../../composables/cms-toast";
60
+ import { cmsApi } from "../../utils/api";
61
+ import { errorMessage } from "../../utils/ui";
62
+ const FORM_ID = "cms-entry-drawer-form";
63
+ const props = defineProps({
64
+ collection: { type: String, required: true },
65
+ config: { type: Object, required: true },
66
+ entryId: { type: [String, null], required: true }
67
+ });
68
+ const open = defineModel("open", { type: Boolean, ...{ default: false } });
69
+ const emit = defineEmits(["saved", "deleted"]);
70
+ const toast = useCmsToast();
71
+ const confirmAction = useCmsConfirm();
72
+ const endpoint = computed(() => `/api/cms/admin/${props.collection}`);
73
+ const isNew = computed(() => props.entryId === null);
74
+ const drafts = computed(() => !!props.config.drafts);
75
+ const formKeys = computed(
76
+ () => drafts.value ? [...Object.keys(props.config.fields), "status"] : Object.keys(props.config.fields)
77
+ );
78
+ const { i18n: contentI18n } = useCmsRuntime();
79
+ const loading = ref(false);
80
+ const saving = ref(false);
81
+ const formState = ref({});
82
+ const snapshot = ref("");
83
+ const entryName = computed(() => {
84
+ const key = props.config.titleField;
85
+ const field = key ? props.config.fields[key] : void 0;
86
+ const value = key ? formState.value[key] : void 0;
87
+ if (!field || value == null || value === "") return "";
88
+ if (isTranslatableField(field)) {
89
+ const record = value;
90
+ return record[contentI18n.defaultLocale] ?? Object.values(record)[0] ?? "";
91
+ }
92
+ return String(value);
93
+ });
94
+ const title = computed(() => {
95
+ if (isNew.value) return "Add entry";
96
+ return entryName.value || "Untitled";
97
+ });
98
+ function emptyState() {
99
+ return Object.fromEntries(formKeys.value.map((key) => [key, null]));
100
+ }
101
+ function pickFields(row) {
102
+ return Object.fromEntries(formKeys.value.map((key) => [key, row[key] ?? null]));
103
+ }
104
+ function setState(state) {
105
+ formState.value = state;
106
+ snapshot.value = JSON.stringify(state);
107
+ }
108
+ const dirty = computed(() => JSON.stringify(formState.value) !== snapshot.value);
109
+ const published = computed({
110
+ get: () => formState.value.status === "published",
111
+ set: (value) => {
112
+ formState.value.status = value ? "published" : "draft";
113
+ }
114
+ });
115
+ function togglePublished() {
116
+ published.value = !published.value;
117
+ }
118
+ function revertStatus() {
119
+ if (!drafts.value) return;
120
+ formState.value.status = JSON.parse(snapshot.value).status ?? null;
121
+ }
122
+ async function load() {
123
+ if (props.entryId === null) {
124
+ setState(emptyState());
125
+ return;
126
+ }
127
+ loading.value = true;
128
+ try {
129
+ const entry = await cmsApi(`${endpoint.value}/${props.entryId}`);
130
+ setState(pickFields(entry));
131
+ } catch (error) {
132
+ toast.add({
133
+ title: "Could not load entry",
134
+ description: errorMessage(error),
135
+ color: "error"
136
+ });
137
+ open.value = false;
138
+ } finally {
139
+ loading.value = false;
140
+ }
141
+ }
142
+ watch(
143
+ () => [open.value, props.entryId],
144
+ ([isOpen]) => {
145
+ if (isOpen) load();
146
+ },
147
+ { immediate: true }
148
+ );
149
+ async function requestClose() {
150
+ if (dirty.value && !await confirmAction("You have unsaved changes. Close anyway?")) return;
151
+ open.value = false;
152
+ }
153
+ async function save() {
154
+ if (saving.value) return;
155
+ saving.value = true;
156
+ try {
157
+ await (isNew.value ? cmsApi(endpoint.value, { method: "POST", body: formState.value }) : cmsApi(`${endpoint.value}/${props.entryId}`, {
158
+ method: "PUT",
159
+ body: formState.value
160
+ }));
161
+ snapshot.value = JSON.stringify(formState.value);
162
+ toast.add({ title: "Saved", color: "success" });
163
+ emit("saved");
164
+ open.value = false;
165
+ } catch (error) {
166
+ toast.add({
167
+ title: "Save failed",
168
+ description: errorMessage(error),
169
+ color: "error"
170
+ });
171
+ } finally {
172
+ saving.value = false;
173
+ }
174
+ }
175
+ async function remove() {
176
+ if (!await confirmAction("Delete this row?")) return;
177
+ saving.value = true;
178
+ try {
179
+ await cmsApi(`${endpoint.value}/${props.entryId}`, { method: "DELETE" });
180
+ snapshot.value = JSON.stringify(formState.value);
181
+ emit("deleted");
182
+ open.value = false;
183
+ } catch (error) {
184
+ toast.add({
185
+ title: "Delete failed",
186
+ description: errorMessage(error),
187
+ color: "error"
188
+ });
189
+ } finally {
190
+ saving.value = false;
191
+ }
192
+ }
193
+ </script>
@@ -0,0 +1,21 @@
1
+ import type { CmsEntry } from '#nuxt-cms';
2
+ type __VLS_Props = {
3
+ collection: string;
4
+ config: CmsEntry;
5
+ entryId: string | null;
6
+ };
7
+ type __VLS_ModelProps = {
8
+ 'open'?: boolean;
9
+ };
10
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
12
+ saved: () => any;
13
+ deleted: () => any;
14
+ "update:open": (value: boolean) => any;
15
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
16
+ onSaved?: (() => any) | undefined;
17
+ onDeleted?: (() => any) | undefined;
18
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
19
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
20
+ declare const _default: typeof __VLS_export;
21
+ export default _default;