@xleddyl/nuxt-cms 0.1.25 → 0.1.27

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 (28) hide show
  1. package/README.md +2 -2
  2. package/dist/module.json +1 -1
  3. package/dist/module.mjs +4 -0
  4. package/dist/runtime/app/components/cms/MediaField.vue +20 -18
  5. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +16 -0
  6. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +65 -0
  7. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +16 -0
  8. package/dist/runtime/app/components/cms/MediaGallery.vue +212 -48
  9. package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -1
  10. package/dist/runtime/app/components/cms/MediaUpload.vue +9 -3
  11. package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -1
  12. package/dist/runtime/app/components/cms/Table.d.vue.ts +10 -2
  13. package/dist/runtime/app/components/cms/Table.vue +30 -2
  14. package/dist/runtime/app/components/cms/Table.vue.d.ts +10 -2
  15. package/dist/runtime/app/pages/admin-collection.vue +14 -3
  16. package/dist/runtime/assets/main.css +1 -1
  17. package/dist/runtime/server/api/collection.get.js +14 -4
  18. package/dist/runtime/server/api/media-presign.post.d.ts +1 -0
  19. package/dist/runtime/server/api/media-presign.post.js +7 -4
  20. package/dist/runtime/server/api/media.post.js +2 -1
  21. package/dist/runtime/server/api/media.put.js +2 -1
  22. package/dist/runtime/server/plugins/media-sync-local.d.ts +2 -0
  23. package/dist/runtime/server/plugins/media-sync-local.js +41 -0
  24. package/dist/runtime/server/utils/media-sync.d.ts +39 -0
  25. package/dist/runtime/server/utils/media-sync.js +227 -0
  26. package/dist/runtime/shared/index.d.ts +2 -0
  27. package/dist/runtime/shared/index.js +6 -0
  28. package/package.json +1 -1
package/README.md CHANGED
@@ -4,7 +4,7 @@ nuxt-cms is a Nuxt module that leverages the Nitro server to ship a lightweight
4
4
 
5
5
  - **Zero extra infrastructure**: the CMS runs inside your app's Nitro server; you deploy one thing.
6
6
  - **Content types in code**: a `cms.config.ts` with `defineCmsConfig()` declares collections, single documents, relations, blocks and translatable fields; database schema, migrations and TypeScript types are generated from it.
7
- - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a read-only local mode), single-admin auth from env credentials.
7
+ - **Admin panel at `/cms`**: entry editing with validation, drafts, media library (S3-compatible storage, or a read-only local mode kept in sync with your `public/` folder), single-admin auth from env credentials.
8
8
  - **Public GraphQL API**: read-only, typed end-to-end via gql.tada, with filtering, sorting and pagination.
9
9
  - **SQLite, Postgres or libSQL/Turso**: a local file database by default, one config line to switch (including remote SQLite over the network).
10
10
 
@@ -78,7 +78,7 @@ Full documentation lives in [`docs/`](docs/README.md):
78
78
  - [Schema](docs/schema.md) — `defineCmsConfig`, entries, field types, relations, blocks, i18n.
79
79
  - [Querying content](docs/querying.md) — GraphQL API, `useCms` / `$cmsQuery`, filters, sorting, pagination.
80
80
  - [Admin panel & security](docs/admin.md) — pages, authentication, sessions, admin REST API.
81
- - [Media](docs/media.md) — S3-compatible storage or read-only local mode, upload flow, allowed file types.
81
+ - [Media](docs/media.md) — S3-compatible storage or read-only local mode (synced from disk at startup), upload flow, allowed file types.
82
82
 
83
83
  ## LLM guide
84
84
 
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.25",
4
+ "version": "0.1.27",
5
5
  "builder": {
6
6
  "@nuxt/module-builder": "1.0.2",
7
7
  "unbuild": "unknown"
package/dist/module.mjs CHANGED
@@ -620,6 +620,7 @@ const module$1 = defineNuxtModule({
620
620
  driver === "postgres" ? "./runtime/server/plugins/migrate-postgres" : driver === "libsql" ? "./runtime/server/plugins/migrate-libsql" : "./runtime/server/plugins/migrate-sqlite"
621
621
  )
622
622
  );
623
+ addServerPlugin(resolver.resolve("./runtime/server/plugins/media-sync-local"));
623
624
  if (!nuxt.options.dev) {
624
625
  addServerPlugin(resolver.resolve("./runtime/server/plugins/session-check"));
625
626
  }
@@ -643,6 +644,8 @@ const module$1 = defineNuxtModule({
643
644
  logger.error(`[nuxt-cms] drizzle-kit generate failed (exit code ${code})`);
644
645
  });
645
646
  }
647
+ const publicDir = resolve(nuxt.options.rootDir, nuxt.options.dir?.public ?? "public");
648
+ const mediaLocalRoot = options.media.storage === "local" && options.media.publicBaseUrl.startsWith("/") ? join(publicDir, ...options.media.publicBaseUrl.split("/").filter(Boolean)) : "";
646
649
  const existingConfig = nuxt.options.runtimeConfig.cms ?? {};
647
650
  nuxt.options.runtimeConfig.cms = {
648
651
  adminEmail: options.admin.email,
@@ -665,6 +668,7 @@ const module$1 = defineNuxtModule({
665
668
  presignExpiry: options.media.presignExpiry,
666
669
  accessKeyId: options.media.accessKeyId,
667
670
  secretAccessKey: options.media.secretAccessKey,
671
+ localRoot: mediaLocalRoot,
668
672
  ...existingConfig.media ?? {}
669
673
  }
670
674
  };
@@ -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="subtle"
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="subtle"
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="subtle"
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,6 +112,9 @@
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>
@@ -108,12 +123,27 @@
108
123
  <CmsButton label="Retry" icon="arrow-path" variant="subtle" @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="subtle"
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;
@@ -3,7 +3,7 @@ type __VLS_Props = {
3
3
  multiple?: boolean;
4
4
  mediaType?: MediaType;
5
5
  accept?: string[];
6
- dense?: boolean;
6
+ folder?: string | null;
7
7
  };
8
8
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
9
  uploaded: (items: MediaItem[]) => any;
@@ -2,7 +2,7 @@
2
2
  <button
3
3
  type="button"
4
4
  class="cms-dropzone"
5
- :class="{ 'is-dense': dense, 'is-over': dragOver, 'is-busy': uploading }"
5
+ :class="{ 'is-over': dragOver, 'is-busy': uploading }"
6
6
  :disabled="uploading"
7
7
  @click="input?.click()"
8
8
  @dragover.prevent="dragOver = true"
@@ -28,6 +28,10 @@
28
28
  uploading ? `Uploading ${Math.min(done + 1, total)} of ${total}\u2026` : multiple ? "Drop files here or click to browse" : "Drop a file here or click to browse"
29
29
  }}
30
30
  </span>
31
+ <span v-if="!uploading" class="cms-dropzone-hint">
32
+ <CmsIcon :name="folder ? 'folder' : 'circle-stack'" class="size-3.5" />
33
+ {{ folder ?? "Library root" }}
34
+ </span>
31
35
  </button>
32
36
  </template>
33
37
 
@@ -38,7 +42,7 @@ const props = defineProps({
38
42
  multiple: { type: Boolean, required: false },
39
43
  mediaType: { type: null, required: false, default: "file" },
40
44
  accept: { type: Array, required: false },
41
- dense: { type: Boolean, required: false }
45
+ folder: { type: [String, null], required: false, default: null }
42
46
  });
43
47
  const emit = defineEmits(["uploaded"]);
44
48
  const toast = useCmsToast();
@@ -77,7 +81,8 @@ async function uploadOne(file) {
77
81
  body: {
78
82
  filename: file.name,
79
83
  contentType: file.type || "application/octet-stream",
80
- size: file.size
84
+ size: file.size,
85
+ folder: props.folder
81
86
  }
82
87
  });
83
88
  const res = await fetch(presign.uploadUrl, {
@@ -90,6 +95,7 @@ async function uploadOne(file) {
90
95
  method: "POST",
91
96
  body: {
92
97
  key: presign.key,
98
+ folder: presign.folder,
93
99
  mime: file.type || null,
94
100
  size: file.size,
95
101
  ...await imageDimensions(file)
@@ -3,7 +3,7 @@ type __VLS_Props = {
3
3
  multiple?: boolean;
4
4
  mediaType?: MediaType;
5
5
  accept?: string[];
6
- dense?: boolean;
6
+ folder?: string | null;
7
7
  };
8
8
  declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
9
9
  uploaded: (items: MediaItem[]) => any;