includio-cms 0.13.2 → 0.13.4
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 +32 -0
- package/ROADMAP.md +19 -2
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/media-gc.js +8 -3
- package/dist/admin/api/regenerate-posters.d.ts +2 -0
- package/dist/admin/api/regenerate-posters.js +32 -0
- package/dist/admin/api/replace.js +4 -0
- package/dist/admin/api/rest/middleware/apiKey.js +7 -1
- package/dist/admin/api/upload.js +4 -0
- package/dist/admin/client/collection/collection-entries.svelte +8 -4
- 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/client/maintenance/maintenance-page.svelte +153 -0
- package/dist/admin/components/fields/seo-field.svelte +30 -16
- package/dist/admin/remote/entry.remote.js +3 -4
- 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 +2 -2
- package/dist/core/server/media/mimeBlocklist.d.ts +1 -0
- package/dist/core/server/media/mimeBlocklist.js +31 -0
- package/dist/core/server/media/operations/batchRegenerateVideoPosters.d.ts +15 -0
- package/dist/core/server/media/operations/batchRegenerateVideoPosters.js +112 -0
- package/dist/files-local/index.d.ts +1 -0
- package/dist/files-local/index.js +3 -140
- package/dist/files-local/sanitizeFilename.js +2 -1
- package/dist/files-local/video.d.ts +9 -0
- package/dist/files-local/video.js +145 -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/updates/0.13.3/index.d.ts +2 -0
- package/dist/updates/0.13.3/index.js +21 -0
- package/dist/updates/0.13.4/index.d.ts +2 -0
- package/dist/updates/0.13.4/index.js +14 -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,38 @@
|
|
|
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.4 — 2026-03-24
|
|
7
|
+
|
|
8
|
+
Video poster regeneration, filename sanitization
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Batch regenerate video posters & thumbnails from admin maintenance page with SSE progress
|
|
12
|
+
- Video poster status reporting in media GC endpoint
|
|
13
|
+
- Extracted video processing to reusable module
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- Filename sanitization normalizes Unicode to NFC (prevents NFD/NFC mismatch)
|
|
17
|
+
|
|
18
|
+
## 0.13.3 — 2026-03-23
|
|
19
|
+
|
|
20
|
+
Security hardening, per-language content fixes
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- Timing-safe API key comparison (prevents timing attacks)
|
|
24
|
+
- MIME type blocklist on upload and file replace endpoints
|
|
25
|
+
- Security headers: X-Content-Type-Options, X-Frame-Options, Referrer-Policy
|
|
26
|
+
- CMS constructor validates that at least one language is configured
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Form submission rate limiting applies to all requests (not just multipart uploads)
|
|
30
|
+
- Form submission uses SvelteKit getClientAddress() instead of spoofable x-forwarded-for header
|
|
31
|
+
- Removed demo reset endpoint with hardcoded fallback secret
|
|
32
|
+
- Added .catch() handlers for unhandled promise rejections in admin components
|
|
33
|
+
- Per-language content language state and entry label fixes
|
|
34
|
+
|
|
35
|
+
### Breaking
|
|
36
|
+
- CMS config with empty languages array now throws an error at initialization
|
|
37
|
+
|
|
6
38
|
## 0.13.2 — 2026-03-20
|
|
7
39
|
|
|
8
40
|
Upload limits, stable reordering, relation filters, image style optimization
|
package/ROADMAP.md
CHANGED
|
@@ -229,6 +229,23 @@
|
|
|
229
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
230
|
- [x] `[fix]` `[P1]` Image style resolution gracefully skips missing styles <!-- files: src/lib/core/server/fields/utils/imageStyles.ts -->
|
|
231
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
|
+
|
|
242
|
+
## 0.13.4 — Video poster regeneration
|
|
243
|
+
|
|
244
|
+
- [x] `[feature]` `[P1]` Batch video poster regeneration — admin maintenance page with SSE progress <!-- files: src/lib/admin/api/regenerate-posters.ts, src/lib/core/server/media/operations/batchRegenerateVideoPosters.ts, src/lib/admin/client/maintenance/maintenance-page.svelte -->
|
|
245
|
+
- [x] `[feature]` `[P1]` Video poster status reporting in media GC endpoint <!-- files: src/lib/admin/api/media-gc.ts -->
|
|
246
|
+
- [x] `[chore]` `[P1]` Extracted video processing to reusable module <!-- files: src/lib/files-local/video.ts, src/lib/files-local/index.ts -->
|
|
247
|
+
- [x] `[fix]` `[P1]` Filename sanitization normalizes Unicode to NFC <!-- files: src/lib/files-local/sanitizeFilename.ts -->
|
|
248
|
+
|
|
232
249
|
## 0.14.0 — SEO module
|
|
233
250
|
|
|
234
251
|
- [ ] `[feature]` `[P1]` SERP preview + character limits for title/description <!-- files: src/lib/admin/components/fields/seo-field.svelte -->
|
|
@@ -246,8 +263,8 @@
|
|
|
246
263
|
- [ ] `[feature]` `[P1]` `sanitizeHTML` utility — general HTML sanitization outside richtext (text fields, SEO fields)
|
|
247
264
|
- [ ] `[feature]` `[P1]` CSP headers — Content-Security-Policy middleware
|
|
248
265
|
- [ ] `[feature]` `[P1]` CSRF protection — tokens for mutating operations
|
|
249
|
-
- [
|
|
250
|
-
- [
|
|
266
|
+
- [x] `[feature]` `[P1]` Rate limiting — form submit endpoints (done in 0.13.3; admin/auth endpoints remaining)
|
|
267
|
+
- [x] `[chore]` `[P1]` Security audit — timing attacks, MIME validation, demo endpoint, rate limiting (done in 0.13.3; `{@html}` review remaining)
|
|
251
268
|
- [ ] `[chore]` `[P1]` Input sanitization audit — review all fields for XSS
|
|
252
269
|
|
|
253
270
|
## 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 regeneratePostersHandlers from './regenerate-posters.js';
|
|
9
10
|
import * as uploadLimitHandlers from './upload-limit.js';
|
|
10
11
|
import { requireAuth } from '../remote/middleware/auth.js';
|
|
11
12
|
import { getCMS } from '../../core/cms.js';
|
|
@@ -19,6 +20,7 @@ export function createAdminApiHandler(options) {
|
|
|
19
20
|
'accept-invite': acceptInviteHandlers,
|
|
20
21
|
'media-gc': mediaGcHandlers,
|
|
21
22
|
'generate-styles': generateStylesHandlers,
|
|
23
|
+
'regenerate-posters': regeneratePostersHandlers,
|
|
22
24
|
'upload-limit': uploadLimitHandlers,
|
|
23
25
|
...options?.extraRoutes
|
|
24
26
|
};
|
|
@@ -2,12 +2,14 @@ import { requireRole } from '../remote/middleware/auth.js';
|
|
|
2
2
|
import { purgeAllImageStyles } from '../../core/server/media/operations/purgeImageStyles.js';
|
|
3
3
|
import { getReconciliationReport, deleteOrphanedDiskFiles } from '../../core/server/media/operations/reconcileMedia.js';
|
|
4
4
|
import { getStylesStatus } from '../../core/server/media/styles/operations/batchGenerateStyles.js';
|
|
5
|
+
import { getVideoPosterStatus } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
|
|
5
6
|
import { json } from '@sveltejs/kit';
|
|
6
7
|
export const GET = async ({ url }) => {
|
|
7
8
|
requireRole('admin');
|
|
8
|
-
const [stylesStatus, report] = await Promise.all([
|
|
9
|
+
const [stylesStatus, report, videoPosterStatus] = await Promise.all([
|
|
9
10
|
getStylesStatus(),
|
|
10
|
-
getReconciliationReport()
|
|
11
|
+
getReconciliationReport(),
|
|
12
|
+
getVideoPosterStatus()
|
|
11
13
|
]);
|
|
12
14
|
return json({
|
|
13
15
|
imageStylesCount: stylesStatus.existingStyles,
|
|
@@ -15,7 +17,10 @@ export const GET = async ({ url }) => {
|
|
|
15
17
|
expectedStylesCount: stylesStatus.expectedStyles,
|
|
16
18
|
missingStylesCount: stylesStatus.missingStyles,
|
|
17
19
|
orphanedDiskFiles: report.orphanedDisk,
|
|
18
|
-
missingDiskRecords: report.missingDisk
|
|
20
|
+
missingDiskRecords: report.missingDisk,
|
|
21
|
+
videosCount: videoPosterStatus.videosCount,
|
|
22
|
+
videosWithPosters: videoPosterStatus.videosWithPosters,
|
|
23
|
+
videosMissingPosters: videoPosterStatus.videosMissingPosters
|
|
19
24
|
});
|
|
20
25
|
};
|
|
21
26
|
export const DELETE = async ({ url }) => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { requireRole } from '../remote/middleware/auth.js';
|
|
2
|
+
import { batchRegenerateVideoPosters } from '../../core/server/media/operations/batchRegenerateVideoPosters.js';
|
|
3
|
+
export const POST = async () => {
|
|
4
|
+
requireRole('admin');
|
|
5
|
+
const abort = new AbortController();
|
|
6
|
+
const encoder = new TextEncoder();
|
|
7
|
+
const stream = new ReadableStream({
|
|
8
|
+
async start(controller) {
|
|
9
|
+
try {
|
|
10
|
+
for await (const event of batchRegenerateVideoPosters(abort.signal)) {
|
|
11
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
catch (e) {
|
|
15
|
+
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ type: 'error', error: e instanceof Error ? e.message : String(e) })}\n\n`));
|
|
16
|
+
}
|
|
17
|
+
finally {
|
|
18
|
+
controller.close();
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
cancel() {
|
|
22
|
+
abort.abort();
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
return new Response(stream, {
|
|
26
|
+
headers: {
|
|
27
|
+
'Content-Type': 'text/event-stream',
|
|
28
|
+
'Cache-Control': 'no-cache',
|
|
29
|
+
Connection: 'keep-alive'
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
};
|
|
@@ -2,6 +2,7 @@ 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';
|
|
5
6
|
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
6
7
|
export const POST = async ({ request }) => {
|
|
7
8
|
requireAuth();
|
|
@@ -14,6 +15,9 @@ export const POST = async ({ request }) => {
|
|
|
14
15
|
if (file.size > MAX_UPLOAD_SIZE) {
|
|
15
16
|
return new Response('File too large', { status: 413 });
|
|
16
17
|
}
|
|
18
|
+
if (isBlockedMimeType(file.type, file.name)) {
|
|
19
|
+
return new Response('File type not allowed', { status: 415 });
|
|
20
|
+
}
|
|
17
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)) {
|
|
18
22
|
return new Response('Invalid fileId', { status: 400 });
|
|
19
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';
|
package/dist/admin/api/upload.js
CHANGED
|
@@ -3,6 +3,7 @@ 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';
|
|
6
7
|
const MAX_UPLOAD_SIZE = getMaxUploadSize();
|
|
7
8
|
export const POST = async ({ request }) => {
|
|
8
9
|
requireAuth();
|
|
@@ -15,6 +16,9 @@ export const POST = async ({ request }) => {
|
|
|
15
16
|
if (file.size > MAX_UPLOAD_SIZE) {
|
|
16
17
|
return new Response('File too large', { status: 413 });
|
|
17
18
|
}
|
|
19
|
+
if (isBlockedMimeType(file.type, file.name)) {
|
|
20
|
+
return new Response('File type not allowed', { status: 415 });
|
|
21
|
+
}
|
|
18
22
|
const dbFile = await uploadFile(file);
|
|
19
23
|
if (tagIdsRaw) {
|
|
20
24
|
try {
|
|
@@ -155,7 +155,7 @@
|
|
|
155
155
|
...relationFilterOptions,
|
|
156
156
|
[field.slug]: labels.map((l) => ({ value: l.id, label: l.label }))
|
|
157
157
|
};
|
|
158
|
-
});
|
|
158
|
+
}).catch(() => {});
|
|
159
159
|
}
|
|
160
160
|
});
|
|
161
161
|
|
|
@@ -250,7 +250,7 @@
|
|
|
250
250
|
if (entriesQuery.current) {
|
|
251
251
|
fetchRelationLabels(entriesQuery.current.entries).then((l) => {
|
|
252
252
|
labelLookup = l;
|
|
253
|
-
});
|
|
253
|
+
}).catch(() => {});
|
|
254
254
|
}
|
|
255
255
|
});
|
|
256
256
|
|
|
@@ -286,7 +286,7 @@
|
|
|
286
286
|
return renderComponent(EntryLink, {
|
|
287
287
|
name: info.row.original.name,
|
|
288
288
|
url: info.row.original.url,
|
|
289
|
-
slug: info.row.original.slug
|
|
289
|
+
slug: info.row.original.slug
|
|
290
290
|
});
|
|
291
291
|
}
|
|
292
292
|
},
|
|
@@ -534,7 +534,11 @@
|
|
|
534
534
|
return {
|
|
535
535
|
id: entry.id,
|
|
536
536
|
name: getRawCollectionEntryLabel(entry, collection, contentLanguage.current),
|
|
537
|
-
slug:
|
|
537
|
+
slug: (() => {
|
|
538
|
+
const s = extractSlug(entry);
|
|
539
|
+
if (!s) return undefined;
|
|
540
|
+
return collection.pathTemplate ? collection.pathTemplate.replace('{slug}', s) : s;
|
|
541
|
+
})(),
|
|
538
542
|
url: `/admin/entries/${entry.id}`,
|
|
539
543
|
status: getEntryStatus(entry),
|
|
540
544
|
createdAt: new Date(entry.createdAt),
|
|
@@ -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}
|