includio-cms 0.13.1 → 0.13.2
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/CHANGELOG.md +18 -0
- package/ROADMAP.md +10 -0
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/replace.js +2 -1
- package/dist/admin/api/rest/routes/upload.js +2 -1
- package/dist/admin/api/upload-limit.d.ts +2 -0
- package/dist/admin/api/upload-limit.js +7 -0
- package/dist/admin/api/upload.js +2 -1
- package/dist/admin/client/collection/collection-entries.svelte +41 -3
- package/dist/admin/components/media/file-upload.svelte +66 -9
- package/dist/admin/remote/entry.remote.d.ts +1 -0
- package/dist/admin/remote/entry.remote.js +5 -3
- package/dist/admin/remote/reorder.d.ts +1 -0
- package/dist/admin/remote/reorder.js +33 -0
- package/dist/core/server/entries/operations/get.js +15 -3
- package/dist/core/server/fields/utils/imageStyles.js +7 -3
- package/dist/core/server/generator/generator.js +3 -1
- package/dist/core/server/media/styles/operations/createMediaStyle.d.ts +2 -2
- package/dist/core/server/media/styles/operations/createMediaStyle.js +5 -3
- package/dist/core/server/media/styles/operations/generateDefaultStyles.js +33 -6
- package/dist/core/server/media/styles/operations/getImageStyle.d.ts +1 -0
- package/dist/core/server/media/styles/operations/getImageStyle.js +3 -0
- package/dist/core/server/media/styles/sharp/generateImageStyle.d.ts +2 -1
- package/dist/core/server/media/styles/sharp/generateImageStyle.js +5 -3
- package/dist/core/server/media/uploadLimit.d.ts +2 -0
- package/dist/core/server/media/uploadLimit.js +26 -0
- package/dist/types/entries.d.ts +1 -0
- package/dist/updates/0.13.2/index.d.ts +2 -0
- package/dist/updates/0.13.2/index.js +20 -0
- package/dist/updates/index.js +2 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
3
3
|
All notable changes to includio-cms are documented here.
|
|
4
4
|
Generated from `src/lib/updates/` — do not edit manually.
|
|
5
5
|
|
|
6
|
+
## 0.13.2 — 2026-03-20
|
|
7
|
+
|
|
8
|
+
Upload limits, stable reordering, relation filters, image style optimization
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Configurable upload size limit via BODY_SIZE_LIMIT env var (supports K/M/G suffixes, default 50M)
|
|
12
|
+
- File upload pre-validation — rejects oversized files before upload with localized error messages
|
|
13
|
+
- Stable slot reordering — preserves positions of filtered-out entries during DnD reorder
|
|
14
|
+
- Collection relation field filtering — filter entries by relation field values
|
|
15
|
+
- Expose _sortOrder on entries for orderable collections
|
|
16
|
+
- Codegen: _sortOrder field in generated TypeScript interfaces for orderable collections
|
|
17
|
+
- getImageStyleIfExists — non-blocking image style lookup (returns null if missing)
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
- Concurrent image style generation with buffer reuse — download original once, generate styles in parallel (limit 3)
|
|
21
|
+
- Image style resolution gracefully skips missing styles instead of blocking
|
|
22
|
+
- Upload endpoints use configurable size limit instead of hardcoded 50MB
|
|
23
|
+
|
|
6
24
|
## 0.13.1 — 2026-03-20
|
|
7
25
|
|
|
8
26
|
Admin UI i18n, codegen cleanup, docs rewrite
|
package/ROADMAP.md
CHANGED
|
@@ -219,6 +219,16 @@
|
|
|
219
219
|
- [x] `[fix]` `[P2]` Layout type: remove unused label property <!-- files: src/lib/types/layout.ts -->
|
|
220
220
|
- [x] `[chore]` `[P1]` Documentation rewrite — new pages for blocks, content, media-field, layout; expanded API, auth, entries, forms, plugins docs
|
|
221
221
|
|
|
222
|
+
## 0.13.2 — Upload limits, stable reordering, image style optimization
|
|
223
|
+
|
|
224
|
+
- [x] `[feature]` `[P1]` Configurable upload size limit — `BODY_SIZE_LIMIT` env var, pre-validation in UI <!-- files: src/lib/core/server/media/uploadLimit.ts, src/lib/admin/api/upload-limit.ts, src/lib/admin/components/media/file-upload.svelte -->
|
|
225
|
+
- [x] `[feature]` `[P1]` Stable slot reordering — preserve filtered-out entry positions during DnD <!-- files: src/lib/admin/remote/reorder.ts, src/lib/admin/remote/entry.remote.ts -->
|
|
226
|
+
- [x] `[feature]` `[P1]` Collection relation field filtering — filter entries by relation values <!-- files: src/lib/admin/client/collection/collection-entries.svelte -->
|
|
227
|
+
- [x] `[feature]` `[P2]` Expose `_sortOrder` on entries + codegen for orderable collections <!-- files: src/lib/core/server/entries/operations/get.ts, src/lib/core/server/generator/generator.ts, src/lib/types/entries.ts -->
|
|
228
|
+
- [x] `[feature]` `[P2]` `getImageStyleIfExists` — non-blocking image style lookup <!-- files: src/lib/core/server/media/styles/operations/getImageStyle.ts -->
|
|
229
|
+
- [x] `[fix]` `[P1]` Concurrent image style generation with buffer reuse (download once, parallel limit 3) <!-- files: src/lib/core/server/media/styles/operations/generateDefaultStyles.ts, src/lib/core/server/media/styles/sharp/generateImageStyle.ts -->
|
|
230
|
+
- [x] `[fix]` `[P1]` Image style resolution gracefully skips missing styles <!-- files: src/lib/core/server/fields/utils/imageStyles.ts -->
|
|
231
|
+
|
|
222
232
|
## 0.14.0 — SEO module
|
|
223
233
|
|
|
224
234
|
- [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
|
|
@@ -6,6 +6,7 @@ import * as inviteHandlers from './invite.js';
|
|
|
6
6
|
import * as acceptInviteHandlers from './accept-invite.js';
|
|
7
7
|
import * as mediaGcHandlers from './media-gc.js';
|
|
8
8
|
import * as generateStylesHandlers from './generate-styles.js';
|
|
9
|
+
import * as uploadLimitHandlers from './upload-limit.js';
|
|
9
10
|
import { requireAuth } from '../remote/middleware/auth.js';
|
|
10
11
|
import { getCMS } from '../../core/cms.js';
|
|
11
12
|
import { lookup } from 'mrmime';
|
|
@@ -18,6 +19,7 @@ export function createAdminApiHandler(options) {
|
|
|
18
19
|
'accept-invite': acceptInviteHandlers,
|
|
19
20
|
'media-gc': mediaGcHandlers,
|
|
20
21
|
'generate-styles': generateStylesHandlers,
|
|
22
|
+
'upload-limit': uploadLimitHandlers,
|
|
21
23
|
...options?.extraRoutes
|
|
22
24
|
};
|
|
23
25
|
const privateMediaGet = async (event) => {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { requireAuth } from '../remote/middleware/auth.js';
|
|
2
2
|
import { replaceFile } from '../../core/server/media/operations/replaceFile.js';
|
|
3
3
|
import { json } from '@sveltejs/kit';
|
|
4
|
-
|
|
4
|
+
import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
|
|
5
|
+
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
5
6
|
export const POST = async ({ request }) => {
|
|
6
7
|
requireAuth();
|
|
7
8
|
const form = await request.formData();
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { json } from '@sveltejs/kit';
|
|
2
2
|
import { uploadFile } from '../../../../core/server/media/operations/uploadFile.js';
|
|
3
3
|
import { getCMS } from '../../../../core/cms.js';
|
|
4
|
-
|
|
4
|
+
import { getMaxUploadSize } from '../../../../core/server/media/uploadLimit.js';
|
|
5
|
+
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
5
6
|
export async function POST(event) {
|
|
6
7
|
const form = await event.request.formData();
|
|
7
8
|
const file = form.get('file');
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { requireAuth } from '../remote/middleware/auth.js';
|
|
2
|
+
import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
|
|
3
|
+
import { json } from '@sveltejs/kit';
|
|
4
|
+
export const GET = async () => {
|
|
5
|
+
requireAuth();
|
|
6
|
+
return json({ maxUploadSize: getMaxUploadSize() });
|
|
7
|
+
};
|
package/dist/admin/api/upload.js
CHANGED
|
@@ -2,7 +2,8 @@ import { requireAuth } from '../remote/middleware/auth.js';
|
|
|
2
2
|
import { uploadFile } from '../../core/server/media/operations/uploadFile.js';
|
|
3
3
|
import { getCMS } from '../../core/cms.js';
|
|
4
4
|
import { json } from '@sveltejs/kit';
|
|
5
|
-
|
|
5
|
+
import { getMaxUploadSize } from '../../core/server/media/uploadLimit.js';
|
|
6
|
+
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
6
7
|
export const POST = async ({ request }) => {
|
|
7
8
|
requireAuth();
|
|
8
9
|
const form = await request.formData();
|
|
@@ -144,6 +144,21 @@
|
|
|
144
144
|
);
|
|
145
145
|
|
|
146
146
|
|
|
147
|
+
// Relation filter options fetched from related collections
|
|
148
|
+
let relationFilterOptions = $state<Record<string, { label: string; value: string }[]>>({});
|
|
149
|
+
|
|
150
|
+
$effect(() => {
|
|
151
|
+
if (relationListFields.length === 0) return;
|
|
152
|
+
for (const field of relationListFields) {
|
|
153
|
+
remotes.getEntryLabels({ slug: field.collection }).then((labels) => {
|
|
154
|
+
relationFilterOptions = {
|
|
155
|
+
...relationFilterOptions,
|
|
156
|
+
[field.slug]: labels.map((l) => ({ value: l.id, label: l.label }))
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
147
162
|
// Data filter configs from select/radio fields in listColumns
|
|
148
163
|
const dataFilterConfigs = $derived(
|
|
149
164
|
(collection.listColumns ?? [])
|
|
@@ -163,6 +178,15 @@
|
|
|
163
178
|
return null;
|
|
164
179
|
})
|
|
165
180
|
.filter((f): f is NonNullable<typeof f> => f !== null)
|
|
181
|
+
.concat(
|
|
182
|
+
relationListFields
|
|
183
|
+
.filter((f) => relationFilterOptions[f.slug]?.length)
|
|
184
|
+
.map((f) => ({
|
|
185
|
+
slug: f.slug,
|
|
186
|
+
label: getLocalizedLabel(f.label, interfaceLanguage.current),
|
|
187
|
+
options: relationFilterOptions[f.slug]
|
|
188
|
+
}))
|
|
189
|
+
)
|
|
166
190
|
);
|
|
167
191
|
|
|
168
192
|
let activeDataFilters = $state<Record<string, string | null>>({});
|
|
@@ -485,6 +509,8 @@
|
|
|
485
509
|
const fieldData = (columnData as Record<string, unknown>)[fieldSlug];
|
|
486
510
|
const isRelation = relationListFields.some((f) => f.slug === fieldSlug);
|
|
487
511
|
if (isRelation) {
|
|
512
|
+
// Store raw UUID(s) for filtering
|
|
513
|
+
customData[`__raw_${fieldSlug}`] = fieldData;
|
|
488
514
|
// Resolve relation UUID(s) to labels
|
|
489
515
|
if (typeof fieldData === 'string') {
|
|
490
516
|
customData[fieldSlug] = lookup[fieldData] ?? '';
|
|
@@ -599,7 +625,7 @@
|
|
|
599
625
|
...old,
|
|
600
626
|
entries: orderedIds.map((id) => old.entries.find((e: RawEntry) => e.id === id)).filter(Boolean)
|
|
601
627
|
}));
|
|
602
|
-
await remotes.reorderEntriesCommand({ orderedIds });
|
|
628
|
+
await remotes.reorderEntriesCommand({ orderedIds, collectionSlug: collection.slug });
|
|
603
629
|
await entriesQuery.refresh();
|
|
604
630
|
override.release();
|
|
605
631
|
}
|
|
@@ -640,8 +666,20 @@
|
|
|
640
666
|
rows = rows.filter((row) => {
|
|
641
667
|
for (const [slug, value] of Object.entries(activeDataFilters)) {
|
|
642
668
|
if (value === null) continue;
|
|
643
|
-
|
|
644
|
-
|
|
669
|
+
// For relation fields, compare against raw UUID(s)
|
|
670
|
+
const rawValue = row.customData[`__raw_${slug}`];
|
|
671
|
+
if (rawValue !== undefined) {
|
|
672
|
+
if (typeof rawValue === 'string') {
|
|
673
|
+
if (rawValue !== value) return false;
|
|
674
|
+
} else if (Array.isArray(rawValue)) {
|
|
675
|
+
if (!rawValue.includes(value)) return false;
|
|
676
|
+
} else {
|
|
677
|
+
return false;
|
|
678
|
+
}
|
|
679
|
+
} else {
|
|
680
|
+
const rowValue = row.customData[slug];
|
|
681
|
+
if (String(rowValue ?? '') !== value) return false;
|
|
682
|
+
}
|
|
645
683
|
}
|
|
646
684
|
return true;
|
|
647
685
|
});
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { getRemotes } from '../../context/remotes.js';
|
|
3
3
|
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
4
4
|
import type { InterfaceLanguage } from '../../../types/languages.js';
|
|
5
|
+
import { toast } from 'svelte-sonner';
|
|
5
6
|
import Upload from '@tabler/icons-svelte/icons/upload';
|
|
6
7
|
import X from '@tabler/icons-svelte/icons/x';
|
|
7
8
|
|
|
@@ -9,21 +10,36 @@
|
|
|
9
10
|
const interfaceLanguage = useInterfaceLanguage();
|
|
10
11
|
const lang: Record<
|
|
11
12
|
InterfaceLanguage,
|
|
12
|
-
{
|
|
13
|
+
{
|
|
14
|
+
addFiles: string;
|
|
15
|
+
dropFiles: string;
|
|
16
|
+
dropHint: string;
|
|
17
|
+
uploading: string;
|
|
18
|
+
uploadComplete: string;
|
|
19
|
+
fileTooLarge: (maxMB: number) => string;
|
|
20
|
+
uploadError: string;
|
|
21
|
+
skippedFiles: (count: number) => string;
|
|
22
|
+
}
|
|
13
23
|
> = {
|
|
14
24
|
pl: {
|
|
15
25
|
addFiles: 'Prześlij pliki',
|
|
16
26
|
dropFiles: 'Upuść pliki tutaj',
|
|
17
27
|
dropHint: 'Przeciągnij pliki tutaj',
|
|
18
28
|
uploading: 'Wysyłanie...',
|
|
19
|
-
uploadComplete: 'Ukończono'
|
|
29
|
+
uploadComplete: 'Ukończono',
|
|
30
|
+
fileTooLarge: (maxMB) => `Plik za duży (max ${maxMB} MB)`,
|
|
31
|
+
uploadError: 'Błąd przesyłania',
|
|
32
|
+
skippedFiles: (count) => `Pominięto ${count} plik(ów) przekraczających limit`
|
|
20
33
|
},
|
|
21
34
|
en: {
|
|
22
35
|
addFiles: 'Upload files',
|
|
23
36
|
dropFiles: 'Drop files here',
|
|
24
37
|
dropHint: 'Drag files here',
|
|
25
38
|
uploading: 'Uploading...',
|
|
26
|
-
uploadComplete: 'Complete'
|
|
39
|
+
uploadComplete: 'Complete',
|
|
40
|
+
fileTooLarge: (maxMB) => `File too large (max ${maxMB} MB)`,
|
|
41
|
+
uploadError: 'Upload error',
|
|
42
|
+
skippedFiles: (count) => `Skipped ${count} file(s) exceeding the limit`
|
|
27
43
|
}
|
|
28
44
|
};
|
|
29
45
|
|
|
@@ -39,7 +55,17 @@
|
|
|
39
55
|
|
|
40
56
|
let inputElement: HTMLInputElement;
|
|
41
57
|
let isDragging = $state(false);
|
|
42
|
-
let uploadProgress = $state<{ name: string; progress: number; complete: boolean }[]>([]);
|
|
58
|
+
let uploadProgress = $state<{ name: string; progress: number; complete: boolean; error?: string }[]>([]);
|
|
59
|
+
let maxUploadSize = $state<number>(50 * 1024 * 1024);
|
|
60
|
+
|
|
61
|
+
$effect(() => {
|
|
62
|
+
fetch('/admin/api/upload-limit')
|
|
63
|
+
.then((r) => r.json())
|
|
64
|
+
.then((data) => {
|
|
65
|
+
if (data.maxUploadSize) maxUploadSize = data.maxUploadSize;
|
|
66
|
+
})
|
|
67
|
+
.catch(() => {});
|
|
68
|
+
});
|
|
43
69
|
|
|
44
70
|
async function uploadFile(file: File, index: number) {
|
|
45
71
|
const form = new FormData();
|
|
@@ -58,13 +84,26 @@
|
|
|
58
84
|
};
|
|
59
85
|
|
|
60
86
|
xhr.onload = () => {
|
|
61
|
-
|
|
62
|
-
|
|
87
|
+
if (xhr.status >= 200 && xhr.status < 300) {
|
|
88
|
+
uploadProgress[index].complete = true;
|
|
89
|
+
uploadProgress[index].progress = 100;
|
|
90
|
+
} else {
|
|
91
|
+
const t = lang[interfaceLanguage.current];
|
|
92
|
+
const error =
|
|
93
|
+
xhr.status === 413
|
|
94
|
+
? t.fileTooLarge(Math.round(maxUploadSize / 1024 / 1024))
|
|
95
|
+
: t.uploadError;
|
|
96
|
+
uploadProgress[index].progress = -1;
|
|
97
|
+
uploadProgress[index].error = error;
|
|
98
|
+
toast.error(error, { description: file.name });
|
|
99
|
+
}
|
|
63
100
|
resolve();
|
|
64
101
|
};
|
|
65
102
|
|
|
66
103
|
xhr.onerror = () => {
|
|
67
104
|
uploadProgress[index].progress = -1;
|
|
105
|
+
uploadProgress[index].error = lang[interfaceLanguage.current].uploadError;
|
|
106
|
+
toast.error(lang[interfaceLanguage.current].uploadError, { description: file.name });
|
|
68
107
|
resolve();
|
|
69
108
|
};
|
|
70
109
|
|
|
@@ -76,9 +115,27 @@
|
|
|
76
115
|
async function handleFiles(files: File[]) {
|
|
77
116
|
if (!files.length) return;
|
|
78
117
|
|
|
79
|
-
|
|
118
|
+
const maxMB = Math.round(maxUploadSize / 1024 / 1024);
|
|
119
|
+
uploadProgress = files.map((f) => ({
|
|
120
|
+
name: f.name,
|
|
121
|
+
progress: f.size > maxUploadSize ? -1 : 0,
|
|
122
|
+
complete: false,
|
|
123
|
+
error: f.size > maxUploadSize ? lang[interfaceLanguage.current].fileTooLarge(maxMB) : undefined
|
|
124
|
+
}));
|
|
125
|
+
|
|
126
|
+
const validFiles = files
|
|
127
|
+
.map((file, i) => ({ file, i }))
|
|
128
|
+
.filter(({ file }) => file.size <= maxUploadSize);
|
|
129
|
+
|
|
130
|
+
const rejectedCount = files.length - validFiles.length;
|
|
131
|
+
if (rejectedCount > 0) {
|
|
132
|
+
const t = lang[interfaceLanguage.current];
|
|
133
|
+
toast.error(t.fileTooLarge(maxMB), {
|
|
134
|
+
description: t.skippedFiles(rejectedCount)
|
|
135
|
+
});
|
|
136
|
+
}
|
|
80
137
|
|
|
81
|
-
await Promise.all(
|
|
138
|
+
await Promise.all(validFiles.map(({ file, i }) => uploadFile(file, i)));
|
|
82
139
|
|
|
83
140
|
remotes.getMedia().refresh();
|
|
84
141
|
|
|
@@ -210,7 +267,7 @@
|
|
|
210
267
|
{#if item.complete}
|
|
211
268
|
{lang[interfaceLanguage.current].uploadComplete}
|
|
212
269
|
{:else if item.progress === -1}
|
|
213
|
-
|
|
270
|
+
{item.error || lang[interfaceLanguage.current].uploadError}
|
|
214
271
|
{:else}
|
|
215
272
|
{item.progress}%
|
|
216
273
|
{/if}
|
|
@@ -73,6 +73,7 @@ export declare const unarchiveEntryCommand: import("@sveltejs/kit").RemoteComman
|
|
|
73
73
|
export declare const deleteEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
|
|
74
74
|
export declare const reorderEntriesCommand: import("@sveltejs/kit").RemoteCommand<{
|
|
75
75
|
orderedIds: string[];
|
|
76
|
+
collectionSlug: string;
|
|
76
77
|
}, Promise<void>>;
|
|
77
78
|
export declare const getEntryVersion: import("@sveltejs/kit").RemoteQueryFunction<{
|
|
78
79
|
id: string;
|
|
@@ -5,6 +5,7 @@ import { getCMS } from '../../core/cms.js';
|
|
|
5
5
|
import { pruneOldDraftVersions, unpublishEntryLang, upsertDraftVersion, updateEntry, updateEntrySchema, updateEntryVersionCommandTypes } from '../../core/server/entries/operations/update.js';
|
|
6
6
|
import z from 'zod';
|
|
7
7
|
import { requireAuth } from './middleware/auth.js';
|
|
8
|
+
import { stableSlotReorder } from './reorder.js';
|
|
8
9
|
import { entryStatuses } from '../../types/entries.js';
|
|
9
10
|
export const getRawEntries = query(z.object({
|
|
10
11
|
ids: z.array(z.string().uuid()).optional(),
|
|
@@ -179,10 +180,11 @@ export const deleteEntryCommand = command(z.string().uuid(), async (id) => {
|
|
|
179
180
|
return getCMS().databaseAdapter.deleteEntry({ id });
|
|
180
181
|
});
|
|
181
182
|
export const reorderEntriesCommand = command(z.object({
|
|
182
|
-
orderedIds: z.array(z.string().uuid())
|
|
183
|
-
|
|
183
|
+
orderedIds: z.array(z.string().uuid()),
|
|
184
|
+
collectionSlug: z.string()
|
|
185
|
+
}), async ({ orderedIds, collectionSlug }) => {
|
|
184
186
|
requireAuth();
|
|
185
|
-
await
|
|
187
|
+
await stableSlotReorder(orderedIds, collectionSlug);
|
|
186
188
|
});
|
|
187
189
|
export const getEntryVersion = query(z.object({
|
|
188
190
|
id: z.string().uuid(),
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stableSlotReorder(orderedIds: string[], collectionSlug: string): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { getDbEntries } from '../../core/server/entries/operations/get.js';
|
|
2
|
+
import { updateEntry } from '../../core/server/entries/operations/update.js';
|
|
3
|
+
export async function stableSlotReorder(orderedIds, collectionSlug) {
|
|
4
|
+
if (orderedIds.length === 0)
|
|
5
|
+
return;
|
|
6
|
+
const allEntries = await getDbEntries({
|
|
7
|
+
slug: collectionSlug,
|
|
8
|
+
orderBy: { column: 'sortOrder', direction: 'asc' }
|
|
9
|
+
});
|
|
10
|
+
const allIds = allEntries.map((e) => e.id);
|
|
11
|
+
const orderedSet = new Set(orderedIds);
|
|
12
|
+
// Find slot positions occupied by orderedIds in the full list
|
|
13
|
+
const slotIndices = [];
|
|
14
|
+
for (let i = 0; i < allIds.length; i++) {
|
|
15
|
+
if (orderedSet.has(allIds[i])) {
|
|
16
|
+
slotIndices.push(i);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
// Fill slots with new order
|
|
20
|
+
const result = [...allIds];
|
|
21
|
+
for (let i = 0; i < orderedIds.length; i++) {
|
|
22
|
+
result[slotIndices[i]] = orderedIds[i];
|
|
23
|
+
}
|
|
24
|
+
// Update only entries whose sortOrder changed
|
|
25
|
+
const updates = [];
|
|
26
|
+
for (let i = 0; i < result.length; i++) {
|
|
27
|
+
const entry = allEntries.find((e) => e.id === result[i]);
|
|
28
|
+
if (!entry || entry.sortOrder !== i) {
|
|
29
|
+
updates.push(updateEntry(result[i], { sortOrder: i }));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
await Promise.all(updates);
|
|
33
|
+
}
|
|
@@ -119,7 +119,7 @@ export const getEntries = async (options = {}) => {
|
|
|
119
119
|
const slugPath = getEntrySlugPath(entry.slug);
|
|
120
120
|
const slug = getSlugFromEntryData(version.data, slugPath, language);
|
|
121
121
|
const _url = slug ? getEntryPath(entry.slug, slug) : undefined;
|
|
122
|
-
|
|
122
|
+
const result = {
|
|
123
123
|
_id: entry.id,
|
|
124
124
|
_slug: entry.slug,
|
|
125
125
|
_type: entry.type,
|
|
@@ -127,6 +127,10 @@ export const getEntries = async (options = {}) => {
|
|
|
127
127
|
_url,
|
|
128
128
|
...populatedData
|
|
129
129
|
};
|
|
130
|
+
if (config.type === 'collection' && config.orderable) {
|
|
131
|
+
result._sortOrder = entry.sortOrder;
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
130
134
|
}
|
|
131
135
|
catch (error) {
|
|
132
136
|
console.error(`[CMS] Failed to populate entry ${entry.id} (${entry.slug}):`, error);
|
|
@@ -206,7 +210,7 @@ export const getEntries = async (options = {}) => {
|
|
|
206
210
|
const slugPath = getEntrySlugPath(dbEntry.slug);
|
|
207
211
|
const slug = getSlugFromEntryData(version.data, slugPath, language);
|
|
208
212
|
const _url = slug ? getEntryPath(dbEntry.slug, slug) : undefined;
|
|
209
|
-
|
|
213
|
+
const result = {
|
|
210
214
|
_id: dbEntry.id,
|
|
211
215
|
_slug: dbEntry.slug,
|
|
212
216
|
_type: dbEntry.type,
|
|
@@ -214,6 +218,10 @@ export const getEntries = async (options = {}) => {
|
|
|
214
218
|
_url,
|
|
215
219
|
...populatedData
|
|
216
220
|
};
|
|
221
|
+
if (config.type === 'collection' && config.orderable) {
|
|
222
|
+
result._sortOrder = dbEntry.sortOrder;
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
217
225
|
}
|
|
218
226
|
catch (error) {
|
|
219
227
|
console.error(`[CMS] Failed to populate entry ${dbEntry.id} (${dbEntry.slug}):`, error);
|
|
@@ -349,7 +357,7 @@ export const getEntryVersion = async (options) => {
|
|
|
349
357
|
const slugPath = getEntrySlugPath(dbEntry.slug);
|
|
350
358
|
const entrySlug = getSlugFromEntryData(dbEntryVersion.data, slugPath, language);
|
|
351
359
|
const _url = entrySlug ? getEntryPath(dbEntry.slug, entrySlug) : undefined;
|
|
352
|
-
|
|
360
|
+
const result = {
|
|
353
361
|
_id: dbEntry.id,
|
|
354
362
|
_slug: dbEntry.slug,
|
|
355
363
|
_type: dbEntry.type,
|
|
@@ -357,6 +365,10 @@ export const getEntryVersion = async (options) => {
|
|
|
357
365
|
_url,
|
|
358
366
|
...populatedData
|
|
359
367
|
};
|
|
368
|
+
if (config.type === 'collection' && config.orderable) {
|
|
369
|
+
result._sortOrder = dbEntry.sortOrder;
|
|
370
|
+
}
|
|
371
|
+
return result;
|
|
360
372
|
}
|
|
361
373
|
catch (error) {
|
|
362
374
|
console.error(`[CMS] Failed to populate entry ${dbEntry.id} (${dbEntry.slug}):`, error);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { getImageStyleIfExists } from '../../media/styles/operations/getImageStyle.js';
|
|
2
2
|
import { getCMS } from '../../../cms.js';
|
|
3
3
|
import { generateBlurDataUrl } from '../../media/utils/generateBlurDataUrl.js';
|
|
4
4
|
export const defaultStyles = [
|
|
@@ -67,7 +67,9 @@ export async function getImageStyles(field, val) {
|
|
|
67
67
|
const [styles, blurDataUrl] = await Promise.all([
|
|
68
68
|
Promise.all(stylesArr.map(async (style) => {
|
|
69
69
|
try {
|
|
70
|
-
const styleDbData = await
|
|
70
|
+
const styleDbData = await getImageStyleIfExists(val.id, style);
|
|
71
|
+
if (!styleDbData)
|
|
72
|
+
return null;
|
|
71
73
|
const result = {
|
|
72
74
|
url: styleDbData.url,
|
|
73
75
|
media: styleDbData.media,
|
|
@@ -86,7 +88,9 @@ export async function getImageStyles(field, val) {
|
|
|
86
88
|
srcset: undefined,
|
|
87
89
|
sizes: undefined
|
|
88
90
|
};
|
|
89
|
-
const variantData = await
|
|
91
|
+
const variantData = await getImageStyleIfExists(val.id, variantStyle);
|
|
92
|
+
if (!variantData)
|
|
93
|
+
return null;
|
|
90
94
|
return `${variantData.url} ${w}w`;
|
|
91
95
|
}
|
|
92
96
|
catch (e) {
|
|
@@ -19,6 +19,8 @@ function generateTypesStringForRecords(type, records) {
|
|
|
19
19
|
const fieldsType = generateFlatTsTypeFromFields(getFieldsFromConfig(single));
|
|
20
20
|
// Strip outer braces to inline fields into the interface
|
|
21
21
|
const innerFields = fieldsType.replace(/^\s*\{/, '').replace(/\}\s*$/, '');
|
|
22
|
+
const sortOrderField = type === 'collection' && 'orderable' in single && single.orderable
|
|
23
|
+
? '_sortOrder: number;\n\t\t\t\t' : '';
|
|
22
24
|
return `
|
|
23
25
|
export interface ${toPascalCase(single.slug)} {
|
|
24
26
|
_id: string;
|
|
@@ -26,7 +28,7 @@ function generateTypesStringForRecords(type, records) {
|
|
|
26
28
|
_type: string;
|
|
27
29
|
_publishedAt: Date | null;
|
|
28
30
|
_url?: string;
|
|
29
|
-
${innerFields}
|
|
31
|
+
${sortOrderField}${innerFields}
|
|
30
32
|
}
|
|
31
33
|
`;
|
|
32
34
|
})
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
import type { ImageFieldStyle } from '../../../../../types/fields.js';
|
|
2
|
-
import type { ImageStyle } from '../../../../../types/media.js';
|
|
3
|
-
export declare function createImageStyle(mediaFileId: string, style: ImageFieldStyle): Promise<ImageStyle>;
|
|
2
|
+
import type { ImageStyle, MediaFile } from '../../../../../types/media.js';
|
|
3
|
+
export declare function createImageStyle(mediaFileId: string, style: ImageFieldStyle, buffer?: Buffer, mediaFile?: MediaFile): Promise<ImageStyle>;
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { getCMS } from '../../../../cms.js';
|
|
2
|
-
import { generateImageStyle } from '../sharp/generateImageStyle.js';
|
|
3
|
-
export async function createImageStyle(mediaFileId, style) {
|
|
2
|
+
import { generateImageStyle, generateImageStyleFromBuffer } from '../sharp/generateImageStyle.js';
|
|
3
|
+
export async function createImageStyle(mediaFileId, style, buffer, mediaFile) {
|
|
4
4
|
const cms = getCMS();
|
|
5
5
|
// Check for existing style to clean up old file after upsert
|
|
6
6
|
const existing = await cms.databaseAdapter.getImageStyle(mediaFileId, style);
|
|
7
7
|
const oldUrl = existing?.url;
|
|
8
|
-
const imageStyleFile =
|
|
8
|
+
const imageStyleFile = buffer && mediaFile
|
|
9
|
+
? await generateImageStyleFromBuffer(buffer, mediaFile, style)
|
|
10
|
+
: await generateImageStyle(mediaFileId, style);
|
|
9
11
|
const imageStyle = await cms.databaseAdapter.createImageStyle(mediaFileId, imageStyleFile, style);
|
|
10
12
|
// Delete old file from disk if URL changed
|
|
11
13
|
if (oldUrl && oldUrl !== imageStyle.url) {
|
|
@@ -1,16 +1,35 @@
|
|
|
1
|
+
import { getCMS } from '../../../../cms.js';
|
|
1
2
|
import { defaultStyles, isProcessableImage, expandStyleFormats, getOriginalFormat } from '../../../fields/utils/imageStyles.js';
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
import { createImageStyle } from './createMediaStyle.js';
|
|
4
|
+
const CONCURRENCY = 3;
|
|
5
|
+
async function runWithConcurrency(tasks, limit) {
|
|
6
|
+
const results = new Array(tasks.length);
|
|
7
|
+
let i = 0;
|
|
8
|
+
async function next() {
|
|
9
|
+
while (i < tasks.length) {
|
|
10
|
+
const idx = i++;
|
|
11
|
+
results[idx] = await tasks[idx]();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, () => next()));
|
|
15
|
+
return results;
|
|
16
|
+
}
|
|
17
|
+
async function downloadOriginalBuffer(mediaFile) {
|
|
18
|
+
const file = await getCMS().filesAdapter.downloadFile(mediaFile.url.split('/').pop() || '');
|
|
19
|
+
if (!file)
|
|
20
|
+
throw new Error('Media file not found');
|
|
21
|
+
return Buffer.from(await file.arrayBuffer());
|
|
22
|
+
}
|
|
23
|
+
function collectStyleTasks(mediaFile) {
|
|
6
24
|
const origFormat = getOriginalFormat(mediaFile);
|
|
7
25
|
const expanded = expandStyleFormats(defaultStyles, origFormat);
|
|
26
|
+
const tasks = [];
|
|
8
27
|
for (const style of expanded) {
|
|
9
|
-
|
|
28
|
+
tasks.push(style);
|
|
10
29
|
if (style.srcset && mediaFile.width) {
|
|
11
30
|
const widths = style.srcset.filter((w) => w <= mediaFile.width);
|
|
12
31
|
for (const w of widths) {
|
|
13
|
-
|
|
32
|
+
tasks.push({
|
|
14
33
|
...style,
|
|
15
34
|
name: `${style.name}_${w}w`,
|
|
16
35
|
width: w,
|
|
@@ -20,6 +39,14 @@ export async function generateDefaultStyles(mediaFile) {
|
|
|
20
39
|
}
|
|
21
40
|
}
|
|
22
41
|
}
|
|
42
|
+
return tasks;
|
|
43
|
+
}
|
|
44
|
+
export async function generateDefaultStyles(mediaFile) {
|
|
45
|
+
if (!isProcessableImage(mediaFile))
|
|
46
|
+
return;
|
|
47
|
+
const buffer = await downloadOriginalBuffer(mediaFile);
|
|
48
|
+
const styles = collectStyleTasks(mediaFile);
|
|
49
|
+
await runWithConcurrency(styles.map((style) => () => createImageStyle(mediaFile.id, style, buffer, mediaFile)), CONCURRENCY);
|
|
23
50
|
}
|
|
24
51
|
export function generateDefaultStylesInBackground(mediaFile) {
|
|
25
52
|
generateDefaultStyles(mediaFile).catch((e) => console.warn('Background style generation failed:', e));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ImageFieldStyle } from '../../../../../types/fields.js';
|
|
2
2
|
import type { ImageStyle } from '../../../../../types/media.js';
|
|
3
3
|
export declare function getImageStyle(mediaFileId: string, style: ImageFieldStyle): Promise<ImageStyle>;
|
|
4
|
+
export declare function getImageStyleIfExists(mediaFileId: string, style: ImageFieldStyle): Promise<ImageStyle | null>;
|
|
@@ -6,3 +6,6 @@ export async function getImageStyle(mediaFileId, style) {
|
|
|
6
6
|
return imageStyle;
|
|
7
7
|
return createImageStyle(mediaFileId, style);
|
|
8
8
|
}
|
|
9
|
+
export async function getImageStyleIfExists(mediaFileId, style) {
|
|
10
|
+
return getCMS().databaseAdapter.getImageStyle(mediaFileId, style);
|
|
11
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ImageFieldStyle } from '../../../../../types/fields.js';
|
|
2
|
-
import type { UploadedMediaFile } from '../../../../../types/media.js';
|
|
2
|
+
import type { MediaFile, UploadedMediaFile } from '../../../../../types/media.js';
|
|
3
3
|
export declare function generateImageStyle(mediaFileId: string, style: ImageFieldStyle): Promise<UploadedMediaFile>;
|
|
4
|
+
export declare function generateImageStyleFromBuffer(buf: Buffer, mediaFile: MediaFile, style: ImageFieldStyle): Promise<UploadedMediaFile>;
|
|
@@ -14,8 +14,10 @@ export async function generateImageStyle(mediaFileId, style) {
|
|
|
14
14
|
if (!file) {
|
|
15
15
|
throw new Error('Media file not found');
|
|
16
16
|
}
|
|
17
|
-
const
|
|
18
|
-
|
|
17
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
18
|
+
return generateImageStyleFromBuffer(buf, mediaFile, style);
|
|
19
|
+
}
|
|
20
|
+
export async function generateImageStyleFromBuffer(buf, mediaFile, style) {
|
|
19
21
|
// Read EXIF orientation before processing
|
|
20
22
|
const metadata = await sharp(buf).metadata();
|
|
21
23
|
// .rotate() applies EXIF orientation to pixels AND strips the tag from output.
|
|
@@ -51,7 +53,7 @@ export async function generateImageStyle(mediaFileId, style) {
|
|
|
51
53
|
const format = style.format ?? originalExt ?? 'jpeg';
|
|
52
54
|
sharpInstance = sharpInstance.toFormat(format, style.quality != null ? { quality: Math.max(1, Math.min(100, style.quality)) } : undefined);
|
|
53
55
|
const outputBuffer = await sharpInstance.toBuffer();
|
|
54
|
-
return getCMS().filesAdapter.uploadFile(new File([new Uint8Array(outputBuffer)], `${
|
|
56
|
+
return getCMS().filesAdapter.uploadFile(new File([new Uint8Array(outputBuffer)], `${mediaFile.id}_${style.name}_${Date.now().toString(36)}.${format}`, {
|
|
55
57
|
type: `image/${format}`
|
|
56
58
|
}));
|
|
57
59
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
const SUFFIX_MULTIPLIERS = {
|
|
2
|
+
K: 1024,
|
|
3
|
+
M: 1024 * 1024,
|
|
4
|
+
G: 1024 * 1024 * 1024
|
|
5
|
+
};
|
|
6
|
+
const DEFAULT_LIMIT = '50M';
|
|
7
|
+
export function parseAsBytes(value) {
|
|
8
|
+
const trimmed = value.trim();
|
|
9
|
+
if (!trimmed)
|
|
10
|
+
return 0;
|
|
11
|
+
const suffix = trimmed.at(-1).toUpperCase();
|
|
12
|
+
const multiplier = SUFFIX_MULTIPLIERS[suffix];
|
|
13
|
+
if (multiplier) {
|
|
14
|
+
const num = Number(trimmed.slice(0, -1));
|
|
15
|
+
if (isNaN(num) || num < 0)
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.floor(num * multiplier);
|
|
18
|
+
}
|
|
19
|
+
const num = Number(trimmed);
|
|
20
|
+
if (isNaN(num) || num < 0)
|
|
21
|
+
return 0;
|
|
22
|
+
return Math.floor(num);
|
|
23
|
+
}
|
|
24
|
+
export function getMaxUploadSize() {
|
|
25
|
+
return parseAsBytes(process.env.BODY_SIZE_LIMIT || DEFAULT_LIMIT);
|
|
26
|
+
}
|
package/dist/types/entries.d.ts
CHANGED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export const update = {
|
|
2
|
+
version: '0.13.2',
|
|
3
|
+
date: '2026-03-20',
|
|
4
|
+
description: 'Upload limits, stable reordering, relation filters, image style optimization',
|
|
5
|
+
features: [
|
|
6
|
+
'Configurable upload size limit via BODY_SIZE_LIMIT env var (supports K/M/G suffixes, default 50M)',
|
|
7
|
+
'File upload pre-validation — rejects oversized files before upload with localized error messages',
|
|
8
|
+
'Stable slot reordering — preserves positions of filtered-out entries during DnD reorder',
|
|
9
|
+
'Collection relation field filtering — filter entries by relation field values',
|
|
10
|
+
'Expose _sortOrder on entries for orderable collections',
|
|
11
|
+
'Codegen: _sortOrder field in generated TypeScript interfaces for orderable collections',
|
|
12
|
+
'getImageStyleIfExists — non-blocking image style lookup (returns null if missing)'
|
|
13
|
+
],
|
|
14
|
+
fixes: [
|
|
15
|
+
'Concurrent image style generation with buffer reuse — download original once, generate styles in parallel (limit 3)',
|
|
16
|
+
'Image style resolution gracefully skips missing styles instead of blocking',
|
|
17
|
+
'Upload endpoints use configurable size limit instead of hardcoded 50MB'
|
|
18
|
+
],
|
|
19
|
+
breakingChanges: []
|
|
20
|
+
};
|
package/dist/updates/index.js
CHANGED
|
@@ -34,7 +34,8 @@ import { update as update0110 } from './0.11.0/index.js';
|
|
|
34
34
|
import { update as update0120 } from './0.12.0/index.js';
|
|
35
35
|
import { update as update0130 } from './0.13.0/index.js';
|
|
36
36
|
import { update as update0131 } from './0.13.1/index.js';
|
|
37
|
-
|
|
37
|
+
import { update as update0132 } from './0.13.2/index.js';
|
|
38
|
+
export const updates = [update0065, update0066, update0067, update0068, update0069, update010, update011, update012, update013, update014, update015, update020, update022, update050, update051, update052, update053, update054, update055, update056, update057, update058, update060, update061, update062, update070, update071, update072, update073, update080, update090, update0100, update0110, update0120, update0130, update0131, update0132];
|
|
38
39
|
export const getUpdatesFrom = (fromVersion) => {
|
|
39
40
|
const fromParts = fromVersion.split('.').map(Number);
|
|
40
41
|
return updates.filter((update) => {
|