@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.
- package/dist/module.json +1 -1
- package/dist/module.mjs +5 -1
- package/dist/runtime/app/components/cms/Alert.d.vue.ts +1 -2
- package/dist/runtime/app/components/cms/Alert.vue +2 -3
- package/dist/runtime/app/components/cms/Alert.vue.d.ts +1 -2
- package/dist/runtime/app/components/cms/BlocksField.vue +2 -2
- package/dist/runtime/app/components/cms/Button.d.vue.ts +1 -1
- package/dist/runtime/app/components/cms/Button.vue.d.ts +1 -1
- package/dist/runtime/app/components/cms/ConfirmModal.vue +1 -1
- package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -1
- package/dist/runtime/app/components/cms/MediaField.vue +20 -18
- package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue +65 -0
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/MediaGallery.vue +213 -49
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -1
- package/dist/runtime/app/components/cms/MediaUpload.vue +9 -3
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -1
- package/dist/runtime/app/components/cms/Modal.d.vue.ts +4 -4
- package/dist/runtime/app/components/cms/Modal.vue +4 -4
- package/dist/runtime/app/components/cms/Modal.vue.d.ts +4 -4
- package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +0 -1
- package/dist/runtime/app/components/cms/PageHeader.vue +0 -4
- package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +0 -1
- package/dist/runtime/app/components/cms/RichTextField.vue +1 -1
- package/dist/runtime/app/components/cms/Table.d.vue.ts +10 -2
- package/dist/runtime/app/components/cms/Table.vue +30 -2
- package/dist/runtime/app/components/cms/Table.vue.d.ts +10 -2
- package/dist/runtime/app/components/cms/Toaster.vue +6 -0
- package/dist/runtime/app/layouts/cms-admin.vue +2 -2
- package/dist/runtime/app/pages/admin-collection.vue +18 -8
- package/dist/runtime/app/pages/admin-entry.vue +2 -2
- package/dist/runtime/app/pages/admin-login.vue +5 -5
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/collection.get.js +14 -4
- package/dist/runtime/server/api/media-presign.post.d.ts +1 -0
- package/dist/runtime/server/api/media-presign.post.js +7 -4
- package/dist/runtime/server/api/media.post.js +2 -1
- package/dist/runtime/server/api/media.put.js +2 -1
- package/dist/runtime/shared/index.d.ts +2 -0
- package/dist/runtime/shared/index.js +6 -0
- package/package.json +4 -2
package/dist/module.json
CHANGED
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(
|
|
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,6 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="cms-alert" :class="`cms-alert-${color ?? '
|
|
3
|
-
<p v-if="title" class="
|
|
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>
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
<div class="flex items-center gap-1">
|
|
9
9
|
<button
|
|
10
10
|
type="button"
|
|
11
|
-
class="cms-
|
|
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="
|
|
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' | '
|
|
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' | '
|
|
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="
|
|
12
|
+
<CmsButton label="Cancel" variant="soft" color="neutral" @click="finish(false)" />
|
|
13
13
|
<CmsButton
|
|
14
14
|
:label="state.confirmLabel ?? 'Confirm'"
|
|
15
15
|
color="error"
|
|
@@ -1,31 +1,33 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div v-if="model" class="cms-card cms-media-
|
|
3
|
-
<
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
<
|
|
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="
|
|
20
|
-
<
|
|
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
|
|
15
|
+
v-for="f in filters"
|
|
34
16
|
:key="f"
|
|
35
17
|
type="button"
|
|
36
18
|
class="cms-pill"
|
|
37
|
-
:class="{ 'is-active':
|
|
38
|
-
@click="
|
|
19
|
+
:class="{ 'is-active': filter === f }"
|
|
20
|
+
@click="filter = f"
|
|
39
21
|
>
|
|
40
|
-
|
|
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="
|
|
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="
|
|
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
|
-
<
|
|
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
|
|
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
|
|
411
|
+
folder: editFolder.value
|
|
248
412
|
}
|
|
249
413
|
});
|
|
250
414
|
editing.value = null;
|