@xleddyl/nuxt-cms 0.1.26 → 0.1.28

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 (42) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +5 -1
  3. package/dist/runtime/app/components/cms/Alert.d.vue.ts +1 -2
  4. package/dist/runtime/app/components/cms/Alert.vue +2 -3
  5. package/dist/runtime/app/components/cms/Alert.vue.d.ts +1 -2
  6. package/dist/runtime/app/components/cms/BlocksField.vue +2 -2
  7. package/dist/runtime/app/components/cms/Button.d.vue.ts +1 -1
  8. package/dist/runtime/app/components/cms/Button.vue.d.ts +1 -1
  9. package/dist/runtime/app/components/cms/ConfirmModal.vue +1 -1
  10. package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -1
  11. package/dist/runtime/app/components/cms/MediaField.vue +20 -18
  12. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +16 -0
  13. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +65 -0
  14. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +16 -0
  15. package/dist/runtime/app/components/cms/MediaGallery.vue +213 -49
  16. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -1
  17. package/dist/runtime/app/components/cms/MediaUpload.vue +9 -3
  18. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -1
  19. package/dist/runtime/app/components/cms/Modal.d.vue.ts +4 -4
  20. package/dist/runtime/app/components/cms/Modal.vue +4 -4
  21. package/dist/runtime/app/components/cms/Modal.vue.d.ts +4 -4
  22. package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +0 -1
  23. package/dist/runtime/app/components/cms/PageHeader.vue +0 -4
  24. package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +0 -1
  25. package/dist/runtime/app/components/cms/RichTextField.vue +1 -1
  26. package/dist/runtime/app/components/cms/Table.d.vue.ts +10 -2
  27. package/dist/runtime/app/components/cms/Table.vue +30 -2
  28. package/dist/runtime/app/components/cms/Table.vue.d.ts +10 -2
  29. package/dist/runtime/app/components/cms/Toaster.vue +6 -0
  30. package/dist/runtime/app/layouts/cms-admin.vue +2 -2
  31. package/dist/runtime/app/pages/admin-collection.vue +18 -8
  32. package/dist/runtime/app/pages/admin-entry.vue +2 -2
  33. package/dist/runtime/app/pages/admin-login.vue +5 -5
  34. package/dist/runtime/assets/main.css +1 -1
  35. package/dist/runtime/server/api/collection.get.js +14 -4
  36. package/dist/runtime/server/api/media-presign.post.d.ts +1 -0
  37. package/dist/runtime/server/api/media-presign.post.js +7 -4
  38. package/dist/runtime/server/api/media.post.js +2 -1
  39. package/dist/runtime/server/api/media.put.js +2 -1
  40. package/dist/runtime/shared/index.d.ts +2 -0
  41. package/dist/runtime/shared/index.js +6 -0
  42. package/package.json +4 -2
package/dist/module.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
3
  "configKey": "cms",
4
- "version": "0.1.26",
4
+ "version": "0.1.28",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -702,7 +702,11 @@ const module$1 = defineNuxtModule({
702
702
  );
703
703
  addVitePlugin(tailwindcss());
704
704
  addVitePlugin(svgLoader({ defaultImport: "url", svgoConfig: { plugins: ["prefixIds"] } }));
705
- nuxt.options.css.push(resolver.resolve("./runtime/assets/main.css"));
705
+ nuxt.options.css.push(
706
+ resolveImport("@fontsource-variable/hanken-grotesk/index.css"),
707
+ resolveImport("@fontsource/fragment-mono/index.css"),
708
+ resolver.resolve("./runtime/assets/main.css")
709
+ );
706
710
  nuxt.hook("app:templates", (app) => {
707
711
  app.layouts["cms-admin"] = {
708
712
  name: "cms-admin",
@@ -1,6 +1,5 @@
1
1
  type __VLS_Props = {
2
- color?: 'error';
3
- variant?: string;
2
+ color?: 'error' | 'success' | 'warning' | 'neutral';
4
3
  title?: string;
5
4
  };
6
5
  declare var __VLS_1: {};
@@ -1,6 +1,6 @@
1
1
  <template>
2
- <div class="cms-alert" :class="`cms-alert-${color ?? 'error'}`">
3
- <p v-if="title" class="font-medium">{{ title }}</p>
2
+ <div class="cms-alert" :class="`cms-alert-${color ?? 'neutral'}`" role="alert">
3
+ <p v-if="title" class="cms-alert-title">{{ title }}</p>
4
4
  <slot />
5
5
  </div>
6
6
  </template>
@@ -8,7 +8,6 @@
8
8
  <script setup>
9
9
  defineProps({
10
10
  color: { type: String, required: false },
11
- variant: { type: String, required: false },
12
11
  title: { type: String, required: false }
13
12
  });
14
13
  </script>
@@ -1,6 +1,5 @@
1
1
  type __VLS_Props = {
2
- color?: 'error';
3
- variant?: string;
2
+ color?: 'error' | 'success' | 'warning' | 'neutral';
4
3
  title?: string;
5
4
  };
6
5
  declare var __VLS_1: {};
@@ -8,7 +8,7 @@
8
8
  <div class="flex items-center gap-1">
9
9
  <button
10
10
  type="button"
11
- class="cms-kicker flex grow items-center gap-1.5 text-left"
11
+ class="cms-block-label flex grow items-center gap-1.5 text-left"
12
12
  :aria-label="isCollapsed(item) ? 'Expand block' : 'Collapse block'"
13
13
  @click="toggleCollapsed(item)"
14
14
  >
@@ -69,7 +69,7 @@
69
69
  </template>
70
70
  </div>
71
71
  <CmsDropdownMenu :items="addItems" class="self-start">
72
- <CmsButton label="Add block" icon="plus" variant="subtle" />
72
+ <CmsButton label="Add block" icon="plus" variant="soft" />
73
73
  </CmsDropdownMenu>
74
74
  </div>
75
75
  </template>
@@ -6,7 +6,7 @@ type __VLS_Props = {
6
6
  trailingIcon?: boolean;
7
7
  size?: 'xs' | 'sm' | 'md' | 'lg';
8
8
  color?: 'primary' | 'neutral' | 'error' | 'success';
9
- variant?: 'solid' | 'subtle' | 'soft' | 'ghost';
9
+ variant?: 'solid' | 'soft' | 'ghost';
10
10
  disabled?: boolean;
11
11
  loading?: boolean;
12
12
  block?: boolean;
@@ -6,7 +6,7 @@ type __VLS_Props = {
6
6
  trailingIcon?: boolean;
7
7
  size?: 'xs' | 'sm' | 'md' | 'lg';
8
8
  color?: 'primary' | 'neutral' | 'error' | 'success';
9
- variant?: 'solid' | 'subtle' | 'soft' | 'ghost';
9
+ variant?: 'solid' | 'soft' | 'ghost';
10
10
  disabled?: boolean;
11
11
  loading?: boolean;
12
12
  block?: boolean;
@@ -9,7 +9,7 @@
9
9
  <div class="cms-form">
10
10
  <p class="text-sm">{{ state.message }}</p>
11
11
  <div class="cms-actions is-end">
12
- <CmsButton label="Cancel" variant="subtle" color="neutral" @click="finish(false)" />
12
+ <CmsButton label="Cancel" variant="soft" color="neutral" @click="finish(false)" />
13
13
  <CmsButton
14
14
  :label="state.confirmLabel ?? 'Confirm'"
15
15
  color="error"
@@ -34,7 +34,7 @@
34
34
  type="submit"
35
35
  :form="FORM_ID"
36
36
  :label="published ? 'Make draft' : 'Publish'"
37
- variant="subtle"
37
+ variant="soft"
38
38
  :loading="saving"
39
39
  :disabled="loading"
40
40
  @click="togglePublished"
@@ -1,31 +1,33 @@
1
1
  <template>
2
- <div v-if="model" class="cms-card cms-media-tile">
3
- <img v-if="kind === 'image' && url" :src="url" alt="" class="cms-media-preview-large" />
4
- <video
5
- v-else-if="kind === 'video' && url"
6
- :src="url"
7
- controls
8
- preload="metadata"
9
- playsinline
10
- class="cms-media-preview-large"
11
- />
12
- <div class="cms-media-bar">
2
+ <div v-if="model" class="cms-card cms-media-field">
3
+ <div class="cms-media-field-preview">
4
+ <img v-if="kind === 'image' && url" :src="url" alt="" />
5
+ <video
6
+ v-else-if="kind === 'video' && url"
7
+ :src="url"
8
+ controls
9
+ preload="metadata"
10
+ playsinline
11
+ />
12
+ <CmsIcon v-else :name="icon" class="size-10" />
13
+ </div>
14
+ <div class="cms-media-field-bar">
13
15
  <CmsIcon :name="icon" class="cms-media-bar-icon size-4" />
14
16
  <span class="cms-media-bar-name" :title="model">{{ mediaFilename(model) }}</span>
15
17
  <CmsButton
16
- icon="photo"
17
- variant="ghost"
18
+ label="Replace"
19
+ icon="arrow-path"
20
+ variant="soft"
18
21
  color="neutral"
19
22
  size="xs"
20
- aria-label="Change"
21
23
  @click="openGallery"
22
24
  />
23
25
  <CmsButton
24
- icon="x-mark"
25
- variant="ghost"
26
- color="neutral"
26
+ label="Remove"
27
+ icon="trash"
28
+ variant="soft"
29
+ color="error"
27
30
  size="xs"
28
- aria-label="Remove"
29
31
  @click="clear"
30
32
  />
31
33
  </div>
@@ -0,0 +1,16 @@
1
+ type __VLS_Props = {
2
+ folders: string[];
3
+ };
4
+ type __VLS_ModelProps = {
5
+ modelValue?: string | null;
6
+ };
7
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
8
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:modelValue": (value: string | null) => any;
10
+ create: (folder: string) => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
12
+ "onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
13
+ onCreate?: ((folder: string) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -0,0 +1,65 @@
1
+ <template>
2
+ <div class="cms-folder-picker">
3
+ <button
4
+ type="button"
5
+ class="cms-pill"
6
+ :class="{ 'is-active': model === null }"
7
+ @click="model = null"
8
+ >
9
+ <CmsIcon name="circle-stack" class="size-3" />Root
10
+ </button>
11
+ <button
12
+ v-for="name in folders"
13
+ :key="name"
14
+ type="button"
15
+ class="cms-pill"
16
+ :class="{ 'is-active': model === name }"
17
+ @click="model = name"
18
+ >
19
+ <CmsIcon name="folder" class="size-3" />{{ name }}
20
+ </button>
21
+ <template v-if="creating">
22
+ <CmsInput
23
+ v-model="draft"
24
+ size="sm"
25
+ autofocus
26
+ placeholder="Folder name"
27
+ class="cms-folder-picker-input"
28
+ @keydown.enter.prevent="commit"
29
+ @keydown.esc="cancel"
30
+ />
31
+ <CmsButton label="Add" size="xs" :disabled="!normalized" @click="commit" />
32
+ <CmsButton label="Cancel" size="xs" variant="ghost" color="neutral" @click="cancel" />
33
+ </template>
34
+ <button v-else type="button" class="cms-pill" @click="startCreating">
35
+ <CmsIcon name="folder-plus" class="size-3" />New folder
36
+ </button>
37
+ </div>
38
+ </template>
39
+
40
+ <script setup>
41
+ import { computed, ref } from "#imports";
42
+ import { normalizeMediaFolder } from "#nuxt-cms";
43
+ defineProps({
44
+ folders: { type: Array, required: true }
45
+ });
46
+ const emit = defineEmits(["create"]);
47
+ const model = defineModel({ type: [String, null], ...{ default: null } });
48
+ const creating = ref(false);
49
+ const draft = ref("");
50
+ const normalized = computed(() => normalizeMediaFolder(draft.value));
51
+ function startCreating() {
52
+ creating.value = true;
53
+ }
54
+ function cancel() {
55
+ creating.value = false;
56
+ draft.value = "";
57
+ }
58
+ function commit() {
59
+ const folder = normalized.value;
60
+ if (!folder) return;
61
+ emit("create", folder);
62
+ model.value = folder;
63
+ cancel();
64
+ }
65
+ </script>
@@ -0,0 +1,16 @@
1
+ type __VLS_Props = {
2
+ folders: string[];
3
+ };
4
+ type __VLS_ModelProps = {
5
+ modelValue?: string | null;
6
+ };
7
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
8
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
+ "update:modelValue": (value: string | null) => any;
10
+ create: (folder: string) => any;
11
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
12
+ "onUpdate:modelValue"?: ((value: string | null) => any) | undefined;
13
+ onCreate?: ((folder: string) => any) | undefined;
14
+ }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
15
+ declare const _default: typeof __VLS_export;
16
+ export default _default;
@@ -7,39 +7,58 @@
7
7
  />
8
8
 
9
9
  <div v-else class="cms-media-gallery">
10
- <CmsMediaUpload
11
- v-if="!readOnly"
12
- :multiple="!selectable"
13
- :dense="selectable"
14
- :media-type="mediaType"
15
- :accept="accept"
16
- @uploaded="onUploaded"
17
- />
10
+ <CmsInput v-model="search" icon="magnifying-glass" placeholder="Search media…" />
18
11
 
19
- <div v-if="!restricted && items.length" class="cms-media-filters">
20
- <button
21
- v-for="f in filters"
22
- :key="f"
23
- type="button"
24
- class="cms-pill"
25
- :class="{ 'is-active': filter === f }"
26
- @click="filter = f"
27
- >
28
- {{ filterLabels[f] }}
29
- </button>
30
- <template v-if="folders.length">
31
- <span class="cms-media-filter-separator" />
12
+ <div v-if="showTypeFilters || folderChips.length || !readOnly" class="cms-media-filters">
13
+ <template v-if="showTypeFilters">
32
14
  <button
33
- v-for="f in folders"
15
+ v-for="f in filters"
34
16
  :key="f"
35
17
  type="button"
36
18
  class="cms-pill"
37
- :class="{ 'is-active': folder === f }"
38
- @click="folder = folder === f ? null : f"
19
+ :class="{ 'is-active': filter === f }"
20
+ @click="filter = f"
39
21
  >
40
- <CmsIcon name="folder" class="size-3" />{{ f }}
22
+ {{ filterLabels[f] }}
41
23
  </button>
42
24
  </template>
25
+ <template v-if="folderChips.length">
26
+ <span v-if="showTypeFilters" class="cms-media-filter-separator" />
27
+ <span
28
+ v-for="chip in folderChips"
29
+ :key="chip.name"
30
+ class="cms-pill-group"
31
+ :class="{ 'is-active': folder === chip.name }"
32
+ >
33
+ <button
34
+ type="button"
35
+ class="cms-pill"
36
+ :class="{ 'is-active': folder === chip.name, 'is-empty': chip.empty }"
37
+ @click="folder = folder === chip.name ? null : chip.name"
38
+ >
39
+ <CmsIcon name="folder" class="size-3" />{{ chip.name }}
40
+ </button>
41
+ <button
42
+ v-if="chip.empty && !readOnly"
43
+ type="button"
44
+ class="cms-pill-discard"
45
+ aria-label="Discard empty folder"
46
+ @click="discardFolder(chip.name)"
47
+ >
48
+ <CmsIcon name="x-mark" class="size-3" />
49
+ </button>
50
+ </span>
51
+ </template>
52
+ <div v-if="!readOnly" class="cms-media-filters-actions">
53
+ <CmsButton
54
+ label="New folder"
55
+ icon="folder-plus"
56
+ variant="soft"
57
+ size="sm"
58
+ @click="openNewFolder"
59
+ />
60
+ <CmsButton icon="plus" size="sm" aria-label="Upload media" @click="uploadOpen = true" />
61
+ </div>
43
62
  </div>
44
63
 
45
64
  <CmsSpinner v-if="loading" />
@@ -50,9 +69,12 @@
50
69
  v-for="item in paged"
51
70
  :key="item.key"
52
71
  :type="selectable ? 'button' : void 0"
72
+ :role="!selectable && !readOnly ? 'button' : void 0"
73
+ :tabindex="!selectable && !readOnly ? 0 : void 0"
53
74
  class="cms-card cms-media-tile"
54
- :class="{ 'is-selectable': selectable }"
55
- @click="selectable && emit('select', { key: item.key, url: item.url })"
75
+ :class="{ 'is-selectable': selectable || !readOnly }"
76
+ @click="onTileClick(item)"
77
+ @keydown.enter="!selectable && !readOnly && openEdit(item)"
56
78
  >
57
79
  <div class="cms-media-preview">
58
80
  <img
@@ -69,24 +91,14 @@
69
91
  playsinline
70
92
  />
71
93
  <CmsIcon v-else :name="mediaIconFor(item.type)" class="size-8" />
72
- <div v-if="!selectable" class="cms-media-actions">
73
- <CmsButton
74
- v-if="!readOnly"
75
- icon="pencil-square"
76
- size="xs"
77
- color="neutral"
78
- variant="solid"
79
- aria-label="Edit details"
80
- @click="openEdit(item)"
81
- />
94
+ <div v-if="!selectable && !readOnly" class="cms-media-actions">
82
95
  <CmsButton
83
- v-if="!readOnly"
84
96
  icon="trash"
85
97
  size="xs"
86
98
  color="error"
87
99
  variant="solid"
88
100
  aria-label="Delete"
89
- @click="remove(item)"
101
+ @click.stop="remove(item)"
90
102
  />
91
103
  </div>
92
104
  <div class="cms-media-info">
@@ -100,20 +112,38 @@
100
112
  <div class="cms-media-name" :title="item.key">
101
113
  {{ mediaFilename(item.key) }}
102
114
  </div>
115
+ <div v-if="item.folder" class="cms-media-folder">
116
+ <CmsIcon name="folder" class="size-3" />{{ item.folder }}
117
+ </div>
103
118
  </div>
104
119
  </component>
105
120
  </div>
106
121
 
107
122
  <CmsEmptyState v-else-if="loadError" icon="exclamation-triangle" title="Could not load media">
108
- <CmsButton label="Retry" icon="arrow-path" variant="subtle" @click="reload" />
123
+ <CmsButton label="Retry" icon="arrow-path" variant="soft" @click="reload" />
109
124
  </CmsEmptyState>
110
125
 
126
+ <CmsEmptyState
127
+ v-else-if="items.length"
128
+ icon="magnifying-glass"
129
+ title="No matching media"
130
+ body="Try a different search or filter."
131
+ />
132
+
111
133
  <CmsEmptyState
112
134
  v-else
113
135
  icon="photo"
114
136
  :title="readOnly ? 'No media' : 'No media yet'"
115
137
  :body="readOnly ? 'No media registered.' : 'Files you upload will show up here.'"
116
- />
138
+ >
139
+ <CmsButton
140
+ v-if="!readOnly"
141
+ label="Upload"
142
+ icon="plus"
143
+ variant="soft"
144
+ @click="uploadOpen = true"
145
+ />
146
+ </CmsEmptyState>
117
147
 
118
148
  <CmsPagination
119
149
  v-if="!loading && visible.length"
@@ -122,11 +152,60 @@
122
152
  :items-per-page="PAGE_SIZE"
123
153
  />
124
154
 
155
+ <CmsModal v-if="!readOnly" v-model:open="uploadOpen" title="Upload media">
156
+ <template #body>
157
+ <div class="cms-form">
158
+ <CmsFormField label="Destination folder">
159
+ <CmsMediaFolderPicker
160
+ v-model="uploadFolder"
161
+ :folders="folderNames"
162
+ @create="registerFolder"
163
+ />
164
+ </CmsFormField>
165
+ <CmsMediaUpload
166
+ :multiple="!selectable"
167
+ :media-type="mediaType"
168
+ :accept="accept"
169
+ :folder="uploadFolder"
170
+ @uploaded="onUploaded"
171
+ />
172
+ </div>
173
+ </template>
174
+ </CmsModal>
175
+
176
+ <CmsModal v-if="!readOnly" v-model:open="newFolderOpen" title="New folder" size="sm">
177
+ <template #body>
178
+ <div class="cms-form">
179
+ <CmsFormField label="Name">
180
+ <CmsInput
181
+ v-model="newFolderName"
182
+ autofocus
183
+ placeholder="e.g. blog/covers"
184
+ @keydown.enter.prevent="createFolder"
185
+ />
186
+ <p class="cms-form-hint">
187
+ {{
188
+ newFolderSlug ? `Files uploaded here are stored under ${newFolderSlug}/` : "Letters, numbers and dashes. Use / for nesting."
189
+ }}
190
+ </p>
191
+ </CmsFormField>
192
+ <div class="cms-actions is-end">
193
+ <CmsButton
194
+ label="Cancel"
195
+ variant="ghost"
196
+ color="neutral"
197
+ @click="newFolderOpen = false"
198
+ />
199
+ <CmsButton label="Create" :disabled="!newFolderSlug" @click="createFolder" />
200
+ </div>
201
+ </div>
202
+ </template>
203
+ </CmsModal>
204
+
125
205
  <CmsModal
126
206
  v-if="!readOnly"
127
207
  :open="!!editing"
128
208
  :title="editing ? mediaFilename(editing.key) : ''"
129
- size="lg"
130
209
  @update:open="
131
210
  (open) => {
132
211
  if (!open) editing = null;
@@ -139,9 +218,19 @@
139
218
  <CmsInput v-model="editAlt" />
140
219
  </CmsFormField>
141
220
  <CmsFormField label="Folder">
142
- <CmsInput v-model="editFolder" />
221
+ <CmsMediaFolderPicker
222
+ v-model="editFolder"
223
+ :folders="folderNames"
224
+ @create="registerFolder"
225
+ />
143
226
  </CmsFormField>
144
227
  <div class="cms-actions is-end">
228
+ <CmsButton
229
+ label="Cancel"
230
+ variant="ghost"
231
+ color="neutral"
232
+ @click="editing = null"
233
+ />
145
234
  <CmsButton label="Save" :loading="editSaving" @click="saveEdit" />
146
235
  </div>
147
236
  </div>
@@ -152,7 +241,7 @@
152
241
 
153
242
  <script setup>
154
243
  import { computed, onMounted, ref, watch } from "#imports";
155
- import { MEDIA_TYPES, mediaFilename, mediaIconFor } from "#nuxt-cms";
244
+ import { MEDIA_TYPES, mediaFilename, mediaIconFor, normalizeMediaFolder } from "#nuxt-cms";
156
245
  import { useCmsConfirm } from "../../composables/cms-confirm";
157
246
  import { useCmsRuntime } from "../../composables/cms-runtime";
158
247
  import { useCmsToast } from "../../composables/cms-toast";
@@ -173,6 +262,7 @@ const toast = useCmsToast();
173
262
  const runtime = useCmsRuntime();
174
263
  const readOnly = computed(() => runtime.mediaStorage === "local");
175
264
  const endpoint = "/api/cms/admin/media";
265
+ const DRAFT_FOLDERS_KEY = "nuxt-cms:media-folders";
176
266
  const items = ref([]);
177
267
  const loading = ref(true);
178
268
  const errorCode = ref(null);
@@ -196,20 +286,67 @@ const restricted = computed(
196
286
  );
197
287
  const filters = ["all", ...MEDIA_TYPES];
198
288
  const filter = ref("all");
199
- const folders = computed(() => {
289
+ const showTypeFilters = computed(() => !restricted.value && items.value.length > 0);
290
+ const search = ref("");
291
+ const draftFolders = ref([]);
292
+ onMounted(() => {
293
+ try {
294
+ const stored = JSON.parse(localStorage.getItem(DRAFT_FOLDERS_KEY) ?? "[]");
295
+ if (Array.isArray(stored)) draftFolders.value = stored.filter((f) => typeof f === "string");
296
+ } catch {
297
+ draftFolders.value = [];
298
+ }
299
+ });
300
+ function persistDraftFolders() {
301
+ try {
302
+ localStorage.setItem(DRAFT_FOLDERS_KEY, JSON.stringify(draftFolders.value));
303
+ } catch {
304
+ }
305
+ }
306
+ const usedFolders = computed(() => {
200
307
  const set = new Set(items.value.map((item) => item.folder).filter((f) => !!f));
201
308
  return [...set].sort();
202
309
  });
310
+ const folderNames = computed(
311
+ () => [.../* @__PURE__ */ new Set([...usedFolders.value, ...draftFolders.value])].sort()
312
+ );
313
+ const folderChips = computed(
314
+ () => folderNames.value.map((name) => ({ name, empty: !usedFolders.value.includes(name) }))
315
+ );
203
316
  const folder = ref(null);
317
+ function registerFolder(name) {
318
+ if (!draftFolders.value.includes(name) && !usedFolders.value.includes(name)) {
319
+ draftFolders.value = [...draftFolders.value, name];
320
+ persistDraftFolders();
321
+ }
322
+ }
323
+ function discardFolder(name) {
324
+ draftFolders.value = draftFolders.value.filter((f) => f !== name);
325
+ persistDraftFolders();
326
+ if (folder.value === name) folder.value = null;
327
+ }
328
+ watch(usedFolders, (used) => {
329
+ const next = draftFolders.value.filter((f) => !used.includes(f));
330
+ if (next.length !== draftFolders.value.length) {
331
+ draftFolders.value = next;
332
+ persistDraftFolders();
333
+ }
334
+ });
204
335
  const visible = computed(() => {
205
336
  const active = restricted.value ?? (filter.value === "all" ? null : filter.value);
206
337
  let list = active ? items.value.filter((item) => item.type === active) : items.value;
207
338
  if (folder.value) list = list.filter((item) => item.folder === folder.value);
339
+ const query = search.value.trim().toLowerCase();
340
+ if (query) {
341
+ list = list.filter(
342
+ (item) => [item.key, item.alt, item.folder].some((value) => value?.toLowerCase().includes(query))
343
+ );
344
+ }
208
345
  return list;
209
346
  });
210
347
  const PAGE_SIZE = 24;
211
348
  const page = ref(1);
212
- watch([filter, folder], () => {
349
+ watch([filter, folder, search], () => {
213
350
  page.value = 1;
214
351
  });
215
352
  watch(visible, (list) => {
@@ -219,21 +356,48 @@ watch(visible, (list) => {
219
356
  const paged = computed(
220
357
  () => visible.value.slice((page.value - 1) * PAGE_SIZE, page.value * PAGE_SIZE)
221
358
  );
359
+ const uploadOpen = ref(false);
360
+ const uploadFolder = ref(null);
361
+ watch(uploadOpen, (open) => {
362
+ if (open) uploadFolder.value = folder.value;
363
+ });
222
364
  function onUploaded(uploaded) {
365
+ uploadOpen.value = false;
223
366
  if (props.selectable && uploaded[0]) {
224
367
  emit("select", uploaded[0]);
225
368
  return;
226
369
  }
227
370
  void reload();
228
371
  }
372
+ const newFolderOpen = ref(false);
373
+ const newFolderName = ref("");
374
+ const newFolderSlug = computed(() => normalizeMediaFolder(newFolderName.value));
375
+ function openNewFolder() {
376
+ newFolderName.value = "";
377
+ newFolderOpen.value = true;
378
+ }
379
+ function createFolder() {
380
+ const name = newFolderSlug.value;
381
+ if (!name) return;
382
+ registerFolder(name);
383
+ folder.value = name;
384
+ newFolderOpen.value = false;
385
+ }
229
386
  const editing = ref(null);
230
387
  const editAlt = ref("");
231
- const editFolder = ref("");
388
+ const editFolder = ref(null);
232
389
  const editSaving = ref(false);
390
+ function onTileClick(item) {
391
+ if (props.selectable) {
392
+ emit("select", { key: item.key, url: item.url });
393
+ return;
394
+ }
395
+ if (!readOnly.value) openEdit(item);
396
+ }
233
397
  function openEdit(item) {
234
398
  editing.value = item;
235
399
  editAlt.value = item.alt ?? "";
236
- editFolder.value = item.folder ?? "";
400
+ editFolder.value = item.folder ?? null;
237
401
  }
238
402
  async function saveEdit() {
239
403
  if (!editing.value) return;
@@ -244,7 +408,7 @@ async function saveEdit() {
244
408
  body: {
245
409
  id: editing.value.id,
246
410
  alt: editAlt.value.trim() || null,
247
- folder: editFolder.value.trim() || null
411
+ folder: editFolder.value
248
412
  }
249
413
  });
250
414
  editing.value = null;