@xleddyl/nuxt-cms 0.1.61 → 0.2.1

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 (147) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +133 -14
  3. package/dist/runtime/app/components/cms/BlocksField.vue +86 -10
  4. package/dist/runtime/app/components/cms/BrandMark.d.vue.ts +6 -0
  5. package/dist/runtime/app/components/cms/BrandMark.vue +19 -0
  6. package/dist/runtime/app/components/cms/BrandMark.vue.d.ts +6 -0
  7. package/dist/runtime/app/components/cms/Checkbox.d.vue.ts +11 -0
  8. package/dist/runtime/app/components/cms/Checkbox.vue +21 -0
  9. package/dist/runtime/app/components/cms/Checkbox.vue.d.ts +11 -0
  10. package/dist/runtime/app/components/cms/Drawer.vue +5 -9
  11. package/dist/runtime/app/components/cms/EmptyState.vue +4 -2
  12. package/dist/runtime/app/components/cms/EntryDrawer.vue +12 -3
  13. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
  14. package/dist/runtime/app/components/cms/EntryForm.vue +104 -30
  15. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
  16. package/dist/runtime/app/components/cms/FieldInput.vue +20 -5
  17. package/dist/runtime/app/components/cms/FormField.d.vue.ts +5 -3
  18. package/dist/runtime/app/components/cms/FormField.vue +8 -2
  19. package/dist/runtime/app/components/cms/FormField.vue.d.ts +5 -3
  20. package/dist/runtime/app/components/cms/Icon.vue +1 -1
  21. package/dist/runtime/app/components/cms/Input.d.vue.ts +0 -1
  22. package/dist/runtime/app/components/cms/Input.vue +7 -4
  23. package/dist/runtime/app/components/cms/Input.vue.d.ts +0 -1
  24. package/dist/runtime/app/components/cms/LocaleSwitch.vue +1 -1
  25. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +0 -1
  26. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +1 -3
  27. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +0 -1
  28. package/dist/runtime/app/components/cms/MediaGallery.vue +24 -10
  29. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
  30. package/dist/runtime/app/components/cms/MediaUpload.vue +71 -41
  31. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
  32. package/dist/runtime/app/components/cms/Modal.vue +5 -9
  33. package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +5 -3
  34. package/dist/runtime/app/components/cms/PageHeader.vue +16 -6
  35. package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +5 -3
  36. package/dist/runtime/app/components/cms/PasswordInput.d.vue.ts +17 -0
  37. package/dist/runtime/app/components/cms/PasswordInput.vue +81 -0
  38. package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
  39. package/dist/runtime/app/components/cms/RelationField.vue +7 -2
  40. package/dist/runtime/app/components/cms/SelectMenu.d.vue.ts +0 -1
  41. package/dist/runtime/app/components/cms/SelectMenu.vue +4 -11
  42. package/dist/runtime/app/components/cms/SelectMenu.vue.d.ts +0 -1
  43. package/dist/runtime/app/components/cms/SettingsAccount.d.vue.ts +3 -0
  44. package/dist/runtime/app/components/cms/SettingsAccount.vue +138 -0
  45. package/dist/runtime/app/components/cms/SettingsAccount.vue.d.ts +3 -0
  46. package/dist/runtime/app/components/cms/SettingsColumns.d.vue.ts +16 -0
  47. package/dist/runtime/app/components/cms/SettingsColumns.vue +102 -0
  48. package/dist/runtime/app/components/cms/SettingsColumns.vue.d.ts +16 -0
  49. package/dist/runtime/app/components/cms/SettingsEntry.d.vue.ts +6 -0
  50. package/dist/runtime/app/components/cms/SettingsEntry.vue +126 -0
  51. package/dist/runtime/app/components/cms/SettingsEntry.vue.d.ts +6 -0
  52. package/dist/runtime/app/components/cms/SettingsLayout.d.vue.ts +16 -0
  53. package/dist/runtime/app/components/cms/SettingsLayout.vue +177 -0
  54. package/dist/runtime/app/components/cms/SettingsLayout.vue.d.ts +16 -0
  55. package/dist/runtime/app/components/cms/SettingsModal.d.vue.ts +16 -0
  56. package/dist/runtime/app/components/cms/SettingsModal.vue +174 -0
  57. package/dist/runtime/app/components/cms/SettingsModal.vue.d.ts +16 -0
  58. package/dist/runtime/app/components/cms/SettingsUsers.d.vue.ts +3 -0
  59. package/dist/runtime/app/components/cms/SettingsUsers.vue +274 -0
  60. package/dist/runtime/app/components/cms/SettingsUsers.vue.d.ts +3 -0
  61. package/dist/runtime/app/components/cms/Spinner.vue +2 -2
  62. package/dist/runtime/app/components/cms/Table.d.vue.ts +6 -8
  63. package/dist/runtime/app/components/cms/Table.vue +42 -47
  64. package/dist/runtime/app/components/cms/Table.vue.d.ts +6 -8
  65. package/dist/runtime/app/components/cms/Textarea.d.vue.ts +0 -1
  66. package/dist/runtime/app/components/cms/Textarea.vue +1 -2
  67. package/dist/runtime/app/components/cms/Textarea.vue.d.ts +0 -1
  68. package/dist/runtime/app/components/cms/Toaster.vue +1 -1
  69. package/dist/runtime/app/components/cms/WelcomeModal.d.vue.ts +3 -0
  70. package/dist/runtime/app/components/cms/WelcomeModal.vue +69 -0
  71. package/dist/runtime/app/components/cms/WelcomeModal.vue.d.ts +3 -0
  72. package/dist/runtime/app/composables/cms-account.d.ts +11 -0
  73. package/dist/runtime/app/composables/cms-account.js +23 -0
  74. package/dist/runtime/app/composables/cms-entry-disabled.js +1 -1
  75. package/dist/runtime/app/composables/cms-media-keys.d.ts +1 -0
  76. package/dist/runtime/app/composables/cms-media-keys.js +5 -0
  77. package/dist/runtime/app/composables/cms-media-library.js +3 -0
  78. package/dist/runtime/app/composables/cms-media-uploader.d.ts +11 -2
  79. package/dist/runtime/app/composables/cms-media-uploader.js +139 -12
  80. package/dist/runtime/app/composables/cms-overlay.d.ts +4 -0
  81. package/dist/runtime/app/composables/cms-overlay.js +23 -0
  82. package/dist/runtime/app/composables/cms-settings.d.ts +12 -0
  83. package/dist/runtime/app/composables/cms-settings.js +59 -0
  84. package/dist/runtime/app/composables/cms-theme.d.ts +9 -0
  85. package/dist/runtime/app/composables/cms-theme.js +27 -0
  86. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
  87. package/dist/runtime/app/layouts/cms-admin.vue +71 -17
  88. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
  89. package/dist/runtime/app/pages/admin-collection.vue +127 -101
  90. package/dist/runtime/app/pages/admin-entry.vue +2 -0
  91. package/dist/runtime/app/pages/admin-login.vue +18 -3
  92. package/dist/runtime/app/pages/admin-media.vue +5 -1
  93. package/dist/runtime/app/utils/ui.d.ts +2 -0
  94. package/dist/runtime/app/utils/ui.js +18 -0
  95. package/dist/runtime/assets/main.css +1 -1
  96. package/dist/runtime/generate.js +1 -1
  97. package/dist/runtime/server/api/account-password.put.d.ts +4 -0
  98. package/dist/runtime/server/api/account-password.put.js +38 -0
  99. package/dist/runtime/server/api/account-welcome.post.d.ts +4 -0
  100. package/dist/runtime/server/api/account-welcome.post.js +14 -0
  101. package/dist/runtime/server/api/account.put.d.ts +3 -0
  102. package/dist/runtime/server/api/account.put.js +25 -0
  103. package/dist/runtime/server/api/collection.get.js +1 -1
  104. package/dist/runtime/server/api/media.delete.js +2 -1
  105. package/dist/runtime/server/api/media.post.js +2 -1
  106. package/dist/runtime/server/api/media.put.js +2 -1
  107. package/dist/runtime/server/api/settings.delete.d.ts +4 -0
  108. package/dist/runtime/server/api/settings.delete.js +12 -0
  109. package/dist/runtime/server/api/settings.get.d.ts +3 -0
  110. package/dist/runtime/server/api/settings.get.js +15 -0
  111. package/dist/runtime/server/api/settings.put.d.ts +3 -0
  112. package/dist/runtime/server/api/settings.put.js +14 -0
  113. package/dist/runtime/server/api/user.delete.d.ts +4 -0
  114. package/dist/runtime/server/api/user.delete.js +12 -0
  115. package/dist/runtime/server/api/user.put.d.ts +3 -0
  116. package/dist/runtime/server/api/user.put.js +27 -0
  117. package/dist/runtime/server/api/users.get.d.ts +3 -0
  118. package/dist/runtime/server/api/users.get.js +11 -0
  119. package/dist/runtime/server/api/users.post.d.ts +3 -0
  120. package/dist/runtime/server/api/users.post.js +36 -0
  121. package/dist/runtime/server/plugins/session-validate.d.ts +2 -0
  122. package/dist/runtime/server/plugins/session-validate.js +13 -0
  123. package/dist/runtime/server/routes/auth/login.post.js +20 -10
  124. package/dist/runtime/server/utils/graphql-depth.js +12 -5
  125. package/dist/runtime/server/utils/graphql.js +2 -2
  126. package/dist/runtime/server/utils/media-folders.js +10 -2
  127. package/dist/runtime/server/utils/media.d.ts +1 -0
  128. package/dist/runtime/server/utils/media.js +4 -1
  129. package/dist/runtime/server/utils/migrate.js +1 -1
  130. package/dist/runtime/server/utils/password.d.ts +3 -0
  131. package/dist/runtime/server/utils/password.js +31 -0
  132. package/dist/runtime/server/utils/require-admin.d.ts +4 -2
  133. package/dist/runtime/server/utils/require-admin.js +32 -28
  134. package/dist/runtime/server/utils/same-origin.d.ts +2 -0
  135. package/dist/runtime/server/utils/same-origin.js +24 -0
  136. package/dist/runtime/server/utils/settings.d.ts +16 -0
  137. package/dist/runtime/server/utils/settings.js +31 -0
  138. package/dist/runtime/server/utils/users.d.ts +15 -0
  139. package/dist/runtime/server/utils/users.js +34 -0
  140. package/dist/runtime/shared/index.d.ts +13 -1
  141. package/dist/runtime/shared/index.js +2 -0
  142. package/dist/runtime/shared/layout.d.ts +33 -0
  143. package/dist/runtime/shared/layout.js +127 -0
  144. package/dist/runtime/shared/users.d.ts +18 -0
  145. package/dist/runtime/shared/users.js +1 -0
  146. package/dist/runtime/shared/validation.js +5 -4
  147. package/package.json +3 -3
@@ -0,0 +1,23 @@
1
+ import { computed, useUserSession } from "#imports";
2
+ const PASSWORD_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789";
3
+ export function generatePassword(length = 16) {
4
+ const values = new Uint32Array(length);
5
+ crypto.getRandomValues(values);
6
+ return Array.from(values, (value) => PASSWORD_ALPHABET[value % PASSWORD_ALPHABET.length]).join(
7
+ ""
8
+ );
9
+ }
10
+ export function initialOf(name, email) {
11
+ return (name?.trim() || email).charAt(0).toUpperCase() || "A";
12
+ }
13
+ export function useCmsAccount() {
14
+ const session = useUserSession();
15
+ const user = computed(() => session.user.value ?? null);
16
+ const email = computed(() => user.value?.email ?? "");
17
+ const isSuperAdmin = computed(() => !!user.value && !user.value.id);
18
+ const displayName = computed(
19
+ () => user.value?.name?.trim() || (isSuperAdmin.value ? "Super admin" : email.value)
20
+ );
21
+ const initial = computed(() => initialOf(user.value?.name, email.value));
22
+ return { user, email, isSuperAdmin, displayName, initial, refresh: session.fetch };
23
+ }
@@ -17,7 +17,7 @@ export function useCmsCollection(name, options = {}) {
17
17
  export function useCmsPage(path, options = {}) {
18
18
  const { locale, key, default: fallback } = options;
19
19
  return useAsyncData(
20
- `cms-page:${String(path)}:${locale ?? ""}`,
20
+ key ?? `cms-page:${String(path)}:${locale ?? ""}`,
21
21
  async () => fallback ? fallback() : null
22
22
  );
23
23
  }
@@ -1,3 +1,4 @@
1
+ export declare function invalidateCmsMediaKeys(): void;
1
2
  export declare function useCmsMediaKeys(): {
2
3
  isMissing: (key: unknown) => boolean;
3
4
  remember: (key: string) => void;
@@ -6,9 +6,14 @@ function load() {
6
6
  known.value = result.source.kind === "none" ? null : new Set(result.items.map((item) => item.key));
7
7
  }).catch(() => {
8
8
  known.value = null;
9
+ loading = null;
9
10
  });
10
11
  return loading;
11
12
  }
13
+ export function invalidateCmsMediaKeys() {
14
+ loading = null;
15
+ known.value = null;
16
+ }
12
17
  export function useCmsMediaKeys() {
13
18
  if (import.meta.client) void load();
14
19
  function isMissing(key) {
@@ -6,6 +6,7 @@ import {
6
6
  } from "../../shared/index.js";
7
7
  import { computed, ref } from "#imports";
8
8
  import { errorMessage } from "../utils/ui.js";
9
+ import { invalidateCmsMediaKeys } from "./cms-media-keys.js";
9
10
  import { useCmsToast } from "./cms-toast.js";
10
11
  const endpoint = "/api/cms/admin/media";
11
12
  export function useCmsMediaLibrary() {
@@ -81,6 +82,7 @@ export function useCmsMediaLibrary() {
81
82
  method: "DELETE",
82
83
  query: { name, recursive: "true" }
83
84
  });
85
+ invalidateCmsMediaKeys();
84
86
  await reload({ quiet: true });
85
87
  });
86
88
  }
@@ -93,6 +95,7 @@ export function useCmsMediaLibrary() {
93
95
  function deleteItems(keys) {
94
96
  return attempt("Delete failed", async () => {
95
97
  await $fetch(`${endpoint}/delete`, { method: "POST", body: { keys } });
98
+ invalidateCmsMediaKeys();
96
99
  await reload({ quiet: true });
97
100
  });
98
101
  }
@@ -1,7 +1,16 @@
1
1
  import type { MediaItem } from '../../shared/index.js';
2
- export declare function matchesAccept(file: File, accept: string | undefined): boolean;
2
+ export interface UploadEntry {
3
+ file: File;
4
+ directory: string | null;
5
+ }
6
+ export interface UploadTree {
7
+ files: UploadEntry[];
8
+ directories: string[];
9
+ }
10
+ export declare function uploadTreeFromFiles(list: FileList | File[]): UploadTree;
11
+ export declare function uploadTreeFromDrop(dataTransfer: DataTransfer): Promise<UploadTree>;
3
12
  export declare function useCmsMediaUploader(): {
4
- upload: (list: FileList | File[], options: {
13
+ upload: (source: FileList | File[] | UploadTree, options: {
5
14
  folder: string | null;
6
15
  accept?: string;
7
16
  multiple?: boolean;
@@ -1,4 +1,9 @@
1
- import { formatFileSize } from "../../shared/index.js";
1
+ import {
2
+ formatFileSize,
3
+ MEDIA_FOLDER_MAX_DEPTH,
4
+ mediaFolderSegments,
5
+ normalizeMediaFolder
6
+ } from "../../shared/index.js";
2
7
  import { ref } from "#imports";
3
8
  import { useCmsRuntime } from "./cms-runtime.js";
4
9
  import { useCmsToast } from "./cms-toast.js";
@@ -34,7 +39,7 @@ async function mediaDimensions(file) {
34
39
  if (file.type.startsWith("video/")) return videoDimensions(file);
35
40
  return {};
36
41
  }
37
- export function matchesAccept(file, accept) {
42
+ function matchesAccept(file, accept) {
38
43
  if (!accept) return true;
39
44
  return accept.split(",").some((raw) => {
40
45
  const pattern = raw.trim().toLowerCase();
@@ -43,6 +48,90 @@ export function matchesAccept(file, accept) {
43
48
  return file.type.toLowerCase() === pattern;
44
49
  });
45
50
  }
51
+ const UPLOAD_CONCURRENCY = 4;
52
+ function isHiddenName(name) {
53
+ return name.startsWith(".");
54
+ }
55
+ function joinPath(parent, name) {
56
+ return parent ? `${parent}/${name}` : name;
57
+ }
58
+ export function uploadTreeFromFiles(list) {
59
+ const files = [];
60
+ const directories = /* @__PURE__ */ new Set();
61
+ for (const file of Array.from(list)) {
62
+ const segments = (file.webkitRelativePath || file.name).split("/").filter(Boolean);
63
+ if (segments.some(isHiddenName)) continue;
64
+ const directory = segments.slice(0, -1).join("/") || null;
65
+ if (directory) directories.add(directory);
66
+ files.push({ file, directory });
67
+ }
68
+ return { files, directories: [...directories] };
69
+ }
70
+ function readDirectory(entry) {
71
+ const reader = entry.createReader();
72
+ const all = [];
73
+ return new Promise((resolve, reject) => {
74
+ const next = () => reader.readEntries((batch) => {
75
+ if (!batch.length) return resolve(all);
76
+ all.push(...batch);
77
+ next();
78
+ }, reject);
79
+ next();
80
+ });
81
+ }
82
+ function entryFile(entry) {
83
+ return new Promise((resolve, reject) => entry.file(resolve, reject));
84
+ }
85
+ async function walkEntry(entry, parent, tree) {
86
+ if (isHiddenName(entry.name)) return;
87
+ if (entry.isFile) {
88
+ tree.files.push({ file: await entryFile(entry), directory: parent });
89
+ return;
90
+ }
91
+ if (!entry.isDirectory) return;
92
+ const directory = joinPath(parent, entry.name);
93
+ tree.directories.push(directory);
94
+ const children = await readDirectory(entry);
95
+ for (const child of children) await walkEntry(child, directory, tree);
96
+ }
97
+ export function uploadTreeFromDrop(dataTransfer) {
98
+ const files = Array.from(dataTransfer.files);
99
+ const entries = Array.from(dataTransfer.items ?? []).filter((item) => item.kind === "file").map((item) => item.webkitGetAsEntry?.() ?? null).filter((entry) => !!entry);
100
+ if (!entries.some((entry) => entry.isDirectory)) {
101
+ return Promise.resolve(uploadTreeFromFiles(files));
102
+ }
103
+ return (async () => {
104
+ const tree = { files: [], directories: [] };
105
+ for (const entry of entries) await walkEntry(entry, null, tree);
106
+ return tree;
107
+ })();
108
+ }
109
+ async function mapWithConcurrency(list, limit, task) {
110
+ const results = new Array(list.length);
111
+ let cursor = 0;
112
+ const worker = async () => {
113
+ while (cursor < list.length) {
114
+ const index = cursor++;
115
+ try {
116
+ results[index] = { status: "fulfilled", value: await task(list[index]) };
117
+ } catch (reason) {
118
+ results[index] = { status: "rejected", reason };
119
+ }
120
+ }
121
+ };
122
+ await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
123
+ return results;
124
+ }
125
+ function targetFolder(base, directory) {
126
+ const joined = [base, directory].filter(Boolean).join("/");
127
+ return {
128
+ folder: normalizeMediaFolder(joined),
129
+ flattened: mediaFolderSegments(joined).length > MEDIA_FOLDER_MAX_DEPTH
130
+ };
131
+ }
132
+ function isWithinOrAbove(folder, other) {
133
+ return other === folder || other.startsWith(`${folder}/`);
134
+ }
46
135
  export function useCmsMediaUploader() {
47
136
  const toast = useCmsToast();
48
137
  const { mediaMaxFileSize } = useCmsRuntime();
@@ -78,9 +167,26 @@ export function useCmsMediaUploader() {
78
167
  done.value++;
79
168
  return item;
80
169
  }
81
- async function upload(list, options) {
82
- const all = Array.from(list);
83
- let files = all.filter((file) => matchesAccept(file, options.accept));
170
+ async function createFolders(folders) {
171
+ const results = await mapWithConcurrency(
172
+ folders,
173
+ UPLOAD_CONCURRENCY,
174
+ (name) => $fetch("/api/cms/admin/media/folders", {
175
+ method: "POST",
176
+ body: { name }
177
+ })
178
+ );
179
+ const failed = results.filter((r) => r.status === "rejected").length;
180
+ if (failed)
181
+ toast.add({
182
+ title: `${failed} folder${failed > 1 ? "s" : ""} could not be created`,
183
+ color: "error"
184
+ });
185
+ }
186
+ async function upload(source, options) {
187
+ const tree = Array.isArray(source) || source instanceof FileList ? uploadTreeFromFiles(source) : source;
188
+ const all = tree.files;
189
+ let files = all.filter((entry) => matchesAccept(entry.file, options.accept));
84
190
  const rejected = all.length - files.length;
85
191
  if (rejected) {
86
192
  toast.add({
@@ -88,21 +194,42 @@ export function useCmsMediaUploader() {
88
194
  color: "error"
89
195
  });
90
196
  }
91
- for (const file of files.filter((file2) => file2.size > mediaMaxFileSize)) {
197
+ for (const { file } of files.filter((entry) => entry.file.size > mediaMaxFileSize)) {
92
198
  toast.add({
93
199
  title: `File too large (max ${formatFileSize(mediaMaxFileSize)}): ${file.name}`,
94
200
  color: "error"
95
201
  });
96
202
  }
97
- files = files.filter((file) => file.size <= mediaMaxFileSize);
98
- if (options.multiple === false) files = files.slice(0, 1);
99
- if (!files.length || uploading.value) return [];
203
+ files = files.filter((entry) => entry.file.size <= mediaMaxFileSize);
204
+ const nested = options.multiple !== false;
205
+ if (!nested) files = files.slice(0, 1).map((entry) => ({ ...entry, directory: null }));
206
+ const directories = nested ? tree.directories : [];
207
+ if (!files.length && !directories.length || uploading.value) return [];
208
+ const planned = files.map((entry) => ({
209
+ file: entry.file,
210
+ ...targetFolder(options.folder, entry.directory)
211
+ }));
212
+ const fileFolders = planned.map((entry) => entry.folder).filter((f) => !!f);
213
+ const emptyFolders = [
214
+ ...new Set(
215
+ directories.map((directory) => targetFolder(options.folder, directory).folder).filter((folder) => !!folder)
216
+ )
217
+ ].filter((folder) => !fileFolders.some((other) => isWithinOrAbove(folder, other)));
218
+ if (planned.some((entry) => entry.flattened)) {
219
+ toast.add({
220
+ title: `Folders nest up to ${MEDIA_FOLDER_MAX_DEPTH} levels`,
221
+ description: "Files in deeper folders were uploaded to the deepest allowed folder."
222
+ });
223
+ }
100
224
  uploading.value = true;
101
225
  done.value = 0;
102
- total.value = files.length;
226
+ total.value = planned.length;
103
227
  try {
104
- const results = await Promise.allSettled(
105
- files.map((file) => uploadOne(file, options.folder))
228
+ if (emptyFolders.length) await createFolders(emptyFolders);
229
+ const results = await mapWithConcurrency(
230
+ planned,
231
+ UPLOAD_CONCURRENCY,
232
+ (entry) => uploadOne(entry.file, entry.folder)
106
233
  );
107
234
  const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
108
235
  const failed = results.length - ok.length;
@@ -0,0 +1,4 @@
1
+ export declare function useCmsOverlay(close: () => void): {
2
+ activate: () => void;
3
+ deactivate: () => void;
4
+ };
@@ -0,0 +1,23 @@
1
+ import { onBeforeUnmount } from "#imports";
2
+ const stack = [];
3
+ function onKeydown(event) {
4
+ if (event.key !== "Escape") return;
5
+ stack[stack.length - 1]?.close();
6
+ }
7
+ export function useCmsOverlay(close) {
8
+ const entry = { close };
9
+ function activate() {
10
+ if (import.meta.server || stack.includes(entry)) return;
11
+ stack.push(entry);
12
+ if (stack.length === 1) document.addEventListener("keydown", onKeydown);
13
+ }
14
+ function deactivate() {
15
+ if (import.meta.server) return;
16
+ const index = stack.indexOf(entry);
17
+ if (index === -1) return;
18
+ stack.splice(index, 1);
19
+ if (stack.length === 0) document.removeEventListener("keydown", onKeydown);
20
+ }
21
+ onBeforeUnmount(deactivate);
22
+ return { activate, deactivate };
23
+ }
@@ -0,0 +1,12 @@
1
+ import type { CmsEntrySettings, CmsFormLayout, CmsSettingsMap } from '#nuxt-cms';
2
+ export declare function useCmsSettingsState(): import("vue").Ref<CmsSettingsMap, CmsSettingsMap>;
3
+ export declare function loadCmsSettings(): Promise<void>;
4
+ export declare function useCmsSettings(): {
5
+ settings: import("vue").Ref<CmsSettingsMap, CmsSettingsMap>;
6
+ save: (name: string, value: CmsEntrySettings) => Promise<void>;
7
+ reset: (name: string) => Promise<void>;
8
+ };
9
+ export declare function entryFields(name: string): Record<string, import("#nuxt-cms").FieldConfig>;
10
+ export declare function entryFormLayout(name: string, settings: CmsSettingsMap, fields?: Record<string, import("#nuxt-cms").FieldConfig>): CmsFormLayout;
11
+ export declare function listExtraColumns(name: string): string[];
12
+ export declare function entryListColumns(name: string, settings: CmsSettingsMap): string[];
@@ -0,0 +1,59 @@
1
+ import { layoutFromConfig, pageAllFields, resolveFormLayout, resolveListColumns } from "#nuxt-cms";
2
+ import { useFetch, useState } from "#imports";
3
+ import cmsConfig from "#cms-config";
4
+ import { cmsApi } from "../utils/api.js";
5
+ const SETTINGS_ENDPOINT = "/api/cms/admin/settings";
6
+ export function useCmsSettingsState() {
7
+ return useState("cms-settings", () => ({}));
8
+ }
9
+ export async function loadCmsSettings() {
10
+ const state = useCmsSettingsState();
11
+ const { data } = await useFetch(SETTINGS_ENDPOINT, { key: "cms-settings" });
12
+ if (data.value) state.value = data.value;
13
+ }
14
+ export function useCmsSettings() {
15
+ const settings = useCmsSettingsState();
16
+ async function save(name, value) {
17
+ const saved = await cmsApi(`${SETTINGS_ENDPOINT}/${name}`, {
18
+ method: "PUT",
19
+ body: value
20
+ });
21
+ settings.value = { ...settings.value, [name]: saved };
22
+ }
23
+ async function reset(name) {
24
+ await cmsApi(`${SETTINGS_ENDPOINT}/${name}`, { method: "DELETE" });
25
+ const next = { ...settings.value };
26
+ delete next[name];
27
+ settings.value = next;
28
+ }
29
+ return { settings, save, reset };
30
+ }
31
+ function entryOf(name) {
32
+ return cmsConfig[name];
33
+ }
34
+ export function entryFields(name) {
35
+ const entry = entryOf(name);
36
+ if (!entry) return {};
37
+ return entry.kind === "page" ? pageAllFields(entry) : entry.fields;
38
+ }
39
+ function defaultFormLayout(name, fields = entryFields(name)) {
40
+ return resolveFormLayout(fields, layoutFromConfig(entryOf(name)?.layout));
41
+ }
42
+ export function entryFormLayout(name, settings, fields = entryFields(name)) {
43
+ const saved = settings[name]?.layout;
44
+ return saved ? resolveFormLayout(fields, saved) : defaultFormLayout(name, fields);
45
+ }
46
+ export function listExtraColumns(name) {
47
+ const entry = entryOf(name);
48
+ return entry?.kind === "collection" && entry.drafts ? ["status"] : [];
49
+ }
50
+ export function entryListColumns(name, settings) {
51
+ const entry = entryOf(name);
52
+ if (!entry) return [];
53
+ return resolveListColumns(
54
+ entry.fields,
55
+ settings[name]?.columns,
56
+ entry.list,
57
+ listExtraColumns(name)
58
+ );
59
+ }
@@ -0,0 +1,9 @@
1
+ import type { Ref } from 'vue';
2
+ export type CmsTheme = 'light' | 'dark' | 'system';
3
+ export declare const CMS_THEMES: {
4
+ value: CmsTheme;
5
+ label: string;
6
+ icon: string;
7
+ }[];
8
+ export declare function useCmsTheme(): import("nuxt/app").CookieRef<CmsTheme>;
9
+ export declare function setCmsTheme(theme: Ref<CmsTheme>, value: CmsTheme): void;
@@ -0,0 +1,27 @@
1
+ import { nextTick, useCookie } from "#imports";
2
+ export const CMS_THEMES = [
3
+ { value: "light", label: "Light", icon: "sun" },
4
+ { value: "dark", label: "Dark", icon: "moon" },
5
+ { value: "system", label: "System", icon: "computer-desktop" }
6
+ ];
7
+ export function useCmsTheme() {
8
+ return useCookie("cms-theme", {
9
+ default: () => "system",
10
+ maxAge: 60 * 60 * 24 * 365,
11
+ sameSite: "lax",
12
+ path: "/"
13
+ });
14
+ }
15
+ export function setCmsTheme(theme, value) {
16
+ if (value === theme.value) return;
17
+ const doc = document;
18
+ const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
19
+ if (!doc.startViewTransition || reduced) {
20
+ theme.value = value;
21
+ return;
22
+ }
23
+ doc.startViewTransition(async () => {
24
+ theme.value = value;
25
+ await nextTick();
26
+ });
27
+ }
@@ -1,6 +1,6 @@
1
- declare var __VLS_19: {};
1
+ declare var __VLS_34: {};
2
2
  type __VLS_Slots = {} & {
3
- default?: (props: typeof __VLS_19) => any;
3
+ default?: (props: typeof __VLS_34) => 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>;
@@ -1,8 +1,14 @@
1
1
  <template>
2
- <div class="cms-scope cms-canvas min-h-screen">
3
- <div class="cms-shell">
2
+ <div class="cms-scope cms-canvas min-h-screen" :data-theme="theme">
3
+ <div class="cms-shell" :class="{ 'is-nav-open': navOpen }">
4
4
  <aside class="cms-sidebar">
5
- <div class="cms-sidebar-brand">nuxt<span class="cms-accent">·</span>cms</div>
5
+ <div class="cms-sidebar-brand">
6
+ <CmsBrandMark />
7
+ <div class="cms-brand-text">
8
+ <span class="cms-brand-name">nuxt-cms</span>
9
+ <span class="cms-brand-meta">Content studio</span>
10
+ </div>
11
+ </div>
6
12
 
7
13
  <nav class="cms-sidebar-nav">
8
14
  <div v-for="group in groups" :key="group.title" class="cms-sidebar-group">
@@ -14,49 +20,81 @@
14
20
  :key="link.name"
15
21
  :to="link.to"
16
22
  class="cms-navlink"
17
- :class="{ 'is-active': route.path === link.to }"
23
+ :class="{ 'is-active': isActive(link.to) }"
18
24
  >
19
- <CmsIcon :name="group.icon" class="cms-navlink-icon size-4 shrink-0" />
25
+ <CmsIcon
26
+ :name="link.icon ?? group.icon"
27
+ class="cms-navlink-icon size-4 shrink-0"
28
+ />
20
29
  <span class="truncate">{{ link.label }}</span>
21
30
  </NuxtLink>
22
31
  </div>
23
32
  </nav>
24
33
 
25
34
  <div class="cms-sidebar-footer">
26
- <CmsButton
27
- label="Sign out"
28
- icon="arrow-right-on-rectangle"
29
- trailing-icon
30
- block
31
- variant="ghost"
32
- color="neutral"
33
- size="xs"
34
- @click="logout"
35
- />
35
+ <button
36
+ type="button"
37
+ class="cms-account"
38
+ :class="{ 'is-open': settingsOpen }"
39
+ aria-label="Open settings"
40
+ @click="openSettings"
41
+ >
42
+ <span class="cms-avatar">{{ initial }}</span>
43
+ <span class="cms-account-text">
44
+ <span class="cms-account-name">{{ displayName }}</span>
45
+ <span class="cms-account-email" :title="email">{{ email }}</span>
46
+ </span>
47
+ <CmsIcon name="ellipsis-horizontal" class="cms-account-more size-4" />
48
+ </button>
36
49
  </div>
37
50
  </aside>
38
51
 
52
+ <div class="cms-sidebar-scrim" @click="navOpen = false" />
53
+
39
54
  <main class="cms-main">
55
+ <header class="cms-topbar">
56
+ <CmsButton
57
+ icon="bars-3"
58
+ variant="ghost"
59
+ color="neutral"
60
+ size="sm"
61
+ aria-label="Open navigation"
62
+ @click="navOpen = true"
63
+ />
64
+ <CmsBrandMark />
65
+ <span class="cms-brand-name">nuxt-cms</span>
66
+ </header>
40
67
  <div class="cms-main-inner">
41
68
  <slot />
42
69
  </div>
43
70
  </main>
44
71
  </div>
45
72
 
73
+ <CmsSettingsModal v-model:open="settingsOpen" v-model:theme="theme" @logout="logout" />
74
+ <CmsWelcomeModal />
46
75
  <CmsToaster />
47
76
  <CmsConfirmModal />
48
77
  </div>
49
78
  </template>
50
79
 
51
80
  <script setup>
52
- import { computed, navigateTo, useRoute, useUserSession } from "#imports";
81
+ import { computed, navigateTo, ref, useRoute, useUserSession, watch } from "#imports";
53
82
  import cmsConfig from "#cms-config";
83
+ import { useCmsTheme } from "../composables/cms-theme";
84
+ import { useCmsAccount } from "../composables/cms-account";
85
+ import { loadCmsSettings } from "../composables/cms-settings";
54
86
  const route = useRoute();
55
87
  const { clear } = useUserSession();
88
+ const { email, displayName, initial } = useCmsAccount();
89
+ const theme = useCmsTheme();
90
+ const navOpen = ref(false);
91
+ const settingsOpen = ref(false);
92
+ await loadCmsSettings();
56
93
  const links = Object.entries(cmsConfig).map(([name, entry]) => ({
57
94
  name,
58
95
  label: entry.label,
59
96
  kind: entry.kind,
97
+ icon: entry.icon,
60
98
  to: `/cms/${name}`
61
99
  }));
62
100
  const groups = computed(
@@ -79,11 +117,27 @@ const groups = computed(
79
117
  {
80
118
  title: "Library",
81
119
  icon: "photo",
82
- links: [{ name: "media", label: "Media", kind: "media", to: "/cms/media" }]
120
+ links: [
121
+ { name: "media", label: "Media", kind: "media", icon: void 0, to: "/cms/media" }
122
+ ]
83
123
  }
84
124
  ].filter((g) => g.links.length)
85
125
  );
126
+ function isActive(to) {
127
+ return route.path === to || route.path.startsWith(`${to}/`);
128
+ }
129
+ watch(
130
+ () => route.fullPath,
131
+ () => {
132
+ navOpen.value = false;
133
+ }
134
+ );
135
+ function openSettings() {
136
+ navOpen.value = false;
137
+ settingsOpen.value = true;
138
+ }
86
139
  async function logout() {
140
+ settingsOpen.value = false;
87
141
  await clear();
88
142
  await navigateTo("/cms/login");
89
143
  }
@@ -1,6 +1,6 @@
1
- declare var __VLS_19: {};
1
+ declare var __VLS_34: {};
2
2
  type __VLS_Slots = {} & {
3
- default?: (props: typeof __VLS_19) => any;
3
+ default?: (props: typeof __VLS_34) => 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>;