includio-cms 0.13.1 → 0.13.3
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 +38 -0
- package/ROADMAP.md +22 -2
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/replace.js +6 -1
- package/dist/admin/api/rest/middleware/apiKey.js +7 -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 +6 -1
- package/dist/admin/client/collection/collection-entries.svelte +48 -6
- package/dist/admin/client/collection/grid-view.svelte +1 -1
- package/dist/admin/client/entry/entry-header.svelte +37 -44
- package/dist/admin/client/entry/entry-header.svelte.d.ts +1 -2
- package/dist/admin/client/entry/entry-version.svelte +9 -3
- package/dist/admin/client/entry/entry.svelte +20 -1
- package/dist/admin/components/fields/seo-field.svelte +30 -16
- 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 +8 -7
- package/dist/admin/remote/reorder.d.ts +1 -0
- package/dist/admin/remote/reorder.js +33 -0
- package/dist/admin/state/content-language.svelte.d.ts +0 -3
- package/dist/admin/state/content-language.svelte.js +7 -11
- package/dist/admin/utils/entryLabel.js +2 -3
- package/dist/cms/runtime/api.d.ts +5 -0
- package/dist/cms/runtime/types.d.ts +13 -8
- package/dist/core/cms.js +3 -0
- package/dist/core/fields/layoutUtils.d.ts +2 -2
- package/dist/core/fields/layoutUtils.js +3 -10
- package/dist/core/server/entries/operations/get.js +17 -5
- package/dist/core/server/fields/utils/imageStyles.js +7 -3
- package/dist/core/server/generator/generator.js +3 -1
- package/dist/core/server/media/mimeBlocklist.d.ts +1 -0
- package/dist/core/server/media/mimeBlocklist.js +31 -0
- 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/paraglide/messages/_index.d.ts +3 -36
- package/dist/paraglide/messages/_index.js +3 -71
- package/dist/paraglide/messages/hello_world.d.ts +5 -0
- package/dist/paraglide/messages/hello_world.js +33 -0
- package/dist/paraglide/messages/login_hello.d.ts +16 -0
- package/dist/paraglide/messages/login_hello.js +34 -0
- package/dist/paraglide/messages/login_please_login.d.ts +16 -0
- package/dist/paraglide/messages/login_please_login.js +34 -0
- package/dist/sveltekit/server/handle.js +8 -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/0.13.3/index.d.ts +2 -0
- package/dist/updates/0.13.3/index.js +21 -0
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
- package/dist/admin/utils/translationStatus.d.ts +0 -17
- package/dist/admin/utils/translationStatus.js +0 -133
- package/dist/demo/reset.d.ts +0 -1
- package/dist/demo/reset.js +0 -26
- package/dist/paraglide/messages/en.d.ts +0 -5
- package/dist/paraglide/messages/en.js +0 -14
- package/dist/paraglide/messages/pl.d.ts +0 -5
- package/dist/paraglide/messages/pl.js +0 -14
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,44 @@
|
|
|
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.3 — 2026-03-23
|
|
7
|
+
|
|
8
|
+
Security hardening, per-language content fixes
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Timing-safe API key comparison (prevents timing attacks)
|
|
12
|
+
- MIME type blocklist on upload and file replace endpoints
|
|
13
|
+
- Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy
|
|
14
|
+
- CMS constructor validates that at least one language is configured
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
- Form submission rate limiting applies to all requests (not just multipart uploads)
|
|
18
|
+
- Form submission uses SvelteKit getClientAddress() instead of spoofable x-forwarded-for header
|
|
19
|
+
- Removed demo reset endpoint with hardcoded fallback secret
|
|
20
|
+
- Added .catch() handlers for unhandled promise rejections in admin components
|
|
21
|
+
- Per-language content language state and entry label fixes
|
|
22
|
+
|
|
23
|
+
### Breaking
|
|
24
|
+
- CMS config with empty languages array now throws an error at initialization
|
|
25
|
+
|
|
26
|
+
## 0.13.2 — 2026-03-20
|
|
27
|
+
|
|
28
|
+
Upload limits, stable reordering, relation filters, image style optimization
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
- Configurable upload size limit via BODY_SIZE_LIMIT env var (supports K/M/G suffixes, default 50M)
|
|
32
|
+
- File upload pre-validation — rejects oversized files before upload with localized error messages
|
|
33
|
+
- Stable slot reordering — preserves positions of filtered-out entries during DnD reorder
|
|
34
|
+
- Collection relation field filtering — filter entries by relation field values
|
|
35
|
+
- Expose _sortOrder on entries for orderable collections
|
|
36
|
+
- Codegen: _sortOrder field in generated TypeScript interfaces for orderable collections
|
|
37
|
+
- getImageStyleIfExists — non-blocking image style lookup (returns null if missing)
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- Concurrent image style generation with buffer reuse — download original once, generate styles in parallel (limit 3)
|
|
41
|
+
- Image style resolution gracefully skips missing styles instead of blocking
|
|
42
|
+
- Upload endpoints use configurable size limit instead of hardcoded 50MB
|
|
43
|
+
|
|
6
44
|
## 0.13.1 — 2026-03-20
|
|
7
45
|
|
|
8
46
|
Admin UI i18n, codegen cleanup, docs rewrite
|
package/ROADMAP.md
CHANGED
|
@@ -219,6 +219,26 @@
|
|
|
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
|
+
|
|
232
|
+
## 0.13.3 — Security hardening
|
|
233
|
+
|
|
234
|
+
- [x] `[fix]` `[P0]` Timing-safe API key comparison (prevents timing attacks) <!-- files: src/lib/admin/api/rest/middleware/apiKey.ts -->
|
|
235
|
+
- [x] `[fix]` `[P0]` Remove demo reset endpoint with hardcoded fallback secret <!-- files: src/routes/api/demo/reset/ -->
|
|
236
|
+
- [x] `[fix]` `[P1]` Form rate limit: all requests + getClientAddress() instead of x-forwarded-for <!-- files: src/routes/api/forms/[slug]/submit/+server.ts -->
|
|
237
|
+
- [x] `[feature]` `[P1]` MIME blocklist on upload/replace endpoints <!-- files: src/lib/core/server/media/mimeBlocklist.ts -->
|
|
238
|
+
- [x] `[feature]` `[P1]` Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy <!-- files: src/lib/sveltekit/server/handle.ts -->
|
|
239
|
+
- [x] `[fix]` `[P1]` CMS constructor validates non-empty languages array <!-- files: src/lib/core/cms.ts -->
|
|
240
|
+
- [x] `[fix]` `[P2]` Unhandled promise rejection handlers in admin components
|
|
241
|
+
|
|
222
242
|
## 0.14.0 — SEO module
|
|
223
243
|
|
|
224
244
|
- [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
|
|
@@ -236,8 +256,8 @@
|
|
|
236
256
|
- [ ] `[feature]` `[P1]` `sanitizeHTML` utility — general HTML sanitization outside richtext (text fields, SEO fields)
|
|
237
257
|
- [ ] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware
|
|
238
258
|
- [ ] `[feature]` `[P1]` CSRF protection — tokens for mutating operations
|
|
239
|
-
- [
|
|
240
|
-
- [
|
|
259
|
+
- [x] `[feature]` `[P1]` Rate limiting — form submit endpoints (done in 0.13.3; admin/auth endpoints remaining)
|
|
260
|
+
- [x] `[chore]` `[P1]` Security audit — timing attacks, MIME validation, demo endpoint, rate limiting (done in 0.13.3; `{@html}` review remaining)
|
|
241
261
|
- [ ] `[chore]` `[P1]` Input sanitization audit — review all fields for XSS
|
|
242
262
|
|
|
243
263
|
## Backlog
|
|
@@ -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,9 @@
|
|
|
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
|
+
import { isBlockedMimeType } from '../../core/server/media/mimeBlocklist.js';
|
|
6
|
+
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
5
7
|
export const POST = async ({ request }) => {
|
|
6
8
|
requireAuth();
|
|
7
9
|
const form = await request.formData();
|
|
@@ -13,6 +15,9 @@ export const POST = async ({ request }) => {
|
|
|
13
15
|
if (file.size > MAX_UPLOAD_SIZE) {
|
|
14
16
|
return new Response('File too large', { status: 413 });
|
|
15
17
|
}
|
|
18
|
+
if (isBlockedMimeType(file.type, file.name)) {
|
|
19
|
+
return new Response('File type not allowed', { status: 415 });
|
|
20
|
+
}
|
|
16
21
|
if (!fileId || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(fileId)) {
|
|
17
22
|
return new Response('Invalid fileId', { status: 400 });
|
|
18
23
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
1
2
|
import { getCMS } from '../../../../core/cms.js';
|
|
2
3
|
export function extractApiKey(event) {
|
|
3
4
|
const authHeader = event.request.headers.get('authorization');
|
|
@@ -6,9 +7,14 @@ export function extractApiKey(event) {
|
|
|
6
7
|
}
|
|
7
8
|
return event.request.headers.get('x-api-key');
|
|
8
9
|
}
|
|
10
|
+
function safeEqual(a, b) {
|
|
11
|
+
if (a.length !== b.length)
|
|
12
|
+
return false;
|
|
13
|
+
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
14
|
+
}
|
|
9
15
|
export function validateApiKey(key) {
|
|
10
16
|
const cms = getCMS();
|
|
11
|
-
return cms.apiKeys.find((k) => k.key
|
|
17
|
+
return cms.apiKeys.find((k) => safeEqual(k.key, key)) ?? null;
|
|
12
18
|
}
|
|
13
19
|
export function setSyntheticUser(event, apiKey) {
|
|
14
20
|
const name = apiKey.name || 'api';
|
|
@@ -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,9 @@ 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
|
+
import { isBlockedMimeType } from '../../core/server/media/mimeBlocklist.js';
|
|
7
|
+
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
6
8
|
export const POST = async ({ request }) => {
|
|
7
9
|
requireAuth();
|
|
8
10
|
const form = await request.formData();
|
|
@@ -14,6 +16,9 @@ export const POST = async ({ request }) => {
|
|
|
14
16
|
if (file.size > MAX_UPLOAD_SIZE) {
|
|
15
17
|
return new Response('File too large', { status: 413 });
|
|
16
18
|
}
|
|
19
|
+
if (isBlockedMimeType(file.type, file.name)) {
|
|
20
|
+
return new Response('File type not allowed', { status: 415 });
|
|
21
|
+
}
|
|
17
22
|
const dbFile = await uploadFile(file);
|
|
18
23
|
if (tagIdsRaw) {
|
|
19
24
|
try {
|
|
@@ -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
|
+
}).catch(() => {});
|
|
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>>({});
|
|
@@ -226,7 +250,7 @@
|
|
|
226
250
|
if (entriesQuery.current) {
|
|
227
251
|
fetchRelationLabels(entriesQuery.current.entries).then((l) => {
|
|
228
252
|
labelLookup = l;
|
|
229
|
-
});
|
|
253
|
+
}).catch(() => {});
|
|
230
254
|
}
|
|
231
255
|
});
|
|
232
256
|
|
|
@@ -262,7 +286,7 @@
|
|
|
262
286
|
return renderComponent(EntryLink, {
|
|
263
287
|
name: info.row.original.name,
|
|
264
288
|
url: info.row.original.url,
|
|
265
|
-
slug: info.row.original.slug
|
|
289
|
+
slug: info.row.original.slug
|
|
266
290
|
});
|
|
267
291
|
}
|
|
268
292
|
},
|
|
@@ -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] ?? '';
|
|
@@ -508,7 +534,11 @@
|
|
|
508
534
|
return {
|
|
509
535
|
id: entry.id,
|
|
510
536
|
name: getRawCollectionEntryLabel(entry, collection, contentLanguage.current),
|
|
511
|
-
slug:
|
|
537
|
+
slug: (() => {
|
|
538
|
+
const s = extractSlug(entry);
|
|
539
|
+
if (!s) return undefined;
|
|
540
|
+
return collection.pathTemplate ? collection.pathTemplate.replace('{slug}', s) : s;
|
|
541
|
+
})(),
|
|
512
542
|
url: `/admin/entries/${entry.id}`,
|
|
513
543
|
status: getEntryStatus(entry),
|
|
514
544
|
createdAt: new Date(entry.createdAt),
|
|
@@ -599,7 +629,7 @@
|
|
|
599
629
|
...old,
|
|
600
630
|
entries: orderedIds.map((id) => old.entries.find((e: RawEntry) => e.id === id)).filter(Boolean)
|
|
601
631
|
}));
|
|
602
|
-
await remotes.reorderEntriesCommand({ orderedIds });
|
|
632
|
+
await remotes.reorderEntriesCommand({ orderedIds, collectionSlug: collection.slug });
|
|
603
633
|
await entriesQuery.refresh();
|
|
604
634
|
override.release();
|
|
605
635
|
}
|
|
@@ -640,8 +670,20 @@
|
|
|
640
670
|
rows = rows.filter((row) => {
|
|
641
671
|
for (const [slug, value] of Object.entries(activeDataFilters)) {
|
|
642
672
|
if (value === null) continue;
|
|
643
|
-
|
|
644
|
-
|
|
673
|
+
// For relation fields, compare against raw UUID(s)
|
|
674
|
+
const rawValue = row.customData[`__raw_${slug}`];
|
|
675
|
+
if (rawValue !== undefined) {
|
|
676
|
+
if (typeof rawValue === 'string') {
|
|
677
|
+
if (rawValue !== value) return false;
|
|
678
|
+
} else if (Array.isArray(rawValue)) {
|
|
679
|
+
if (!rawValue.includes(value)) return false;
|
|
680
|
+
} else {
|
|
681
|
+
return false;
|
|
682
|
+
}
|
|
683
|
+
} else {
|
|
684
|
+
const rowValue = row.customData[slug];
|
|
685
|
+
if (String(rowValue ?? '') !== value) return false;
|
|
686
|
+
}
|
|
645
687
|
}
|
|
646
688
|
return true;
|
|
647
689
|
});
|
|
@@ -11,12 +11,11 @@
|
|
|
11
11
|
import LayoutSidebar from '@tabler/icons-svelte/icons/layout-sidebar';
|
|
12
12
|
import SendIcon from '@tabler/icons-svelte/icons/send';
|
|
13
13
|
import ChevronDownIcon from '@tabler/icons-svelte/icons/chevron-down';
|
|
14
|
-
import
|
|
14
|
+
import CopyIcon from '@tabler/icons-svelte/icons/copy';
|
|
15
15
|
import * as DropdownMenu from '../../../components/ui/dropdown-menu/index.js';
|
|
16
16
|
import { hasHybridContext, getHybridContext } from './hybrid/hybrid-context.svelte.js';
|
|
17
17
|
import { getEntryStatus } from './utils.js';
|
|
18
18
|
import { onMount } from 'svelte';
|
|
19
|
-
import type { LangStatus } from '../../utils/translationStatus.js';
|
|
20
19
|
|
|
21
20
|
const contentLanguage = getContentLanguage();
|
|
22
21
|
const interfaceLanguage = useInterfaceLanguage();
|
|
@@ -27,17 +26,20 @@
|
|
|
27
26
|
publish: string;
|
|
28
27
|
update: string;
|
|
29
28
|
saveDraft: string;
|
|
29
|
+
copyFrom: string;
|
|
30
30
|
}
|
|
31
31
|
> = {
|
|
32
32
|
en: {
|
|
33
33
|
publish: 'Publish',
|
|
34
34
|
update: 'Update',
|
|
35
|
-
saveDraft: 'Save draft'
|
|
35
|
+
saveDraft: 'Save draft',
|
|
36
|
+
copyFrom: 'Copy from'
|
|
36
37
|
},
|
|
37
38
|
pl: {
|
|
38
39
|
publish: 'Publikuj',
|
|
39
40
|
update: 'Aktualizuj',
|
|
40
|
-
saveDraft: 'Zapisz szkic'
|
|
41
|
+
saveDraft: 'Zapisz szkic',
|
|
42
|
+
copyFrom: 'Kopiuj z'
|
|
41
43
|
}
|
|
42
44
|
};
|
|
43
45
|
|
|
@@ -54,7 +56,7 @@
|
|
|
54
56
|
saveStatus?: SaveStatus;
|
|
55
57
|
isArchived?: boolean;
|
|
56
58
|
onScrollToIssue?: (fieldSlug: string, nodePos: number) => void;
|
|
57
|
-
|
|
59
|
+
onCopyFromLang?: (lang: string) => void;
|
|
58
60
|
};
|
|
59
61
|
|
|
60
62
|
let {
|
|
@@ -68,7 +70,7 @@
|
|
|
68
70
|
saveStatus = 'idle',
|
|
69
71
|
isArchived = false,
|
|
70
72
|
onScrollToIssue,
|
|
71
|
-
|
|
73
|
+
onCopyFromLang
|
|
72
74
|
}: Props = $props();
|
|
73
75
|
let { collection } = entry;
|
|
74
76
|
|
|
@@ -93,29 +95,8 @@
|
|
|
93
95
|
});
|
|
94
96
|
const shortcutLabel = $derived(isMac ? '⌘S' : 'Ctrl+S');
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
if (status.status === 'complete') return 'bg-[var(--success)]';
|
|
99
|
-
if (status.status === 'partial') return 'bg-[var(--warning)]';
|
|
100
|
-
return 'bg-[var(--text-light)]';
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function statusLabel(lang: string, status: LangStatus | undefined): string {
|
|
104
|
-
if (!status) return lang.toUpperCase();
|
|
105
|
-
if (status.status === 'complete') return `${lang.toUpperCase()} \u2713`;
|
|
106
|
-
if (status.status === 'partial') return `${lang.toUpperCase()} \u26A0`;
|
|
107
|
-
return lang.toUpperCase();
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function statusTooltip(lang: string, status: LangStatus | undefined): string {
|
|
111
|
-
if (!status) return lang.toUpperCase();
|
|
112
|
-
if (status.status === 'complete') return `${lang.toUpperCase()}: 100%`;
|
|
113
|
-
const missing = status.missingFields.map((f) => f.label).join(', ');
|
|
114
|
-
return `${lang.toUpperCase()}: ${status.percentage}% — ${interfaceLanguage.current === 'pl' ? 'brakuje' : 'missing'}: ${missing}`;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const isNonDefaultLang = $derived(
|
|
118
|
-
contentLanguage.all.length > 1 && contentLanguage.current !== contentLanguage.all[0]
|
|
98
|
+
const otherLanguages = $derived(
|
|
99
|
+
contentLanguage.all.filter((l) => l !== contentLanguage.current)
|
|
119
100
|
);
|
|
120
101
|
</script>
|
|
121
102
|
|
|
@@ -134,34 +115,46 @@
|
|
|
134
115
|
{#if contentLanguage.all.length > 1}
|
|
135
116
|
<div class="border-border bg-muted inline-flex overflow-hidden rounded-lg border">
|
|
136
117
|
{#each contentLanguage.all as lang}
|
|
137
|
-
{@const langStatus = translationStatus?.[lang]}
|
|
138
118
|
<button
|
|
139
119
|
type="button"
|
|
140
120
|
role="tab"
|
|
141
121
|
aria-selected={lang === contentLanguage.current}
|
|
142
|
-
title={
|
|
143
|
-
class="inline-flex items-center
|
|
122
|
+
title={lang.toUpperCase()}
|
|
123
|
+
class="inline-flex items-center px-2.5 py-1 text-xs font-semibold transition-colors {lang ===
|
|
144
124
|
contentLanguage.current
|
|
145
125
|
? 'text-primary bg-white shadow-sm ring-1 ring-primary/20'
|
|
146
126
|
: 'text-muted-foreground hover:text-foreground bg-transparent'}"
|
|
147
127
|
onclick={() => (contentLanguage.current = lang)}
|
|
148
128
|
>
|
|
149
|
-
|
|
150
|
-
{statusLabel(lang, langStatus)}
|
|
129
|
+
{lang.toUpperCase()}
|
|
151
130
|
</button>
|
|
152
131
|
{/each}
|
|
153
132
|
</div>
|
|
154
133
|
|
|
155
|
-
<!--
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
134
|
+
<!-- Copy from other language -->
|
|
135
|
+
{#if onCopyFromLang && otherLanguages.length > 0}
|
|
136
|
+
<DropdownMenu.Root>
|
|
137
|
+
<DropdownMenu.Trigger>
|
|
138
|
+
{#snippet child({ props })}
|
|
139
|
+
<button
|
|
140
|
+
{...props}
|
|
141
|
+
type="button"
|
|
142
|
+
class="text-muted-foreground hover:text-foreground inline-flex items-center rounded-md p-1.5 transition-colors hover:bg-[var(--lavender-lighter)]"
|
|
143
|
+
title={lang[interfaceLanguage.current].copyFrom}
|
|
144
|
+
>
|
|
145
|
+
<CopyIcon class="size-4" />
|
|
146
|
+
</button>
|
|
147
|
+
{/snippet}
|
|
148
|
+
</DropdownMenu.Trigger>
|
|
149
|
+
<DropdownMenu.Content align="end" class="w-48">
|
|
150
|
+
{#each otherLanguages as sourceLang}
|
|
151
|
+
<DropdownMenu.Item onclick={() => onCopyFromLang?.(sourceLang)}>
|
|
152
|
+
{lang[interfaceLanguage.current].copyFrom} {sourceLang.toUpperCase()}
|
|
153
|
+
</DropdownMenu.Item>
|
|
154
|
+
{/each}
|
|
155
|
+
</DropdownMenu.Content>
|
|
156
|
+
</DropdownMenu.Root>
|
|
157
|
+
{/if}
|
|
165
158
|
|
|
166
159
|
<div class="bg-border mx-1 h-5 w-px shrink-0"></div>
|
|
167
160
|
{/if}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { DbEntryVersion, RawEntry } from '../../../types/entries.js';
|
|
2
2
|
import type { UpdateEntryVersionCommandType } from '../../../core/server/entries/operations/update.js';
|
|
3
3
|
import type { Field } from '../../../types/fields.js';
|
|
4
|
-
import type { LangStatus } from '../../utils/translationStatus.js';
|
|
5
4
|
type SaveStatus = 'idle' | 'saving' | 'saved' | 'unsaved' | 'error';
|
|
6
5
|
type Props = {
|
|
7
6
|
entry: RawEntry;
|
|
@@ -14,7 +13,7 @@ type Props = {
|
|
|
14
13
|
saveStatus?: SaveStatus;
|
|
15
14
|
isArchived?: boolean;
|
|
16
15
|
onScrollToIssue?: (fieldSlug: string, nodePos: number) => void;
|
|
17
|
-
|
|
16
|
+
onCopyFromLang?: (lang: string) => void;
|
|
18
17
|
};
|
|
19
18
|
declare const EntryHeader: import("svelte").Component<Props, {}, "">;
|
|
20
19
|
type EntryHeader = ReturnType<typeof EntryHeader>;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { page } from '$app/state';
|
|
3
|
+
import { getContentLanguage } from '../../state/content-language.svelte.js';
|
|
3
4
|
import type { RawEntry } from '../../../types/entries.js';
|
|
4
5
|
import Entry from './entry.svelte';
|
|
5
6
|
|
|
@@ -9,11 +10,16 @@
|
|
|
9
10
|
|
|
10
11
|
let { entry }: Props = $props();
|
|
11
12
|
|
|
13
|
+
const contentLanguage = getContentLanguage();
|
|
12
14
|
const version = $derived(page.url.searchParams.get('version') || '');
|
|
13
15
|
|
|
14
|
-
const editingEntry = $derived(
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
const editingEntry = $derived.by(() => {
|
|
17
|
+
if (version) {
|
|
18
|
+
return entry.versions.find((ver) => ver.id === version) || entry.versions[0];
|
|
19
|
+
}
|
|
20
|
+
const lang = contentLanguage.current;
|
|
21
|
+
return entry.publishedVersions[lang] || entry.draftVersions[lang] || entry.versions[0];
|
|
22
|
+
});
|
|
17
23
|
</script>
|
|
18
24
|
|
|
19
25
|
{#key editingEntry.id}
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
import { getFieldsFromConfig, hasLayout } from '../../../core/fields/layoutUtils.js';
|
|
27
27
|
import type { ValidationErrors } from 'sveltekit-superforms';
|
|
28
28
|
import { createHybridContext } from './hybrid/hybrid-context.svelte.js';
|
|
29
|
-
import { onMount } from 'svelte';
|
|
29
|
+
import { onMount, setContext } from 'svelte';
|
|
30
30
|
import { get } from 'svelte/store';
|
|
31
31
|
const contentLanguage = getContentLanguage();
|
|
32
32
|
const remotes = getRemotes();
|
|
@@ -140,6 +140,9 @@
|
|
|
140
140
|
let { collection } = entry;
|
|
141
141
|
const isArchived = $derived(!!entry.archivedAt);
|
|
142
142
|
|
|
143
|
+
setContext('cms-path-template', collection.pathTemplate || null);
|
|
144
|
+
setContext('cms-entry-published', entry.publishedVersions[contentLanguage.current] != null);
|
|
145
|
+
|
|
143
146
|
// Create form once at component level — localized: false since data is flat single-language
|
|
144
147
|
const collectionSchema = generateZodSchemaFromFields(
|
|
145
148
|
getFieldsFromConfig(collection),
|
|
@@ -539,6 +542,21 @@
|
|
|
539
542
|
|
|
540
543
|
const t = $derived(lang[interfaceLanguage.current]);
|
|
541
544
|
const isHybrid = $derived(hybridContext.mode === 'hybrid' && !!collection.previewUrl);
|
|
545
|
+
|
|
546
|
+
function onCopyFromLang(sourceLang: string) {
|
|
547
|
+
const sourceVersion =
|
|
548
|
+
entry.draftVersions[sourceLang] ?? entry.publishedVersions[sourceLang];
|
|
549
|
+
if (!sourceVersion?.data) return;
|
|
550
|
+
|
|
551
|
+
const confirmMsg =
|
|
552
|
+
interfaceLanguage.current === 'pl'
|
|
553
|
+
? `Nadpisze aktualne dane danymi z wersji ${sourceLang.toUpperCase()}. Kontynuować?`
|
|
554
|
+
: `This will overwrite current data with data from ${sourceLang.toUpperCase()} version. Continue?`;
|
|
555
|
+
|
|
556
|
+
if (!confirm(confirmMsg)) return;
|
|
557
|
+
|
|
558
|
+
form.form.set(sourceVersion.data);
|
|
559
|
+
}
|
|
542
560
|
</script>
|
|
543
561
|
|
|
544
562
|
<div class={isHybrid ? 'flex h-full flex-col overflow-hidden' : ''}>
|
|
@@ -553,6 +571,7 @@
|
|
|
553
571
|
fields={getFieldsFromConfig(collection)}
|
|
554
572
|
getFormData={() => get(form.form)}
|
|
555
573
|
onScrollToIssue={scrollToIssue}
|
|
574
|
+
{onCopyFromLang}
|
|
556
575
|
/>
|
|
557
576
|
|
|
558
577
|
{#if validationErrors.length > 0}
|
|
@@ -8,15 +8,22 @@
|
|
|
8
8
|
MediaField,
|
|
9
9
|
SeoField,
|
|
10
10
|
SeoFieldData,
|
|
11
|
+
SlugField as SlugFieldType,
|
|
11
12
|
TextField
|
|
12
13
|
} from '../../../types/fields.js';
|
|
13
|
-
import {
|
|
14
|
+
import { formFieldProxy, type FormPathLeaves } from 'sveltekit-superforms';
|
|
15
|
+
import Input from '../../../components/ui/input/input.svelte';
|
|
16
|
+
import * as Form from '../../../components/ui/form/index.js';
|
|
17
|
+
import { getContext, untrack } from 'svelte';
|
|
14
18
|
import slugify from '../../imports/slugify.js';
|
|
15
19
|
import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
|
|
16
20
|
import { getLocalizedLabel } from '../../utils/collectionLabel.js';
|
|
17
21
|
import { Switch } from '../../../components/ui/switch/index.js';
|
|
18
22
|
|
|
19
23
|
const interfaceLanguage = useInterfaceLanguage();
|
|
24
|
+
const pathTemplate = getContext<string | null>('cms-path-template');
|
|
25
|
+
const pathPrefix = pathTemplate ? pathTemplate.replace('{slug}', '') : '/';
|
|
26
|
+
const wasPublished = getContext<boolean>('cms-entry-published') ?? false;
|
|
20
27
|
|
|
21
28
|
type Props = {
|
|
22
29
|
field: SeoField;
|
|
@@ -172,6 +179,10 @@
|
|
|
172
179
|
return 'text-destructive';
|
|
173
180
|
}
|
|
174
181
|
|
|
182
|
+
// Slug field proxy for direct input binding
|
|
183
|
+
const slugPath = joinPath(String(path), 'slug');
|
|
184
|
+
const { value: slugValue } = formFieldProxy(form, slugPath as FormPathLeaves<Record<string, unknown>>);
|
|
185
|
+
|
|
175
186
|
// Auto-gen: track last auto-generated value
|
|
176
187
|
let lastAutoSlug = '';
|
|
177
188
|
let lastAutoTitle = '';
|
|
@@ -179,6 +190,7 @@
|
|
|
179
190
|
// Auto slug toggle
|
|
180
191
|
let autoSlug = $state((() => {
|
|
181
192
|
if (!field.slugSource) return false;
|
|
193
|
+
if (wasPublished) return false;
|
|
182
194
|
const sourceRaw = ($formData as Record<string, unknown>)[field.slugSource];
|
|
183
195
|
if (!sourceRaw || typeof sourceRaw !== 'string') return true;
|
|
184
196
|
const slugPath = joinPath(String(path), 'slug');
|
|
@@ -238,21 +250,23 @@
|
|
|
238
250
|
</script>
|
|
239
251
|
|
|
240
252
|
<div class="space-y-4">
|
|
241
|
-
<!-- Slug field with auto/manual toggle -->
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
<
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
253
|
+
<!-- Slug field with auto/manual toggle + path prefix -->
|
|
254
|
+
<Form.Field {form} name={slugPath} class="space-y-1">
|
|
255
|
+
<div class="flex items-center justify-between">
|
|
256
|
+
<Form.Label>{getLocalizedLabel(labels.slug.label, interfaceLanguage.current)}</Form.Label>
|
|
257
|
+
{#if field.slugSource}
|
|
258
|
+
<div class="flex items-center gap-2">
|
|
259
|
+
<span class="text-sm font-medium text-muted-foreground">Auto</span>
|
|
260
|
+
<Switch bind:checked={autoSlug} onCheckedChange={onAutoSlugToggle} />
|
|
261
|
+
</div>
|
|
262
|
+
{/if}
|
|
263
|
+
</div>
|
|
264
|
+
<div class="flex">
|
|
265
|
+
<span class="border-input bg-muted text-muted-foreground flex h-9 shrink-0 items-center rounded-l-md border border-r-0 px-2.5 font-mono text-sm">{pathPrefix}</span>
|
|
266
|
+
<Input bind:value={$slugValue} readonly={autoSlug} class="rounded-l-none border-l-0" />
|
|
267
|
+
</div>
|
|
268
|
+
<Form.Description>{getLocalizedLabel(labels.slug.description, interfaceLanguage.current)}</Form.Description>
|
|
269
|
+
</Form.Field>
|
|
256
270
|
{#each fields as f}
|
|
257
271
|
<div>
|
|
258
272
|
<FieldRenderer
|