@xleddyl/nuxt-cms 0.1.61 → 0.2.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 (115) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +117 -9
  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/EmptyState.vue +4 -2
  11. package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -0
  12. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
  13. package/dist/runtime/app/components/cms/EntryForm.vue +105 -30
  14. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
  15. package/dist/runtime/app/components/cms/FieldInput.vue +20 -5
  16. package/dist/runtime/app/components/cms/FormField.d.vue.ts +5 -3
  17. package/dist/runtime/app/components/cms/FormField.vue +8 -2
  18. package/dist/runtime/app/components/cms/FormField.vue.d.ts +5 -3
  19. package/dist/runtime/app/components/cms/Icon.vue +1 -1
  20. package/dist/runtime/app/components/cms/MediaGallery.vue +24 -10
  21. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
  22. package/dist/runtime/app/components/cms/MediaUpload.vue +71 -41
  23. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
  24. package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +5 -3
  25. package/dist/runtime/app/components/cms/PageHeader.vue +16 -6
  26. package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +5 -3
  27. package/dist/runtime/app/components/cms/PasswordInput.d.vue.ts +17 -0
  28. package/dist/runtime/app/components/cms/PasswordInput.vue +77 -0
  29. package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
  30. package/dist/runtime/app/components/cms/SelectMenu.vue +3 -5
  31. package/dist/runtime/app/components/cms/SettingsAccount.d.vue.ts +3 -0
  32. package/dist/runtime/app/components/cms/SettingsAccount.vue +138 -0
  33. package/dist/runtime/app/components/cms/SettingsAccount.vue.d.ts +3 -0
  34. package/dist/runtime/app/components/cms/SettingsColumns.d.vue.ts +16 -0
  35. package/dist/runtime/app/components/cms/SettingsColumns.vue +102 -0
  36. package/dist/runtime/app/components/cms/SettingsColumns.vue.d.ts +16 -0
  37. package/dist/runtime/app/components/cms/SettingsEntry.d.vue.ts +6 -0
  38. package/dist/runtime/app/components/cms/SettingsEntry.vue +133 -0
  39. package/dist/runtime/app/components/cms/SettingsEntry.vue.d.ts +6 -0
  40. package/dist/runtime/app/components/cms/SettingsLayout.d.vue.ts +16 -0
  41. package/dist/runtime/app/components/cms/SettingsLayout.vue +177 -0
  42. package/dist/runtime/app/components/cms/SettingsLayout.vue.d.ts +16 -0
  43. package/dist/runtime/app/components/cms/SettingsModal.d.vue.ts +16 -0
  44. package/dist/runtime/app/components/cms/SettingsModal.vue +179 -0
  45. package/dist/runtime/app/components/cms/SettingsModal.vue.d.ts +16 -0
  46. package/dist/runtime/app/components/cms/SettingsUsers.d.vue.ts +3 -0
  47. package/dist/runtime/app/components/cms/SettingsUsers.vue +270 -0
  48. package/dist/runtime/app/components/cms/SettingsUsers.vue.d.ts +3 -0
  49. package/dist/runtime/app/components/cms/Spinner.vue +2 -2
  50. package/dist/runtime/app/components/cms/Table.d.vue.ts +7 -2
  51. package/dist/runtime/app/components/cms/Table.vue +38 -1
  52. package/dist/runtime/app/components/cms/Table.vue.d.ts +7 -2
  53. package/dist/runtime/app/components/cms/Toaster.vue +1 -1
  54. package/dist/runtime/app/components/cms/WelcomeModal.d.vue.ts +3 -0
  55. package/dist/runtime/app/components/cms/WelcomeModal.vue +69 -0
  56. package/dist/runtime/app/components/cms/WelcomeModal.vue.d.ts +3 -0
  57. package/dist/runtime/app/composables/cms-account.d.ts +11 -0
  58. package/dist/runtime/app/composables/cms-account.js +23 -0
  59. package/dist/runtime/app/composables/cms-media-uploader.d.ts +11 -1
  60. package/dist/runtime/app/composables/cms-media-uploader.js +138 -11
  61. package/dist/runtime/app/composables/cms-settings.d.ts +13 -0
  62. package/dist/runtime/app/composables/cms-settings.js +59 -0
  63. package/dist/runtime/app/composables/cms-theme.d.ts +9 -0
  64. package/dist/runtime/app/composables/cms-theme.js +27 -0
  65. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
  66. package/dist/runtime/app/layouts/cms-admin.vue +71 -17
  67. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
  68. package/dist/runtime/app/pages/admin-collection.vue +127 -101
  69. package/dist/runtime/app/pages/admin-entry.vue +1 -0
  70. package/dist/runtime/app/pages/admin-login.vue +18 -3
  71. package/dist/runtime/app/pages/admin-media.vue +5 -1
  72. package/dist/runtime/app/utils/ui.d.ts +2 -0
  73. package/dist/runtime/app/utils/ui.js +18 -0
  74. package/dist/runtime/assets/main.css +1 -1
  75. package/dist/runtime/server/api/account-password.put.d.ts +4 -0
  76. package/dist/runtime/server/api/account-password.put.js +38 -0
  77. package/dist/runtime/server/api/account-welcome.post.d.ts +4 -0
  78. package/dist/runtime/server/api/account-welcome.post.js +14 -0
  79. package/dist/runtime/server/api/account.put.d.ts +3 -0
  80. package/dist/runtime/server/api/account.put.js +25 -0
  81. package/dist/runtime/server/api/settings.delete.d.ts +4 -0
  82. package/dist/runtime/server/api/settings.delete.js +12 -0
  83. package/dist/runtime/server/api/settings.get.d.ts +3 -0
  84. package/dist/runtime/server/api/settings.get.js +15 -0
  85. package/dist/runtime/server/api/settings.put.d.ts +3 -0
  86. package/dist/runtime/server/api/settings.put.js +14 -0
  87. package/dist/runtime/server/api/user.delete.d.ts +4 -0
  88. package/dist/runtime/server/api/user.delete.js +12 -0
  89. package/dist/runtime/server/api/user.put.d.ts +3 -0
  90. package/dist/runtime/server/api/user.put.js +27 -0
  91. package/dist/runtime/server/api/users.get.d.ts +3 -0
  92. package/dist/runtime/server/api/users.get.js +11 -0
  93. package/dist/runtime/server/api/users.post.d.ts +3 -0
  94. package/dist/runtime/server/api/users.post.js +32 -0
  95. package/dist/runtime/server/plugins/session-validate.d.ts +2 -0
  96. package/dist/runtime/server/plugins/session-validate.js +13 -0
  97. package/dist/runtime/server/routes/auth/login.post.js +18 -8
  98. package/dist/runtime/server/utils/password.d.ts +3 -0
  99. package/dist/runtime/server/utils/password.js +31 -0
  100. package/dist/runtime/server/utils/require-admin.d.ts +6 -2
  101. package/dist/runtime/server/utils/require-admin.js +33 -28
  102. package/dist/runtime/server/utils/same-origin.d.ts +2 -0
  103. package/dist/runtime/server/utils/same-origin.js +24 -0
  104. package/dist/runtime/server/utils/settings.d.ts +16 -0
  105. package/dist/runtime/server/utils/settings.js +31 -0
  106. package/dist/runtime/server/utils/users.d.ts +15 -0
  107. package/dist/runtime/server/utils/users.js +34 -0
  108. package/dist/runtime/shared/index.d.ts +13 -1
  109. package/dist/runtime/shared/index.js +2 -0
  110. package/dist/runtime/shared/layout.d.ts +34 -0
  111. package/dist/runtime/shared/layout.js +130 -0
  112. package/dist/runtime/shared/users.d.ts +18 -0
  113. package/dist/runtime/shared/users.js +1 -0
  114. package/dist/runtime/shared/validation.js +5 -4
  115. package/package.json +3 -3
@@ -0,0 +1,177 @@
1
+ <template>
2
+ <div class="cms-layout-editor" :class="{ 'is-dragging': dragging }">
3
+ <p class="cms-settings-hint">
4
+ Drag a field up or down to move it. Drop it on the side of another field to put them in the
5
+ same row (up to {{ MAX_LAYOUT_ROW_FIELDS }}).
6
+ </p>
7
+ <div class="cms-layout-areas">
8
+ <div v-for="area in AREAS" :key="area.id" class="cms-layout-area" :class="`is-${area.id}`">
9
+ <div
10
+ v-if="!model[area.id].some((section2) => section2.rows.length || section2.title)"
11
+ class="cms-layout-empty"
12
+ :class="{ 'is-over': isOver({ type: 'area', area: area.id }) }"
13
+ @dragover.prevent="setTarget({ type: 'area', area: area.id })"
14
+ @drop.prevent="drop"
15
+ >
16
+ {{ area.empty }}
17
+ </div>
18
+ <div
19
+ v-for="(section, sectionIndex) in model[area.id]"
20
+ v-else
21
+ :key="`${area.id}-${sectionIndex}`"
22
+ class="cms-layout-section"
23
+ :class="{ 'is-titled': !!section.title }"
24
+ >
25
+ <p v-if="section.title" class="cms-layout-section-title">
26
+ {{ section.title }}
27
+ <span v-if="section.collapsed" class="cms-badge is-muted">Collapsed</span>
28
+ </p>
29
+ <template v-for="(row, rowIndex) in section.rows" :key="row.join('|')">
30
+ <div
31
+ class="cms-layout-gap"
32
+ :class="{ 'is-over': isOver(gapTarget(area.id, sectionIndex, rowIndex)) }"
33
+ @dragover.prevent="setTarget(gapTarget(area.id, sectionIndex, rowIndex))"
34
+ @drop.prevent="drop"
35
+ />
36
+ <div class="cms-layout-row">
37
+ <div
38
+ v-for="key in row"
39
+ :key="key"
40
+ class="cms-layout-chip"
41
+ :class="{
42
+ 'is-dragging': dragging === key,
43
+ 'is-drop-before': isOver({ type: 'inline', key, after: false }),
44
+ 'is-drop-after': isOver({ type: 'inline', key, after: true }),
45
+ 'is-blocked': blocked === key
46
+ }"
47
+ draggable="true"
48
+ @dragstart="onDragStart($event, key)"
49
+ @dragover.prevent="onChipOver($event, key, row)"
50
+ @drop.prevent="drop"
51
+ @dragend="onDragEnd"
52
+ >
53
+ <CmsIcon name="bars-2" class="cms-settings-grip size-3.5" />
54
+ <CmsIcon :name="iconOf(key)" class="cms-settings-item-icon size-3.5" />
55
+ <span class="cms-layout-chip-label">{{ fields[key]?.label ?? key }}</span>
56
+ <span v-if="tabLabel(key)" class="cms-layout-chip-tab">{{
57
+ tabLabel(key)
58
+ }}</span>
59
+ </div>
60
+ </div>
61
+ </template>
62
+ <div
63
+ class="cms-layout-gap is-end"
64
+ :class="{
65
+ 'is-over': isOver(gapTarget(area.id, sectionIndex, section.rows.length)),
66
+ 'is-empty': !section.rows.length
67
+ }"
68
+ @dragover.prevent="
69
+ setTarget(gapTarget(area.id, sectionIndex, section.rows.length))
70
+ "
71
+ @drop.prevent="drop"
72
+ >
73
+ <span v-if="!section.rows.length">Drop fields here</span>
74
+ </div>
75
+ </div>
76
+ </div>
77
+ </div>
78
+ </div>
79
+ </template>
80
+
81
+ <script setup>
82
+ import { MAX_LAYOUT_ROW_FIELDS, fieldTab } from "#nuxt-cms";
83
+ import { ref } from "#imports";
84
+ import { fieldIcon } from "../../utils/ui";
85
+ const AREAS = [{ id: "main", empty: "Drop fields here" }];
86
+ const props = defineProps({
87
+ fields: { type: Object, required: true },
88
+ tabs: { type: Array, required: false }
89
+ });
90
+ const model = defineModel({ type: Object, ...{ required: true } });
91
+ const dragging = ref(null);
92
+ const target = ref(null);
93
+ const blocked = ref(null);
94
+ function iconOf(key) {
95
+ const field = props.fields[key];
96
+ return field ? fieldIcon(field) : "bars-3-bottom-left";
97
+ }
98
+ function tabLabel(key) {
99
+ const field = props.fields[key];
100
+ if (!field || !props.tabs?.length) return "";
101
+ const id = fieldTab(field, props.tabs);
102
+ return props.tabs.find((tab) => tab.id === id)?.label ?? "";
103
+ }
104
+ function gapTarget(area, section, row) {
105
+ return { type: "gap", area, section, row };
106
+ }
107
+ function sameTarget(a, b) {
108
+ return !!a && JSON.stringify(a) === JSON.stringify(b);
109
+ }
110
+ function isOver(candidate) {
111
+ return sameTarget(target.value, candidate);
112
+ }
113
+ function setTarget(candidate) {
114
+ blocked.value = null;
115
+ if (!sameTarget(target.value, candidate)) target.value = candidate;
116
+ }
117
+ function onDragStart(event, key) {
118
+ dragging.value = key;
119
+ event.dataTransfer?.setData("text/plain", key);
120
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = "move";
121
+ }
122
+ function onChipOver(event, key, row) {
123
+ if (!dragging.value || key === dragging.value) {
124
+ target.value = null;
125
+ return;
126
+ }
127
+ const full = row.length >= MAX_LAYOUT_ROW_FIELDS && !row.includes(dragging.value);
128
+ if (full) {
129
+ target.value = null;
130
+ blocked.value = key;
131
+ return;
132
+ }
133
+ const box = event.currentTarget.getBoundingClientRect();
134
+ setTarget({ type: "inline", key, after: event.clientX > box.left + box.width / 2 });
135
+ }
136
+ function onDragEnd() {
137
+ dragging.value = null;
138
+ target.value = null;
139
+ blocked.value = null;
140
+ }
141
+ function cloneLayout(layout) {
142
+ const copy = (sections) => sections.map((section) => ({ ...section, rows: section.rows.map((row) => [...row]) }));
143
+ return { main: copy(layout.main) };
144
+ }
145
+ function drop() {
146
+ const key = dragging.value;
147
+ const to = target.value;
148
+ onDragEnd();
149
+ if (!key || !to) return;
150
+ const next = cloneLayout(model.value);
151
+ const sections = next.main;
152
+ const anchorRow = to.type === "gap" ? next[to.area][to.section]?.rows[to.row] ?? null : null;
153
+ for (const section of sections) {
154
+ for (const row of section.rows) {
155
+ const index = row.indexOf(key);
156
+ if (index !== -1) row.splice(index, 1);
157
+ }
158
+ }
159
+ if (to.type === "inline") {
160
+ const row = sections.flatMap((section) => section.rows).find((r) => r.includes(to.key));
161
+ if (!row) return;
162
+ row.splice(row.indexOf(to.key) + (to.after ? 1 : 0), 0, key);
163
+ } else if (to.type === "gap") {
164
+ const section = next[to.area][to.section];
165
+ if (!section) return;
166
+ const index = anchorRow ? section.rows.indexOf(anchorRow) : section.rows.length;
167
+ section.rows.splice(index === -1 ? section.rows.length : index, 0, [key]);
168
+ } else {
169
+ next[to.area] = [...next[to.area].filter((s) => s.title), { rows: [[key]] }];
170
+ }
171
+ for (const section of sections) {
172
+ section.rows = section.rows.filter((row) => row.length);
173
+ }
174
+ next.main = next.main.filter((section) => section.title || section.rows.length);
175
+ model.value = next;
176
+ }
177
+ </script>
@@ -0,0 +1,16 @@
1
+ import type { CmsFormLayout, CmsTab, FieldConfig } from '#nuxt-cms';
2
+ type __VLS_Props = {
3
+ fields: Record<string, FieldConfig>;
4
+ tabs?: CmsTab[];
5
+ };
6
+ type __VLS_ModelProps = {
7
+ modelValue: CmsFormLayout;
8
+ };
9
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
10
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
11
+ "update:modelValue": (value: CmsFormLayout) => any;
12
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
13
+ "onUpdate:modelValue"?: ((value: CmsFormLayout) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -0,0 +1,16 @@
1
+ import { type CmsTheme } from '../../composables/cms-theme.js';
2
+ type __VLS_ModelProps = {
3
+ 'open'?: boolean;
4
+ 'theme': CmsTheme;
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ logout: () => any;
8
+ "update:open": (value: boolean) => any;
9
+ "update:theme": (value: CmsTheme) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
11
+ onLogout?: (() => any) | undefined;
12
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
13
+ "onUpdate:theme"?: ((value: CmsTheme) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -0,0 +1,179 @@
1
+ <template>
2
+ <Teleport to="body">
3
+ <Transition name="cms-modal">
4
+ <div v-if="open" class="cms-scope cms-overlay" @click.self="close">
5
+ <div
6
+ class="cms-modal cms-settings"
7
+ role="dialog"
8
+ aria-modal="true"
9
+ aria-label="Settings"
10
+ >
11
+ <aside class="cms-settings-nav">
12
+ <CmsButton
13
+ icon="x-mark"
14
+ variant="ghost"
15
+ color="neutral"
16
+ size="sm"
17
+ aria-label="Close settings"
18
+ class="cms-settings-close"
19
+ @click="close"
20
+ />
21
+ <div class="cms-settings-nav-list">
22
+ <button
23
+ type="button"
24
+ class="cms-settings-tab"
25
+ :class="{ 'is-active': active === 'general' }"
26
+ @click="active = 'general'"
27
+ >
28
+ <CmsIcon name="cog-6-tooth" class="size-4 shrink-0" />
29
+ General
30
+ </button>
31
+ <button
32
+ type="button"
33
+ class="cms-settings-tab"
34
+ :class="{ 'is-active': active === 'account' }"
35
+ @click="active = 'account'"
36
+ >
37
+ <CmsIcon name="user-circle" class="size-4 shrink-0" />
38
+ Account
39
+ </button>
40
+ <button
41
+ v-if="isSuperAdmin"
42
+ type="button"
43
+ class="cms-settings-tab"
44
+ :class="{ 'is-active': active === 'users' }"
45
+ @click="active = 'users'"
46
+ >
47
+ <CmsIcon name="users" class="size-4 shrink-0" />
48
+ Users
49
+ </button>
50
+ <p class="cms-settings-nav-label">Content</p>
51
+ <button
52
+ v-for="item in entries"
53
+ :key="item.name"
54
+ type="button"
55
+ class="cms-settings-tab"
56
+ :class="{ 'is-active': active === item.key }"
57
+ @click="active = item.key"
58
+ >
59
+ <CmsIcon :name="item.icon" class="size-4 shrink-0" />
60
+ <span class="truncate">{{ item.label }}</span>
61
+ </button>
62
+ </div>
63
+ <hr class="cms-settings-divider" />
64
+ <button type="button" class="cms-settings-tab is-danger" @click="emit('logout')">
65
+ <CmsIcon name="arrow-right-start-on-rectangle" class="size-4 shrink-0" />
66
+ Sign out
67
+ </button>
68
+ </aside>
69
+
70
+ <section v-if="active === 'account'" class="cms-settings-content">
71
+ <CmsSettingsAccount />
72
+ </section>
73
+ <section v-else-if="active === 'users'" class="cms-settings-content">
74
+ <CmsSettingsUsers />
75
+ </section>
76
+ <section v-else-if="activeEntry" class="cms-settings-content">
77
+ <CmsSettingsEntry :key="activeEntry" :name="activeEntry" />
78
+ </section>
79
+ <section v-else class="cms-settings-content">
80
+ <h2 class="cms-title cms-title-sm">General</h2>
81
+
82
+ <div class="cms-settings-group">
83
+ <div class="cms-settings-heading">
84
+ <p class="cms-settings-label">Theme</p>
85
+ <p class="cms-settings-hint">
86
+ Choose how the panel looks. System follows your device.
87
+ </p>
88
+ </div>
89
+ <div class="cms-theme-cards" role="radiogroup" aria-label="Theme">
90
+ <button
91
+ v-for="option in CMS_THEMES"
92
+ :key="option.value"
93
+ type="button"
94
+ role="radio"
95
+ :aria-checked="theme === option.value"
96
+ class="cms-theme-card"
97
+ :class="{ 'is-active': theme === option.value }"
98
+ @click="selectTheme(option.value)"
99
+ >
100
+ <span class="cms-theme-preview" :class="`is-${option.value}`">
101
+ <span
102
+ v-for="half in previewHalves(option.value)"
103
+ :key="half"
104
+ class="cms-theme-preview-app"
105
+ :class="`is-${half}`"
106
+ >
107
+ <span class="cms-theme-preview-side" />
108
+ <span class="cms-theme-preview-panel">
109
+ <span class="cms-theme-preview-line is-accent" />
110
+ <span class="cms-theme-preview-line" />
111
+ <span class="cms-theme-preview-line is-short" />
112
+ </span>
113
+ </span>
114
+ </span>
115
+ <span class="cms-theme-card-label">
116
+ <CmsIcon :name="option.icon" class="size-3.5" />
117
+ {{ option.label }}
118
+ <CmsIcon
119
+ v-if="theme === option.value"
120
+ name="check"
121
+ class="cms-theme-card-check size-3.5"
122
+ />
123
+ </span>
124
+ </button>
125
+ </div>
126
+ </div>
127
+ </section>
128
+ </div>
129
+ </div>
130
+ </Transition>
131
+ </Teleport>
132
+ </template>
133
+
134
+ <script setup>
135
+ import { computed, onBeforeUnmount, ref, watch } from "#imports";
136
+ import cmsConfig from "#cms-config";
137
+ import { CMS_THEMES, setCmsTheme } from "../../composables/cms-theme";
138
+ import { useCmsAccount } from "../../composables/cms-account";
139
+ const { isSuperAdmin } = useCmsAccount();
140
+ const open = defineModel("open", { type: Boolean, ...{ default: false } });
141
+ const theme = defineModel("theme", { type: String, ...{ required: true } });
142
+ const emit = defineEmits(["logout"]);
143
+ const KIND_ICONS = {
144
+ collection: "square-3-stack-3d",
145
+ single: "document-text",
146
+ page: "window"
147
+ };
148
+ const ENTRY_PREFIX = "entry:";
149
+ const entries = Object.entries(cmsConfig).map(([name, entry]) => ({
150
+ name,
151
+ key: `${ENTRY_PREFIX}${name}`,
152
+ label: entry.label,
153
+ icon: entry.icon ?? KIND_ICONS[entry.kind]
154
+ }));
155
+ const active = ref("general");
156
+ const activeEntry = computed(
157
+ () => active.value.startsWith(ENTRY_PREFIX) ? active.value.slice(ENTRY_PREFIX.length) : null
158
+ );
159
+ function selectTheme(value) {
160
+ setCmsTheme(theme, value);
161
+ }
162
+ function previewHalves(value) {
163
+ return value === "system" ? ["light", "dark"] : [value];
164
+ }
165
+ function close() {
166
+ open.value = false;
167
+ }
168
+ function onKeydown(event) {
169
+ if (event.key === "Escape") close();
170
+ }
171
+ watch(open, (value) => {
172
+ if (import.meta.server) return;
173
+ if (value) document.addEventListener("keydown", onKeydown);
174
+ else document.removeEventListener("keydown", onKeydown);
175
+ });
176
+ onBeforeUnmount(() => {
177
+ if (import.meta.client) document.removeEventListener("keydown", onKeydown);
178
+ });
179
+ </script>
@@ -0,0 +1,16 @@
1
+ import { type CmsTheme } from '../../composables/cms-theme.js';
2
+ type __VLS_ModelProps = {
3
+ 'open'?: boolean;
4
+ 'theme': CmsTheme;
5
+ };
6
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_ModelProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ logout: () => any;
8
+ "update:open": (value: boolean) => any;
9
+ "update:theme": (value: CmsTheme) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_ModelProps> & Readonly<{
11
+ onLogout?: (() => any) | undefined;
12
+ "onUpdate:open"?: ((value: boolean) => any) | undefined;
13
+ "onUpdate:theme"?: ((value: CmsTheme) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ 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;
@@ -0,0 +1,270 @@
1
+ <template>
2
+ <div class="cms-settings-entry">
3
+ <div class="cms-settings-entry-header">
4
+ <div class="cms-settings-heading">
5
+ <h2 class="cms-title cms-title-sm">Users</h2>
6
+ <p class="cms-settings-hint">
7
+ Users can do everything in the panel, except manage users.
8
+ </p>
9
+ </div>
10
+ <CmsButton
11
+ v-if="!creating"
12
+ label="Add user"
13
+ icon="user-plus"
14
+ size="sm"
15
+ @click="startCreate"
16
+ />
17
+ </div>
18
+
19
+ <div v-if="created" class="cms-credentials">
20
+ <div class="cms-settings-heading">
21
+ <p class="cms-settings-label">{{ created.title }}</p>
22
+ <p class="cms-settings-hint">
23
+ Share these credentials with the user. The password does not show again.
24
+ </p>
25
+ </div>
26
+ <dl class="cms-credentials-list">
27
+ <dt>Email</dt>
28
+ <dd>{{ created.email }}</dd>
29
+ <dt>Password</dt>
30
+ <dd class="cms-mono">{{ created.password }}</dd>
31
+ </dl>
32
+ <div class="cms-actions is-end">
33
+ <CmsButton
34
+ :label="copied ? 'Copied' : 'Copy credentials'"
35
+ :icon="copied ? 'clipboard-document-check' : 'clipboard-document'"
36
+ variant="soft"
37
+ size="sm"
38
+ @click="copyCredentials"
39
+ />
40
+ <CmsButton label="Done" size="sm" @click="created = null" />
41
+ </div>
42
+ </div>
43
+
44
+ <div v-if="creating" class="cms-credentials">
45
+ <p class="cms-settings-label">New user</p>
46
+ <CmsForm :state="draft" @submit="create">
47
+ <CmsAlert v-if="formError" color="error" :title="formError" />
48
+ <div class="cms-form-row" style="--cms-row-cols: 2">
49
+ <CmsFormField label="Email" name="email" required>
50
+ <CmsInput
51
+ v-model="draft.email"
52
+ type="email"
53
+ icon="envelope"
54
+ placeholder="name@example.com"
55
+ autocomplete="off"
56
+ />
57
+ </CmsFormField>
58
+ <CmsFormField label="Name" name="name">
59
+ <CmsInput v-model="draft.name" placeholder="Optional" autocomplete="off" />
60
+ </CmsFormField>
61
+ </div>
62
+ <CmsFormField label="Password" name="password" required>
63
+ <CmsPasswordInput v-model="draft.password" generate copy />
64
+ </CmsFormField>
65
+ <div class="cms-actions is-end">
66
+ <CmsButton
67
+ label="Cancel"
68
+ variant="ghost"
69
+ color="neutral"
70
+ size="sm"
71
+ @click="cancel"
72
+ />
73
+ <CmsButton
74
+ type="submit"
75
+ label="Create user"
76
+ size="sm"
77
+ :loading="busy"
78
+ :disabled="!draft.email || draft.password.length < MIN_PASSWORD_LENGTH"
79
+ />
80
+ </div>
81
+ </CmsForm>
82
+ </div>
83
+
84
+ <CmsSpinner v-if="loading" />
85
+
86
+ <ul v-else-if="users.length" class="cms-settings-list">
87
+ <li v-for="account in users" :key="account.id" class="cms-user-row">
88
+ <div class="cms-user-main">
89
+ <span class="cms-avatar">{{ initialOf(account.name, account.email) }}</span>
90
+ <div class="cms-account-text">
91
+ <span class="cms-account-name">{{ account.name || account.email }}</span>
92
+ <span class="cms-account-email">
93
+ {{ account.name ? `${account.email} \xB7 ` : "" }}{{ lastLogin(account) }}
94
+ </span>
95
+ </div>
96
+ <div class="cms-user-actions">
97
+ <CmsButton
98
+ icon="key"
99
+ variant="ghost"
100
+ color="neutral"
101
+ size="sm"
102
+ title="Set a new password"
103
+ aria-label="Set a new password"
104
+ @click="startReset(account)"
105
+ />
106
+ <CmsButton
107
+ icon="trash"
108
+ variant="ghost"
109
+ color="error"
110
+ size="sm"
111
+ title="Delete user"
112
+ aria-label="Delete user"
113
+ @click="remove(account)"
114
+ />
115
+ </div>
116
+ </div>
117
+ <CmsForm
118
+ v-if="resetting === account.id"
119
+ :state="reset"
120
+ class="cms-user-reset"
121
+ @submit="saveReset(account)"
122
+ >
123
+ <CmsFormField label="New password" name="password">
124
+ <CmsPasswordInput v-model="reset.password" generate copy />
125
+ </CmsFormField>
126
+ <div class="cms-actions is-end">
127
+ <CmsButton
128
+ label="Cancel"
129
+ variant="ghost"
130
+ color="neutral"
131
+ size="sm"
132
+ @click="resetting = null"
133
+ />
134
+ <CmsButton
135
+ type="submit"
136
+ label="Set password"
137
+ size="sm"
138
+ :loading="busy"
139
+ :disabled="reset.password.length < MIN_PASSWORD_LENGTH"
140
+ />
141
+ </div>
142
+ </CmsForm>
143
+ </li>
144
+ </ul>
145
+
146
+ <CmsEmptyState
147
+ v-else-if="!creating"
148
+ icon="users"
149
+ title="No users yet"
150
+ body="Add a user to give someone access to the panel."
151
+ />
152
+ </div>
153
+ </template>
154
+
155
+ <script setup>
156
+ import { MIN_PASSWORD_LENGTH } from "#nuxt-cms";
157
+ import { onMounted, ref } from "#imports";
158
+ import { generatePassword, initialOf } from "../../composables/cms-account";
159
+ import { useCmsConfirm } from "../../composables/cms-confirm";
160
+ import { useCmsToast } from "../../composables/cms-toast";
161
+ import { cmsApi } from "../../utils/api";
162
+ import { errorMessage } from "../../utils/ui";
163
+ const toast = useCmsToast();
164
+ const confirmAction = useCmsConfirm();
165
+ const users = ref([]);
166
+ const loading = ref(true);
167
+ const busy = ref(false);
168
+ async function load() {
169
+ try {
170
+ users.value = await cmsApi("/api/cms/users");
171
+ } catch (error) {
172
+ toast.add({ title: "Could not load users", description: errorMessage(error), color: "error" });
173
+ } finally {
174
+ loading.value = false;
175
+ }
176
+ }
177
+ onMounted(load);
178
+ function lastLogin(account) {
179
+ if (!account.lastLoginAt) return "Never signed in";
180
+ return `Last sign-in ${new Date(account.lastLoginAt).toLocaleString(void 0, {
181
+ dateStyle: "medium",
182
+ timeStyle: "short"
183
+ })}`;
184
+ }
185
+ const creating = ref(false);
186
+ const draft = ref({ email: "", name: "", password: "" });
187
+ const formError = ref(null);
188
+ const created = ref(null);
189
+ const copied = ref(false);
190
+ function startCreate() {
191
+ created.value = null;
192
+ formError.value = null;
193
+ draft.value = { email: "", name: "", password: generatePassword() };
194
+ creating.value = true;
195
+ }
196
+ function cancel() {
197
+ creating.value = false;
198
+ }
199
+ async function create() {
200
+ busy.value = true;
201
+ formError.value = null;
202
+ try {
203
+ const account = await cmsApi("/api/cms/users", {
204
+ method: "POST",
205
+ body: {
206
+ email: draft.value.email,
207
+ name: draft.value.name || null,
208
+ password: draft.value.password
209
+ }
210
+ });
211
+ users.value = [...users.value, account];
212
+ created.value = {
213
+ title: "User created",
214
+ email: account.email,
215
+ password: draft.value.password
216
+ };
217
+ copied.value = false;
218
+ creating.value = false;
219
+ } catch (error) {
220
+ formError.value = errorMessage(error) ?? "User not created.";
221
+ } finally {
222
+ busy.value = false;
223
+ }
224
+ }
225
+ async function copyCredentials() {
226
+ if (!created.value) return;
227
+ await navigator.clipboard.writeText(
228
+ `Email: ${created.value.email}
229
+ Password: ${created.value.password}`
230
+ );
231
+ copied.value = true;
232
+ }
233
+ const resetting = ref(null);
234
+ const reset = ref({ password: "" });
235
+ function startReset(account) {
236
+ reset.value = { password: generatePassword() };
237
+ resetting.value = resetting.value === account.id ? null : account.id;
238
+ }
239
+ async function saveReset(account) {
240
+ busy.value = true;
241
+ try {
242
+ const updated = await cmsApi(`/api/cms/users/${account.id}`, {
243
+ method: "PUT",
244
+ body: { password: reset.value.password }
245
+ });
246
+ users.value = users.value.map((u) => u.id === updated.id ? updated : u);
247
+ created.value = {
248
+ title: "Password changed",
249
+ email: account.email,
250
+ password: reset.value.password
251
+ };
252
+ copied.value = false;
253
+ resetting.value = null;
254
+ } catch (error) {
255
+ toast.add({ title: "Password not changed", description: errorMessage(error), color: "error" });
256
+ } finally {
257
+ busy.value = false;
258
+ }
259
+ }
260
+ async function remove(account) {
261
+ if (!await confirmAction(`Delete ${account.email}? They lose access to the panel.`)) return;
262
+ try {
263
+ await cmsApi(`/api/cms/users/${account.id}`, { method: "DELETE" });
264
+ users.value = users.value.filter((u) => u.id !== account.id);
265
+ toast.add({ title: "User deleted", color: "success" });
266
+ } catch (error) {
267
+ toast.add({ title: "User not deleted", description: errorMessage(error), color: "error" });
268
+ }
269
+ }
270
+ </script>