@xleddyl/nuxt-cms 0.1.60 → 0.2.0
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 +137 -9
- package/dist/runtime/app/components/cms/BlocksField.vue +86 -10
- package/dist/runtime/app/components/cms/BrandMark.d.vue.ts +6 -0
- package/dist/runtime/app/components/cms/BrandMark.vue +19 -0
- package/dist/runtime/app/components/cms/BrandMark.vue.d.ts +6 -0
- package/dist/runtime/app/components/cms/Checkbox.d.vue.ts +11 -0
- package/dist/runtime/app/components/cms/Checkbox.vue +21 -0
- package/dist/runtime/app/components/cms/Checkbox.vue.d.ts +11 -0
- package/dist/runtime/app/components/cms/EmptyState.vue +4 -2
- package/dist/runtime/app/components/cms/EntryDrawer.vue +1 -0
- package/dist/runtime/app/components/cms/EntryForm.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/EntryForm.vue +105 -30
- package/dist/runtime/app/components/cms/EntryForm.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/FieldInput.vue +20 -5
- package/dist/runtime/app/components/cms/FormField.d.vue.ts +5 -3
- package/dist/runtime/app/components/cms/FormField.vue +8 -2
- package/dist/runtime/app/components/cms/FormField.vue.d.ts +5 -3
- package/dist/runtime/app/components/cms/Icon.vue +1 -1
- package/dist/runtime/app/components/cms/MediaDetail.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue +169 -0
- package/dist/runtime/app/components/cms/MediaDetail.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/MediaField.vue +9 -2
- package/dist/runtime/app/components/cms/MediaFolderPicker.d.vue.ts +2 -2
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue +55 -54
- package/dist/runtime/app/components/cms/MediaFolderPicker.vue.d.ts +2 -2
- package/dist/runtime/app/components/cms/MediaGallery.d.vue.ts +8 -2
- package/dist/runtime/app/components/cms/MediaGallery.vue +927 -336
- package/dist/runtime/app/components/cms/MediaGallery.vue.d.ts +8 -2
- package/dist/runtime/app/components/cms/MediaUpload.d.vue.ts +1 -0
- package/dist/runtime/app/components/cms/MediaUpload.vue +76 -139
- package/dist/runtime/app/components/cms/MediaUpload.vue.d.ts +1 -0
- package/dist/runtime/app/components/cms/PageHeader.d.vue.ts +5 -3
- package/dist/runtime/app/components/cms/PageHeader.vue +16 -6
- package/dist/runtime/app/components/cms/PageHeader.vue.d.ts +5 -3
- package/dist/runtime/app/components/cms/PasswordInput.d.vue.ts +17 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue +77 -0
- package/dist/runtime/app/components/cms/PasswordInput.vue.d.ts +17 -0
- package/dist/runtime/app/components/cms/SelectMenu.vue +3 -5
- package/dist/runtime/app/components/cms/SettingsAccount.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsAccount.vue +138 -0
- package/dist/runtime/app/components/cms/SettingsAccount.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsColumns.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsColumns.vue +102 -0
- package/dist/runtime/app/components/cms/SettingsColumns.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsEntry.d.vue.ts +6 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue +133 -0
- package/dist/runtime/app/components/cms/SettingsEntry.vue.d.ts +6 -0
- package/dist/runtime/app/components/cms/SettingsLayout.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsLayout.vue +177 -0
- package/dist/runtime/app/components/cms/SettingsLayout.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsModal.d.vue.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsModal.vue +179 -0
- package/dist/runtime/app/components/cms/SettingsModal.vue.d.ts +16 -0
- package/dist/runtime/app/components/cms/SettingsUsers.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/SettingsUsers.vue +270 -0
- package/dist/runtime/app/components/cms/SettingsUsers.vue.d.ts +3 -0
- package/dist/runtime/app/components/cms/Spinner.vue +2 -2
- package/dist/runtime/app/components/cms/Table.d.vue.ts +7 -2
- package/dist/runtime/app/components/cms/Table.vue +38 -1
- package/dist/runtime/app/components/cms/Table.vue.d.ts +7 -2
- package/dist/runtime/app/components/cms/Toaster.vue +1 -1
- package/dist/runtime/app/components/cms/WelcomeModal.d.vue.ts +3 -0
- package/dist/runtime/app/components/cms/WelcomeModal.vue +69 -0
- package/dist/runtime/app/components/cms/WelcomeModal.vue.d.ts +3 -0
- package/dist/runtime/app/composables/cms-account.d.ts +11 -0
- package/dist/runtime/app/composables/cms-account.js +23 -0
- package/dist/runtime/app/composables/cms-media-library.d.ts +75 -0
- package/dist/runtime/app/composables/cms-media-library.js +151 -0
- package/dist/runtime/app/composables/cms-media-uploader.d.ts +22 -0
- package/dist/runtime/app/composables/cms-media-uploader.js +244 -0
- package/dist/runtime/app/composables/cms-settings.d.ts +13 -0
- package/dist/runtime/app/composables/cms-settings.js +59 -0
- package/dist/runtime/app/composables/cms-theme.d.ts +9 -0
- package/dist/runtime/app/composables/cms-theme.js +27 -0
- package/dist/runtime/app/layouts/cms-admin.d.vue.ts +2 -2
- package/dist/runtime/app/layouts/cms-admin.vue +71 -17
- package/dist/runtime/app/layouts/cms-admin.vue.d.ts +2 -2
- package/dist/runtime/app/pages/admin-collection.vue +127 -101
- package/dist/runtime/app/pages/admin-entry.vue +1 -0
- package/dist/runtime/app/pages/admin-login.vue +18 -3
- package/dist/runtime/app/pages/admin-media.vue +19 -3
- package/dist/runtime/app/utils/ui.d.ts +2 -0
- package/dist/runtime/app/utils/ui.js +18 -0
- package/dist/runtime/assets/main.css +1 -1
- package/dist/runtime/server/api/account-password.put.d.ts +4 -0
- package/dist/runtime/server/api/account-password.put.js +38 -0
- package/dist/runtime/server/api/account-welcome.post.d.ts +4 -0
- package/dist/runtime/server/api/account-welcome.post.js +14 -0
- package/dist/runtime/server/api/account.put.d.ts +3 -0
- package/dist/runtime/server/api/account.put.js +25 -0
- package/dist/runtime/server/api/media-delete.post.d.ts +4 -0
- package/dist/runtime/server/api/media-delete.post.js +23 -0
- package/dist/runtime/server/api/media-folder.delete.js +6 -16
- package/dist/runtime/server/api/media-folder.patch.d.ts +5 -0
- package/dist/runtime/server/api/media-folder.patch.js +68 -0
- package/dist/runtime/server/api/media-folder.post.js +3 -15
- package/dist/runtime/server/api/media-move.post.d.ts +5 -0
- package/dist/runtime/server/api/media-move.post.js +28 -0
- package/dist/runtime/server/api/media-usage.post.d.ts +5 -0
- package/dist/runtime/server/api/media-usage.post.js +15 -0
- package/dist/runtime/server/api/settings.delete.d.ts +4 -0
- package/dist/runtime/server/api/settings.delete.js +12 -0
- package/dist/runtime/server/api/settings.get.d.ts +3 -0
- package/dist/runtime/server/api/settings.get.js +15 -0
- package/dist/runtime/server/api/settings.put.d.ts +3 -0
- package/dist/runtime/server/api/settings.put.js +14 -0
- package/dist/runtime/server/api/user.delete.d.ts +4 -0
- package/dist/runtime/server/api/user.delete.js +12 -0
- package/dist/runtime/server/api/user.put.d.ts +3 -0
- package/dist/runtime/server/api/user.put.js +27 -0
- package/dist/runtime/server/api/users.get.d.ts +3 -0
- package/dist/runtime/server/api/users.get.js +11 -0
- package/dist/runtime/server/api/users.post.d.ts +3 -0
- package/dist/runtime/server/api/users.post.js +32 -0
- package/dist/runtime/server/plugins/session-validate.d.ts +2 -0
- package/dist/runtime/server/plugins/session-validate.js +13 -0
- package/dist/runtime/server/routes/auth/login.post.js +18 -8
- package/dist/runtime/server/utils/media-folders.d.ts +86 -0
- package/dist/runtime/server/utils/media-folders.js +37 -0
- package/dist/runtime/server/utils/media-references.d.ts +7 -1
- package/dist/runtime/server/utils/media-references.js +40 -0
- package/dist/runtime/server/utils/media-usage.d.ts +2 -0
- package/dist/runtime/server/utils/media-usage.js +18 -0
- package/dist/runtime/server/utils/password.d.ts +3 -0
- package/dist/runtime/server/utils/password.js +31 -0
- package/dist/runtime/server/utils/require-admin.d.ts +6 -2
- package/dist/runtime/server/utils/require-admin.js +33 -28
- package/dist/runtime/server/utils/same-origin.d.ts +2 -0
- package/dist/runtime/server/utils/same-origin.js +24 -0
- package/dist/runtime/server/utils/settings.d.ts +16 -0
- package/dist/runtime/server/utils/settings.js +31 -0
- package/dist/runtime/server/utils/users.d.ts +15 -0
- package/dist/runtime/server/utils/users.js +34 -0
- package/dist/runtime/shared/index.d.ts +29 -1
- package/dist/runtime/shared/index.js +36 -2
- package/dist/runtime/shared/layout.d.ts +34 -0
- package/dist/runtime/shared/layout.js +130 -0
- package/dist/runtime/shared/users.d.ts +18 -0
- package/dist/runtime/shared/users.js +1 -0
- package/dist/runtime/shared/validation.js +5 -4
- package/package.json +3 -3
|
@@ -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,22 @@
|
|
|
1
|
+
import type { MediaItem } from '../../shared/index.js';
|
|
2
|
+
export declare function matchesAccept(file: File, accept: string | undefined): boolean;
|
|
3
|
+
export interface UploadEntry {
|
|
4
|
+
file: File;
|
|
5
|
+
directory: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface UploadTree {
|
|
8
|
+
files: UploadEntry[];
|
|
9
|
+
directories: string[];
|
|
10
|
+
}
|
|
11
|
+
export declare function uploadTreeFromFiles(list: FileList | File[]): UploadTree;
|
|
12
|
+
export declare function uploadTreeFromDrop(dataTransfer: DataTransfer): Promise<UploadTree>;
|
|
13
|
+
export declare function useCmsMediaUploader(): {
|
|
14
|
+
upload: (source: FileList | File[] | UploadTree, options: {
|
|
15
|
+
folder: string | null;
|
|
16
|
+
accept?: string;
|
|
17
|
+
multiple?: boolean;
|
|
18
|
+
}) => Promise<MediaItem[]>;
|
|
19
|
+
uploading: import("vue").Ref<boolean, boolean>;
|
|
20
|
+
done: import("vue").Ref<number, number>;
|
|
21
|
+
total: import("vue").Ref<number, number>;
|
|
22
|
+
};
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatFileSize,
|
|
3
|
+
MEDIA_FOLDER_MAX_DEPTH,
|
|
4
|
+
mediaFolderSegments,
|
|
5
|
+
normalizeMediaFolder
|
|
6
|
+
} from "../../shared/index.js";
|
|
7
|
+
import { ref } from "#imports";
|
|
8
|
+
import { useCmsRuntime } from "./cms-runtime.js";
|
|
9
|
+
import { useCmsToast } from "./cms-toast.js";
|
|
10
|
+
async function imageDimensions(file) {
|
|
11
|
+
try {
|
|
12
|
+
const bitmap = await createImageBitmap(file);
|
|
13
|
+
const dims = { width: bitmap.width, height: bitmap.height };
|
|
14
|
+
bitmap.close();
|
|
15
|
+
return dims;
|
|
16
|
+
} catch {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function videoDimensions(file) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const url = URL.createObjectURL(file);
|
|
23
|
+
const video = document.createElement("video");
|
|
24
|
+
const settle = (dims) => {
|
|
25
|
+
URL.revokeObjectURL(url);
|
|
26
|
+
resolve(dims);
|
|
27
|
+
};
|
|
28
|
+
video.preload = "metadata";
|
|
29
|
+
video.muted = true;
|
|
30
|
+
video.onloadedmetadata = () => settle(
|
|
31
|
+
video.videoWidth && video.videoHeight ? { width: video.videoWidth, height: video.videoHeight } : {}
|
|
32
|
+
);
|
|
33
|
+
video.onerror = () => settle({});
|
|
34
|
+
video.src = url;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
async function mediaDimensions(file) {
|
|
38
|
+
if (file.type.startsWith("image/")) return imageDimensions(file);
|
|
39
|
+
if (file.type.startsWith("video/")) return videoDimensions(file);
|
|
40
|
+
return {};
|
|
41
|
+
}
|
|
42
|
+
export function matchesAccept(file, accept) {
|
|
43
|
+
if (!accept) return true;
|
|
44
|
+
return accept.split(",").some((raw) => {
|
|
45
|
+
const pattern = raw.trim().toLowerCase();
|
|
46
|
+
if (pattern.endsWith("/*")) return file.type.toLowerCase().startsWith(pattern.slice(0, -1));
|
|
47
|
+
if (pattern.startsWith(".")) return file.name.toLowerCase().endsWith(pattern);
|
|
48
|
+
return file.type.toLowerCase() === pattern;
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
const UPLOAD_CONCURRENCY = 4;
|
|
52
|
+
function isHiddenName(name) {
|
|
53
|
+
return name.startsWith(".");
|
|
54
|
+
}
|
|
55
|
+
function joinPath(parent, name) {
|
|
56
|
+
return parent ? `${parent}/${name}` : name;
|
|
57
|
+
}
|
|
58
|
+
export function uploadTreeFromFiles(list) {
|
|
59
|
+
const files = [];
|
|
60
|
+
const directories = /* @__PURE__ */ new Set();
|
|
61
|
+
for (const file of Array.from(list)) {
|
|
62
|
+
const segments = (file.webkitRelativePath || file.name).split("/").filter(Boolean);
|
|
63
|
+
if (segments.some(isHiddenName)) continue;
|
|
64
|
+
const directory = segments.slice(0, -1).join("/") || null;
|
|
65
|
+
if (directory) directories.add(directory);
|
|
66
|
+
files.push({ file, directory });
|
|
67
|
+
}
|
|
68
|
+
return { files, directories: [...directories] };
|
|
69
|
+
}
|
|
70
|
+
function readDirectory(entry) {
|
|
71
|
+
const reader = entry.createReader();
|
|
72
|
+
const all = [];
|
|
73
|
+
return new Promise((resolve, reject) => {
|
|
74
|
+
const next = () => reader.readEntries((batch) => {
|
|
75
|
+
if (!batch.length) return resolve(all);
|
|
76
|
+
all.push(...batch);
|
|
77
|
+
next();
|
|
78
|
+
}, reject);
|
|
79
|
+
next();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function entryFile(entry) {
|
|
83
|
+
return new Promise((resolve, reject) => entry.file(resolve, reject));
|
|
84
|
+
}
|
|
85
|
+
async function walkEntry(entry, parent, tree) {
|
|
86
|
+
if (isHiddenName(entry.name)) return;
|
|
87
|
+
if (entry.isFile) {
|
|
88
|
+
tree.files.push({ file: await entryFile(entry), directory: parent });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (!entry.isDirectory) return;
|
|
92
|
+
const directory = joinPath(parent, entry.name);
|
|
93
|
+
tree.directories.push(directory);
|
|
94
|
+
const children = await readDirectory(entry);
|
|
95
|
+
for (const child of children) await walkEntry(child, directory, tree);
|
|
96
|
+
}
|
|
97
|
+
export function uploadTreeFromDrop(dataTransfer) {
|
|
98
|
+
const files = Array.from(dataTransfer.files);
|
|
99
|
+
const entries = Array.from(dataTransfer.items ?? []).filter((item) => item.kind === "file").map((item) => item.webkitGetAsEntry?.() ?? null).filter((entry) => !!entry);
|
|
100
|
+
if (!entries.some((entry) => entry.isDirectory)) {
|
|
101
|
+
return Promise.resolve(uploadTreeFromFiles(files));
|
|
102
|
+
}
|
|
103
|
+
return (async () => {
|
|
104
|
+
const tree = { files: [], directories: [] };
|
|
105
|
+
for (const entry of entries) await walkEntry(entry, null, tree);
|
|
106
|
+
return tree;
|
|
107
|
+
})();
|
|
108
|
+
}
|
|
109
|
+
async function mapWithConcurrency(list, limit, task) {
|
|
110
|
+
const results = new Array(list.length);
|
|
111
|
+
let cursor = 0;
|
|
112
|
+
const worker = async () => {
|
|
113
|
+
while (cursor < list.length) {
|
|
114
|
+
const index = cursor++;
|
|
115
|
+
try {
|
|
116
|
+
results[index] = { status: "fulfilled", value: await task(list[index]) };
|
|
117
|
+
} catch (reason) {
|
|
118
|
+
results[index] = { status: "rejected", reason };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await Promise.all(Array.from({ length: Math.min(limit, list.length) }, worker));
|
|
123
|
+
return results;
|
|
124
|
+
}
|
|
125
|
+
function targetFolder(base, directory) {
|
|
126
|
+
const joined = [base, directory].filter(Boolean).join("/");
|
|
127
|
+
return {
|
|
128
|
+
folder: normalizeMediaFolder(joined),
|
|
129
|
+
flattened: mediaFolderSegments(joined).length > MEDIA_FOLDER_MAX_DEPTH
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function isWithinOrAbove(folder, other) {
|
|
133
|
+
return other === folder || other.startsWith(`${folder}/`);
|
|
134
|
+
}
|
|
135
|
+
export function useCmsMediaUploader() {
|
|
136
|
+
const toast = useCmsToast();
|
|
137
|
+
const { mediaMaxFileSize } = useCmsRuntime();
|
|
138
|
+
const uploading = ref(false);
|
|
139
|
+
const done = ref(0);
|
|
140
|
+
const total = ref(0);
|
|
141
|
+
async function uploadOne(file, folder) {
|
|
142
|
+
const presign = await $fetch("/api/cms/admin/media/presign", {
|
|
143
|
+
method: "POST",
|
|
144
|
+
body: {
|
|
145
|
+
filename: file.name,
|
|
146
|
+
contentType: file.type || "application/octet-stream",
|
|
147
|
+
size: file.size,
|
|
148
|
+
folder
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
const res = await fetch(presign.uploadUrl, {
|
|
152
|
+
method: "PUT",
|
|
153
|
+
headers: presign.headers,
|
|
154
|
+
body: file
|
|
155
|
+
});
|
|
156
|
+
if (!res.ok) throw new Error(`Upload failed (${res.status})`);
|
|
157
|
+
const item = await $fetch("/api/cms/admin/media", {
|
|
158
|
+
method: "POST",
|
|
159
|
+
body: {
|
|
160
|
+
key: presign.key,
|
|
161
|
+
folder: presign.folder,
|
|
162
|
+
mime: file.type || null,
|
|
163
|
+
size: file.size,
|
|
164
|
+
...await mediaDimensions(file)
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
done.value++;
|
|
168
|
+
return item;
|
|
169
|
+
}
|
|
170
|
+
async function createFolders(folders) {
|
|
171
|
+
const results = await mapWithConcurrency(
|
|
172
|
+
folders,
|
|
173
|
+
UPLOAD_CONCURRENCY,
|
|
174
|
+
(name) => $fetch("/api/cms/admin/media/folders", {
|
|
175
|
+
method: "POST",
|
|
176
|
+
body: { name }
|
|
177
|
+
})
|
|
178
|
+
);
|
|
179
|
+
const failed = results.filter((r) => r.status === "rejected").length;
|
|
180
|
+
if (failed)
|
|
181
|
+
toast.add({
|
|
182
|
+
title: `${failed} folder${failed > 1 ? "s" : ""} could not be created`,
|
|
183
|
+
color: "error"
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
async function upload(source, options) {
|
|
187
|
+
const tree = Array.isArray(source) || source instanceof FileList ? uploadTreeFromFiles(source) : source;
|
|
188
|
+
const all = tree.files;
|
|
189
|
+
let files = all.filter((entry) => matchesAccept(entry.file, options.accept));
|
|
190
|
+
const rejected = all.length - files.length;
|
|
191
|
+
if (rejected) {
|
|
192
|
+
toast.add({
|
|
193
|
+
title: `${rejected} file${rejected > 1 ? "s" : ""} of an unsupported type skipped`,
|
|
194
|
+
color: "error"
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const { file } of files.filter((entry) => entry.file.size > mediaMaxFileSize)) {
|
|
198
|
+
toast.add({
|
|
199
|
+
title: `File too large (max ${formatFileSize(mediaMaxFileSize)}): ${file.name}`,
|
|
200
|
+
color: "error"
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
files = files.filter((entry) => entry.file.size <= mediaMaxFileSize);
|
|
204
|
+
const nested = options.multiple !== false;
|
|
205
|
+
if (!nested) files = files.slice(0, 1).map((entry) => ({ ...entry, directory: null }));
|
|
206
|
+
const directories = nested ? tree.directories : [];
|
|
207
|
+
if (!files.length && !directories.length || uploading.value) return [];
|
|
208
|
+
const planned = files.map((entry) => ({
|
|
209
|
+
file: entry.file,
|
|
210
|
+
...targetFolder(options.folder, entry.directory)
|
|
211
|
+
}));
|
|
212
|
+
const fileFolders = planned.map((entry) => entry.folder).filter((f) => !!f);
|
|
213
|
+
const emptyFolders = [
|
|
214
|
+
...new Set(
|
|
215
|
+
directories.map((directory) => targetFolder(options.folder, directory).folder).filter((folder) => !!folder)
|
|
216
|
+
)
|
|
217
|
+
].filter((folder) => !fileFolders.some((other) => isWithinOrAbove(folder, other)));
|
|
218
|
+
if (planned.some((entry) => entry.flattened)) {
|
|
219
|
+
toast.add({
|
|
220
|
+
title: `Folders nest up to ${MEDIA_FOLDER_MAX_DEPTH} levels`,
|
|
221
|
+
description: "Files in deeper folders were uploaded to the deepest allowed folder."
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
uploading.value = true;
|
|
225
|
+
done.value = 0;
|
|
226
|
+
total.value = planned.length;
|
|
227
|
+
try {
|
|
228
|
+
if (emptyFolders.length) await createFolders(emptyFolders);
|
|
229
|
+
const results = await mapWithConcurrency(
|
|
230
|
+
planned,
|
|
231
|
+
UPLOAD_CONCURRENCY,
|
|
232
|
+
(entry) => uploadOne(entry.file, entry.folder)
|
|
233
|
+
);
|
|
234
|
+
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
235
|
+
const failed = results.length - ok.length;
|
|
236
|
+
if (failed)
|
|
237
|
+
toast.add({ title: `${failed} upload${failed > 1 ? "s" : ""} failed`, color: "error" });
|
|
238
|
+
return ok;
|
|
239
|
+
} finally {
|
|
240
|
+
uploading.value = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { upload, uploading, done, total };
|
|
244
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { CmsEntrySettings, CmsFormLayout, CmsSettingsMap } from '#nuxt-cms';
|
|
2
|
+
export declare function useCmsSettingsState(): import("vue").Ref<CmsSettingsMap, CmsSettingsMap>;
|
|
3
|
+
export declare function loadCmsSettings(): Promise<void>;
|
|
4
|
+
export declare function useCmsSettings(): {
|
|
5
|
+
settings: import("vue").Ref<CmsSettingsMap, CmsSettingsMap>;
|
|
6
|
+
save: (name: string, value: CmsEntrySettings) => Promise<void>;
|
|
7
|
+
reset: (name: string) => Promise<void>;
|
|
8
|
+
};
|
|
9
|
+
export declare function entryFields(name: string): Record<string, import("#nuxt-cms").FieldConfig>;
|
|
10
|
+
export declare function defaultFormLayout(name: string, fields?: Record<string, import("#nuxt-cms").FieldConfig>): CmsFormLayout;
|
|
11
|
+
export declare function entryFormLayout(name: string, settings: CmsSettingsMap, fields?: Record<string, import("#nuxt-cms").FieldConfig>): CmsFormLayout;
|
|
12
|
+
export declare function listExtraColumns(name: string): string[];
|
|
13
|
+
export declare function entryListColumns(name: string, settings: CmsSettingsMap): string[];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { layoutFromConfig, pageAllFields, resolveFormLayout, resolveListColumns } from "#nuxt-cms";
|
|
2
|
+
import { useFetch, useState } from "#imports";
|
|
3
|
+
import cmsConfig from "#cms-config";
|
|
4
|
+
import { cmsApi } from "../utils/api.js";
|
|
5
|
+
const SETTINGS_ENDPOINT = "/api/cms/admin/settings";
|
|
6
|
+
export function useCmsSettingsState() {
|
|
7
|
+
return useState("cms-settings", () => ({}));
|
|
8
|
+
}
|
|
9
|
+
export async function loadCmsSettings() {
|
|
10
|
+
const state = useCmsSettingsState();
|
|
11
|
+
const { data } = await useFetch(SETTINGS_ENDPOINT, { key: "cms-settings" });
|
|
12
|
+
if (data.value) state.value = data.value;
|
|
13
|
+
}
|
|
14
|
+
export function useCmsSettings() {
|
|
15
|
+
const settings = useCmsSettingsState();
|
|
16
|
+
async function save(name, value) {
|
|
17
|
+
const saved = await cmsApi(`${SETTINGS_ENDPOINT}/${name}`, {
|
|
18
|
+
method: "PUT",
|
|
19
|
+
body: value
|
|
20
|
+
});
|
|
21
|
+
settings.value = { ...settings.value, [name]: saved };
|
|
22
|
+
}
|
|
23
|
+
async function reset(name) {
|
|
24
|
+
await cmsApi(`${SETTINGS_ENDPOINT}/${name}`, { method: "DELETE" });
|
|
25
|
+
const next = { ...settings.value };
|
|
26
|
+
delete next[name];
|
|
27
|
+
settings.value = next;
|
|
28
|
+
}
|
|
29
|
+
return { settings, save, reset };
|
|
30
|
+
}
|
|
31
|
+
function entryOf(name) {
|
|
32
|
+
return cmsConfig[name];
|
|
33
|
+
}
|
|
34
|
+
export function entryFields(name) {
|
|
35
|
+
const entry = entryOf(name);
|
|
36
|
+
if (!entry) return {};
|
|
37
|
+
return entry.kind === "page" ? pageAllFields(entry) : entry.fields;
|
|
38
|
+
}
|
|
39
|
+
export function defaultFormLayout(name, fields = entryFields(name)) {
|
|
40
|
+
return resolveFormLayout(fields, layoutFromConfig(entryOf(name)?.layout));
|
|
41
|
+
}
|
|
42
|
+
export function entryFormLayout(name, settings, fields = entryFields(name)) {
|
|
43
|
+
const saved = settings[name]?.layout;
|
|
44
|
+
return saved ? resolveFormLayout(fields, saved) : defaultFormLayout(name, fields);
|
|
45
|
+
}
|
|
46
|
+
export function listExtraColumns(name) {
|
|
47
|
+
const entry = entryOf(name);
|
|
48
|
+
return entry?.kind === "collection" && entry.drafts ? ["status"] : [];
|
|
49
|
+
}
|
|
50
|
+
export function entryListColumns(name, settings) {
|
|
51
|
+
const entry = entryOf(name);
|
|
52
|
+
if (!entry) return [];
|
|
53
|
+
return resolveListColumns(
|
|
54
|
+
entry.fields,
|
|
55
|
+
settings[name]?.columns,
|
|
56
|
+
entry.list,
|
|
57
|
+
listExtraColumns(name)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Ref } from 'vue';
|
|
2
|
+
export type CmsTheme = 'light' | 'dark' | 'system';
|
|
3
|
+
export declare const CMS_THEMES: {
|
|
4
|
+
value: CmsTheme;
|
|
5
|
+
label: string;
|
|
6
|
+
icon: string;
|
|
7
|
+
}[];
|
|
8
|
+
export declare function useCmsTheme(): import("nuxt/app").CookieRef<CmsTheme>;
|
|
9
|
+
export declare function setCmsTheme(theme: Ref<CmsTheme>, value: CmsTheme): void;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { nextTick, useCookie } from "#imports";
|
|
2
|
+
export const CMS_THEMES = [
|
|
3
|
+
{ value: "light", label: "Light", icon: "sun" },
|
|
4
|
+
{ value: "dark", label: "Dark", icon: "moon" },
|
|
5
|
+
{ value: "system", label: "System", icon: "computer-desktop" }
|
|
6
|
+
];
|
|
7
|
+
export function useCmsTheme() {
|
|
8
|
+
return useCookie("cms-theme", {
|
|
9
|
+
default: () => "system",
|
|
10
|
+
maxAge: 60 * 60 * 24 * 365,
|
|
11
|
+
sameSite: "lax",
|
|
12
|
+
path: "/"
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
export function setCmsTheme(theme, value) {
|
|
16
|
+
if (value === theme.value) return;
|
|
17
|
+
const doc = document;
|
|
18
|
+
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
19
|
+
if (!doc.startViewTransition || reduced) {
|
|
20
|
+
theme.value = value;
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
doc.startViewTransition(async () => {
|
|
24
|
+
theme.value = value;
|
|
25
|
+
await nextTick();
|
|
26
|
+
});
|
|
27
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
declare var
|
|
1
|
+
declare var __VLS_34: {};
|
|
2
2
|
type __VLS_Slots = {} & {
|
|
3
|
-
default?: (props: typeof
|
|
3
|
+
default?: (props: typeof __VLS_34) => any;
|
|
4
4
|
};
|
|
5
5
|
declare const __VLS_base: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>;
|
|
6
6
|
declare const __VLS_export: __VLS_WithSlots<typeof __VLS_base, __VLS_Slots>;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
<template>
|
|
2
|
-
<div class="cms-scope cms-canvas min-h-screen">
|
|
3
|
-
<div class="cms-shell">
|
|
2
|
+
<div class="cms-scope cms-canvas min-h-screen" :data-theme="theme">
|
|
3
|
+
<div class="cms-shell" :class="{ 'is-nav-open': navOpen }">
|
|
4
4
|
<aside class="cms-sidebar">
|
|
5
|
-
<div class="cms-sidebar-brand">
|
|
5
|
+
<div class="cms-sidebar-brand">
|
|
6
|
+
<CmsBrandMark />
|
|
7
|
+
<div class="cms-brand-text">
|
|
8
|
+
<span class="cms-brand-name">nuxt-cms</span>
|
|
9
|
+
<span class="cms-brand-meta">Content studio</span>
|
|
10
|
+
</div>
|
|
11
|
+
</div>
|
|
6
12
|
|
|
7
13
|
<nav class="cms-sidebar-nav">
|
|
8
14
|
<div v-for="group in groups" :key="group.title" class="cms-sidebar-group">
|
|
@@ -14,49 +20,81 @@
|
|
|
14
20
|
:key="link.name"
|
|
15
21
|
:to="link.to"
|
|
16
22
|
class="cms-navlink"
|
|
17
|
-
:class="{ 'is-active':
|
|
23
|
+
:class="{ 'is-active': isActive(link.to) }"
|
|
18
24
|
>
|
|
19
|
-
<CmsIcon
|
|
25
|
+
<CmsIcon
|
|
26
|
+
:name="link.icon ?? group.icon"
|
|
27
|
+
class="cms-navlink-icon size-4 shrink-0"
|
|
28
|
+
/>
|
|
20
29
|
<span class="truncate">{{ link.label }}</span>
|
|
21
30
|
</NuxtLink>
|
|
22
31
|
</div>
|
|
23
32
|
</nav>
|
|
24
33
|
|
|
25
34
|
<div class="cms-sidebar-footer">
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
class="cms-account"
|
|
38
|
+
:class="{ 'is-open': settingsOpen }"
|
|
39
|
+
aria-label="Open settings"
|
|
40
|
+
@click="openSettings"
|
|
41
|
+
>
|
|
42
|
+
<span class="cms-avatar">{{ initial }}</span>
|
|
43
|
+
<span class="cms-account-text">
|
|
44
|
+
<span class="cms-account-name">{{ displayName }}</span>
|
|
45
|
+
<span class="cms-account-email" :title="email">{{ email }}</span>
|
|
46
|
+
</span>
|
|
47
|
+
<CmsIcon name="ellipsis-horizontal" class="cms-account-more size-4" />
|
|
48
|
+
</button>
|
|
36
49
|
</div>
|
|
37
50
|
</aside>
|
|
38
51
|
|
|
52
|
+
<div class="cms-sidebar-scrim" @click="navOpen = false" />
|
|
53
|
+
|
|
39
54
|
<main class="cms-main">
|
|
55
|
+
<header class="cms-topbar">
|
|
56
|
+
<CmsButton
|
|
57
|
+
icon="bars-3"
|
|
58
|
+
variant="ghost"
|
|
59
|
+
color="neutral"
|
|
60
|
+
size="sm"
|
|
61
|
+
aria-label="Open navigation"
|
|
62
|
+
@click="navOpen = true"
|
|
63
|
+
/>
|
|
64
|
+
<CmsBrandMark />
|
|
65
|
+
<span class="cms-brand-name">nuxt-cms</span>
|
|
66
|
+
</header>
|
|
40
67
|
<div class="cms-main-inner">
|
|
41
68
|
<slot />
|
|
42
69
|
</div>
|
|
43
70
|
</main>
|
|
44
71
|
</div>
|
|
45
72
|
|
|
73
|
+
<CmsSettingsModal v-model:open="settingsOpen" v-model:theme="theme" @logout="logout" />
|
|
74
|
+
<CmsWelcomeModal />
|
|
46
75
|
<CmsToaster />
|
|
47
76
|
<CmsConfirmModal />
|
|
48
77
|
</div>
|
|
49
78
|
</template>
|
|
50
79
|
|
|
51
80
|
<script setup>
|
|
52
|
-
import { computed, navigateTo, useRoute, useUserSession } from "#imports";
|
|
81
|
+
import { computed, navigateTo, ref, useRoute, useUserSession, watch } from "#imports";
|
|
53
82
|
import cmsConfig from "#cms-config";
|
|
83
|
+
import { useCmsTheme } from "../composables/cms-theme";
|
|
84
|
+
import { useCmsAccount } from "../composables/cms-account";
|
|
85
|
+
import { loadCmsSettings } from "../composables/cms-settings";
|
|
54
86
|
const route = useRoute();
|
|
55
87
|
const { clear } = useUserSession();
|
|
88
|
+
const { email, displayName, initial } = useCmsAccount();
|
|
89
|
+
const theme = useCmsTheme();
|
|
90
|
+
const navOpen = ref(false);
|
|
91
|
+
const settingsOpen = ref(false);
|
|
92
|
+
await loadCmsSettings();
|
|
56
93
|
const links = Object.entries(cmsConfig).map(([name, entry]) => ({
|
|
57
94
|
name,
|
|
58
95
|
label: entry.label,
|
|
59
96
|
kind: entry.kind,
|
|
97
|
+
icon: entry.icon,
|
|
60
98
|
to: `/cms/${name}`
|
|
61
99
|
}));
|
|
62
100
|
const groups = computed(
|
|
@@ -79,11 +117,27 @@ const groups = computed(
|
|
|
79
117
|
{
|
|
80
118
|
title: "Library",
|
|
81
119
|
icon: "photo",
|
|
82
|
-
links: [
|
|
120
|
+
links: [
|
|
121
|
+
{ name: "media", label: "Media", kind: "media", icon: void 0, to: "/cms/media" }
|
|
122
|
+
]
|
|
83
123
|
}
|
|
84
124
|
].filter((g) => g.links.length)
|
|
85
125
|
);
|
|
126
|
+
function isActive(to) {
|
|
127
|
+
return route.path === to || route.path.startsWith(`${to}/`);
|
|
128
|
+
}
|
|
129
|
+
watch(
|
|
130
|
+
() => route.fullPath,
|
|
131
|
+
() => {
|
|
132
|
+
navOpen.value = false;
|
|
133
|
+
}
|
|
134
|
+
);
|
|
135
|
+
function openSettings() {
|
|
136
|
+
navOpen.value = false;
|
|
137
|
+
settingsOpen.value = true;
|
|
138
|
+
}
|
|
86
139
|
async function logout() {
|
|
140
|
+
settingsOpen.value = false;
|
|
87
141
|
await clear();
|
|
88
142
|
await navigateTo("/cms/login");
|
|
89
143
|
}
|