@xleddyl/nuxt-cms 0.1.9

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 (138) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/dist/module.d.mts +36 -0
  4. package/dist/module.json +9 -0
  5. package/dist/module.mjs +765 -0
  6. package/dist/runtime/app/components/cms/BlocksField.d.vue.ts +15 -0
  7. package/dist/runtime/app/components/cms/BlocksField.vue +110 -0
  8. package/dist/runtime/app/components/cms/BlocksField.vue.d.ts +15 -0
  9. package/dist/runtime/app/components/cms/EmptyState.d.vue.ts +18 -0
  10. package/dist/runtime/app/components/cms/EmptyState.vue +20 -0
  11. package/dist/runtime/app/components/cms/EmptyState.vue.d.ts +18 -0
  12. package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +23 -0
  13. package/dist/runtime/app/components/cms/EntryForm.vue +78 -0
  14. package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +23 -0
  15. package/dist/runtime/app/components/cms/FieldInput.d.vue.ts +16 -0
  16. package/dist/runtime/app/components/cms/FieldInput.vue +85 -0
  17. package/dist/runtime/app/components/cms/FieldInput.vue.d.ts +16 -0
  18. package/dist/runtime/app/components/cms/JsonField.d.vue.ts +10 -0
  19. package/dist/runtime/app/components/cms/JsonField.vue +51 -0
  20. package/dist/runtime/app/components/cms/JsonField.vue.d.ts +10 -0
  21. package/dist/runtime/app/components/cms/MediaField.d.vue.ts +16 -0
  22. package/dist/runtime/app/components/cms/MediaField.vue +81 -0
  23. package/dist/runtime/app/components/cms/MediaField.vue.d.ts +16 -0
  24. package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +19 -0
  25. package/dist/runtime/app/components/cms/MediaGallery.vue +309 -0
  26. package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +19 -0
  27. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +14 -0
  28. package/dist/runtime/app/components/cms/MediaUpload.vue +132 -0
  29. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +14 -0
  30. package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +19 -0
  31. package/dist/runtime/app/components/cms/PageHeader.vue +23 -0
  32. package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +19 -0
  33. package/dist/runtime/app/components/cms/RelationField.d.vue.ts +16 -0
  34. package/dist/runtime/app/components/cms/RelationField.vue +87 -0
  35. package/dist/runtime/app/components/cms/RelationField.vue.d.ts +16 -0
  36. package/dist/runtime/app/components/cms/RichTextField.d.vue.ts +10 -0
  37. package/dist/runtime/app/components/cms/RichTextField.vue +123 -0
  38. package/dist/runtime/app/components/cms/RichTextField.vue.d.ts +10 -0
  39. package/dist/runtime/app/components/cms/SlugField.d.vue.ts +14 -0
  40. package/dist/runtime/app/components/cms/SlugField.vue +44 -0
  41. package/dist/runtime/app/components/cms/SlugField.vue.d.ts +14 -0
  42. package/dist/runtime/app/components/cms/TranslatableField.d.vue.ts +15 -0
  43. package/dist/runtime/app/components/cms/TranslatableField.vue +64 -0
  44. package/dist/runtime/app/components/cms/TranslatableField.vue.d.ts +15 -0
  45. package/dist/runtime/app/composables/cms-query.d.ts +4 -0
  46. package/dist/runtime/app/composables/cms-query.js +18 -0
  47. package/dist/runtime/app/composables/cms-runtime.d.ts +5 -0
  48. package/dist/runtime/app/composables/cms-runtime.js +4 -0
  49. package/dist/runtime/app/i18n/en.json +93 -0
  50. package/dist/runtime/app/i18n/it.json +93 -0
  51. package/dist/runtime/app/layouts/cms-admin.d.vue.ts +13 -0
  52. package/dist/runtime/app/layouts/cms-admin.vue +97 -0
  53. package/dist/runtime/app/layouts/cms-admin.vue.d.ts +13 -0
  54. package/dist/runtime/app/middleware/cms-auth.d.ts +2 -0
  55. package/dist/runtime/app/middleware/cms-auth.js +7 -0
  56. package/dist/runtime/app/pages/admin-collection.d.vue.ts +3 -0
  57. package/dist/runtime/app/pages/admin-collection.vue +273 -0
  58. package/dist/runtime/app/pages/admin-collection.vue.d.ts +3 -0
  59. package/dist/runtime/app/pages/admin-entry.d.vue.ts +3 -0
  60. package/dist/runtime/app/pages/admin-entry.vue +156 -0
  61. package/dist/runtime/app/pages/admin-entry.vue.d.ts +3 -0
  62. package/dist/runtime/app/pages/admin-index.d.vue.ts +3 -0
  63. package/dist/runtime/app/pages/admin-index.vue +11 -0
  64. package/dist/runtime/app/pages/admin-index.vue.d.ts +3 -0
  65. package/dist/runtime/app/pages/admin-login.d.vue.ts +3 -0
  66. package/dist/runtime/app/pages/admin-login.vue +87 -0
  67. package/dist/runtime/app/pages/admin-login.vue.d.ts +3 -0
  68. package/dist/runtime/app/pages/admin-media.d.vue.ts +3 -0
  69. package/dist/runtime/app/pages/admin-media.vue +13 -0
  70. package/dist/runtime/app/pages/admin-media.vue.d.ts +3 -0
  71. package/dist/runtime/app/utils/api.d.ts +7 -0
  72. package/dist/runtime/app/utils/api.js +3 -0
  73. package/dist/runtime/app/utils/ui.d.ts +8 -0
  74. package/dist/runtime/app/utils/ui.js +9 -0
  75. package/dist/runtime/assets/main.css +1 -0
  76. package/dist/runtime/server/api/collection.get.d.ts +6 -0
  77. package/dist/runtime/server/api/collection.get.js +19 -0
  78. package/dist/runtime/server/api/collection.post.d.ts +2 -0
  79. package/dist/runtime/server/api/collection.post.js +30 -0
  80. package/dist/runtime/server/api/collection.put.d.ts +2 -0
  81. package/dist/runtime/server/api/collection.put.js +28 -0
  82. package/dist/runtime/server/api/item.delete.d.ts +4 -0
  83. package/dist/runtime/server/api/item.delete.js +19 -0
  84. package/dist/runtime/server/api/item.get.d.ts +2 -0
  85. package/dist/runtime/server/api/item.get.js +18 -0
  86. package/dist/runtime/server/api/item.put.d.ts +2 -0
  87. package/dist/runtime/server/api/item.put.js +40 -0
  88. package/dist/runtime/server/api/media-presign.post.d.ts +11 -0
  89. package/dist/runtime/server/api/media-presign.post.js +51 -0
  90. package/dist/runtime/server/api/media.delete.d.ts +4 -0
  91. package/dist/runtime/server/api/media.delete.js +25 -0
  92. package/dist/runtime/server/api/media.get.d.ts +5 -0
  93. package/dist/runtime/server/api/media.get.js +13 -0
  94. package/dist/runtime/server/api/media.post.d.ts +3 -0
  95. package/dist/runtime/server/api/media.post.js +32 -0
  96. package/dist/runtime/server/api/media.put.d.ts +3 -0
  97. package/dist/runtime/server/api/media.put.js +28 -0
  98. package/dist/runtime/server/plugins/migrate-postgres.d.ts +2 -0
  99. package/dist/runtime/server/plugins/migrate-postgres.js +9 -0
  100. package/dist/runtime/server/plugins/migrate-sqlite.d.ts +2 -0
  101. package/dist/runtime/server/plugins/migrate-sqlite.js +9 -0
  102. package/dist/runtime/server/plugins/session-check.d.ts +2 -0
  103. package/dist/runtime/server/plugins/session-check.js +9 -0
  104. package/dist/runtime/server/routes/auth/login.post.d.ts +4 -0
  105. package/dist/runtime/server/routes/auth/login.post.js +62 -0
  106. package/dist/runtime/server/routes/graphql.d.ts +2 -0
  107. package/dist/runtime/server/routes/graphql.js +34 -0
  108. package/dist/runtime/server/tsconfig.json +3 -0
  109. package/dist/runtime/server/utils/custom-id.d.ts +2 -0
  110. package/dist/runtime/server/utils/custom-id.js +10 -0
  111. package/dist/runtime/server/utils/db-errors.d.ts +1 -0
  112. package/dist/runtime/server/utils/db-errors.js +31 -0
  113. package/dist/runtime/server/utils/db-postgres.d.ts +3 -0
  114. package/dist/runtime/server/utils/db-postgres.js +16 -0
  115. package/dist/runtime/server/utils/db-sqlite.d.ts +4 -0
  116. package/dist/runtime/server/utils/db-sqlite.js +17 -0
  117. package/dist/runtime/server/utils/graphql-depth.d.ts +4 -0
  118. package/dist/runtime/server/utils/graphql-depth.js +72 -0
  119. package/dist/runtime/server/utils/graphql.d.ts +14 -0
  120. package/dist/runtime/server/utils/graphql.js +317 -0
  121. package/dist/runtime/server/utils/media.d.ts +24 -0
  122. package/dist/runtime/server/utils/media.js +38 -0
  123. package/dist/runtime/server/utils/registry.d.ts +20 -0
  124. package/dist/runtime/server/utils/registry.js +47 -0
  125. package/dist/runtime/server/utils/relations.d.ts +12 -0
  126. package/dist/runtime/server/utils/relations.js +80 -0
  127. package/dist/runtime/server/utils/require-admin.d.ts +2 -0
  128. package/dist/runtime/server/utils/require-admin.js +13 -0
  129. package/dist/runtime/shared/empty-config.d.ts +2 -0
  130. package/dist/runtime/shared/empty-config.js +1 -0
  131. package/dist/runtime/shared/graphql-sdl.d.ts +5 -0
  132. package/dist/runtime/shared/graphql-sdl.js +147 -0
  133. package/dist/runtime/shared/index.d.ts +62 -0
  134. package/dist/runtime/shared/index.js +55 -0
  135. package/dist/runtime/shared/validation.d.ts +16 -0
  136. package/dist/runtime/shared/validation.js +82 -0
  137. package/dist/types.d.mts +3 -0
  138. package/package.json +93 -0
@@ -0,0 +1,309 @@
1
+ <template>
2
+ <CmsEmptyState
3
+ v-if="notConfigured"
4
+ icon="i-lucide-plug-zap"
5
+ :title="t('cms.media.notConfiguredTitle')"
6
+ :body="t('cms.media.notConfiguredBody')"
7
+ />
8
+
9
+ <div v-else class="flex flex-col gap-5">
10
+ <CmsMediaUpload
11
+ :multiple="!selectable"
12
+ :dense="selectable"
13
+ :media-type="mediaType"
14
+ :accept="accept"
15
+ @uploaded="onUploaded"
16
+ />
17
+
18
+ <div v-if="!restricted && items.length" class="flex flex-wrap items-center gap-1">
19
+ <button
20
+ v-for="f in filters"
21
+ :key="f"
22
+ type="button"
23
+ class="cms-pill"
24
+ :class="{ 'is-active': filter === f }"
25
+ @click="filter = f"
26
+ >
27
+ {{ t(`cms.media.filter.${f}`) }}
28
+ </button>
29
+ <template v-if="folders.length">
30
+ <span class="bg-(--cms-line) mx-1 h-4 w-px" />
31
+ <button
32
+ v-for="f in folders"
33
+ :key="f"
34
+ type="button"
35
+ class="cms-pill"
36
+ :class="{ 'is-active': folder === f }"
37
+ @click="folder = folder === f ? null : f"
38
+ >
39
+ <UIcon name="i-lucide-folder" class="mr-1 size-3" />{{ f }}
40
+ </button>
41
+ </template>
42
+ </div>
43
+
44
+ <div v-if="loading" class="flex justify-center py-10">
45
+ <UIcon name="i-lucide-loader-circle" class="text-(--ui-text-dimmed) size-6 animate-spin" />
46
+ </div>
47
+
48
+ <div
49
+ v-else-if="visible.length"
50
+ class="grid gap-4"
51
+ :class="
52
+ selectable ? 'grid-cols-2 sm:grid-cols-3' : 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
53
+ "
54
+ >
55
+ <component
56
+ :is="selectable ? 'button' : 'div'"
57
+ v-for="item in visible"
58
+ :key="item.key"
59
+ :type="selectable ? 'button' : void 0"
60
+ class="cms-card group overflow-hidden text-left"
61
+ :class="{
62
+ 'hover:border-(--cms-fern) hover:shadow-md': selectable
63
+ }"
64
+ @click="selectable && emit('select', { key: item.key, url: item.url })"
65
+ >
66
+ <div class="bg-(--cms-field) relative flex aspect-square items-center justify-center">
67
+ <img
68
+ v-if="item.type === 'image' && item.url"
69
+ :src="item.url"
70
+ alt=""
71
+ loading="lazy"
72
+ class="absolute inset-0 size-full object-cover"
73
+ />
74
+ <video
75
+ v-else-if="item.type === 'video' && item.url"
76
+ :src="item.url"
77
+ preload="metadata"
78
+ muted
79
+ playsinline
80
+ class="absolute inset-0 size-full object-cover"
81
+ />
82
+ <UIcon
83
+ v-else
84
+ :name="mediaIconFor(item.type)"
85
+ class="text-(--ui-text-dimmed) size-8"
86
+ />
87
+ <div
88
+ v-if="!selectable"
89
+ class="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100"
90
+ >
91
+ <UButton
92
+ icon="i-lucide-pencil"
93
+ size="xs"
94
+ color="neutral"
95
+ variant="solid"
96
+ class="rounded-full shadow-sm"
97
+ :aria-label="t('cms.media.edit')"
98
+ @click="openEdit(item)"
99
+ />
100
+ <UButton
101
+ icon="i-lucide-copy"
102
+ size="xs"
103
+ color="neutral"
104
+ variant="solid"
105
+ class="rounded-full shadow-sm"
106
+ :aria-label="t('cms.media.copy')"
107
+ @click="copy(item)"
108
+ />
109
+ <UButton
110
+ icon="i-lucide-trash-2"
111
+ size="xs"
112
+ color="error"
113
+ variant="solid"
114
+ class="rounded-full shadow-sm"
115
+ :aria-label="t('cms.media.delete')"
116
+ @click="remove(item)"
117
+ />
118
+ </div>
119
+ </div>
120
+ <div class="flex flex-col gap-1 px-3.5 py-2.5">
121
+ <div class="truncate text-[13px] font-medium" :title="item.key">
122
+ {{ mediaFilename(item.key) }}
123
+ </div>
124
+ <div class="cms-kicker">
125
+ {{ extension(item.key) }} · {{ formatSize(item.size)
126
+ }}<template v-if="item.width && item.height">
127
+ · {{ item.width }}×{{ item.height }}
128
+ </template>
129
+ </div>
130
+ </div>
131
+ </component>
132
+ </div>
133
+
134
+ <CmsEmptyState
135
+ v-else-if="loadError"
136
+ icon="i-lucide-triangle-alert"
137
+ :title="t('cms.media.loadError')"
138
+ >
139
+ <UButton
140
+ :label="t('cms.media.retry')"
141
+ icon="i-lucide-refresh-cw"
142
+ variant="subtle"
143
+ class="mt-3 rounded-full px-4"
144
+ @click="reload"
145
+ />
146
+ </CmsEmptyState>
147
+
148
+ <CmsEmptyState
149
+ v-else
150
+ icon="i-lucide-image"
151
+ :title="t('cms.media.emptyTitle')"
152
+ :body="t('cms.media.emptyBody')"
153
+ />
154
+
155
+ <UModal
156
+ :open="!!editing"
157
+ :title="editing ? mediaFilename(editing.key) : ''"
158
+ :ui="CMS_MODAL_UI"
159
+ @update:open="
160
+ (open) => {
161
+ if (!open) editing = null;
162
+ }
163
+ "
164
+ >
165
+ <template #body>
166
+ <div class="flex flex-col gap-4">
167
+ <UFormField :label="t('cms.media.alt')">
168
+ <UInput v-model="editAlt" size="lg" class="w-full" />
169
+ </UFormField>
170
+ <UFormField :label="t('cms.media.folder')">
171
+ <UInput v-model="editFolder" size="lg" class="w-full" />
172
+ </UFormField>
173
+ <div class="flex justify-end">
174
+ <UButton
175
+ :label="t('cms.form.save')"
176
+ :loading="editSaving"
177
+ class="rounded-full px-6"
178
+ @click="saveEdit"
179
+ />
180
+ </div>
181
+ </div>
182
+ </template>
183
+ </UModal>
184
+ </div>
185
+ </template>
186
+
187
+ <script setup>
188
+ import { computed, onMounted, ref, useI18n, useToast } from "#imports";
189
+ import { MEDIA_TYPES, mediaFilename, mediaIconFor } from "#nuxt-cms";
190
+ import { CMS_MODAL_UI, errorMessage } from "../../utils/ui";
191
+ const props = defineProps({
192
+ selectable: { type: Boolean, required: false },
193
+ mediaType: { type: null, required: false },
194
+ accept: { type: Array, required: false }
195
+ });
196
+ const emit = defineEmits(["select"]);
197
+ const { t } = useI18n();
198
+ const toast = useToast();
199
+ const endpoint = "/api/cms/admin/media";
200
+ const items = ref([]);
201
+ const loading = ref(true);
202
+ const errorCode = ref(null);
203
+ async function reload() {
204
+ loading.value = true;
205
+ errorCode.value = null;
206
+ try {
207
+ const page = await $fetch(endpoint);
208
+ items.value = page.items;
209
+ } catch (err) {
210
+ errorCode.value = err.statusCode ?? 500;
211
+ } finally {
212
+ loading.value = false;
213
+ }
214
+ }
215
+ onMounted(reload);
216
+ const notConfigured = computed(() => errorCode.value === 501);
217
+ const loadError = computed(() => errorCode.value !== null && errorCode.value !== 501);
218
+ const restricted = computed(
219
+ () => props.mediaType && props.mediaType !== "file" ? props.mediaType : null
220
+ );
221
+ const filters = ["all", ...MEDIA_TYPES];
222
+ const filter = ref("all");
223
+ const folders = computed(() => {
224
+ const set = new Set(items.value.map((item) => item.folder).filter((f) => !!f));
225
+ return [...set].sort();
226
+ });
227
+ const folder = ref(null);
228
+ const visible = computed(() => {
229
+ const active = restricted.value ?? (filter.value === "all" ? null : filter.value);
230
+ let list = active ? items.value.filter((item) => item.type === active) : items.value;
231
+ if (folder.value) list = list.filter((item) => item.folder === folder.value);
232
+ return list;
233
+ });
234
+ function onUploaded(uploaded) {
235
+ if (props.selectable && uploaded[0]) {
236
+ emit("select", uploaded[0]);
237
+ return;
238
+ }
239
+ void reload();
240
+ }
241
+ const editing = ref(null);
242
+ const editAlt = ref("");
243
+ const editFolder = ref("");
244
+ const editSaving = ref(false);
245
+ function openEdit(item) {
246
+ editing.value = item;
247
+ editAlt.value = item.alt ?? "";
248
+ editFolder.value = item.folder ?? "";
249
+ }
250
+ async function saveEdit() {
251
+ if (!editing.value) return;
252
+ editSaving.value = true;
253
+ try {
254
+ await $fetch(endpoint, {
255
+ method: "PUT",
256
+ body: {
257
+ id: editing.value.id,
258
+ alt: editAlt.value.trim() || null,
259
+ folder: editFolder.value.trim() || null
260
+ }
261
+ });
262
+ editing.value = null;
263
+ await reload();
264
+ } catch (err) {
265
+ toast.add({
266
+ title: t("cms.toast.saveFailed"),
267
+ description: errorMessage(err),
268
+ color: "error"
269
+ });
270
+ } finally {
271
+ editSaving.value = false;
272
+ }
273
+ }
274
+ function extension(key) {
275
+ const dot = key.lastIndexOf(".");
276
+ return dot === -1 ? "file" : key.slice(dot + 1).toLowerCase();
277
+ }
278
+ function formatSize(bytes) {
279
+ if (bytes == null) return "\u2014";
280
+ if (bytes < 1024) return `${bytes} B`;
281
+ let value = bytes;
282
+ for (const unit of ["KB", "MB", "GB"]) {
283
+ value /= 1024;
284
+ if (value < 1024) return `${value < 10 ? value.toFixed(1) : Math.round(value)} ${unit}`;
285
+ }
286
+ return `${Math.round(value / 1024)} TB`;
287
+ }
288
+ async function copy(item) {
289
+ try {
290
+ await navigator.clipboard.writeText(item.url ?? item.key);
291
+ toast.add({ title: t("cms.media.copied"), color: "success" });
292
+ } catch {
293
+ toast.add({ title: t("cms.media.copyError"), color: "error" });
294
+ }
295
+ }
296
+ async function remove(item) {
297
+ if (!confirm(t("cms.media.deleteConfirm"))) return;
298
+ try {
299
+ await $fetch(endpoint, { method: "DELETE", query: { key: item.key } });
300
+ await reload();
301
+ } catch (err) {
302
+ toast.add({
303
+ title: t("cms.media.deleteFailed"),
304
+ description: errorMessage(err),
305
+ color: "error"
306
+ });
307
+ }
308
+ }
309
+ </script>
@@ -0,0 +1,19 @@
1
+ import type { MediaType } from '#nuxt-cms';
2
+ type __VLS_Props = {
3
+ selectable?: boolean;
4
+ mediaType?: MediaType;
5
+ accept?: string[];
6
+ };
7
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
+ select: (item: {
9
+ key: string;
10
+ url: string | null;
11
+ }) => any;
12
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
13
+ onSelect?: ((item: {
14
+ key: string;
15
+ url: string | null;
16
+ }) => any) | undefined;
17
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
+ declare const _default: typeof __VLS_export;
19
+ export default _default;
@@ -0,0 +1,14 @@
1
+ import type { MediaItem, MediaType } from '#nuxt-cms';
2
+ type __VLS_Props = {
3
+ multiple?: boolean;
4
+ mediaType?: MediaType;
5
+ accept?: string[];
6
+ dense?: boolean;
7
+ };
8
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ uploaded: (items: MediaItem[]) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
11
+ onUploaded?: ((items: MediaItem[]) => any) | undefined;
12
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ declare const _default: typeof __VLS_export;
14
+ export default _default;
@@ -0,0 +1,132 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="cms-dropzone"
5
+ :class="{ 'is-dense': dense, 'is-over': dragOver, 'is-busy': uploading }"
6
+ :disabled="uploading"
7
+ @click="input?.click()"
8
+ @dragover.prevent="dragOver = true"
9
+ @dragleave.prevent="dragOver = false"
10
+ @drop.prevent="onDrop"
11
+ >
12
+ <input
13
+ ref="input"
14
+ type="file"
15
+ class="hidden"
16
+ :accept="acceptAttr"
17
+ :multiple="multiple"
18
+ @change="onChange"
19
+ @click.stop
20
+ />
21
+ <UIcon
22
+ :name="uploading ? 'i-lucide-loader-circle' : 'i-lucide-upload-cloud'"
23
+ class="size-5 shrink-0"
24
+ :class="{ 'animate-spin': uploading }"
25
+ />
26
+ <span class="text-[13px] font-medium">
27
+ {{
28
+ uploading ? t("cms.media.uploading", { done: Math.min(done + 1, total), total }) : t(multiple ? "cms.media.dropHint" : "cms.media.dropHintSingle")
29
+ }}
30
+ </span>
31
+ </button>
32
+ </template>
33
+
34
+ <script setup>
35
+ import { computed, ref, useI18n, useToast } from "#imports";
36
+ const props = defineProps({
37
+ multiple: { type: Boolean, required: false },
38
+ mediaType: { type: null, required: false, default: "file" },
39
+ accept: { type: Array, required: false },
40
+ dense: { type: Boolean, required: false }
41
+ });
42
+ const emit = defineEmits(["uploaded"]);
43
+ const { t } = useI18n();
44
+ const toast = useToast();
45
+ async function imageDimensions(file) {
46
+ if (!file.type.startsWith("image/")) return {};
47
+ try {
48
+ const bitmap = await createImageBitmap(file);
49
+ const dims = { width: bitmap.width, height: bitmap.height };
50
+ bitmap.close();
51
+ return dims;
52
+ } catch {
53
+ return {};
54
+ }
55
+ }
56
+ const input = ref(null);
57
+ const dragOver = ref(false);
58
+ const uploading = ref(false);
59
+ const done = ref(0);
60
+ const total = ref(0);
61
+ const acceptAttr = computed(
62
+ () => props.accept?.length ? props.accept.join(",") : props.mediaType === "image" ? "image/*" : props.mediaType === "video" ? "video/*" : void 0
63
+ );
64
+ function matchesAccept(file) {
65
+ if (!acceptAttr.value) return true;
66
+ return acceptAttr.value.split(",").some((raw) => {
67
+ const pattern = raw.trim().toLowerCase();
68
+ if (pattern.endsWith("/*")) return file.type.toLowerCase().startsWith(pattern.slice(0, -1));
69
+ if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern);
70
+ return file.type.toLowerCase() === pattern;
71
+ });
72
+ }
73
+ const MAX_FILE_SIZE = 10 * 1024 * 1024;
74
+ async function uploadOne(file) {
75
+ const presign = await $fetch("/api/cms/admin/media/presign", {
76
+ method: "POST",
77
+ body: {
78
+ filename: file.name,
79
+ contentType: file.type || "application/octet-stream",
80
+ size: file.size
81
+ }
82
+ });
83
+ const res = await fetch(presign.uploadUrl, {
84
+ method: "PUT",
85
+ headers: presign.headers,
86
+ body: file
87
+ });
88
+ if (!res.ok) throw new Error(`Upload failed (${res.status})`);
89
+ const item = await $fetch("/api/cms/admin/media", {
90
+ method: "POST",
91
+ body: {
92
+ key: presign.key,
93
+ mime: file.type || null,
94
+ size: file.size,
95
+ ...await imageDimensions(file)
96
+ }
97
+ });
98
+ done.value++;
99
+ return item;
100
+ }
101
+ async function handleFiles(list) {
102
+ let files = Array.from(list).filter(matchesAccept);
103
+ const tooLarge = files.filter((file) => file.size > MAX_FILE_SIZE);
104
+ for (const file of tooLarge) {
105
+ toast.add({ title: t("cms.media.tooLarge", { name: file.name }), color: "error" });
106
+ }
107
+ files = files.filter((file) => file.size <= MAX_FILE_SIZE);
108
+ if (!props.multiple) files = files.slice(0, 1);
109
+ if (!files.length || uploading.value) return;
110
+ uploading.value = true;
111
+ done.value = 0;
112
+ total.value = files.length;
113
+ try {
114
+ const results = await Promise.allSettled(files.map(uploadOne));
115
+ const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
116
+ const failed = results.length - ok.length;
117
+ if (failed) toast.add({ title: t("cms.media.uploadFailed", failed), color: "error" });
118
+ if (ok.length) emit("uploaded", ok);
119
+ } finally {
120
+ uploading.value = false;
121
+ }
122
+ }
123
+ function onChange(event) {
124
+ const el = event.target;
125
+ if (el.files?.length) void handleFiles(el.files);
126
+ el.value = "";
127
+ }
128
+ function onDrop(event) {
129
+ dragOver.value = false;
130
+ if (event.dataTransfer?.files.length) void handleFiles(event.dataTransfer.files);
131
+ }
132
+ </script>
@@ -0,0 +1,14 @@
1
+ import type { MediaItem, MediaType } from '#nuxt-cms';
2
+ type __VLS_Props = {
3
+ multiple?: boolean;
4
+ mediaType?: MediaType;
5
+ accept?: string[];
6
+ dense?: boolean;
7
+ };
8
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ uploaded: (items: MediaItem[]) => any;
10
+ }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
11
+ onUploaded?: ((items: MediaItem[]) => any) | undefined;
12
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
13
+ declare const _default: typeof __VLS_export;
14
+ export default _default;
@@ -0,0 +1,19 @@
1
+ type __VLS_Props = {
2
+ kicker: string;
3
+ title: string;
4
+ };
5
+ declare var __VLS_1: {}, __VLS_3: {};
6
+ type __VLS_Slots = {} & {
7
+ badge?: (props: typeof __VLS_1) => any;
8
+ } & {
9
+ default?: (props: typeof __VLS_3) => any;
10
+ };
11
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
13
+ declare const _default: typeof __VLS_export;
14
+ export default _default;
15
+ type __VLS_WithSlots<T, S> = T & {
16
+ new (): {
17
+ $slots: S;
18
+ };
19
+ };
@@ -0,0 +1,23 @@
1
+ <template>
2
+ <header class="flex items-end justify-between gap-4">
3
+ <div class="flex flex-col gap-2">
4
+ <div class="cms-kicker">
5
+ {{ kicker }}
6
+ </div>
7
+ <div class="flex items-center gap-3">
8
+ <h1 class="cms-display text-(--ui-text-highlighted) text-[32px]/none font-medium">
9
+ {{ title }}
10
+ </h1>
11
+ <slot name="badge" />
12
+ </div>
13
+ </div>
14
+ <slot />
15
+ </header>
16
+ </template>
17
+
18
+ <script setup>
19
+ defineProps({
20
+ kicker: { type: String, required: true },
21
+ title: { type: String, required: true }
22
+ });
23
+ </script>
@@ -0,0 +1,19 @@
1
+ type __VLS_Props = {
2
+ kicker: string;
3
+ title: string;
4
+ };
5
+ declare var __VLS_1: {}, __VLS_3: {};
6
+ type __VLS_Slots = {} & {
7
+ badge?: (props: typeof __VLS_1) => any;
8
+ } & {
9
+ default?: (props: typeof __VLS_3) => any;
10
+ };
11
+ declare const __VLS_base: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
12
+ declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
13
+ declare const _default: typeof __VLS_export;
14
+ export default _default;
15
+ type __VLS_WithSlots<T, S> = T & {
16
+ new (): {
17
+ $slots: S;
18
+ };
19
+ };
@@ -0,0 +1,16 @@
1
+ type __VLS_Props = {
2
+ to: string;
3
+ required?: boolean;
4
+ multiple?: boolean;
5
+ };
6
+ type __VLS_ModelProps = {
7
+ modelValue: string | string[] | null;
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: string | string[] | null) => any;
12
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
13
+ "onUpdate:modelValue"?: ((value: string | string[] | null) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -0,0 +1,87 @@
1
+ <template>
2
+ <USelectMenu
3
+ v-if="multiple"
4
+ v-model="many"
5
+ multiple
6
+ :items="options"
7
+ value-key="value"
8
+ :loading="loading"
9
+ size="lg"
10
+ class="w-full"
11
+ />
12
+ <USelectMenu
13
+ v-else
14
+ v-model="single"
15
+ :items="singleItems"
16
+ value-key="value"
17
+ :loading="loading"
18
+ size="lg"
19
+ class="w-full"
20
+ />
21
+ </template>
22
+
23
+ <script setup>
24
+ import { isTranslatableField } from "#nuxt-cms";
25
+ import { computed, onMounted, ref, useI18n, useToast } from "#imports";
26
+ import cmsConfig from "#cms-config";
27
+ import { useCmsRuntime } from "../../composables/cms-runtime";
28
+ import { errorMessage } from "../../utils/ui";
29
+ const props = defineProps({
30
+ to: { type: String, required: true },
31
+ required: { type: Boolean, required: false },
32
+ multiple: { type: Boolean, required: false }
33
+ });
34
+ const model = defineModel({ type: [String, Array, null], ...{ required: true } });
35
+ const { t } = useI18n();
36
+ const toast = useToast();
37
+ const target = cmsConfig[props.to];
38
+ const rows = ref([]);
39
+ const loading = ref(true);
40
+ onMounted(async () => {
41
+ try {
42
+ rows.value = await $fetch(`/api/cms/admin/${props.to}`);
43
+ } catch (err) {
44
+ toast.add({
45
+ title: t("cms.toast.loadFailed"),
46
+ description: errorMessage(err),
47
+ color: "error"
48
+ });
49
+ } finally {
50
+ loading.value = false;
51
+ }
52
+ });
53
+ const { i18n: contentI18n } = useCmsRuntime();
54
+ const titleField = target?.titleField ?? "id";
55
+ const titleFieldConfig = target?.fields[titleField];
56
+ function optionLabel(row) {
57
+ const value = row[titleField];
58
+ if (value != null && titleFieldConfig && isTranslatableField(titleFieldConfig)) {
59
+ const record = value;
60
+ const raw = record[contentI18n.defaultLocale] ?? Object.values(record)[0] ?? "";
61
+ return raw || `#${row.id}`;
62
+ }
63
+ return String(value ?? `#${row.id}`);
64
+ }
65
+ const options = computed(
66
+ () => rows.value.map((row) => ({
67
+ label: optionLabel(row),
68
+ value: row.id
69
+ }))
70
+ );
71
+ const singleItems = computed(() => [
72
+ ...props.required ? [] : [{ label: "\u2014", value: null }],
73
+ ...options.value
74
+ ]);
75
+ const single = computed({
76
+ get: () => model.value ?? null,
77
+ set: (value) => {
78
+ model.value = value;
79
+ }
80
+ });
81
+ const many = computed({
82
+ get: () => model.value ?? [],
83
+ set: (value) => {
84
+ model.value = value;
85
+ }
86
+ });
87
+ </script>
@@ -0,0 +1,16 @@
1
+ type __VLS_Props = {
2
+ to: string;
3
+ required?: boolean;
4
+ multiple?: boolean;
5
+ };
6
+ type __VLS_ModelProps = {
7
+ modelValue: string | string[] | null;
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: string | string[] | null) => any;
12
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
13
+ "onUpdate:modelValue"?: ((value: string | string[] | null) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;