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
@@ -68,7 +68,7 @@
68
68
  // --- URL ---
69
69
  function addUrlItem() {
70
70
  if (atMax) return;
71
- const item: UrlFieldData = { url: {}, text: {}, newTab: false };
71
+ const item: UrlFieldData = { url: '', text: '', newTab: false };
72
72
  value = [...(value ?? []), item];
73
73
  }
74
74
 
@@ -210,10 +210,10 @@
210
210
  <Input
211
211
  type="url"
212
212
  placeholder="URL..."
213
- value={urlItem.url?.[contentLanguage.current] ?? ''}
213
+ value={typeof urlItem.url === 'string' ? urlItem.url : ''}
214
214
  oninput={(e) => {
215
215
  const val = e.currentTarget.value;
216
- const updated = { ...urlItem, url: { ...(urlItem.url ?? {}), [contentLanguage.current]: val } };
216
+ const updated = { ...urlItem, url: val };
217
217
  const arr = [...(value ?? [])];
218
218
  arr[index] = updated;
219
219
  value = arr;
@@ -224,10 +224,10 @@
224
224
  type="text"
225
225
  placeholder="Tekst linku..."
226
226
  class="flex-1"
227
- value={urlItem.text?.[contentLanguage.current] ?? ''}
227
+ value={typeof urlItem.text === 'string' ? urlItem.text : ''}
228
228
  oninput={(e) => {
229
229
  const val = e.currentTarget.value;
230
- const updated = { ...urlItem, text: { ...(urlItem.text ?? {}), [contentLanguage.current]: val } };
230
+ const updated = { ...urlItem, text: val };
231
231
  const arr = [...(value ?? [])];
232
232
  arr[index] = updated;
233
233
  value = arr;
@@ -4,30 +4,21 @@
4
4
 
5
5
  <script lang="ts" generics="T extends Record<string, unknown>">
6
6
  import type {
7
- RichtextField as RichtextFieldType,
8
7
  ContentField as ContentFieldType,
9
8
  TextField as TextFieldType
10
9
  } from '../../../types/fields.js';
11
- import * as Tabs from '../../../components/ui/tabs/index.js';
12
10
  import * as Form from '../../../components/ui/form/index.js';
13
11
  import { type FormPathLeaves, type SuperForm } from 'sveltekit-superforms';
14
12
  import TextField from './text-field.svelte';
15
- import { joinPath, setAtPath } from '../../utils/objectPath.js';
16
- import { getContentLanguage } from '../../state/content-language.svelte.js';
17
13
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
18
- import { getContext } from 'svelte';
19
14
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
20
15
  import RequiredLabel from './required-label.svelte';
21
- import ClipboardIcon from '@tabler/icons-svelte/icons/clipboard';
22
- import type { InterfaceLanguage } from '../../../types/languages.js';
23
16
  import FieldValueBridge from './field-value-bridge.svelte';
24
17
 
25
- const contentLanguage = getContentLanguage();
26
18
  const interfaceLanguage = useInterfaceLanguage();
27
- const inInlineBlock = getContext<boolean>('inInlineBlock') ?? false;
28
19
 
29
20
  type Props = {
30
- field: TextFieldType | RichtextFieldType | ContentFieldType;
21
+ field: TextFieldType | ContentFieldType;
31
22
  form: SuperForm<T>;
32
23
  path: FormPathLeaves<T, string | undefined>;
33
24
  };
@@ -40,75 +31,11 @@
40
31
  const hasConstraints = $derived(
41
32
  isText && (field.minLength !== undefined || field.maxLength !== undefined || field.pattern !== undefined)
42
33
  );
43
- const isMultiLang = $derived(contentLanguage.all.length > 1);
44
- const defaultLang = $derived(contentLanguage.all[0]);
45
-
46
- const copyLang: Record<InterfaceLanguage, { copyFrom: (lang: string) => string }> = {
47
- en: { copyFrom: (lang: string) => `Copy from ${lang.toUpperCase()}` },
48
- pl: { copyFrom: (lang: string) => `Kopiuj z ${lang.toUpperCase()}` }
49
- };
50
-
51
- const refLang: Record<InterfaceLanguage, { reference: (lang: string) => string }> = {
52
- en: { reference: (lang: string) => `${lang.toUpperCase()} (reference)` },
53
- pl: { reference: (lang: string) => `${lang.toUpperCase()} (referencja)` }
54
- };
55
34
 
56
35
  function resolvePathValue(data: Record<string, unknown>, dotPath: string): unknown {
57
36
  return dotPath.split('.').reduce<unknown>((obj, key) => (obj as Record<string, unknown>)?.[key], data);
58
37
  }
59
38
 
60
- function isValueEmpty(value: unknown): boolean {
61
- if (value == null) return true;
62
- if (typeof value === 'string') return value.length === 0;
63
- if (typeof value === 'object' && 'type' in (value as Record<string, unknown>)) {
64
- const doc = value as { type: string; content?: unknown[] };
65
- return doc.type === 'doc' && (!doc.content || doc.content.length === 0);
66
- }
67
- return false;
68
- }
69
-
70
- function getSourceValue(lang: string): unknown {
71
- return resolvePathValue($formData, joinPath(path, lang));
72
- }
73
-
74
- function findSourceLang(currentLang: string): string | null {
75
- for (const l of contentLanguage.all) {
76
- if (l === currentLang) continue;
77
- const val = getSourceValue(l);
78
- if (!isValueEmpty(val)) return l;
79
- }
80
- return null;
81
- }
82
-
83
- function copyFrom(sourceLang: string, targetLang: string) {
84
- const sourceVal = getSourceValue(sourceLang);
85
- if (sourceVal == null) return;
86
-
87
- const targetPath = joinPath(path, targetLang);
88
- const value =
89
- field.type === 'content' && typeof sourceVal === 'object'
90
- ? structuredClone(sourceVal)
91
- : sourceVal;
92
- setAtPath($formData as Record<string, unknown>, targetPath, value);
93
- $formData = $formData;
94
- }
95
-
96
- function getReferenceText(lang: string): string {
97
- const val = getSourceValue(lang);
98
- if (typeof val === 'string') return val;
99
- if (val && typeof val === 'object' && 'type' in (val as Record<string, unknown>)) {
100
- return interfaceLanguage.current === 'pl'
101
- ? '(treść w edytorze)'
102
- : '(editor content)';
103
- }
104
- return '';
105
- }
106
-
107
- function getLangFillStatus(lang: string): boolean {
108
- const val = getSourceValue(lang);
109
- return !isValueEmpty(val);
110
- }
111
-
112
39
  function constraintHint(): string {
113
40
  if (field.type !== 'text') return '';
114
41
  const parts: string[] = [];
@@ -122,137 +49,65 @@
122
49
  return parts.join('');
123
50
  }
124
51
 
125
- function dotTooltip(): string {
126
- const parts = contentLanguage.all.map((l) => {
127
- const filled = getLangFillStatus(l);
128
- const statusText =
129
- interfaceLanguage.current === 'pl'
130
- ? filled ? 'uzupełnione' : 'puste'
131
- : filled ? 'filled' : 'empty';
132
- return `${l.toUpperCase()}: ${statusText}`;
133
- });
134
- return parts.join(', ');
135
- }
136
-
137
- // Lazy-load richtext and content field components
138
- let RichtextField: typeof TextField | null = $state(null);
52
+ // Lazy-load content field component
139
53
  let ContentField: typeof TextField | null = $state(null);
140
54
 
141
55
  $effect(() => {
142
- if (field.type === 'richtext' && !RichtextField) {
143
- import('./richtext-field.svelte').then((m) => { RichtextField = m.default; });
144
- }
145
56
  if (field.type === 'content' && !ContentField) {
146
57
  import('./content-field.svelte').then((m) => { ContentField = m.default; });
147
58
  }
148
59
  });
149
60
  </script>
150
61
 
151
- <Tabs.Root
152
- onValueChange={(val) => {
153
- contentLanguage.current = val;
154
- }}
155
- value={contentLanguage.current}
156
- >
157
- {#each contentLanguage.all as lang}
158
- <Tabs.Content value={lang}>
159
- <Form.Field {form} name={joinPath(path, lang) as FormPathLeaves<T, string | undefined>}>
160
- <Form.Control>
161
- {#snippet children({ props })}
162
- {#if field.label}
163
- <div class="flex items-center gap-1.5">
164
- <RequiredLabel required={field.required}>{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
165
- {#if isMultiLang && !inInlineBlock && (field.required || contentLanguage.all.some(l => getLangFillStatus(l)))}
166
- <span
167
- class="inline-flex items-center gap-1"
168
- title={dotTooltip()}
169
- aria-label={dotTooltip()}
170
- >
171
- {#each contentLanguage.all as l}
172
- <span
173
- class="inline-block size-1.5 rounded-full {getLangFillStatus(l) ? 'bg-[var(--success)]' : 'bg-[var(--warning)]'}"
174
- ></span>
175
- {/each}
176
- </span>
177
- {/if}
178
- </div>
179
- {/if}
180
-
181
- {#if contentLanguage.referenceMode && isMultiLang && !inInlineBlock}
182
- {@const refSource = findSourceLang(lang)}
183
- {#if refSource}
184
- {@const refText = getReferenceText(refSource)}
185
- {#if refText}
186
- <div class="mb-2 rounded-lg border-l-2 border-[var(--primary)]/20 bg-[var(--muted)]/50 p-3 text-sm text-[var(--muted-foreground)]">
187
- <div class="mb-1 text-xs font-semibold text-[var(--text-light)]">{refLang[interfaceLanguage.current].reference(refSource)}</div>
188
- <div class="whitespace-pre-wrap">{refText}</div>
189
- </div>
190
- {/if}
191
- {/if}
192
- {/if}
193
-
194
- {#if field.type === 'text'}
195
- <FieldValueBridge {form} path={joinPath(path, lang) as FormPathLeaves<T>} component={TextField} {field} {...props} />
196
- {:else if field.type === 'richtext' && RichtextField}
197
- <FieldValueBridge {form} path={joinPath(path, lang) as FormPathLeaves<T>} component={RichtextField} {field} {...props} />
198
- {:else if field.type === 'content' && ContentField}
199
- <FieldValueBridge {form} path={joinPath(path, lang) as FormPathLeaves<T>} component={ContentField} {field} {...props} />
200
- {:else}
201
- <div class="h-32 animate-pulse rounded-md bg-accent"></div>
202
- {/if}
203
-
204
- {#if isMultiLang && !inInlineBlock}
205
- {@const currentVal = resolvePathValue($formData, joinPath(path, lang))}
206
- {@const sourceLang = isValueEmpty(currentVal) ? findSourceLang(lang) : null}
207
- {#if sourceLang}
208
- <button
209
- type="button"
210
- class="mt-1 inline-flex items-center gap-1 text-xs text-[var(--muted-foreground)] transition-colors hover:text-[var(--primary)]"
211
- onclick={() => copyFrom(sourceLang, lang)}
212
- >
213
- <ClipboardIcon class="size-3" />
214
- {copyLang[interfaceLanguage.current].copyFrom(sourceLang)}
215
- </button>
216
- {/if}
217
- {/if}
218
- {/snippet}
219
- </Form.Control>
220
-
221
- {#if isText}
222
- {@const val = resolvePathValue($formData, joinPath(path, lang))}
223
- {@const charCount = typeof val === 'string' ? val.length : 0}
224
- {@const atLimit = field.type === 'text' && field.maxLength !== undefined && charCount >= field.maxLength}
225
- <div class="flex items-start justify-between gap-4">
226
- {#if field.description || hasConstraints}
227
- <Form.Description class="flex-1">
228
- {#if field.description}{getLocalizedLabel(field.description, interfaceLanguage.current)}{/if}
229
- {#if hasConstraints}
230
- {#if field.description && constraintHint()}<br />{/if}
231
- {#if constraintHint()}{constraintHint()}{/if}
232
- {#if field.type === 'text' && field.pattern}
233
- {#if constraintHint()} · {/if}Format: <code class="text-xs bg-muted px-1 py-0.5 rounded">{field.pattern}</code>
234
- {/if}
235
- {/if}
236
- </Form.Description>
237
- {:else}
238
- <div></div>
62
+ <Form.Field {form} name={path as FormPathLeaves<T, string | undefined>}>
63
+ <Form.Control>
64
+ {#snippet children({ props })}
65
+ {#if field.label}
66
+ <RequiredLabel required={field.required}>{getLocalizedLabel(field.label, interfaceLanguage.current)}</RequiredLabel>
67
+ {/if}
68
+
69
+ {#if field.type === 'text'}
70
+ <FieldValueBridge {form} {path} component={TextField} {field} {...props} />
71
+ {:else if field.type === 'content' && ContentField}
72
+ <FieldValueBridge {form} {path} component={ContentField} {field} {...props} />
73
+ {:else}
74
+ <div class="h-32 animate-pulse rounded-md bg-accent"></div>
75
+ {/if}
76
+ {/snippet}
77
+ </Form.Control>
78
+
79
+ {#if isText}
80
+ {@const val = resolvePathValue($formData, path)}
81
+ {@const charCount = typeof val === 'string' ? val.length : 0}
82
+ {@const atLimit = field.type === 'text' && field.maxLength !== undefined && charCount >= field.maxLength}
83
+ <div class="flex items-start justify-between gap-4">
84
+ {#if field.description || hasConstraints}
85
+ <Form.Description class="flex-1">
86
+ {#if field.description}{getLocalizedLabel(field.description, interfaceLanguage.current)}{/if}
87
+ {#if hasConstraints}
88
+ {#if field.description && constraintHint()}<br />{/if}
89
+ {#if constraintHint()}{constraintHint()}{/if}
90
+ {#if field.type === 'text' && field.pattern}
91
+ {#if constraintHint()} · {/if}Format: <code class="text-xs bg-muted px-1 py-0.5 rounded">{field.pattern}</code>
239
92
  {/if}
240
- <span class="shrink-0 text-xs {atLimit ? 'text-destructive' : 'text-muted-foreground'}" aria-live="polite">
241
- {#if field.type === 'text' && field.maxLength !== undefined}
242
- {charCount} / {field.maxLength}
243
- {:else if field.type === 'text' && field.minLength !== undefined}
244
- {charCount} (min. {field.minLength})
245
- {:else}
246
- {charCount}
247
- {/if}
248
- </span>
249
- </div>
250
- {:else if field.description}
251
- <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
93
+ {/if}
94
+ </Form.Description>
95
+ {:else}
96
+ <div></div>
97
+ {/if}
98
+ <span class="shrink-0 text-xs {atLimit ? 'text-destructive' : 'text-muted-foreground'}" aria-live="polite">
99
+ {#if field.type === 'text' && field.maxLength !== undefined}
100
+ {charCount} / {field.maxLength}
101
+ {:else if field.type === 'text' && field.minLength !== undefined}
102
+ {charCount} (min. {field.minLength})
103
+ {:else}
104
+ {charCount}
252
105
  {/if}
253
-
254
- <Form.FieldErrors />
255
- </Form.Field>
256
- </Tabs.Content>
257
- {/each}
258
- </Tabs.Root>
106
+ </span>
107
+ </div>
108
+ {:else if field.description}
109
+ <Form.Description>{getLocalizedLabel(field.description, interfaceLanguage.current)}</Form.Description>
110
+ {/if}
111
+
112
+ <Form.FieldErrors />
113
+ </Form.Field>
@@ -1,8 +1,8 @@
1
- import type { RichtextField as RichtextFieldType, ContentField as ContentFieldType, TextField as TextFieldType } from '../../../types/fields.js';
1
+ import type { ContentField as ContentFieldType, TextField as TextFieldType } from '../../../types/fields.js';
2
2
  import { type FormPathLeaves, type SuperForm } from 'sveltekit-superforms';
3
3
  declare function $$render<T extends Record<string, unknown>>(): {
4
4
  props: {
5
- field: TextFieldType | RichtextFieldType | ContentFieldType;
5
+ field: TextFieldType | ContentFieldType;
6
6
  form: SuperForm<T>;
7
7
  path: FormPathLeaves<T, string | undefined>;
8
8
  };
@@ -3,7 +3,6 @@
3
3
  </script>
4
4
 
5
5
  <script lang="ts" generics="T extends Record<string, unknown>">
6
- import { urlFieldDataSchema } from '../../../schemas/field/url.js';
7
6
  import type { UrlField, UrlFieldData } from '../../../types/fields.js';
8
7
  import { onMount } from 'svelte';
9
8
  import {
@@ -25,22 +24,23 @@
25
24
  const { value } = formFieldProxy(form, path) satisfies FormFieldProxy<UrlFieldData | undefined>;
26
25
 
27
26
  onMount(() => {
28
- if (!$value) {
29
- $value = { id: '', url: {} };
30
- } else if (typeof $value !== 'object' || Array.isArray($value)) {
31
- $value = { id: '', url: {} };
27
+ if (!$value || typeof $value !== 'object' || Array.isArray($value)) {
28
+ $value = { id: '', url: '' };
32
29
  } else if (!('url' in $value)) {
33
- // old format: Record<string, string> from text field → use as url
34
- // but only if all values are strings; otherwise reset
35
- const entries = Object.entries($value as Record<string, unknown>);
36
- const allStrings = entries.length > 0 && entries.every(([, v]) => typeof v === 'string');
37
- $value = allStrings
38
- ? { id: '', url: $value as unknown as Record<string, string> }
39
- : { id: '', url: {} };
30
+ $value = { id: '', url: '' };
31
+ } else if (typeof ($value as Record<string, unknown>).url === 'object') {
32
+ // Migrate old { url: { pl: "..." } } → flat string
33
+ const urlObj = ($value as Record<string, unknown>).url as Record<string, string>;
34
+ const firstVal = Object.values(urlObj)[0] ?? '';
35
+ $value = { ...$value, url: firstVal };
40
36
  }
41
37
 
42
- if (field.text && !$value.text) {
43
- $value = { ...$value, text: {} };
38
+ if (field.text && $value.text == null) {
39
+ $value = { ...$value, text: '' };
40
+ } else if (field.text && typeof $value.text === 'object') {
41
+ // Migrate old { text: { pl: "..." } } → flat string
42
+ const textObj = $value.text as unknown as Record<string, string>;
43
+ $value = { ...$value, text: Object.values(textObj)[0] ?? '' };
44
44
  }
45
45
 
46
46
  if (field.newTab && $value.newTab === undefined) {
@@ -51,17 +51,7 @@
51
51
  $value = { ...$value, rel: '' };
52
52
  }
53
53
 
54
- if (!urlFieldDataSchema.safeParse($value).success) {
55
- $value = {
56
- id: '',
57
- url: {},
58
- ...(field.text ? { text: {} } : {}),
59
- ...(field.newTab ? { newTab: false } : {}),
60
- ...(field.rel ? { rel: '' } : {})
61
- };
62
- }
63
-
64
- fieldValid = urlFieldDataSchema.safeParse($value).success;
54
+ fieldValid = true;
65
55
  });
66
56
 
67
57
  let fieldValid = $state(false);
@@ -6,7 +6,6 @@
6
6
  import Badge from '../../../components/ui/badge/badge.svelte';
7
7
  import Checkbox from '../../../components/ui/checkbox/checkbox.svelte';
8
8
  import Label from '../../../components/ui/label/label.svelte';
9
- import { getContentLanguage } from '../../state/content-language.svelte.js';
10
9
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
11
10
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
12
11
  import { isExternalUrl } from '../../../core/fields/urlUtils.js';
@@ -19,7 +18,6 @@
19
18
  import ChevronRight from '@tabler/icons-svelte/icons/chevron-right';
20
19
 
21
20
  const remotes = getRemotes();
22
- const contentLanguage = getContentLanguage();
23
21
  const interfaceLanguage = useInterfaceLanguage();
24
22
 
25
23
  const labels = {
@@ -39,11 +37,10 @@
39
37
  internal: { en: 'Internal', pl: 'Wewnętrzny' }
40
38
  };
41
39
 
42
- type EntryWithSeo = Entry & {
43
- data: {
44
- seo?: SeoFieldData;
45
- [key: string]: unknown;
46
- };
40
+ type EntryWithSeo = {
41
+ _id: string;
42
+ _slug: string;
43
+ seo?: SeoFieldData;
47
44
  };
48
45
 
49
46
  type Props = {
@@ -55,8 +52,8 @@
55
52
 
56
53
  let isLinked = $derived(value.id != null && value.id.length > 0);
57
54
 
58
- // Current URL for the active language
59
- let currentUrl = $derived(value.url[contentLanguage.current] ?? '');
55
+ // URL is now a flat string
56
+ let currentUrl = $derived(typeof value.url === 'string' ? value.url : '');
60
57
  let currentIsExternal = $derived(currentUrl ? isExternalUrl(currentUrl) : false);
61
58
 
62
59
  // Autocomplete state
@@ -64,7 +61,7 @@
64
61
  let popoverOpen = $state(false);
65
62
  let activeIndex = $state(-1);
66
63
  let searchTimeout: ReturnType<typeof setTimeout> | null = null;
67
- let linkedEntry = $state<(Entry & { data: { seo?: SeoFieldData } }) | null>(null);
64
+ let linkedEntry = $state<EntryWithSeo | null>(null);
68
65
 
69
66
  // Rel collapsible
70
67
  let relOpen = $state(false);
@@ -81,14 +78,14 @@
81
78
  }
82
79
  });
83
80
 
84
- async function fetchLinkedEntry(id: string | undefined) {
81
+ async function fetchLinkedEntry(id: string | undefined): Promise<EntryWithSeo | null> {
85
82
  if (!id) return null;
86
- return remotes.getEntry({ id });
83
+ return remotes.getEntry({ id }) as Promise<EntryWithSeo | null>;
87
84
  }
88
85
 
89
86
  function searchEntries() {
90
87
  if (searchTimeout) clearTimeout(searchTimeout);
91
- const url = value.url[contentLanguage.current] ?? '';
88
+ const url = typeof value.url === 'string' ? value.url : '';
92
89
  if (isLinked || url.length < 3) {
93
90
  autocompleteSuggestions = [];
94
91
  popoverOpen = false;
@@ -101,7 +98,7 @@
101
98
  ]) as [EntryWithSeo[], EntryWithSeo[]];
102
99
  const combined = [...slugResults, ...titleResults];
103
100
  const deduped = combined.filter(
104
- (entry, index, self) => index === self.findIndex((e) => e.id === entry.id)
101
+ (entry, index, self) => index === self.findIndex((e) => e._id === entry._id)
105
102
  );
106
103
  autocompleteSuggestions = deduped.slice(0, 5);
107
104
  popoverOpen = autocompleteSuggestions.length > 0;
@@ -110,7 +107,7 @@
110
107
  }
111
108
 
112
109
  function linkEntry(entry: EntryWithSeo) {
113
- value.id = entry.id;
110
+ value.id = entry._id;
114
111
  linkedEntry = entry;
115
112
  autocompleteSuggestions = [];
116
113
  popoverOpen = false;
@@ -170,11 +167,11 @@
170
167
  <div class="flex min-w-0 flex-1 items-center gap-2 py-1.5">
171
168
  <Badge variant="secondary" class="max-w-full gap-1.5 truncate">
172
169
  <span class="truncate font-medium">
173
- {linkedEntry.data.seo?.title || linkedEntry.id}
170
+ {linkedEntry.seo?.title || linkedEntry._id}
174
171
  </span>
175
- {#if linkedEntry.data.seo?.slug}
172
+ {#if linkedEntry.seo?.slug}
176
173
  <span class="text-muted-foreground truncate text-[10px] font-normal">
177
- {linkedEntry.data.seo.slug}
174
+ {linkedEntry.seo.slug}
178
175
  </span>
179
176
  {/if}
180
177
  </Badge>
@@ -191,42 +188,38 @@
191
188
  </InputGroup.Addon>
192
189
  </InputGroup.Root>
193
190
  {:else}
194
- {#each contentLanguage.all as lang}
195
- <div class={contentLanguage.current === lang ? '' : 'hidden'}>
196
- <InputGroup.Root>
197
- <InputGroup.Addon align="inline-start">
198
- <GlobeIcon class="size-4" />
199
- </InputGroup.Addon>
200
- <InputGroup.Input
201
- bind:value={value.url[lang]}
202
- type="text"
203
- placeholder={field.placeholder?.[lang] || getLocalizedLabel(labels.placeholder, interfaceLanguage.current)}
204
- oninput={searchEntries}
205
- onkeydown={handleUrlKeydown}
206
- role="combobox"
207
- aria-expanded={popoverOpen}
208
- aria-controls="url-suggestions"
209
- aria-autocomplete="list"
210
- aria-activedescendant={activeIndex >= 0 ? `url-suggestion-${activeIndex}` : undefined}
211
- />
212
- {#if currentUrl && contentLanguage.current === lang}
213
- <InputGroup.Addon align="inline-end">
214
- {#if currentIsExternal}
215
- <Badge variant="outline" class="text-[10px]">
216
- <ExternalLinkIcon class="size-3" />
217
- {getLocalizedLabel(labels.external, interfaceLanguage.current)}
218
- </Badge>
219
- {:else if currentUrl.length > 0}
220
- <Badge variant="secondary" class="text-[10px]">
221
- <LinkIcon class="size-3" />
222
- {getLocalizedLabel(labels.internal, interfaceLanguage.current)}
223
- </Badge>
224
- {/if}
225
- </InputGroup.Addon>
191
+ <InputGroup.Root>
192
+ <InputGroup.Addon align="inline-start">
193
+ <GlobeIcon class="size-4" />
194
+ </InputGroup.Addon>
195
+ <InputGroup.Input
196
+ bind:value={value.url}
197
+ type="text"
198
+ placeholder={getLocalizedLabel(labels.placeholder, interfaceLanguage.current)}
199
+ oninput={searchEntries}
200
+ onkeydown={handleUrlKeydown}
201
+ role="combobox"
202
+ aria-expanded={popoverOpen}
203
+ aria-controls="url-suggestions"
204
+ aria-autocomplete="list"
205
+ aria-activedescendant={activeIndex >= 0 ? `url-suggestion-${activeIndex}` : undefined}
206
+ />
207
+ {#if currentUrl}
208
+ <InputGroup.Addon align="inline-end">
209
+ {#if currentIsExternal}
210
+ <Badge variant="outline" class="text-[10px]">
211
+ <ExternalLinkIcon class="size-3" />
212
+ {getLocalizedLabel(labels.external, interfaceLanguage.current)}
213
+ </Badge>
214
+ {:else if currentUrl.length > 0}
215
+ <Badge variant="secondary" class="text-[10px]">
216
+ <LinkIcon class="size-3" />
217
+ {getLocalizedLabel(labels.internal, interfaceLanguage.current)}
218
+ </Badge>
226
219
  {/if}
227
- </InputGroup.Root>
228
- </div>
229
- {/each}
220
+ </InputGroup.Addon>
221
+ {/if}
222
+ </InputGroup.Root>
230
223
  {/if}
231
224
 
232
225
  <!-- Floating autocomplete dropdown -->
@@ -247,10 +240,10 @@
247
240
  >
248
241
  <LinkIcon class="text-muted-foreground size-3.5 shrink-0" />
249
242
  <span class="truncate text-sm">
250
- {suggestion.data.seo?.title || getLocalizedLabel(labels.noTitle, interfaceLanguage.current)}
243
+ {suggestion.seo?.title || getLocalizedLabel(labels.noTitle, interfaceLanguage.current)}
251
244
  </span>
252
245
  <span class="text-muted-foreground ml-auto shrink-0 text-xs">
253
- {suggestion.data.seo?.slug || ''}
246
+ {suggestion.seo?.slug || ''}
254
247
  </span>
255
248
  </button>
256
249
  {/each}
@@ -259,24 +252,20 @@
259
252
  </div>
260
253
 
261
254
  <!-- Options (text, newTab, rel) -->
262
- {#if (field.text || field.newTab || field.rel) && (field.text && value.text || field.newTab || field.rel)}
255
+ {#if field.text || field.newTab || field.rel}
263
256
  <div class="mt-2 space-y-2">
264
- <!-- Link text -->
265
- {#if field.text && value.text}
266
- {#each contentLanguage.all as lang}
267
- <div class={contentLanguage.current === lang ? '' : 'hidden'}>
268
- <InputGroup.Root>
269
- <InputGroup.Addon align="inline-start">
270
- <TextIcon class="size-4" />
271
- </InputGroup.Addon>
272
- <InputGroup.Input
273
- bind:value={value.text[lang]}
274
- type="text"
275
- placeholder={getLocalizedLabel(labels.linkText, interfaceLanguage.current)}
276
- />
277
- </InputGroup.Root>
278
- </div>
279
- {/each}
257
+ <!-- Link text (now flat string) -->
258
+ {#if field.text}
259
+ <InputGroup.Root>
260
+ <InputGroup.Addon align="inline-start">
261
+ <TextIcon class="size-4" />
262
+ </InputGroup.Addon>
263
+ <InputGroup.Input
264
+ bind:value={value.text}
265
+ type="text"
266
+ placeholder={getLocalizedLabel(labels.linkText, interfaceLanguage.current)}
267
+ />
268
+ </InputGroup.Root>
280
269
  {/if}
281
270
 
282
271
  <!-- New tab + rel row -->
@@ -31,9 +31,10 @@
31
31
  accept?: string;
32
32
  onUpload?: () => void;
33
33
  dropZoneRef?: HTMLElement | null;
34
+ tagIds?: string[];
34
35
  };
35
36
 
36
- let { accept, onUpload, dropZoneRef = $bindable(null) }: Props =
37
+ let { accept, onUpload, dropZoneRef = $bindable(null), tagIds }: Props =
37
38
  $props();
38
39
 
39
40
  let inputElement: HTMLInputElement;
@@ -43,6 +44,9 @@
43
44
  async function uploadFile(file: File, index: number) {
44
45
  const form = new FormData();
45
46
  form.append('file', file);
47
+ if (tagIds && tagIds.length > 0) {
48
+ form.append('tagIds', JSON.stringify(tagIds));
49
+ }
46
50
 
47
51
  return new Promise<void>((resolve) => {
48
52
  const xhr = new XMLHttpRequest();