@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
@@ -0,0 +1,227 @@
1
+ import { open, readdir, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ export const MEDIA_SYNC_MIME_TYPES = {
4
+ jpg: "image/jpeg",
5
+ jpeg: "image/jpeg",
6
+ png: "image/png",
7
+ webp: "image/webp",
8
+ avif: "image/avif",
9
+ gif: "image/gif",
10
+ svg: "image/svg+xml",
11
+ mp4: "video/mp4",
12
+ webm: "video/webm",
13
+ mov: "video/quicktime",
14
+ mp3: "audio/mpeg",
15
+ wav: "audio/wav",
16
+ ogg: "audio/ogg",
17
+ m4a: "audio/mp4",
18
+ pdf: "application/pdf"
19
+ };
20
+ const HEADER_BYTES = 65536;
21
+ export function mediaSyncExtension(key) {
22
+ const name = key.split("/").pop() ?? key;
23
+ const dot = name.lastIndexOf(".");
24
+ if (dot <= 0) return null;
25
+ return name.slice(dot + 1).toLowerCase();
26
+ }
27
+ export function isSyncableMediaKey(key) {
28
+ const name = key.split("/").pop() ?? key;
29
+ if (!name || name.startsWith(".")) return false;
30
+ const extension = mediaSyncExtension(key);
31
+ return !!extension && extension in MEDIA_SYNC_MIME_TYPES;
32
+ }
33
+ export function mediaMimeForKey(key) {
34
+ const extension = mediaSyncExtension(key);
35
+ return (extension && MEDIA_SYNC_MIME_TYPES[extension]) ?? null;
36
+ }
37
+ export function mediaSyncFolder(key) {
38
+ const slash = key.lastIndexOf("/");
39
+ return slash === -1 ? null : key.slice(0, slash) || null;
40
+ }
41
+ function view(bytes) {
42
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
43
+ }
44
+ function ascii(bytes, offset, length) {
45
+ let out = "";
46
+ for (let i = offset; i < offset + length && i < bytes.length; i++) {
47
+ out += String.fromCharCode(bytes[i]);
48
+ }
49
+ return out;
50
+ }
51
+ function startsWith(bytes, offset, signature) {
52
+ if (bytes.length < offset + signature.length) return false;
53
+ return signature.every((byte, index) => bytes[offset + index] === byte);
54
+ }
55
+ const PNG_SIGNATURE = [137, 80, 78, 71, 13, 10, 26, 10];
56
+ export function pngImageSize(bytes) {
57
+ if (!startsWith(bytes, 0, PNG_SIGNATURE)) return null;
58
+ if (bytes.length < 24 || ascii(bytes, 12, 4) !== "IHDR") return null;
59
+ const data = view(bytes);
60
+ const width = data.getUint32(16);
61
+ const height = data.getUint32(20);
62
+ return width && height ? { width, height } : null;
63
+ }
64
+ const JPEG_STANDALONE_MARKERS = /* @__PURE__ */ new Set([
65
+ 1,
66
+ 216,
67
+ 208,
68
+ 209,
69
+ 210,
70
+ 211,
71
+ 212,
72
+ 213,
73
+ 214,
74
+ 215
75
+ ]);
76
+ const JPEG_SOF_MARKERS = /* @__PURE__ */ new Set([
77
+ 192,
78
+ 193,
79
+ 194,
80
+ 195,
81
+ 197,
82
+ 198,
83
+ 199,
84
+ 201,
85
+ 202,
86
+ 203,
87
+ 205,
88
+ 206,
89
+ 207
90
+ ]);
91
+ export function jpegImageSize(bytes) {
92
+ if (bytes.length < 4 || bytes[0] !== 255 || bytes[1] !== 216) return null;
93
+ const data = view(bytes);
94
+ let offset = 2;
95
+ while (offset + 3 < bytes.length) {
96
+ if (bytes[offset] !== 255) {
97
+ offset++;
98
+ continue;
99
+ }
100
+ let marker = bytes[offset + 1];
101
+ while (marker === 255 && offset + 2 < bytes.length) {
102
+ offset++;
103
+ marker = bytes[offset + 1];
104
+ }
105
+ if (JPEG_STANDALONE_MARKERS.has(marker)) {
106
+ offset += 2;
107
+ continue;
108
+ }
109
+ if (marker === 217 || marker === 218) return null;
110
+ const segmentLength = data.getUint16(offset + 2);
111
+ if (segmentLength < 2) return null;
112
+ if (JPEG_SOF_MARKERS.has(marker)) {
113
+ if (offset + 9 > bytes.length) return null;
114
+ const height = data.getUint16(offset + 5);
115
+ const width = data.getUint16(offset + 7);
116
+ return width && height ? { width, height } : null;
117
+ }
118
+ offset += 2 + segmentLength;
119
+ }
120
+ return null;
121
+ }
122
+ export function webpImageSize(bytes) {
123
+ if (bytes.length < 16 || ascii(bytes, 0, 4) !== "RIFF" || ascii(bytes, 8, 4) !== "WEBP") {
124
+ return null;
125
+ }
126
+ const data = view(bytes);
127
+ const format = ascii(bytes, 12, 4);
128
+ if (format === "VP8 ") {
129
+ if (bytes.length < 30) return null;
130
+ if (bytes[23] !== 157 || bytes[24] !== 1 || bytes[25] !== 42) return null;
131
+ const width = data.getUint16(26, true) & 16383;
132
+ const height = data.getUint16(28, true) & 16383;
133
+ return width && height ? { width, height } : null;
134
+ }
135
+ if (format === "VP8L") {
136
+ if (bytes.length < 25 || bytes[20] !== 47) return null;
137
+ const bits = data.getUint32(21, true);
138
+ return { width: (bits & 16383) + 1, height: (bits >>> 14 & 16383) + 1 };
139
+ }
140
+ if (format === "VP8X") {
141
+ if (bytes.length < 30) return null;
142
+ const width = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
143
+ const height = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
144
+ return { width, height };
145
+ }
146
+ return null;
147
+ }
148
+ export function gifImageSize(bytes) {
149
+ if (bytes.length < 10 || ascii(bytes, 0, 3) !== "GIF") return null;
150
+ const version = ascii(bytes, 3, 3);
151
+ if (version !== "87a" && version !== "89a") return null;
152
+ const data = view(bytes);
153
+ const width = data.getUint16(6, true);
154
+ const height = data.getUint16(8, true);
155
+ return width && height ? { width, height } : null;
156
+ }
157
+ export function imageSizeFromBuffer(bytes) {
158
+ return pngImageSize(bytes) ?? gifImageSize(bytes) ?? webpImageSize(bytes) ?? jpegImageSize(bytes);
159
+ }
160
+ async function readHeader(path, size) {
161
+ const length = Math.min(size, HEADER_BYTES);
162
+ if (length <= 0) return null;
163
+ const handle = await open(path, "r");
164
+ try {
165
+ const buffer = new Uint8Array(length);
166
+ const { bytesRead } = await handle.read(buffer, 0, length, 0);
167
+ return buffer.subarray(0, bytesRead);
168
+ } finally {
169
+ await handle.close();
170
+ }
171
+ }
172
+ export async function scanMediaDirectory(root) {
173
+ const files = [];
174
+ const walk = async (dir, prefix) => {
175
+ const entries = await readdir(dir, { withFileTypes: true });
176
+ for (const entry of entries) {
177
+ if (entry.name.startsWith(".")) continue;
178
+ const key = prefix ? `${prefix}/${entry.name}` : entry.name;
179
+ const path = join(dir, entry.name);
180
+ if (entry.isDirectory()) {
181
+ await walk(path, key);
182
+ continue;
183
+ }
184
+ if (!entry.isFile() || !isSyncableMediaKey(key)) continue;
185
+ const info = await stat(path);
186
+ files.push({ key, size: info.size });
187
+ }
188
+ };
189
+ await walk(root, "");
190
+ return files.sort((a, b) => a.key < b.key ? -1 : a.key > b.key ? 1 : 0);
191
+ }
192
+ export function planMediaSync(files, rows) {
193
+ const rowByKey = new Map(rows.map((row) => [row.key, row]));
194
+ const insert = [];
195
+ const update = [];
196
+ for (const file of files) {
197
+ const row = rowByKey.get(file.key);
198
+ if (!row) insert.push(file);
199
+ else if (row.size !== file.size) update.push(file);
200
+ }
201
+ const scanned = new Set(files.map((file) => file.key));
202
+ const remove = rows.filter((row) => !scanned.has(row.key)).map((row) => row.key);
203
+ return { insert, update, remove };
204
+ }
205
+ export async function readMediaFileMeta(root, file) {
206
+ const mime = mediaMimeForKey(file.key);
207
+ let size = null;
208
+ if (mime === "image/png" || mime === "image/jpeg" || mime === "image/webp" || mime === "image/gif") {
209
+ const header = await readHeader(join(root, ...file.key.split("/")), file.size);
210
+ size = header ? imageSizeFromBuffer(header) : null;
211
+ }
212
+ return {
213
+ key: file.key,
214
+ folder: mediaSyncFolder(file.key),
215
+ mime,
216
+ size: file.size,
217
+ width: size?.width ?? null,
218
+ height: size?.height ?? null
219
+ };
220
+ }
221
+ export function chunked(items, size) {
222
+ const chunks = [];
223
+ for (let index = 0; index < items.length; index += size) {
224
+ chunks.push(items.slice(index, index + size));
225
+ }
226
+ return chunks;
227
+ }
@@ -15,6 +15,8 @@ export declare function mediaTypeFor(mime: string | null | undefined, key: strin
15
15
  export declare function mediaIconFor(type: MediaType): string;
16
16
  export declare function mediaPublicUrl(baseUrl: string | null | undefined, key: string): string | null;
17
17
  export declare function slugify(value: string): string;
18
+ export declare const MEDIA_FOLDER_MAX_DEPTH = 4;
19
+ export declare function normalizeMediaFolder(value: string | null | undefined): string | null;
18
20
  export interface MediaItem {
19
21
  id: number;
20
22
  key: string;
@@ -44,6 +44,12 @@ export function mediaPublicUrl(baseUrl, key) {
44
44
  export function slugify(value) {
45
45
  return value.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036F]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
46
46
  }
47
+ export const MEDIA_FOLDER_MAX_DEPTH = 4;
48
+ export function normalizeMediaFolder(value) {
49
+ if (!value) return null;
50
+ const segments = value.split("/").map(slugify).filter(Boolean).slice(0, MEDIA_FOLDER_MAX_DEPTH);
51
+ return segments.length ? segments.join("/") : null;
52
+ }
47
53
  export function isTranslatableField(field) {
48
54
  return !!field.translatable && (field.type === "text" || field.type === "richtext");
49
55
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xleddyl/nuxt-cms",
3
- "version": "0.1.25",
3
+ "version": "0.1.27",
4
4
  "description": "Lightweight CMS that ships with your Nuxt app: runs on the Nitro server, content types defined in code, /cms admin panel, GraphQL API, SQLite or Postgres. No external CMS needed!",
5
5
  "license": "MIT",
6
6
  "author": "Edoardo Alberti (https://github.com/xleddyl)",