@xleddyl/nuxt-cms 0.1.59 → 0.1.61

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 (38) hide show
  1. package/dist/module.json +1 -1
  2. package/dist/module.mjs +53 -23
  3. package/dist/runtime/app/components/cms/MediaDetail.d.vue.ts +16 -0
  4. package/dist/runtime/app/components/cms/MediaDetail.vue +169 -0
  5. package/dist/runtime/app/components/cms/MediaDetail.vue.d.ts +16 -0
  6. package/dist/runtime/app/components/cms/MediaField.vue +9 -2
  7. package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +2 -2
  8. package/dist/runtime/app/components/cms/MediaFolderPicker.vue +55 -54
  9. package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +2 -2
  10. package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +8 -2
  11. package/dist/runtime/app/components/cms/MediaGallery.vue +913 -336
  12. package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
  13. package/dist/runtime/app/components/cms/MediaUpload.vue +10 -103
  14. package/dist/runtime/app/composables/cms-media-library.d.ts +75 -0
  15. package/dist/runtime/app/composables/cms-media-library.js +151 -0
  16. package/dist/runtime/app/composables/cms-media-uploader.d.ts +12 -0
  17. package/dist/runtime/app/composables/cms-media-uploader.js +117 -0
  18. package/dist/runtime/app/pages/admin-media.vue +14 -2
  19. package/dist/runtime/assets/main.css +1 -1
  20. package/dist/runtime/server/api/media-delete.post.d.ts +4 -0
  21. package/dist/runtime/server/api/media-delete.post.js +23 -0
  22. package/dist/runtime/server/api/media-folder.delete.js +6 -16
  23. package/dist/runtime/server/api/media-folder.patch.d.ts +5 -0
  24. package/dist/runtime/server/api/media-folder.patch.js +68 -0
  25. package/dist/runtime/server/api/media-folder.post.js +3 -15
  26. package/dist/runtime/server/api/media-move.post.d.ts +5 -0
  27. package/dist/runtime/server/api/media-move.post.js +28 -0
  28. package/dist/runtime/server/api/media-usage.post.d.ts +5 -0
  29. package/dist/runtime/server/api/media-usage.post.js +15 -0
  30. package/dist/runtime/server/utils/media-folders.d.ts +86 -0
  31. package/dist/runtime/server/utils/media-folders.js +37 -0
  32. package/dist/runtime/server/utils/media-references.d.ts +7 -1
  33. package/dist/runtime/server/utils/media-references.js +40 -0
  34. package/dist/runtime/server/utils/media-usage.d.ts +2 -0
  35. package/dist/runtime/server/utils/media-usage.js +18 -0
  36. package/dist/runtime/shared/index.d.ts +23 -1
  37. package/dist/runtime/shared/index.js +86 -2
  38. package/package.json +1 -1
@@ -4,16 +4,22 @@ type __VLS_Props = {
4
4
  mediaType?: MediaType | MediaType[];
5
5
  accept?: string[];
6
6
  };
7
- declare const __VLS_export: import("vue").DefineComponent<__VLS_Props, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
7
+ type __VLS_ModelProps = {
8
+ 'folder'?: string | null;
9
+ };
10
+ type __VLS_PublicProps = __VLS_Props & __VLS_ModelProps;
11
+ declare const __VLS_export: import("vue").DefineComponent<__VLS_PublicProps, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {
8
12
  select: (item: {
9
13
  key: string;
10
14
  url: string | null;
11
15
  }) => any;
12
- }, string, import("vue").PublicProps, Readonly<__VLS_Props> & Readonly<{
16
+ "update:folder": (value: string | null) => any;
17
+ }, string, import("vue").PublicProps, Readonly<__VLS_PublicProps> & Readonly<{
13
18
  onSelect?: ((item: {
14
19
  key: string;
15
20
  url: string | null;
16
21
  }) => any) | undefined;
22
+ "onUpdate:folder"?: ((value: string | null) => any) | undefined;
17
23
  }>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, false, {}, any>;
18
24
  declare const _default: typeof __VLS_export;
19
25
  export default _default;
@@ -7,7 +7,7 @@
7
7
  @click="input?.click()"
8
8
  @dragover.prevent="dragOver = true"
9
9
  @dragleave.prevent="dragOver = false"
10
- @drop.prevent="onDrop"
10
+ @drop.prevent.stop="onDrop"
11
11
  >
12
12
  <input
13
13
  ref="input"
@@ -36,10 +36,9 @@
36
36
  </template>
37
37
 
38
38
  <script setup>
39
- import { formatFileSize, mediaTypeAccept } from "#nuxt-cms";
39
+ import { mediaTypeAccept } from "#nuxt-cms";
40
40
  import { computed, ref } from "#imports";
41
- import { useCmsRuntime } from "../../composables/cms-runtime";
42
- import { useCmsToast } from "../../composables/cms-toast";
41
+ import { useCmsMediaUploader } from "../../composables/cms-media-uploader";
43
42
  const props = defineProps({
44
43
  multiple: { type: Boolean, required: false },
45
44
  mediaType: { type: null, required: false, default: "file" },
@@ -47,111 +46,19 @@ const props = defineProps({
47
46
  folder: { type: [String, null], required: false, default: null }
48
47
  });
49
48
  const emit = defineEmits(["uploaded"]);
50
- const toast = useCmsToast();
51
- const { mediaMaxFileSize } = useCmsRuntime();
52
- async function imageDimensions(file) {
53
- try {
54
- const bitmap = await createImageBitmap(file);
55
- const dims = { width: bitmap.width, height: bitmap.height };
56
- bitmap.close();
57
- return dims;
58
- } catch {
59
- return {};
60
- }
61
- }
62
- function videoDimensions(file) {
63
- return new Promise((resolve) => {
64
- const url = URL.createObjectURL(file);
65
- const video = document.createElement("video");
66
- const settle = (dims) => {
67
- URL.revokeObjectURL(url);
68
- resolve(dims);
69
- };
70
- video.preload = "metadata";
71
- video.muted = true;
72
- video.onloadedmetadata = () => settle(
73
- video.videoWidth && video.videoHeight ? { width: video.videoWidth, height: video.videoHeight } : {}
74
- );
75
- video.onerror = () => settle({});
76
- video.src = url;
77
- });
78
- }
79
- async function mediaDimensions(file) {
80
- if (file.type.startsWith("image/")) return imageDimensions(file);
81
- if (file.type.startsWith("video/")) return videoDimensions(file);
82
- return {};
83
- }
49
+ const { upload, uploading, done, total } = useCmsMediaUploader();
84
50
  const input = ref(null);
85
51
  const dragOver = ref(false);
86
- const uploading = ref(false);
87
- const done = ref(0);
88
- const total = ref(0);
89
52
  const acceptAttr = computed(
90
53
  () => (props.accept?.length ? props.accept : mediaTypeAccept(props.mediaType))?.join(",")
91
54
  );
92
- function matchesAccept(file) {
93
- if (!acceptAttr.value) return true;
94
- return acceptAttr.value.split(",").some((raw) => {
95
- const pattern = raw.trim().toLowerCase();
96
- if (pattern.endsWith("/*")) return file.type.toLowerCase().startsWith(pattern.slice(0, -1));
97
- if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern);
98
- return file.type.toLowerCase() === pattern;
99
- });
100
- }
101
- async function uploadOne(file) {
102
- const presign = await $fetch("/api/cms/admin/media/presign", {
103
- method: "POST",
104
- body: {
105
- filename: file.name,
106
- contentType: file.type || "application/octet-stream",
107
- size: file.size,
108
- folder: props.folder
109
- }
110
- });
111
- const res = await fetch(presign.uploadUrl, {
112
- method: "PUT",
113
- headers: presign.headers,
114
- body: file
115
- });
116
- if (!res.ok) throw new Error(`Upload failed (${res.status})`);
117
- const item = await $fetch("/api/cms/admin/media", {
118
- method: "POST",
119
- body: {
120
- key: presign.key,
121
- folder: presign.folder,
122
- mime: file.type || null,
123
- size: file.size,
124
- ...await mediaDimensions(file)
125
- }
126
- });
127
- done.value++;
128
- return item;
129
- }
130
55
  async function handleFiles(list) {
131
- let files = Array.from(list).filter(matchesAccept);
132
- const tooLarge = files.filter((file) => file.size > mediaMaxFileSize);
133
- for (const file of tooLarge) {
134
- toast.add({
135
- title: `File too large (max ${formatFileSize(mediaMaxFileSize)}): ${file.name}`,
136
- color: "error"
137
- });
138
- }
139
- files = files.filter((file) => file.size <= mediaMaxFileSize);
140
- if (!props.multiple) files = files.slice(0, 1);
141
- if (!files.length || uploading.value) return;
142
- uploading.value = true;
143
- done.value = 0;
144
- total.value = files.length;
145
- try {
146
- const results = await Promise.allSettled(files.map(uploadOne));
147
- const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
148
- const failed = results.length - ok.length;
149
- if (failed)
150
- toast.add({ title: `${failed} upload${failed > 1 ? "s" : ""} failed`, color: "error" });
151
- if (ok.length) emit("uploaded", ok);
152
- } finally {
153
- uploading.value = false;
154
- }
56
+ const items = await upload(list, {
57
+ folder: props.folder,
58
+ accept: acceptAttr.value,
59
+ multiple: props.multiple
60
+ });
61
+ if (items.length) emit("uploaded", items);
155
62
  }
156
63
  function onChange(event) {
157
64
  const el = event.target;
@@ -0,0 +1,75 @@
1
+ import type { MediaItem, MediaSourceInfo, MediaUsage } from '../../shared/index.js';
2
+ export interface MediaFolderStats {
3
+ files: number;
4
+ folders: number;
5
+ }
6
+ export declare function useCmsMediaLibrary(): {
7
+ items: import("vue").Ref<{
8
+ id?: number | undefined;
9
+ key: string;
10
+ alt: string | null;
11
+ folder: string | null;
12
+ mime: string | null;
13
+ size: number | null;
14
+ width: number | null;
15
+ height: number | null;
16
+ createdAt: string | null;
17
+ type: import("../../shared/index.js").MediaType;
18
+ url: string | null;
19
+ }[], MediaItem[] | {
20
+ id?: number | undefined;
21
+ key: string;
22
+ alt: string | null;
23
+ folder: string | null;
24
+ mime: string | null;
25
+ size: number | null;
26
+ width: number | null;
27
+ height: number | null;
28
+ createdAt: string | null;
29
+ type: import("../../shared/index.js").MediaType;
30
+ url: string | null;
31
+ }[]>;
32
+ source: import("vue").Ref<{
33
+ kind: import("../../shared/index.js").MediaSourceKind;
34
+ root: string;
35
+ builtAt: string | null;
36
+ } | null, MediaSourceInfo | {
37
+ kind: import("../../shared/index.js").MediaSourceKind;
38
+ root: string;
39
+ builtAt: string | null;
40
+ } | null>;
41
+ loading: import("vue").Ref<boolean, boolean>;
42
+ errorCode: import("vue").Ref<number | null, number | null>;
43
+ folders: import("vue").ComputedRef<string[]>;
44
+ stats: import("vue").ComputedRef<Map<string, MediaFolderStats>>;
45
+ reload: (options?: {
46
+ quiet?: boolean;
47
+ }) => Promise<void>;
48
+ childFolders: (parent: string | null) => string[];
49
+ itemsWithin: (folder: string) => {
50
+ id?: number | undefined;
51
+ key: string;
52
+ alt: string | null;
53
+ folder: string | null;
54
+ mime: string | null;
55
+ size: number | null;
56
+ width: number | null;
57
+ height: number | null;
58
+ createdAt: string | null;
59
+ type: import("../../shared/index.js").MediaType;
60
+ url: string | null;
61
+ }[];
62
+ createFolder: (name: string) => Promise<boolean>;
63
+ moveFolder: (from: string, to: string) => Promise<boolean>;
64
+ deleteFolder: (name: string) => Promise<boolean>;
65
+ moveItems: (keys: string[], folder: string | null) => Promise<boolean>;
66
+ deleteItems: (keys: string[]) => Promise<boolean>;
67
+ updateItem: (key: string, changes: {
68
+ alt?: string | null;
69
+ folder?: string | null;
70
+ }) => Promise<boolean>;
71
+ usage: (keys: string[]) => Promise<Record<string, MediaUsage[]> | null>;
72
+ };
73
+ export type CmsMediaLibrary = ReturnType<typeof useCmsMediaLibrary>;
74
+ export declare function usageSummary(usage: Record<string, MediaUsage[]> | null, keys: string[]): string;
75
+ export declare function usageLink(usage: MediaUsage): string;
@@ -0,0 +1,151 @@
1
+ import {
2
+ expandMediaFolders,
3
+ isWithinMediaFolder,
4
+ mediaFolderAncestors,
5
+ mediaFolderParent
6
+ } from "../../shared/index.js";
7
+ import { computed, ref } from "#imports";
8
+ import { errorMessage } from "../utils/ui.js";
9
+ import { useCmsToast } from "./cms-toast.js";
10
+ const endpoint = "/api/cms/admin/media";
11
+ export function useCmsMediaLibrary() {
12
+ const toast = useCmsToast();
13
+ const items = ref([]);
14
+ const serverFolders = ref([]);
15
+ const source = ref(null);
16
+ const loading = ref(true);
17
+ const errorCode = ref(null);
18
+ async function reload(options = {}) {
19
+ if (!options.quiet) loading.value = true;
20
+ errorCode.value = null;
21
+ try {
22
+ const result = await $fetch(endpoint);
23
+ items.value = result.items;
24
+ serverFolders.value = result.folders ?? [];
25
+ source.value = result.source;
26
+ } catch (err) {
27
+ errorCode.value = err.statusCode ?? 500;
28
+ } finally {
29
+ loading.value = false;
30
+ }
31
+ }
32
+ const folders = computed(
33
+ () => expandMediaFolders([...serverFolders.value, ...items.value.map((item) => item.folder)])
34
+ );
35
+ const stats = computed(() => {
36
+ const map = /* @__PURE__ */ new Map();
37
+ for (const folder of folders.value) map.set(folder, { files: 0, folders: 0 });
38
+ for (const folder of folders.value) {
39
+ const parent = mediaFolderParent(folder);
40
+ if (parent) map.get(parent).folders++;
41
+ }
42
+ for (const item of items.value) {
43
+ if (!item.folder) continue;
44
+ for (const folder of mediaFolderAncestors(item.folder)) {
45
+ const entry = map.get(folder);
46
+ if (entry) entry.files++;
47
+ }
48
+ }
49
+ return map;
50
+ });
51
+ function childFolders(parent) {
52
+ return folders.value.filter((folder) => mediaFolderParent(folder) === parent);
53
+ }
54
+ function itemsWithin(folder) {
55
+ return items.value.filter((item) => isWithinMediaFolder(item.folder, folder));
56
+ }
57
+ async function attempt(title, action) {
58
+ try {
59
+ await action();
60
+ return true;
61
+ } catch (err) {
62
+ toast.add({ title, description: errorMessage(err), color: "error" });
63
+ return false;
64
+ }
65
+ }
66
+ function createFolder(name) {
67
+ return attempt("Could not create the folder", async () => {
68
+ await $fetch(`${endpoint}/folders`, { method: "POST", body: { name } });
69
+ await reload({ quiet: true });
70
+ });
71
+ }
72
+ function moveFolder(from, to) {
73
+ return attempt("Could not move the folder", async () => {
74
+ await $fetch(`${endpoint}/folders`, { method: "PATCH", body: { from, to } });
75
+ await reload({ quiet: true });
76
+ });
77
+ }
78
+ function deleteFolder(name) {
79
+ return attempt("Could not delete the folder", async () => {
80
+ await $fetch(`${endpoint}/folders`, {
81
+ method: "DELETE",
82
+ query: { name, recursive: "true" }
83
+ });
84
+ await reload({ quiet: true });
85
+ });
86
+ }
87
+ function moveItems(keys, folder) {
88
+ return attempt("Could not move the files", async () => {
89
+ await $fetch(`${endpoint}/move`, { method: "POST", body: { keys, folder } });
90
+ await reload({ quiet: true });
91
+ });
92
+ }
93
+ function deleteItems(keys) {
94
+ return attempt("Delete failed", async () => {
95
+ await $fetch(`${endpoint}/delete`, { method: "POST", body: { keys } });
96
+ await reload({ quiet: true });
97
+ });
98
+ }
99
+ function updateItem(key, changes) {
100
+ return attempt("Save failed", async () => {
101
+ await $fetch(endpoint, { method: "PUT", body: { key, ...changes } });
102
+ await reload({ quiet: true });
103
+ });
104
+ }
105
+ async function usage(keys) {
106
+ if (!keys.length) return {};
107
+ try {
108
+ const result = await $fetch(`${endpoint}/usage`, {
109
+ method: "POST",
110
+ body: { keys }
111
+ });
112
+ return result.usage;
113
+ } catch {
114
+ return null;
115
+ }
116
+ }
117
+ return {
118
+ items,
119
+ source,
120
+ loading,
121
+ errorCode,
122
+ folders,
123
+ stats,
124
+ reload,
125
+ childFolders,
126
+ itemsWithin,
127
+ createFolder,
128
+ moveFolder,
129
+ deleteFolder,
130
+ moveItems,
131
+ deleteItems,
132
+ updateItem,
133
+ usage
134
+ };
135
+ }
136
+ export function usageSummary(usage, keys) {
137
+ if (!usage) return "";
138
+ const entries = /* @__PURE__ */ new Set();
139
+ let used = 0;
140
+ for (const key of keys) {
141
+ const list = usage[key] ?? [];
142
+ if (list.length) used++;
143
+ for (const item of list) entries.add(`${item.collection}:${item.id ?? ""}`);
144
+ }
145
+ if (!used) return "";
146
+ const files = keys.length === 1 ? "It is" : `${used} of them are`;
147
+ return `${files} used in ${entries.size} entr${entries.size === 1 ? "y" : "ies"}, which will keep a broken link.`;
148
+ }
149
+ export function usageLink(usage) {
150
+ return usage.id === null ? `/cms/${usage.collection}` : `/cms/${usage.collection}/${usage.id}`;
151
+ }
@@ -0,0 +1,12 @@
1
+ import type { MediaItem } from '../../shared/index.js';
2
+ export declare function matchesAccept(file: File, accept: string | undefined): boolean;
3
+ export declare function useCmsMediaUploader(): {
4
+ upload: (list: FileList | File[], options: {
5
+ folder: string | null;
6
+ accept?: string;
7
+ multiple?: boolean;
8
+ }) => Promise<MediaItem[]>;
9
+ uploading: import("vue").Ref<boolean, boolean>;
10
+ done: import("vue").Ref<number, number>;
11
+ total: import("vue").Ref<number, number>;
12
+ };
@@ -0,0 +1,117 @@
1
+ import { formatFileSize } from "../../shared/index.js";
2
+ import { ref } from "#imports";
3
+ import { useCmsRuntime } from "./cms-runtime.js";
4
+ import { useCmsToast } from "./cms-toast.js";
5
+ async function imageDimensions(file) {
6
+ try {
7
+ const bitmap = await createImageBitmap(file);
8
+ const dims = { width: bitmap.width, height: bitmap.height };
9
+ bitmap.close();
10
+ return dims;
11
+ } catch {
12
+ return {};
13
+ }
14
+ }
15
+ function videoDimensions(file) {
16
+ return new Promise((resolve) => {
17
+ const url = URL.createObjectURL(file);
18
+ const video = document.createElement("video");
19
+ const settle = (dims) => {
20
+ URL.revokeObjectURL(url);
21
+ resolve(dims);
22
+ };
23
+ video.preload = "metadata";
24
+ video.muted = true;
25
+ video.onloadedmetadata = () => settle(
26
+ video.videoWidth && video.videoHeight ? { width: video.videoWidth, height: video.videoHeight } : {}
27
+ );
28
+ video.onerror = () => settle({});
29
+ video.src = url;
30
+ });
31
+ }
32
+ async function mediaDimensions(file) {
33
+ if (file.type.startsWith("image/")) return imageDimensions(file);
34
+ if (file.type.startsWith("video/")) return videoDimensions(file);
35
+ return {};
36
+ }
37
+ export function matchesAccept(file, accept) {
38
+ if (!accept) return true;
39
+ return accept.split(",").some((raw) => {
40
+ const pattern = raw.trim().toLowerCase();
41
+ if (pattern.endsWith("/*")) return file.type.toLowerCase().startsWith(pattern.slice(0, -1));
42
+ if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern);
43
+ return file.type.toLowerCase() === pattern;
44
+ });
45
+ }
46
+ export function useCmsMediaUploader() {
47
+ const toast = useCmsToast();
48
+ const { mediaMaxFileSize } = useCmsRuntime();
49
+ const uploading = ref(false);
50
+ const done = ref(0);
51
+ const total = ref(0);
52
+ async function uploadOne(file, folder) {
53
+ const presign = await $fetch("/api/cms/admin/media/presign", {
54
+ method: "POST",
55
+ body: {
56
+ filename: file.name,
57
+ contentType: file.type || "application/octet-stream",
58
+ size: file.size,
59
+ folder
60
+ }
61
+ });
62
+ const res = await fetch(presign.uploadUrl, {
63
+ method: "PUT",
64
+ headers: presign.headers,
65
+ body: file
66
+ });
67
+ if (!res.ok) throw new Error(`Upload failed (${res.status})`);
68
+ const item = await $fetch("/api/cms/admin/media", {
69
+ method: "POST",
70
+ body: {
71
+ key: presign.key,
72
+ folder: presign.folder,
73
+ mime: file.type || null,
74
+ size: file.size,
75
+ ...await mediaDimensions(file)
76
+ }
77
+ });
78
+ done.value++;
79
+ return item;
80
+ }
81
+ async function upload(list, options) {
82
+ const all = Array.from(list);
83
+ let files = all.filter((file) => matchesAccept(file, options.accept));
84
+ const rejected = all.length - files.length;
85
+ if (rejected) {
86
+ toast.add({
87
+ title: `${rejected} file${rejected > 1 ? "s" : ""} of an unsupported type skipped`,
88
+ color: "error"
89
+ });
90
+ }
91
+ for (const file of files.filter((file2) => file2.size > mediaMaxFileSize)) {
92
+ toast.add({
93
+ title: `File too large (max ${formatFileSize(mediaMaxFileSize)}): ${file.name}`,
94
+ color: "error"
95
+ });
96
+ }
97
+ files = files.filter((file) => file.size <= mediaMaxFileSize);
98
+ if (options.multiple === false) files = files.slice(0, 1);
99
+ if (!files.length || uploading.value) return [];
100
+ uploading.value = true;
101
+ done.value = 0;
102
+ total.value = files.length;
103
+ try {
104
+ const results = await Promise.allSettled(
105
+ files.map((file) => uploadOne(file, options.folder))
106
+ );
107
+ const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
108
+ const failed = results.length - ok.length;
109
+ if (failed)
110
+ toast.add({ title: `${failed} upload${failed > 1 ? "s" : ""} failed`, color: "error" });
111
+ return ok;
112
+ } finally {
113
+ uploading.value = false;
114
+ }
115
+ }
116
+ return { upload, uploading, done, total };
117
+ }
@@ -6,13 +6,25 @@
6
6
  </template>
7
7
  </CmsPageHeader>
8
8
 
9
- <CmsMediaGallery />
9
+ <CmsMediaGallery v-model:folder="folder" />
10
10
  </div>
11
11
  </template>
12
12
 
13
13
  <script setup>
14
- import { definePageMeta } from "#imports";
14
+ import { computed, definePageMeta, navigateTo, useRoute } from "#imports";
15
+ import { normalizeMediaFolder } from "#nuxt-cms";
15
16
  import { useCmsRuntime } from "../composables/cms-runtime";
16
17
  definePageMeta({ layout: "cms-admin", middleware: "cms-auth" });
17
18
  const runtime = useCmsRuntime();
19
+ const route = useRoute();
20
+ const folder = computed({
21
+ get: () => {
22
+ const value = route.query.folder;
23
+ return normalizeMediaFolder(typeof value === "string" ? value : null);
24
+ },
25
+ set: (value) => {
26
+ const { folder: _folder, ...query } = route.query;
27
+ void navigateTo({ query: value ? { ...query, folder: value } : query });
28
+ }
29
+ });
18
30
  </script>