includio-cms 0.7.2 → 0.13.0

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 (164) hide show
  1. package/CHANGELOG.md +110 -0
  2. package/ROADMAP.md +40 -2
  3. package/dist/admin/api/generate-styles.d.ts +2 -0
  4. package/dist/admin/api/generate-styles.js +32 -0
  5. package/dist/admin/api/handler.js +33 -0
  6. package/dist/admin/api/media-gc.js +10 -4
  7. package/dist/admin/api/rest/handler.js +17 -0
  8. package/dist/admin/api/rest/routes/collections.js +25 -13
  9. package/dist/admin/api/rest/routes/entries.d.ts +1 -1
  10. package/dist/admin/api/rest/routes/entries.js +10 -10
  11. package/dist/admin/api/rest/routes/media.d.ts +2 -0
  12. package/dist/admin/api/rest/routes/media.js +9 -0
  13. package/dist/admin/api/rest/routes/schema.d.ts +5 -0
  14. package/dist/admin/api/rest/routes/schema.js +152 -0
  15. package/dist/admin/api/rest/routes/singletons.d.ts +1 -1
  16. package/dist/admin/api/rest/routes/singletons.js +8 -7
  17. package/dist/admin/api/rest/routes/upload.d.ts +2 -0
  18. package/dist/admin/api/rest/routes/upload.js +28 -0
  19. package/dist/admin/api/upload.js +13 -0
  20. package/dist/admin/client/collection/collection-entries.svelte +19 -6
  21. package/dist/admin/client/entry/entry.svelte +21 -23
  22. package/dist/admin/client/entry/header/a11y-validator.js +2 -2
  23. package/dist/admin/client/entry/header/publish-panel.svelte +33 -85
  24. package/dist/admin/client/entry/header/status-badge.svelte +2 -2
  25. package/dist/admin/client/entry/header/version-history-sheet.svelte +9 -9
  26. package/dist/admin/client/entry/header/visibility.svelte +16 -10
  27. package/dist/admin/client/entry/utils.d.ts +3 -0
  28. package/dist/admin/client/entry/utils.js +22 -4
  29. package/dist/admin/client/form/form-submission/form-submission-page.svelte +4 -1
  30. package/dist/admin/client/form/form-submission/submission-field.svelte +10 -0
  31. package/dist/admin/client/index.d.ts +1 -0
  32. package/dist/admin/client/index.js +1 -0
  33. package/dist/admin/client/maintenance/maintenance-page.svelte +146 -2
  34. package/dist/admin/components/fields/blocks-field.svelte +9 -10
  35. package/dist/admin/components/fields/field-renderer.svelte +4 -8
  36. package/dist/admin/components/fields/object-field.svelte +7 -12
  37. package/dist/admin/components/fields/select-field.svelte +8 -2
  38. package/dist/admin/components/fields/seo-field.svelte +40 -93
  39. package/dist/admin/components/fields/simple-array-field.svelte +5 -5
  40. package/dist/admin/components/fields/text-field-wrapper.svelte +52 -197
  41. package/dist/admin/components/fields/text-field-wrapper.svelte.d.ts +2 -2
  42. package/dist/admin/components/fields/url-field-wrapper.svelte +15 -25
  43. package/dist/admin/components/fields/url-field.svelte +61 -72
  44. package/dist/admin/components/media/file-upload.svelte +5 -1
  45. package/dist/admin/components/media/file-upload.svelte.d.ts +1 -0
  46. package/dist/admin/components/media/media-library.svelte +109 -37
  47. package/dist/admin/components/media/media-selector.svelte +79 -11
  48. package/dist/admin/components/media/tag-sidebar.svelte +10 -6
  49. package/dist/admin/components/media/tag-sidebar.svelte.d.ts +7 -2
  50. package/dist/admin/components/tiptap/InlineBlockNodeView.svelte +21 -93
  51. package/dist/admin/components/tiptap/inline-block-node.js +6 -5
  52. package/dist/admin/components/tiptap/link-dialog.svelte +10 -11
  53. package/dist/admin/components/tiptap/slash-command.js +1 -1
  54. package/dist/admin/remote/entry.remote.d.ts +2 -5
  55. package/dist/admin/remote/entry.remote.js +22 -27
  56. package/dist/admin/remote/media.remote.d.ts +15 -0
  57. package/dist/admin/remote/media.remote.js +18 -2
  58. package/dist/admin/remote/preview.remote.js +3 -1
  59. package/dist/admin/utils/entryLabel.js +9 -6
  60. package/dist/admin/utils/translationStatus.js +1 -2
  61. package/dist/cli/scaffold/admin.js +34 -2
  62. package/dist/cms/runtime/api.d.ts +16 -12
  63. package/dist/cms/runtime/api.js +7 -6
  64. package/dist/cms/runtime/remote.js +2 -2
  65. package/dist/cms/runtime/schemas.d.ts +1 -1
  66. package/dist/cms/runtime/schemas.js +1 -1
  67. package/dist/cms/runtime/types.d.ts +118 -112
  68. package/dist/cms/runtime/types.js +0 -12
  69. package/dist/core/cms.d.ts +3 -1
  70. package/dist/core/cms.js +30 -0
  71. package/dist/core/fields/fieldSchemaToTs.js +9 -15
  72. package/dist/core/fields/formFieldSchemaToTs.js +7 -0
  73. package/dist/core/server/entries/operations/create.js +10 -4
  74. package/dist/core/server/entries/operations/get.d.ts +1 -0
  75. package/dist/core/server/entries/operations/get.js +186 -191
  76. package/dist/core/server/entries/operations/update.d.ts +6 -7
  77. package/dist/core/server/entries/operations/update.js +20 -38
  78. package/dist/core/server/fields/populateEntry.js +16 -52
  79. package/dist/core/server/fields/resolveImageFields.js +69 -120
  80. package/dist/core/server/fields/resolveRelationFields.js +30 -51
  81. package/dist/core/server/fields/resolveRichtextLinks.js +46 -100
  82. package/dist/core/server/fields/resolveTypographyOrphans.bench.d.ts +1 -0
  83. package/dist/core/server/fields/resolveTypographyOrphans.bench.js +87 -0
  84. package/dist/core/server/fields/resolveTypographyOrphans.d.ts +3 -0
  85. package/dist/core/server/fields/resolveTypographyOrphans.js +128 -0
  86. package/dist/core/server/fields/resolveUrlFields.js +47 -56
  87. package/dist/core/server/fields/utils/fixOrphans.d.ts +5 -0
  88. package/dist/core/server/fields/utils/fixOrphans.js +12 -0
  89. package/dist/core/server/fields/utils/imageStyles.d.ts +4 -2
  90. package/dist/core/server/fields/utils/imageStyles.js +41 -25
  91. package/dist/core/server/fields/utils/resolveMedia.js +1 -6
  92. package/dist/core/server/forms/submissions/operations/delete.js +26 -2
  93. package/dist/core/server/forms/submissions/utils/parseMultipart.d.ts +2 -0
  94. package/dist/core/server/forms/submissions/utils/parseMultipart.js +75 -0
  95. package/dist/core/server/generator/fields.d.ts +6 -0
  96. package/dist/core/server/generator/fields.js +43 -5
  97. package/dist/core/server/generator/formFieldSchemaToString.js +10 -0
  98. package/dist/core/server/generator/formFields.js +1 -0
  99. package/dist/core/server/generator/generator.js +98 -30
  100. package/dist/core/server/media/operations/getFiles.d.ts +5 -0
  101. package/dist/core/server/media/operations/getFiles.js +6 -0
  102. package/dist/core/server/media/operations/uploadPrivateFile.d.ts +4 -0
  103. package/dist/core/server/media/operations/uploadPrivateFile.js +8 -0
  104. package/dist/core/server/media/styles/operations/batchGenerateStyles.d.ts +16 -0
  105. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +144 -0
  106. package/dist/db-postgres/index.js +303 -37
  107. package/dist/db-postgres/schema/entry.d.ts +0 -94
  108. package/dist/db-postgres/schema/entry.js +0 -6
  109. package/dist/db-postgres/schema/entryVersion.d.ts +17 -0
  110. package/dist/db-postgres/schema/entryVersion.js +1 -0
  111. package/dist/entity/index.d.ts +9 -4
  112. package/dist/entity/index.js +24 -24
  113. package/dist/files-local/index.js +43 -0
  114. package/dist/paraglide/messages/_index.d.ts +36 -3
  115. package/dist/paraglide/messages/_index.js +71 -3
  116. package/dist/paraglide/messages/en.d.ts +5 -0
  117. package/dist/paraglide/messages/en.js +14 -0
  118. package/dist/paraglide/messages/pl.d.ts +5 -0
  119. package/dist/paraglide/messages/pl.js +14 -0
  120. package/dist/sveltekit/components/preview.svelte +2 -326
  121. package/dist/sveltekit/components/preview.svelte.d.ts +5 -16
  122. package/dist/sveltekit/server/index.d.ts +2 -1
  123. package/dist/sveltekit/server/index.js +2 -1
  124. package/dist/sveltekit/server/preview.js +4 -7
  125. package/dist/types/adapters/db.d.ts +15 -1
  126. package/dist/types/adapters/files.d.ts +6 -0
  127. package/dist/types/cms.d.ts +5 -0
  128. package/dist/types/entries.d.ts +54 -18
  129. package/dist/types/fields.d.ts +14 -24
  130. package/dist/types/formFields.d.ts +7 -2
  131. package/dist/types/index.d.ts +2 -2
  132. package/dist/types/structured-content.d.ts +5 -0
  133. package/dist/updates/0.10.0/index.d.ts +2 -0
  134. package/dist/updates/0.10.0/index.js +15 -0
  135. package/dist/updates/0.11.0/index.d.ts +2 -0
  136. package/dist/updates/0.11.0/index.js +12 -0
  137. package/dist/updates/0.12.0/index.d.ts +2 -0
  138. package/dist/updates/0.12.0/index.js +12 -0
  139. package/dist/updates/0.13.0/index.d.ts +2 -0
  140. package/dist/updates/0.13.0/index.js +10 -0
  141. package/dist/updates/0.7.3/index.d.ts +2 -0
  142. package/dist/updates/0.7.3/index.js +10 -0
  143. package/dist/updates/0.8.0/index.d.ts +2 -0
  144. package/dist/updates/0.8.0/index.js +18 -0
  145. package/dist/updates/0.8.0/migrate.d.ts +2 -0
  146. package/dist/updates/0.8.0/migrate.js +101 -0
  147. package/dist/updates/0.9.0/index.d.ts +2 -0
  148. package/dist/updates/0.9.0/index.js +38 -0
  149. package/dist/updates/index.js +8 -1
  150. package/package.json +7 -6
  151. package/dist/admin/components/fields/image-field.svelte +0 -198
  152. package/dist/admin/components/fields/image-field.svelte.d.ts +0 -8
  153. package/dist/admin/components/fields/richtext-field.svelte +0 -13
  154. package/dist/admin/components/fields/richtext-field.svelte.d.ts +0 -8
  155. package/dist/admin/components/tiptap.svelte +0 -11
  156. package/dist/admin/components/tiptap.svelte.d.ts +0 -6
  157. package/dist/core/server/entries/utils/getEntryTranslation.d.ts +0 -1
  158. package/dist/core/server/entries/utils/getEntryTranslation.js +0 -18
  159. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  160. package/dist/paraglide/messages/hello_world.js +0 -33
  161. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  162. package/dist/paraglide/messages/login_hello.js +0 -34
  163. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  164. package/dist/paraglide/messages/login_please_login.js +0 -34
@@ -7,15 +7,33 @@ export function getEntryVersionStatus(version) {
7
7
  }
8
8
  return 'published';
9
9
  }
10
+ /** Get entry status for a specific language */
11
+ export function getEntryStatusForLang(entry, lang) {
12
+ if (entry.archivedAt) {
13
+ return 'archived';
14
+ }
15
+ const publishedVersion = entry.publishedVersions[lang];
16
+ const scheduledVersion = entry.scheduledVersions[lang];
17
+ if (publishedVersion) {
18
+ return 'published';
19
+ }
20
+ if (scheduledVersion) {
21
+ return 'scheduled';
22
+ }
23
+ return 'draft';
24
+ }
25
+ /** Get overall entry status — published if any lang is published */
10
26
  export function getEntryStatus(entry) {
11
27
  if (entry.archivedAt) {
12
28
  return 'archived';
13
29
  }
14
- if (entry.publishedVersionId) {
15
- if (entry.publishedAt && new Date(entry.publishedAt) > new Date()) {
16
- return 'scheduled';
17
- }
30
+ const hasPublished = Object.values(entry.publishedVersions).some((v) => v != null);
31
+ if (hasPublished) {
18
32
  return 'published';
19
33
  }
34
+ const hasScheduled = Object.values(entry.scheduledVersions).some((v) => v != null);
35
+ if (hasScheduled) {
36
+ return 'scheduled';
37
+ }
20
38
  return 'draft';
21
39
  }
@@ -46,6 +46,8 @@
46
46
 
47
47
  const formSubmissionQuery = $derived(submissionId ? remotes.getFormSubmission(submissionId) : null);
48
48
 
49
+ let markedAsRead = false;
50
+
49
51
  $effect(() => {
50
52
  const submission = formSubmissionQuery?.current;
51
53
  if (submission) {
@@ -65,7 +67,8 @@
65
67
  ];
66
68
  }
67
69
 
68
- if (!submission.read) {
70
+ if (!submission.read && !markedAsRead) {
71
+ markedAsRead = true;
69
72
  remotes.updateFormSubmission({ id: submission.id, read: true });
70
73
  }
71
74
  }
@@ -5,6 +5,7 @@
5
5
  import Phone from '@tabler/icons-svelte/icons/phone';
6
6
  import MessageSquare from '@tabler/icons-svelte/icons/message';
7
7
  import FileText from '@tabler/icons-svelte/icons/file-text';
8
+ import Download from '@tabler/icons-svelte/icons/download';
8
9
  import CheckCircle from '@tabler/icons-svelte/icons/circle-check';
9
10
  import CircleX from '@tabler/icons-svelte/icons/circle-x';
10
11
  import Hash from '@tabler/icons-svelte/icons/hash';
@@ -28,11 +29,13 @@
28
29
  });
29
30
 
30
31
  const isBoolean = $derived(typeof value === 'boolean');
32
+ const isFile = $derived(fieldType === 'file' && typeof value === 'string' && value.length > 0);
31
33
  const isEmail = $derived(fieldType === 'email' || (typeof value === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)));
32
34
  const isPhone = $derived(fieldType === 'phone' || (typeof value === 'string' && /^[\d\s\-+()]+$/.test(value) && String(value).length >= 9));
33
35
 
34
36
  const iconType = $derived.by(() => {
35
37
  if (isBoolean) return value ? 'check' : 'x';
38
+ if (isFile) return 'download';
36
39
  if (isEmail) return 'mail';
37
40
  if (isPhone) return 'phone';
38
41
  if (fieldType === 'textarea') return 'message';
@@ -64,6 +67,8 @@
64
67
  <Hash class="size-4 text-muted-foreground" />
65
68
  {:else if iconType === 'calendar'}
66
69
  <Calendar class="size-4 text-muted-foreground" />
70
+ {:else if iconType === 'download'}
71
+ <Download class="size-4 text-muted-foreground" />
67
72
  {:else}
68
73
  <FileText class="size-4 text-muted-foreground" />
69
74
  {/if}
@@ -81,6 +86,11 @@
81
86
  <span class="text-red-600">Nie</span>
82
87
  {/if}
83
88
  </div>
89
+ {:else if isFile}
90
+ <a href={String(value)} target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-2 text-primary hover:underline">
91
+ <Download class="size-4" />
92
+ Download
93
+ </a>
84
94
  {:else if isEmail}
85
95
  <a href="mailto:{value}" class="text-primary hover:underline break-all">{displayValue()}</a>
86
96
  {:else if isPhone}
@@ -9,5 +9,6 @@ export { default as FormPage } from './form/form-page.svelte';
9
9
  export { default as FormSubmissionPage } from './form/form-submission/form-submission-page.svelte';
10
10
  export { default as UsersPage } from './users/users-page.svelte';
11
11
  export { default as AcceptInvitePage } from './users/accept-invite-page.svelte';
12
+ export { default as ResetPasswordPage } from './login/reset-password-page.svelte';
12
13
  export { default as MaintenancePage } from './maintenance/maintenance-page.svelte';
13
14
  export { default as MediaSelector } from '../components/media/media-selector.svelte';
@@ -9,5 +9,6 @@ export { default as FormPage } from './form/form-page.svelte';
9
9
  export { default as FormSubmissionPage } from './form/form-submission/form-submission-page.svelte';
10
10
  export { default as UsersPage } from './users/users-page.svelte';
11
11
  export { default as AcceptInvitePage } from './users/accept-invite-page.svelte';
12
+ export { default as ResetPasswordPage } from './login/reset-password-page.svelte';
12
13
  export { default as MaintenancePage } from './maintenance/maintenance-page.svelte';
13
14
  export { default as MediaSelector } from '../components/media/media-selector.svelte';
@@ -5,12 +5,18 @@
5
5
  import FileSearch from '@tabler/icons-svelte/icons/file-search';
6
6
  import Photo from '@tabler/icons-svelte/icons/photo';
7
7
  import AlertTriangle from '@tabler/icons-svelte/icons/alert-triangle';
8
+ import PlayerPlay from '@tabler/icons-svelte/icons/player-play';
9
+ import PlayerStop from '@tabler/icons-svelte/icons/player-stop';
10
+ import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
8
11
  import Button from '../../../components/ui/button/button.svelte';
9
12
  import * as Card from '../../../components/ui/card/index.js';
10
13
  import { toast } from 'svelte-sonner';
11
14
 
12
15
  interface GcReport {
13
16
  imageStylesCount: number;
17
+ processableImagesCount: number;
18
+ expectedStylesCount: number;
19
+ missingStylesCount: number;
14
20
  orphanedDiskFiles: string[];
15
21
  missingDiskRecords: { table: string; id: string; url: string }[];
16
22
  }
@@ -20,6 +26,18 @@
20
26
  let purging = $state(false);
21
27
  let cleaningOrphans = $state(false);
22
28
 
29
+ // Batch generation state
30
+ let generating = $state(false);
31
+ let genTotal = $state(0);
32
+ let genProcessed = $state(0);
33
+ let genCreated = $state(0);
34
+ let genSkipped = $state(0);
35
+ let genCurrentFile = $state('');
36
+ let genErrors = $state(0);
37
+ let genAbort: AbortController | null = null;
38
+
39
+ let genPercent = $derived(genTotal > 0 ? Math.round((genProcessed / genTotal) * 100) : 0);
40
+
23
41
  async function loadReport() {
24
42
  loading = true;
25
43
  try {
@@ -63,6 +81,77 @@
63
81
  }
64
82
  }
65
83
 
84
+ async function startBatchGenerate() {
85
+ generating = true;
86
+ genTotal = 0;
87
+ genProcessed = 0;
88
+ genCreated = 0;
89
+ genSkipped = 0;
90
+ genCurrentFile = '';
91
+ genErrors = 0;
92
+ genAbort = new AbortController();
93
+
94
+ try {
95
+ const res = await fetch('/admin/api/generate-styles', {
96
+ method: 'POST',
97
+ signal: genAbort.signal
98
+ });
99
+
100
+ if (!res.ok) throw new Error('Failed to start');
101
+ if (!res.body) throw new Error('No response body');
102
+
103
+ const reader = res.body.getReader();
104
+ const decoder = new TextDecoder();
105
+ let buffer = '';
106
+
107
+ while (true) {
108
+ const { done, value } = await reader.read();
109
+ if (done) break;
110
+
111
+ buffer += decoder.decode(value, { stream: true });
112
+ const chunks = buffer.split('\n\n');
113
+ buffer = chunks.pop() || '';
114
+
115
+ for (const chunk of chunks) {
116
+ if (!chunk.startsWith('data: ')) continue;
117
+ const event = JSON.parse(chunk.slice(6));
118
+
119
+ genTotal = event.total ?? genTotal;
120
+ genProcessed = event.processed ?? genProcessed;
121
+ genCreated = event.created ?? genCreated;
122
+ genSkipped = event.skipped ?? genSkipped;
123
+ genCurrentFile = event.currentFile ?? genCurrentFile;
124
+
125
+ if (event.type === 'error') {
126
+ genErrors++;
127
+ }
128
+
129
+ if (event.type === 'done') {
130
+ const parts = [`Przetworzono ${genTotal} obrazów`];
131
+ if (genCreated > 0) parts.push(`utworzono ${genCreated} styli`);
132
+ if (genSkipped > 0) parts.push(`pominięto ${genSkipped} (już istnieją)`);
133
+ if (genErrors > 0) parts.push(`${genErrors} błędów`);
134
+ toast.success(parts.join(', '));
135
+ }
136
+ }
137
+ }
138
+ } catch (e) {
139
+ if (e instanceof DOMException && e.name === 'AbortError') {
140
+ toast.info(`Przerwano po ${genProcessed}/${genTotal} obrazów`);
141
+ } else {
142
+ toast.error('Błąd podczas generowania styli');
143
+ }
144
+ } finally {
145
+ generating = false;
146
+ genAbort = null;
147
+ await loadReport();
148
+ }
149
+ }
150
+
151
+ function cancelBatchGenerate() {
152
+ genAbort?.abort();
153
+ }
154
+
66
155
  $effect(() => {
67
156
  loadReport();
68
157
  });
@@ -94,14 +183,69 @@
94
183
  </Card.Description>
95
184
  </Card.Header>
96
185
  <Card.Content>
97
- <p class="mb-4 text-3xl font-bold" style="color: var(--primary);">
186
+ <p class="mb-1 text-3xl font-bold" style="color: var(--primary);">
98
187
  {report.imageStylesCount}
188
+ <span class="text-base font-normal" style="color: var(--muted-foreground);">/ {report.expectedStylesCount}</span>
189
+ </p>
190
+ <p class="mb-4 text-xs" style="color: var(--muted-foreground);">
191
+ {report.processableImagesCount} obrazów, {report.expectedStylesCount} oczekiwanych styli
99
192
  </p>
193
+
194
+ <!-- Batch generate -->
195
+ {#if generating}
196
+ <div class="mb-4">
197
+ <div class="mb-1 flex items-center justify-between text-xs" style="color: var(--muted-foreground);">
198
+ <span>{genProcessed}/{genTotal} obrazów</span>
199
+ <span>{genPercent}%</span>
200
+ </div>
201
+ <div class="h-2 w-full overflow-hidden rounded-full" style="background: var(--muted, #e5e7eb);">
202
+ <div
203
+ class="h-full rounded-full transition-all duration-300"
204
+ style="width: {genPercent}%; background: var(--primary);"
205
+ ></div>
206
+ </div>
207
+ <p class="mt-1 text-xs" style="color: var(--muted-foreground);">
208
+ Utworzono: {genCreated}, pominięto: {genSkipped}{genErrors > 0 ? `, błędów: ${genErrors}` : ''}
209
+ </p>
210
+ <p class="mt-0.5 truncate text-xs" style="color: var(--muted-foreground);">
211
+ {genCurrentFile}
212
+ </p>
213
+ <Button
214
+ variant="outline"
215
+ size="sm"
216
+ onclick={cancelBatchGenerate}
217
+ class="mt-2"
218
+ >
219
+ <PlayerStop class="size-4" />
220
+ Anuluj
221
+ </Button>
222
+ </div>
223
+ {:else if report.missingStylesCount > 0}
224
+ <div class="mb-3">
225
+ <p class="mb-2 text-sm" style="color: var(--warning, #C4893A);">
226
+ Brakuje {report.missingStylesCount} styli — generowanie odbywa się przy wyświetleniu, co może spowalniać aplikację.
227
+ </p>
228
+ <Button
229
+ variant="default"
230
+ size="sm"
231
+ onclick={startBatchGenerate}
232
+ >
233
+ <PlayerPlay class="size-4" />
234
+ Generuj brakujące style
235
+ </Button>
236
+ </div>
237
+ {:else}
238
+ <div class="mb-3 flex items-center gap-1.5 text-sm" style="color: var(--success, #3A8A5C);">
239
+ <CircleCheck class="size-4" />
240
+ Wszystkie style wygenerowane
241
+ </div>
242
+ {/if}
243
+
100
244
  <Button
101
245
  variant="destructive"
102
246
  size="sm"
103
247
  onclick={purgeStyles}
104
- disabled={purging || report.imageStylesCount === 0}
248
+ disabled={purging || generating || report.imageStylesCount === 0}
105
249
  >
106
250
  {#if purging}
107
251
  <Loader2 class="size-4 animate-spin" />
@@ -66,8 +66,8 @@
66
66
  }
67
67
 
68
68
  $value = $value.reduce((acc: ObjectFieldData[], item: ObjectFieldData) => {
69
- if (!item.slug) return acc;
70
- if (field.of.find((o) => o.slug === item.slug) === undefined) return acc;
69
+ if (!item._slug) return acc;
70
+ if (field.of.find((o) => o.slug === item._slug) === undefined) return acc;
71
71
  acc.push(item);
72
72
  return acc;
73
73
  }, [] as ObjectFieldData[]);
@@ -81,7 +81,7 @@
81
81
  if (defaults && defaults[i]) {
82
82
  current.push({ _id: generateId(), ...JSON.parse(JSON.stringify(defaults[i])) });
83
83
  } else {
84
- current.push({ _id: generateId(), slug: field.of[0].slug, data: {} });
84
+ current.push({ _id: generateId(), _slug: field.of[0].slug });
85
85
  }
86
86
  }
87
87
  } else {
@@ -97,8 +97,7 @@
97
97
  ...($value ?? []),
98
98
  {
99
99
  _id: generateId(),
100
- slug: field.slug,
101
- data: {}
100
+ _slug: field.slug
102
101
  }
103
102
  ];
104
103
 
@@ -161,14 +160,14 @@
161
160
  }
162
161
 
163
162
  function getAccordionLabel(item: ObjectFieldData) {
164
- const objectConfig = field.of.find((o) => o.slug === item.slug) as ObjectFieldType;
163
+ const objectConfig = field.of.find((o) => o.slug === item._slug) as ObjectFieldType;
165
164
 
166
165
  if (
167
166
  objectConfig.accordionLabelField &&
168
167
  typeof objectConfig.accordionLabelField === 'string' &&
169
168
  objectConfig.accordionLabelField.trim().length > 0
170
169
  ) {
171
- const label = item.data[objectConfig.accordionLabelField] as string | Record<string, string>;
170
+ const label = item[objectConfig.accordionLabelField] as string | Record<string, string>;
172
171
 
173
172
  if (typeof label === 'string' && label.trim().length > 0) {
174
173
  return `${label}`;
@@ -294,9 +293,9 @@
294
293
  animate:flip={{ duration: 200 }}
295
294
  >
296
295
  {#key index}
297
- {#if $value[index].data && $value[index].slug}
296
+ {#if $value[index]._slug}
298
297
  {@const item = $value[index]}
299
- {@const objectField = field.of.find((option) => option.slug === item.slug)}
298
+ {@const objectField = field.of.find((option) => option.slug === item._slug)}
300
299
 
301
300
  {#if objectField}
302
301
  <Accordion.Item value={index.toString()} class="overflow-hidden rounded-md border-0" data-depth={depth + 1}>
@@ -391,7 +390,7 @@
391
390
  {:else}
392
391
  <p class="text-red-500">
393
392
  Invalid field configuration. Unknown slug:
394
- {$value[index].slug}
393
+ {$value[index]._slug}
395
394
  </p>
396
395
  {/if}
397
396
  {:else}
@@ -41,10 +41,10 @@
41
41
  const fieldsWithNoDescription: FieldType[] = ['boolean', 'object', 'blocks', 'seo'];
42
42
  const fieldsWithNoLabel: FieldType[] = ['boolean', 'object', 'blocks', 'seo'];
43
43
 
44
- const fieldsWithAlternativeDescription: FieldType[] = ['image', 'media', 'object', 'blocks'];
44
+ const fieldsWithAlternativeDescription: FieldType[] = ['media', 'object', 'blocks'];
45
45
 
46
- function isTextField(field: Field): field is Extract<Field, { type: 'text' | 'richtext' | 'content' }> {
47
- return ['text', 'richtext', 'content'].includes(field.type);
46
+ function isTextField(field: Field): field is Extract<Field, { type: 'text' | 'content' }> {
47
+ return ['text', 'content'].includes(field.type);
48
48
  }
49
49
 
50
50
  function isRadioField(field: Field): field is Extract<Field, { type: 'radio' }> {
@@ -121,11 +121,7 @@
121
121
  <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
122
122
  {/if}
123
123
 
124
- {#if field.type === 'image'}
125
- {#await import('./image-field.svelte') then { default: ImageField }}
126
- <ImageField {field} bind:value={$value} />
127
- {/await}
128
- {:else if field.type === 'media'}
124
+ {#if field.type === 'media'}
129
125
  {#await import('./media-field.svelte') then { default: MediaField }}
130
126
  <MediaField {field} bind:value={$value} />
131
127
  {/await}
@@ -32,21 +32,16 @@
32
32
 
33
33
  onMount(() => {
34
34
  if (!$value) {
35
- $value = { slug: field.slug, data: {} };
35
+ $value = { _slug: field.slug };
36
36
  return;
37
37
  }
38
38
 
39
- if (!$value.slug) {
40
- $value.slug = field.slug;
39
+ if (!$value._slug) {
40
+ $value._slug = field.slug;
41
41
  }
42
42
 
43
- if ($value.slug !== field.slug) {
44
- $value.slug = field.slug;
45
- $value.data = {};
46
- }
47
-
48
- if (typeof $value.data !== 'object' || $value.data === null) {
49
- $value.data = {};
43
+ if ($value._slug !== field.slug) {
44
+ $value = { _slug: field.slug };
50
45
  }
51
46
  });
52
47
 
@@ -59,8 +54,8 @@
59
54
  {#snippet content()}
60
55
  <div class="space-y-4">
61
56
  {#each field.fields as f}
62
- {#if evaluateCondition(f.showWhen, (slug) => $value?.data?.[slug] ?? $formData[slug])}
63
- {@const fieldPath = joinPath(path, 'data', f.slug)}
57
+ {#if evaluateCondition(f.showWhen, (slug) => $value?.[slug] ?? $formData[slug])}
58
+ {@const fieldPath = joinPath(path, f.slug)}
64
59
  {@const showFlash = isFlashing(fieldPath)}
65
60
  <div
66
61
  data-field-path={fieldPath}
@@ -30,8 +30,14 @@
30
30
  }
31
31
 
32
32
  onMount(() => {
33
- if (value === undefined) {
34
- value = field.multiple ? [] : '';
33
+ if (field.multiple) {
34
+ if (value === undefined) {
35
+ value = [];
36
+ } else if (typeof value === 'string') {
37
+ value = value ? [value] : [];
38
+ }
39
+ } else if (value === undefined) {
40
+ value = '';
35
41
  }
36
42
  });
37
43
  </script>
@@ -5,7 +5,7 @@
5
5
  import { getAtPath, setAtPath } from '../../utils/objectPath.js';
6
6
  import type {
7
7
  Field,
8
- ImageField,
8
+ MediaField,
9
9
  SeoField,
10
10
  SeoFieldData,
11
11
  TextField
@@ -13,12 +13,10 @@
13
13
  import { untrack } from 'svelte';
14
14
  import slugify from '../../imports/slugify.js';
15
15
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
16
- import { getContentLanguage } from '../../state/content-language.svelte.js';
17
16
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
18
17
  import { Switch } from '../../../components/ui/switch/index.js';
19
18
 
20
19
  const interfaceLanguage = useInterfaceLanguage();
21
- const contentLanguage = getContentLanguage();
22
20
 
23
21
  type Props = {
24
22
  field: SeoField;
@@ -124,11 +122,12 @@
124
122
  multiline: true
125
123
  };
126
124
 
127
- const ogImageField: ImageField = {
128
- type: 'image',
125
+ const ogImageField: MediaField = {
126
+ type: 'media',
129
127
  slug: 'ogImage',
130
128
  label: labels.ogImage.label,
131
129
  description: labels.ogImage.description,
130
+ accept: 'image/*',
132
131
  required: false
133
132
  };
134
133
 
@@ -150,29 +149,19 @@
150
149
  customCodeField
151
150
  ];
152
151
 
153
- // Helper: read a value from formData, handling both localized (object) and plain (string) values
154
- function readSourceValue(sourcePath: string): string | undefined {
155
- const raw = ($formData as Record<string, unknown>)[sourcePath];
156
- if (typeof raw === 'string') return raw;
157
- if (raw && typeof raw === 'object') {
158
- return (raw as Record<string, string>)[contentLanguage.current];
159
- }
160
- return undefined;
161
- }
162
-
163
152
  // Helper: read value at a dot-path from formData
164
153
  function readPath(dotPath: string): string | undefined {
165
154
  const val = getAtPath($formData as Record<string, unknown>, dotPath);
166
155
  return typeof val === 'string' ? val : undefined;
167
156
  }
168
157
 
169
- // Character count for current language
158
+ // Character count data is flat, no lang nesting
170
159
  let titleLength = $derived.by(() => {
171
- const val = readPath(joinPath(String(path), 'title', contentLanguage.current));
160
+ const val = readPath(joinPath(String(path), 'title'));
172
161
  return val?.length ?? 0;
173
162
  });
174
163
  let descLength = $derived.by(() => {
175
- const val = readPath(joinPath(String(path), 'description', contentLanguage.current));
164
+ const val = readPath(joinPath(String(path), 'description'));
176
165
  return val?.length ?? 0;
177
166
  });
178
167
 
@@ -183,109 +172,67 @@
183
172
  return 'text-destructive';
184
173
  }
185
174
 
186
- // Auto-gen: track last auto-generated values per language
187
- let lastAutoSlugs: Record<string, string> = {};
188
- let lastAutoTitles: Record<string, string> = {};
175
+ // Auto-gen: track last auto-generated value
176
+ let lastAutoSlug = '';
177
+ let lastAutoTitle = '';
189
178
 
190
- // Auto slug toggle: ON by default when slugSource is set
191
- // Initializes to false if existing slug differs from what auto-gen would produce
179
+ // Auto slug toggle
192
180
  let autoSlug = $state((() => {
193
181
  if (!field.slugSource) return false;
194
182
  const sourceRaw = ($formData as Record<string, unknown>)[field.slugSource];
195
- if (!sourceRaw) return true;
196
- const pairs: [string, string][] =
197
- typeof sourceRaw === 'string'
198
- ? [[contentLanguage.current, sourceRaw]]
199
- : typeof sourceRaw === 'object'
200
- ? Object.entries(sourceRaw as Record<string, string>).filter(([, v]) => typeof v === 'string' && v)
201
- : [];
202
- for (const [lang, text] of pairs) {
203
- const targetPath = joinPath(String(path), 'slug', lang);
204
- const current = getAtPath($formData as Record<string, unknown>, targetPath) as string | undefined;
205
- const expected = slugify(String(text), { lower: true, strict: true, trim: true });
206
- if (current != null && current !== expected) return false;
207
- }
183
+ if (!sourceRaw || typeof sourceRaw !== 'string') return true;
184
+ const slugPath = joinPath(String(path), 'slug');
185
+ const current = getAtPath($formData as Record<string, unknown>, slugPath) as string | undefined;
186
+ const expected = slugify(sourceRaw, { lower: true, strict: true, trim: true });
187
+ if (current != null && current !== expected) return false;
208
188
  return true;
209
189
  })());
210
190
 
211
- // slugSource → auto-gen seo.slug per language
191
+ // slugSource → auto-gen seo.slug (flat, no per-lang)
212
192
  $effect(() => {
213
193
  if (!field.slugSource || !autoSlug) return;
214
194
  const sourceRaw = ($formData as Record<string, unknown>)[field.slugSource];
215
- if (!sourceRaw) return;
195
+ if (!sourceRaw || typeof sourceRaw !== 'string') return;
216
196
 
217
197
  untrack(() => {
218
- const pairs: [string, string][] =
219
- typeof sourceRaw === 'string'
220
- ? [[contentLanguage.current, sourceRaw]]
221
- : typeof sourceRaw === 'object'
222
- ? Object.entries(sourceRaw as Record<string, string>).filter(([, v]) => typeof v === 'string' && v)
223
- : [];
224
-
225
- let changed = false;
226
- for (const [lang, text] of pairs) {
227
- const targetPath = joinPath(String(path), 'slug', lang);
228
- const current = getAtPath($formData as Record<string, unknown>, targetPath) as string | undefined;
229
- const newSlug = slugify(String(text), { lower: true, strict: true, trim: true });
230
- if (newSlug !== current) {
231
- setAtPath($formData as Record<string, unknown>, targetPath, newSlug);
232
- changed = true;
233
- }
234
- lastAutoSlugs[lang] = newSlug;
198
+ const slugPath = joinPath(String(path), 'slug');
199
+ const current = getAtPath($formData as Record<string, unknown>, slugPath) as string | undefined;
200
+ const newSlug = slugify(sourceRaw, { lower: true, strict: true, trim: true });
201
+ if (newSlug !== current) {
202
+ setAtPath($formData as Record<string, unknown>, slugPath, newSlug);
203
+ $formData = $formData;
235
204
  }
236
- if (changed) $formData = $formData;
205
+ lastAutoSlug = newSlug;
237
206
  });
238
207
  });
239
208
 
240
209
  function onAutoSlugToggle(checked: boolean) {
241
210
  if (!checked || !field.slugSource) return;
242
- // Regenerate slug from current source when toggling ON
243
211
  const sourceRaw = ($formData as Record<string, unknown>)[field.slugSource];
244
- if (!sourceRaw) return;
245
- const pairs: [string, string][] =
246
- typeof sourceRaw === 'string'
247
- ? [[contentLanguage.current, sourceRaw]]
248
- : typeof sourceRaw === 'object'
249
- ? Object.entries(sourceRaw as Record<string, string>).filter(([, v]) => typeof v === 'string' && v)
250
- : [];
251
- let changed = false;
252
- for (const [lang, text] of pairs) {
253
- const targetPath = joinPath(String(path), 'slug', lang);
254
- const newSlug = slugify(String(text), { lower: true, strict: true, trim: true });
255
- setAtPath($formData as Record<string, unknown>, targetPath, newSlug);
256
- lastAutoSlugs[lang] = newSlug;
257
- changed = true;
258
- }
259
- if (changed) $formData = $formData;
212
+ if (!sourceRaw || typeof sourceRaw !== 'string') return;
213
+ const slugPath = joinPath(String(path), 'slug');
214
+ const newSlug = slugify(sourceRaw, { lower: true, strict: true, trim: true });
215
+ setAtPath($formData as Record<string, unknown>, slugPath, newSlug);
216
+ lastAutoSlug = newSlug;
217
+ $formData = $formData;
260
218
  }
261
219
 
262
- // titleSource → auto-fill seo.title per language
220
+ // titleSource → auto-fill seo.title (flat)
263
221
  $effect(() => {
264
222
  if (!field.titleSource) return;
265
223
  const sourceRaw = ($formData as Record<string, unknown>)[field.titleSource];
266
- if (!sourceRaw) return;
224
+ if (!sourceRaw || typeof sourceRaw !== 'string') return;
267
225
 
268
226
  untrack(() => {
269
- const pairs: [string, string][] =
270
- typeof sourceRaw === 'string'
271
- ? [[contentLanguage.current, sourceRaw]]
272
- : typeof sourceRaw === 'object'
273
- ? Object.entries(sourceRaw as Record<string, string>).filter(([, v]) => typeof v === 'string' && v)
274
- : [];
275
-
276
- let changed = false;
277
- for (const [lang, text] of pairs) {
278
- const targetPath = joinPath(String(path), 'title', lang);
279
- const current = getAtPath($formData as Record<string, unknown>, targetPath) as string | undefined;
280
- if (!current || current === lastAutoTitles[lang]) {
281
- if (text !== current) {
282
- setAtPath($formData as Record<string, unknown>, targetPath, text);
283
- changed = true;
284
- }
285
- lastAutoTitles[lang] = text;
227
+ const titlePath = joinPath(String(path), 'title');
228
+ const current = getAtPath($formData as Record<string, unknown>, titlePath) as string | undefined;
229
+ if (!current || current === lastAutoTitle) {
230
+ if (sourceRaw !== current) {
231
+ setAtPath($formData as Record<string, unknown>, titlePath, sourceRaw);
232
+ $formData = $formData;
286
233
  }
234
+ lastAutoTitle = sourceRaw;
287
235
  }
288
- if (changed) $formData = $formData;
289
236
  });
290
237
  });
291
238
  </script>