includio-cms 0.13.0 → 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.
Files changed (59) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/ROADMAP.md +24 -0
  3. package/dist/admin/api/handler.js +2 -0
  4. package/dist/admin/api/replace.js +2 -1
  5. package/dist/admin/api/rest/routes/upload.js +2 -1
  6. package/dist/admin/api/upload-limit.d.ts +2 -0
  7. package/dist/admin/api/upload-limit.js +7 -0
  8. package/dist/admin/api/upload.js +2 -1
  9. package/dist/admin/client/collection/collection-entries.svelte +58 -11
  10. package/dist/admin/client/users/users-page.svelte +5 -6
  11. package/dist/admin/client/users/users-page.svelte.d.ts +1 -4
  12. package/dist/admin/components/fields/block-picker-modal.svelte +13 -4
  13. package/dist/admin/components/fields/blocks-field.svelte +31 -9
  14. package/dist/admin/components/fields/simple-array-field.svelte +22 -11
  15. package/dist/admin/components/layout/layout-renderer.svelte +10 -4
  16. package/dist/admin/components/media/file-preview.svelte +10 -1
  17. package/dist/admin/components/media/file-upload.svelte +66 -9
  18. package/dist/admin/components/media/files-list.svelte +12 -3
  19. package/dist/admin/components/media/media-selector.svelte +11 -5
  20. package/dist/admin/components/tiptap/FigureNodeView.svelte +15 -10
  21. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +32 -1
  22. package/dist/admin/components/tiptap/SlashCommandPopup.svelte +8 -3
  23. package/dist/admin/components/tiptap/editor-toolbar.svelte +28 -23
  24. package/dist/admin/components/tiptap/image-dialog.svelte +12 -7
  25. package/dist/admin/components/tiptap/lang.d.ts +77 -0
  26. package/dist/admin/components/tiptap/lang.js +170 -0
  27. package/dist/admin/components/tiptap/link-dialog.svelte +22 -18
  28. package/dist/admin/components/tiptap/slash-command.js +26 -22
  29. package/dist/admin/components/tiptap/table-dialog.svelte +9 -4
  30. package/dist/admin/components/tiptap/video-dialog.svelte +6 -1
  31. package/dist/admin/remote/email.remote.d.ts +1 -0
  32. package/dist/admin/remote/email.remote.js +5 -0
  33. package/dist/admin/remote/entry.remote.d.ts +1 -0
  34. package/dist/admin/remote/entry.remote.js +6 -4
  35. package/dist/admin/remote/index.d.ts +1 -0
  36. package/dist/admin/remote/index.js +1 -0
  37. package/dist/admin/remote/reorder.d.ts +1 -0
  38. package/dist/admin/remote/reorder.js +33 -0
  39. package/dist/core/server/entries/operations/get.js +15 -3
  40. package/dist/core/server/fields/utils/imageStyles.js +7 -3
  41. package/dist/core/server/generator/fields.js +2 -2
  42. package/dist/core/server/generator/generator.js +4 -2
  43. package/dist/core/server/media/styles/operations/createMediaStyle.d.ts +2 -2
  44. package/dist/core/server/media/styles/operations/createMediaStyle.js +5 -3
  45. package/dist/core/server/media/styles/operations/generateDefaultStyles.js +33 -6
  46. package/dist/core/server/media/styles/operations/getImageStyle.d.ts +1 -0
  47. package/dist/core/server/media/styles/operations/getImageStyle.js +3 -0
  48. package/dist/core/server/media/styles/sharp/generateImageStyle.d.ts +2 -1
  49. package/dist/core/server/media/styles/sharp/generateImageStyle.js +5 -3
  50. package/dist/core/server/media/uploadLimit.d.ts +2 -0
  51. package/dist/core/server/media/uploadLimit.js +26 -0
  52. package/dist/types/entries.d.ts +1 -0
  53. package/dist/types/layout.d.ts +0 -1
  54. package/dist/updates/0.13.1/index.d.ts +2 -0
  55. package/dist/updates/0.13.1/index.js +20 -0
  56. package/dist/updates/0.13.2/index.d.ts +2 -0
  57. package/dist/updates/0.13.2/index.js +20 -0
  58. package/dist/updates/index.js +3 -1
  59. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,42 @@
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
+
24
+ ## 0.13.1 — 2026-03-20
25
+
26
+ Admin UI i18n, codegen cleanup, docs rewrite
27
+
28
+ ### Added
29
+ - Admin UI i18n — TipTap editor, blocks field, array field, media components support pl/en interface language
30
+ - Inline block accordion labels — show field value in collapsed block header
31
+ - Blocks field UrlFieldData label support
32
+ - Email configuration remote endpoint
33
+
34
+ ### Fixed
35
+ - Codegen: remove unused FlatImageFieldData/FlatVideoFieldData, use ImageFieldData/VideoFieldData
36
+ - Layout renderer: hide card header when label is empty
37
+ - Collection entries: improved relation label fetching with UUID validation and multi-version support
38
+ - Users page: client-side email config check, remove server load dependency
39
+ - Entry remote: stricter UUID validation for ids parameter
40
+ - Layout type: remove unused label property from layout node
41
+
6
42
  ## 0.13.0 — 2026-03-19
7
43
 
8
44
  Private file uploads for form submissions
package/ROADMAP.md CHANGED
@@ -205,6 +205,30 @@
205
205
 
206
206
  - [x] `[feature]` `[P1]` Private file uploads — form submissions can upload files to non-public directory
207
207
 
208
+ ## 0.13.1 — Admin i18n, codegen cleanup, docs
209
+
210
+ - [x] `[feature]` `[P1]` Admin UI i18n — TipTap, blocks, array, media components support pl/en <!-- files: src/lib/admin/components/tiptap/lang.ts -->
211
+ - [x] `[feature]` `[P2]` Inline block accordion labels — show field value in collapsed header <!-- files: src/lib/admin/components/tiptap/InlineBlockNodeView.svelte -->
212
+ - [x] `[feature]` `[P2]` Blocks field UrlFieldData label support <!-- files: src/lib/admin/components/fields/blocks-field.svelte -->
213
+ - [x] `[feature]` `[P2]` Email configuration remote endpoint <!-- files: src/lib/admin/remote/email.remote.ts -->
214
+ - [x] `[fix]` `[P1]` Codegen: remove unused Flat*FieldData types, use ImageFieldData/VideoFieldData <!-- files: src/lib/core/server/generator/fields.ts, src/lib/core/server/generator/generator.ts -->
215
+ - [x] `[fix]` `[P1]` Layout renderer: hide card header when label empty <!-- files: src/lib/admin/components/layout/layout-renderer.svelte -->
216
+ - [x] `[fix]` `[P1]` Collection entries: improved relation label fetching (UUID validation, multi-version) <!-- files: src/lib/admin/client/collection/collection-entries.svelte -->
217
+ - [x] `[fix]` `[P1]` Users page: client-side email config, remove server load <!-- files: src/lib/admin/client/users/users-page.svelte -->
218
+ - [x] `[fix]` `[P2]` Entry remote: stricter UUID validation for ids <!-- files: src/lib/admin/remote/entry.remote.ts -->
219
+ - [x] `[fix]` `[P2]` Layout type: remove unused label property <!-- files: src/lib/types/layout.ts -->
220
+ - [x] `[chore]` `[P1]` Documentation rewrite — new pages for blocks, content, media-field, layout; expanded API, auth, entries, forms, plugins docs
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
+
208
232
  ## 0.14.0 — SEO module
209
233
 
210
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
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
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
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
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,2 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ export declare const GET: RequestHandler;
@@ -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
+ };
@@ -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
- const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
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>>({});
@@ -433,6 +457,8 @@
433
457
  return issues.filter((i) => i.type === 'warning').length;
434
458
  }
435
459
 
460
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
461
+
436
462
  async function fetchRelationLabels(entries: RawEntry[]): Promise<Record<string, string>> {
437
463
  if (relationListFields.length === 0) return {};
438
464
 
@@ -441,14 +467,21 @@
441
467
  uuidsByCollection.set(field.collection, new Set());
442
468
  }
443
469
  for (const entry of entries) {
444
- const data = getVersionData(entry);
445
- if (!data) continue;
446
- for (const field of relationListFields) {
447
- const raw = (data as Record<string, unknown>)[field.slug];
448
- if (!raw) continue;
449
- const set = uuidsByCollection.get(field.collection)!;
450
- if (typeof raw === 'string') set.add(raw);
451
- if (Array.isArray(raw)) raw.forEach((v) => typeof v === 'string' && set.add(v));
470
+ // Collect UUIDs from all version sources (draft + published) to match mapEntryToRow
471
+ const lang = contentLanguage.current;
472
+ const dataSources = [
473
+ entry.publishedVersions[lang]?.data,
474
+ entry.draftVersions[lang]?.data,
475
+ getVersionData(entry)
476
+ ].filter(Boolean) as Record<string, unknown>[];
477
+ for (const data of dataSources) {
478
+ for (const field of relationListFields) {
479
+ const raw = data[field.slug];
480
+ if (!raw) continue;
481
+ const set = uuidsByCollection.get(field.collection)!;
482
+ if (typeof raw === 'string' && UUID_RE.test(raw)) set.add(raw);
483
+ if (Array.isArray(raw)) raw.forEach((v) => typeof v === 'string' && UUID_RE.test(v) && set.add(v));
484
+ }
452
485
  }
453
486
  }
454
487
 
@@ -476,6 +509,8 @@
476
509
  const fieldData = (columnData as Record<string, unknown>)[fieldSlug];
477
510
  const isRelation = relationListFields.some((f) => f.slug === fieldSlug);
478
511
  if (isRelation) {
512
+ // Store raw UUID(s) for filtering
513
+ customData[`__raw_${fieldSlug}`] = fieldData;
479
514
  // Resolve relation UUID(s) to labels
480
515
  if (typeof fieldData === 'string') {
481
516
  customData[fieldSlug] = lookup[fieldData] ?? '';
@@ -590,7 +625,7 @@
590
625
  ...old,
591
626
  entries: orderedIds.map((id) => old.entries.find((e: RawEntry) => e.id === id)).filter(Boolean)
592
627
  }));
593
- await remotes.reorderEntriesCommand({ orderedIds });
628
+ await remotes.reorderEntriesCommand({ orderedIds, collectionSlug: collection.slug });
594
629
  await entriesQuery.refresh();
595
630
  override.release();
596
631
  }
@@ -631,8 +666,20 @@
631
666
  rows = rows.filter((row) => {
632
667
  for (const [slug, value] of Object.entries(activeDataFilters)) {
633
668
  if (value === null) continue;
634
- const rowValue = row.customData[slug];
635
- if (String(rowValue ?? '') !== value) return false;
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
+ }
636
683
  }
637
684
  return true;
638
685
  });
@@ -32,12 +32,7 @@
32
32
  import UserSessionsSheet from './user-sessions-sheet.svelte';
33
33
  import InviteUserDialog from './invite-user-dialog.svelte';
34
34
  import PendingInvitations from './pending-invitations.svelte';
35
-
36
- type Props = {
37
- emailConfigured?: boolean;
38
- };
39
-
40
- let { emailConfigured = false }: Props = $props();
35
+ import { getRemotes } from '../../helpers/index.js';
41
36
 
42
37
  type User = {
43
38
  id: string;
@@ -47,6 +42,10 @@
47
42
  createdAt: Date;
48
43
  };
49
44
 
45
+ const remotes = getRemotes();
46
+ const emailQuery = $derived(remotes.getEmailConfigured());
47
+ const emailConfigured = $derived(emailQuery.data === true);
48
+
50
49
  const interfaceLanguage = useInterfaceLanguage();
51
50
  const lang = $derived(usersLang[interfaceLanguage.current]);
52
51
  const session = authClient.useSession();
@@ -1,6 +1,3 @@
1
- type Props = {
2
- emailConfigured?: boolean;
3
- };
4
- declare const UsersPage: import("svelte").Component<Props, {}, "">;
1
+ declare const UsersPage: import("svelte").Component<Record<string, never>, {}, "">;
5
2
  type UsersPage = ReturnType<typeof UsersPage>;
6
3
  export default UsersPage;
@@ -9,6 +9,15 @@
9
9
 
10
10
  const interfaceLanguage = useInterfaceLanguage();
11
11
 
12
+ import type { InterfaceLanguage } from '../../../types/languages.js';
13
+ const pickerLang: Record<InterfaceLanguage, {
14
+ addBlock: string; chooseBlockType: string; searchBlocks: string; noBlocksFound: string;
15
+ }> = {
16
+ pl: { addBlock: 'Dodaj blok', chooseBlockType: 'Wybierz typ bloku', searchBlocks: 'Szukaj bloków...', noBlocksFound: 'Nie znaleziono bloków' },
17
+ en: { addBlock: 'Add block', chooseBlockType: 'Choose a block type to add', searchBlocks: 'Search blocks...', noBlocksFound: 'No blocks found' }
18
+ };
19
+ const pt = $derived(pickerLang[interfaceLanguage.current]);
20
+
12
21
  type Props = {
13
22
  open: boolean;
14
23
  options: ObjectField[];
@@ -39,15 +48,15 @@
39
48
  <Dialog.Root bind:open>
40
49
  <Dialog.Content class="max-w-3xl">
41
50
  <Dialog.Header>
42
- <Dialog.Title>Add Block</Dialog.Title>
43
- <Dialog.Description>Choose a block type to add</Dialog.Description>
51
+ <Dialog.Title>{pt.addBlock}</Dialog.Title>
52
+ <Dialog.Description>{pt.chooseBlockType}</Dialog.Description>
44
53
  </Dialog.Header>
45
54
 
46
55
  <div class="relative">
47
56
  <SearchIcon class="text-muted-foreground absolute left-3 top-1/2 size-4 -translate-y-1/2" />
48
57
  <Input
49
58
  type="text"
50
- placeholder="Search blocks..."
59
+ placeholder={pt.searchBlocks}
51
60
  class="pl-10"
52
61
  bind:value={searchQuery}
53
62
  />
@@ -90,7 +99,7 @@
90
99
 
91
100
  {#if filteredOptions.length === 0}
92
101
  <div class="py-8 text-center">
93
- <p class="text-muted-foreground">No blocks found</p>
102
+ <p class="text-muted-foreground">{pt.noBlocksFound}</p>
94
103
  </div>
95
104
  {/if}
96
105
  </Dialog.Content>
@@ -32,6 +32,16 @@
32
32
  const contentLanguage = getContentLanguage();
33
33
  const interfaceLanguage = useInterfaceLanguage();
34
34
 
35
+ import type { InterfaceLanguage } from '../../../types/languages.js';
36
+ const blocksLang: Record<InterfaceLanguage, {
37
+ collapseAll: string; showAll: string; openMenu: string; duplicate: string;
38
+ moveUp: string; moveDown: string; delete: string; addBlock: string; elements: string;
39
+ }> = {
40
+ pl: { collapseAll: 'Zwiń wszystko', showAll: 'Rozwiń wszystko', openMenu: 'Otwórz menu', duplicate: 'Duplikuj', moveUp: 'Przenieś wyżej', moveDown: 'Przenieś niżej', delete: 'Usuń', addBlock: 'Dodaj blok', elements: 'elementów' },
41
+ en: { collapseAll: 'Collapse all', showAll: 'Show all', openMenu: 'Open menu', duplicate: 'Duplicate', moveUp: 'Move up', moveDown: 'Move down', delete: 'Delete', addBlock: 'Add block', elements: 'elements' }
42
+ };
43
+ const bt = $derived(blocksLang[interfaceLanguage.current]);
44
+
35
45
  function generateId(): string {
36
46
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
37
47
  return crypto.randomUUID();
@@ -174,6 +184,18 @@
174
184
  }
175
185
 
176
186
  if (typeof label === 'object' && label !== null) {
187
+ // UrlFieldData — has `url` property
188
+ if ('url' in label) {
189
+ const urlData = label as { url: string | Record<string, string>; text?: string | Record<string, string> };
190
+ const displayValue = urlData.text || urlData.url;
191
+ if (typeof displayValue === 'string' && displayValue.trim().length > 0) {
192
+ return displayValue;
193
+ }
194
+ if (typeof displayValue === 'object' && displayValue !== null) {
195
+ return displayValue[contentLanguage.current] ?? '';
196
+ }
197
+ }
198
+
177
199
  const objectLabel = label as Record<string, string>;
178
200
  return `${objectLabel[contentLanguage.current]}`;
179
201
  }
@@ -229,7 +251,7 @@
229
251
  >{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel
230
252
  >
231
253
  {#if isFixedLength}
232
- <span class="text-muted-foreground text-xs">{fixedCount} elementów</span>
254
+ <span class="text-muted-foreground text-xs">{fixedCount} {bt.elements}</span>
233
255
  {:else if field.maxItems !== undefined}
234
256
  <span class="text-xs {atMax ? 'text-destructive' : 'text-muted-foreground'}"
235
257
  >{$value?.length ?? 0} / {field.maxItems}</span
@@ -244,7 +266,7 @@
244
266
  variant="ghost"
245
267
  onclick={() => {
246
268
  accordionOpenState = [];
247
- }}>Collapse All</Button
269
+ }}>{bt.collapseAll}</Button
248
270
  >
249
271
  <Button
250
272
  size="sm"
@@ -254,7 +276,7 @@
254
276
  if ($value) {
255
277
  accordionOpenState = $value.map((_, i) => i.toString());
256
278
  }
257
- }}>Show All</Button
279
+ }}>{bt.showAll}</Button
258
280
  >
259
281
  </div>
260
282
  </div>
@@ -334,7 +356,7 @@
334
356
  {#snippet child({ props })}
335
357
  <Button variant="ghost" size="icon" {...props}>
336
358
  <DotsVerticalIcon />
337
- <span class="sr-only">Open menu</span>
359
+ <span class="sr-only">{bt.openMenu}</span>
338
360
  </Button>
339
361
  {/snippet}
340
362
  </DropdownMenu.Trigger>
@@ -344,27 +366,27 @@
344
366
  onclick={() => duplicateItem(index)}
345
367
  disabled={atMax}
346
368
  >
347
- Duplicate
369
+ {bt.duplicate}
348
370
  </DropdownMenu.Item>
349
371
  {/if}
350
372
  <DropdownMenu.Item
351
373
  onclick={() => moveItemUp(index)}
352
374
  disabled={index === 0}
353
375
  >
354
- Move up
376
+ {bt.moveUp}
355
377
  </DropdownMenu.Item>
356
378
  <DropdownMenu.Item
357
379
  onclick={() => moveItemDown(index)}
358
380
  disabled={$value && index === $value.length - 1}
359
381
  >
360
- Move down
382
+ {bt.moveDown}
361
383
  </DropdownMenu.Item>
362
384
  {#if !isFixedLength}
363
385
  <DropdownMenu.Item
364
386
  variant="destructive"
365
387
  onclick={() => removeItem(index)}
366
388
  >
367
- Delete
389
+ {bt.delete}
368
390
  </DropdownMenu.Item>
369
391
  {/if}
370
392
  </DropdownMenu.Content>
@@ -413,7 +435,7 @@
413
435
  onclick={() => (blockPickerOpen = true)}
414
436
  >
415
437
  <CirclePlus />
416
- Add Block
438
+ {bt.addBlock}
417
439
  </Button>
418
440
  </div>
419
441
  <BlockPickerModal
@@ -11,6 +11,17 @@
11
11
  const contentLanguage = getContentLanguage();
12
12
  const interfaceLanguage = useInterfaceLanguage();
13
13
 
14
+ import type { InterfaceLanguage } from '../../../types/languages.js';
15
+ const arrayLang: Record<InterfaceLanguage, {
16
+ typeAndEnter: string; add: string; typeNumber: string; addLink: string;
17
+ urlPlaceholder: string; linkText: string; newTab: string;
18
+ removeItem: string; removeLink: string;
19
+ }> = {
20
+ pl: { typeAndEnter: 'Wpisz i naciśnij Enter...', add: 'Dodaj', typeNumber: 'Wpisz liczbę...', addLink: 'Dodaj link', urlPlaceholder: 'URL...', linkText: 'Tekst linku...', newTab: 'Nowa karta', removeItem: 'Usuń element', removeLink: 'Usuń link' },
21
+ en: { typeAndEnter: 'Type and press Enter...', add: 'Add', typeNumber: 'Enter a number...', addLink: 'Add link', urlPlaceholder: 'URL...', linkText: 'Link text...', newTab: 'New tab', removeItem: 'Remove item', removeLink: 'Remove link' }
22
+ };
23
+ const at = $derived(arrayLang[interfaceLanguage.current]);
24
+
14
25
  type Props = {
15
26
  field: ArrayField;
16
27
  value: unknown[] | undefined;
@@ -122,7 +133,7 @@
122
133
  type="button"
123
134
  class="text-[#555566] hover:text-[#C44B4B] transition-colors"
124
135
  onclick={() => removeItem(index)}
125
- aria-label="Usuń element"
136
+ aria-label={at.removeItem}
126
137
  >
127
138
  <X class="h-3.5 w-3.5" />
128
139
  </button>
@@ -134,7 +145,7 @@
134
145
  <div class="flex items-center gap-2">
135
146
  <Input
136
147
  type="text"
137
- placeholder="Wpisz i naciśnij Enter..."
148
+ placeholder={at.typeAndEnter}
138
149
  bind:value={textInput}
139
150
  onkeydown={handleTextKeydown}
140
151
  disabled={atMax}
@@ -142,7 +153,7 @@
142
153
  />
143
154
  <Button size="sm" type="button" variant="outline" disabled={atMax || !textInput.trim()} onclick={addTextItem}>
144
155
  <CirclePlus class="h-4 w-4" />
145
- Dodaj
156
+ {at.add}
146
157
  </Button>
147
158
  </div>
148
159
 
@@ -167,7 +178,7 @@
167
178
  type="button"
168
179
  class="text-[#555566] hover:text-[#C44B4B] transition-colors"
169
180
  onclick={() => removeItem(index)}
170
- aria-label="Usuń element"
181
+ aria-label={at.removeItem}
171
182
  >
172
183
  <X class="h-3.5 w-3.5" />
173
184
  </button>
@@ -179,7 +190,7 @@
179
190
  <div class="flex items-center gap-2">
180
191
  <Input
181
192
  type="number"
182
- placeholder="Wpisz liczbę..."
193
+ placeholder={at.typeNumber}
183
194
  bind:value={numberInput}
184
195
  onkeydown={handleNumberKeydown}
185
196
  disabled={atMax}
@@ -187,7 +198,7 @@
187
198
  />
188
199
  <Button size="sm" type="button" variant="outline" disabled={atMax || numberInput === ''} onclick={addNumberItem}>
189
200
  <CirclePlus class="h-4 w-4" />
190
- Dodaj
201
+ {at.add}
191
202
  </Button>
192
203
  </div>
193
204
 
@@ -209,7 +220,7 @@
209
220
  <div class="flex-1 space-y-2">
210
221
  <Input
211
222
  type="url"
212
- placeholder="URL..."
223
+ placeholder={at.urlPlaceholder}
213
224
  value={typeof urlItem.url === 'string' ? urlItem.url : ''}
214
225
  oninput={(e) => {
215
226
  const val = e.currentTarget.value;
@@ -222,7 +233,7 @@
222
233
  <div class="flex items-center gap-2">
223
234
  <Input
224
235
  type="text"
225
- placeholder="Tekst linku..."
236
+ placeholder={at.linkText}
226
237
  class="flex-1"
227
238
  value={typeof urlItem.text === 'string' ? urlItem.text : ''}
228
239
  oninput={(e) => {
@@ -245,7 +256,7 @@
245
256
  }}
246
257
  class="accent-[#5B4A9E]"
247
258
  />
248
- Nowa karta
259
+ {at.newTab}
249
260
  </label>
250
261
  </div>
251
262
  </div>
@@ -253,7 +264,7 @@
253
264
  type="button"
254
265
  class="mt-1.5 text-[#555566] hover:text-[#C44B4B] transition-colors"
255
266
  onclick={() => removeItem(index)}
256
- aria-label="Usuń link"
267
+ aria-label={at.removeLink}
257
268
  >
258
269
  <X class="h-4 w-4" />
259
270
  </button>
@@ -264,7 +275,7 @@
264
275
 
265
276
  <Button size="sm" type="button" variant="outline" disabled={atMax} onclick={addUrlItem}>
266
277
  <CirclePlus class="h-4 w-4" />
267
- Dodaj link
278
+ {at.addLink}
268
279
  </Button>
269
280
 
270
281
  {#if field.maxItems !== undefined}
@@ -150,10 +150,12 @@
150
150
  </div>
151
151
 
152
152
  {:else if node.type === 'card'}
153
- <div role="group" aria-label={getLabel(node)} class="layout-card">
154
- <div class="layout-card-header">
155
- {getLabel(node)}
156
- </div>
153
+ <div role="group" aria-label={getLabel(node) || undefined} class="layout-card" class:no-header={!getLabel(node)}>
154
+ {#if getLabel(node)}
155
+ <div class="layout-card-header">
156
+ {getLabel(node)}
157
+ </div>
158
+ {/if}
157
159
  <div class="layout-card-body">
158
160
  {#if isLayoutLeaf(node) && node.autoGrid}
159
161
  <div class="layout-auto-grid">
@@ -268,6 +270,10 @@
268
270
  padding: 10px 16px 16px;
269
271
  }
270
272
 
273
+ .layout-card.no-header .layout-card-body {
274
+ padding-top: 16px;
275
+ }
276
+
271
277
  /* ═══════════ ACCORDION ═══════════ */
272
278
  .layout-accordion-wrapper {
273
279
  background: var(--card);
@@ -5,6 +5,15 @@
5
5
  import VideoOff from '@tabler/icons-svelte/icons/video-off';
6
6
 
7
7
  import { getRemotes } from '../../context/remotes.js';
8
+ import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
9
+ import type { InterfaceLanguage } from '../../../types/languages.js';
10
+
11
+ const interfaceLanguage = useInterfaceLanguage();
12
+ const previewLang: Record<InterfaceLanguage, { loading: string }> = {
13
+ pl: { loading: 'Ładowanie...' },
14
+ en: { loading: 'Loading...' }
15
+ };
16
+ const pvt = $derived(previewLang[interfaceLanguage.current]);
8
17
 
9
18
  const remotes = getRemotes();
10
19
 
@@ -20,7 +29,7 @@
20
29
 
21
30
  <div class="border">
22
31
  {#if !fileQuery.ready}
23
- Loading...
32
+ {pvt.loading}
24
33
  {:else if fileQuery.current}
25
34
  {@const file = fileQuery.current}
26
35
  {#if file.type === 'image'}