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
@@ -3,11 +3,12 @@ import { getCMS } from '../../../../core/cms.js';
3
3
  import { getRawEntries } from '../../../../core/server/entries/operations/get.js';
4
4
  import { createEntry } from '../../../../core/server/entries/operations/create.js';
5
5
  import { upsertDraftVersion } from '../../../../core/server/entries/operations/update.js';
6
- export async function GET(_event, slug) {
6
+ export async function GET(event, slug) {
7
7
  const cms = getCMS();
8
8
  if (!cms.singles[slug]) {
9
9
  return json({ error: `Singleton "${slug}" not found` }, { status: 404 });
10
10
  }
11
+ const lang = event.url.searchParams.get('lang') || cms.languages[0] || 'en';
11
12
  const entries = await getRawEntries({ slug });
12
13
  const entry = entries[0];
13
14
  if (!entry) {
@@ -24,11 +25,10 @@ export async function GET(_event, slug) {
24
25
  type: entry.type,
25
26
  createdAt: entry.createdAt,
26
27
  updatedAt: entry.updatedAt,
27
- publishedAt: entry.publishedAt,
28
- draftData: entry.draftVersion?.data ?? null,
29
- publishedData: entry.publishedVersion?.data ?? null,
30
- draftVersionId: entry.draftVersion?.id ?? null,
31
- publishedVersionId: entry.publishedVersion?.id ?? null
28
+ draftData: entry.draftVersions[lang]?.data ?? null,
29
+ publishedData: entry.publishedVersions[lang]?.data ?? null,
30
+ draftVersionId: entry.draftVersions[lang]?.id ?? null,
31
+ publishedVersionId: entry.publishedVersions[lang]?.id ?? null
32
32
  });
33
33
  }
34
34
  export async function PUT(event, slug) {
@@ -36,6 +36,7 @@ export async function PUT(event, slug) {
36
36
  if (!cms.singles[slug]) {
37
37
  return json({ error: `Singleton "${slug}" not found` }, { status: 404 });
38
38
  }
39
+ const lang = event.url.searchParams.get('lang') || cms.languages[0] || 'en';
39
40
  const body = await event.request.json().catch(() => null);
40
41
  if (!body?.data || typeof body.data !== 'object') {
41
42
  return json({ error: 'Request body must contain "data" object' }, { status: 400 });
@@ -51,7 +52,7 @@ export async function PUT(event, slug) {
51
52
  return json({ error: 'Failed to create singleton entry' }, { status: 500 });
52
53
  }
53
54
  }
54
- const version = await upsertDraftVersion(entry.id, body.data, { skipValidation: true });
55
+ const version = await upsertDraftVersion(entry.id, body.data, lang, { skipValidation: true });
55
56
  return json({
56
57
  id: entry.id,
57
58
  versionId: version.id,
@@ -0,0 +1,2 @@
1
+ import { type RequestEvent } from '@sveltejs/kit';
2
+ export declare function POST(event: RequestEvent): Promise<Response>;
@@ -0,0 +1,28 @@
1
+ import { json } from '@sveltejs/kit';
2
+ import { uploadFile } from '../../../../core/server/media/operations/uploadFile.js';
3
+ import { getCMS } from '../../../../core/cms.js';
4
+ const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
5
+ export async function POST(event) {
6
+ const form = await event.request.formData();
7
+ const file = form.get('file');
8
+ const tagIdsRaw = form.get('tagIds');
9
+ if (!file || file.size === 0) {
10
+ return json({ error: 'No file provided' }, { status: 400 });
11
+ }
12
+ if (file.size > MAX_UPLOAD_SIZE) {
13
+ return json({ error: 'File too large (max 50MB)' }, { status: 413 });
14
+ }
15
+ const dbFile = await uploadFile(file);
16
+ if (tagIdsRaw) {
17
+ try {
18
+ const tagIds = JSON.parse(tagIdsRaw);
19
+ if (Array.isArray(tagIds) && tagIds.length > 0) {
20
+ await getCMS().databaseAdapter.setMediaFileTags(dbFile.id, tagIds);
21
+ }
22
+ }
23
+ catch {
24
+ // ignore invalid tagIds JSON
25
+ }
26
+ }
27
+ return json(dbFile, { status: 201 });
28
+ }
@@ -1,11 +1,13 @@
1
1
  import { requireAuth } from '../remote/middleware/auth.js';
2
2
  import { uploadFile } from '../../core/server/media/operations/uploadFile.js';
3
+ import { getCMS } from '../../core/cms.js';
3
4
  import { json } from '@sveltejs/kit';
4
5
  const MAX_UPLOAD_SIZE = 50 * 1024 * 1024; // 50MB
5
6
  export const POST = async ({ request }) => {
6
7
  requireAuth();
7
8
  const form = await request.formData();
8
9
  const file = form.get('file');
10
+ const tagIdsRaw = form.get('tagIds');
9
11
  if (!file || file.size === 0) {
10
12
  return new Response('No file', { status: 400 });
11
13
  }
@@ -13,5 +15,16 @@ export const POST = async ({ request }) => {
13
15
  return new Response('File too large', { status: 413 });
14
16
  }
15
17
  const dbFile = await uploadFile(file);
18
+ if (tagIdsRaw) {
19
+ try {
20
+ const tagIds = JSON.parse(tagIdsRaw);
21
+ if (Array.isArray(tagIds) && tagIds.length > 0) {
22
+ await getCMS().databaseAdapter.setMediaFileTags(dbFile.id, tagIds);
23
+ }
24
+ }
25
+ catch {
26
+ // ignore invalid tagIds JSON
27
+ }
28
+ }
16
29
  return json(dbFile);
17
30
  };
@@ -378,8 +378,20 @@
378
378
  pendingDeleteId = null;
379
379
  }
380
380
 
381
+ function getVersionData(entry: RawEntry): Record<string, unknown> | undefined {
382
+ const lang = contentLanguage.current;
383
+ const draft = entry.draftVersions[lang];
384
+ const published = entry.publishedVersions[lang];
385
+ if (draft?.data) return draft.data as Record<string, unknown>;
386
+ if (published?.data) return published.data as Record<string, unknown>;
387
+ // Fallback: any lang
388
+ for (const v of Object.values(entry.draftVersions)) if (v?.data) return v.data as Record<string, unknown>;
389
+ for (const v of Object.values(entry.publishedVersions)) if (v?.data) return v.data as Record<string, unknown>;
390
+ return undefined;
391
+ }
392
+
381
393
  function getSearchText(entry: RawEntry): string {
382
- const data = entry.draftVersion?.data || entry.publishedVersion?.data;
394
+ const data = getVersionData(entry);
383
395
  if (!data) return '';
384
396
  return extractTextValues(data).join(' ').toLowerCase();
385
397
  }
@@ -404,7 +416,7 @@
404
416
 
405
417
  function extractSlug(entry: RawEntry): string | undefined {
406
418
  if (!seoField) return undefined;
407
- const data = entry.draftVersion?.data || entry.publishedVersion?.data;
419
+ const data = getVersionData(entry);
408
420
  if (!data) return undefined;
409
421
  const seoData = data[seoField.slug];
410
422
  if (seoData && typeof seoData === 'object' && 'slug' in seoData) {
@@ -414,7 +426,7 @@
414
426
  }
415
427
 
416
428
  function countA11yWarnings(entry: RawEntry): number {
417
- const data = entry.draftVersion?.data || entry.publishedVersion?.data;
429
+ const data = getVersionData(entry);
418
430
  if (!data) return 0;
419
431
  const a11yLang = interfaceLanguage.current === 'pl' ? a11yLangPl : a11yLangEn;
420
432
  const issues = validateA11y(data as Record<string, unknown>, collection.fields, a11yLang);
@@ -429,7 +441,7 @@
429
441
  uuidsByCollection.set(field.collection, new Set());
430
442
  }
431
443
  for (const entry of entries) {
432
- const data = entry.publishedVersion?.data || entry.draftVersion?.data;
444
+ const data = getVersionData(entry);
433
445
  if (!data) continue;
434
446
  for (const field of relationListFields) {
435
447
  const raw = (data as Record<string, unknown>)[field.slug];
@@ -454,9 +466,10 @@
454
466
  }
455
467
 
456
468
  function mapEntryToRow(entry: RawEntry, lookup: Record<string, string> = {}): CollectionDataTableRow {
457
- const data = entry.draftVersion?.data || entry.publishedVersion?.data || {};
469
+ const data = getVersionData(entry) || {};
458
470
  // For custom columns, prefer published data (complete) over draft (may be partial)
459
- const columnData = entry.publishedVersion?.data || entry.draftVersion?.data || {};
471
+ const lang = contentLanguage.current;
472
+ const columnData = entry.publishedVersions[lang]?.data || entry.draftVersions[lang]?.data || data;
460
473
  const customData: Record<string, unknown> = {};
461
474
  if (collection.listColumns) {
462
475
  for (const fieldSlug of collection.listColumns) {
@@ -28,8 +28,6 @@
28
28
  import { createHybridContext } from './hybrid/hybrid-context.svelte.js';
29
29
  import { onMount } from 'svelte';
30
30
  import { get } from 'svelte/store';
31
- import { computeTranslationStatus } from '../../utils/translationStatus.js';
32
-
33
31
  const contentLanguage = getContentLanguage();
34
32
  const remotes = getRemotes();
35
33
  const interfaceLanguage = useInterfaceLanguage();
@@ -142,10 +140,11 @@
142
140
  let { collection } = entry;
143
141
  const isArchived = $derived(!!entry.archivedAt);
144
142
 
145
- // Create form once at component level
143
+ // Create form once at component level — localized: false since data is flat single-language
146
144
  const collectionSchema = generateZodSchemaFromFields(
147
145
  getFieldsFromConfig(collection),
148
- contentLanguage.all
146
+ contentLanguage.all,
147
+ { localized: false }
149
148
  );
150
149
  const form = superForm(defaults(editingEntry.data, zod4(collectionSchema)), {
151
150
  validators: zod4Client(collectionSchema),
@@ -166,15 +165,6 @@
166
165
 
167
166
  const AUTOSAVE_DELAY = 30000; // 30 seconds
168
167
 
169
- // Reactive translation status — must live here where we can subscribe to form.form store
170
- const formStore = form.form;
171
- const collectionFields = getFieldsFromConfig(collection);
172
- const translationStatus = $derived.by(() => {
173
- if (contentLanguage.all.length <= 1) return null;
174
- const data = $formStore;
175
- return computeTranslationStatus(data, collectionFields, contentLanguage.all);
176
- });
177
-
178
168
  function scheduleAutosave() {
179
169
  if (isArchived) return;
180
170
  if (autosaveTimer) {
@@ -195,15 +185,18 @@
195
185
 
196
186
  saveStatus = 'saving';
197
187
  try {
188
+ const currentLang = contentLanguage.current;
198
189
  const result = await remotes.updateEntryVersionCommand({
199
190
  entryId: entry.id,
191
+ lang: currentLang,
200
192
  data: currentFormData,
201
193
  type: 'draft'
202
194
  });
203
195
  lastSavedData = currentData;
204
196
  saveStatus = 'saved';
205
197
  // If we're editing the published version and saved a draft, track it for the banner
206
- if (entry.publishedVersion && editingEntry.id === entry.publishedVersion.id && result?.id) {
198
+ const publishedVersion = entry.publishedVersions[currentLang];
199
+ if (publishedVersion && editingEntry.id === publishedVersion.id && result?.id) {
207
200
  savedDraftVersionId = result.id;
208
201
  }
209
202
  // Reset to idle after 3s
@@ -270,6 +263,7 @@
270
263
  try {
271
264
  await remotes.updateEntryVersionCommand({
272
265
  entryId: entry.id,
266
+ lang: contentLanguage.current,
273
267
  data: get(form.form),
274
268
  type
275
269
  });
@@ -298,6 +292,7 @@
298
292
  try {
299
293
  await remotes.updateEntryVersionCommand({
300
294
  entryId: entry.id,
295
+ lang: contentLanguage.current,
301
296
  data: validatedForm.data,
302
297
  type,
303
298
  scheduledAt
@@ -501,21 +496,25 @@
501
496
  }
502
497
  });
503
498
 
499
+ // Per-language version lookups
500
+ const currentPublishedVersion = $derived(entry.publishedVersions[contentLanguage.current]);
501
+ const currentDraftVersion = $derived(entry.draftVersions[contentLanguage.current]);
502
+
504
503
  // Banner: viewing published version + a separate draft exists
505
504
  const showDraftBanner = $derived(
506
- entry.publishedVersion != null &&
507
- editingEntry.id === entry.publishedVersion.id &&
508
- entry.draftVersion != null &&
509
- entry.draftVersion.id !== entry.publishedVersion.id &&
510
- entry.draftVersion.createdAt > entry.publishedVersion.createdAt
505
+ currentPublishedVersion != null &&
506
+ editingEntry.id === currentPublishedVersion.id &&
507
+ currentDraftVersion != null &&
508
+ currentDraftVersion.id !== currentPublishedVersion.id &&
509
+ currentDraftVersion.createdAt > currentPublishedVersion.createdAt
511
510
  );
512
511
 
513
512
  // Banner: viewing draft + a published version exists
514
513
  const showPublishedBanner = $derived(
515
- entry.publishedVersion != null && editingEntry.id !== entry.publishedVersion.id
514
+ currentPublishedVersion != null && editingEntry.id !== currentPublishedVersion.id
516
515
  );
517
516
 
518
- const draftVersionId = $derived(entry.draftVersion?.id ?? savedDraftVersionId);
517
+ const draftVersionId = $derived(currentDraftVersion?.id ?? savedDraftVersionId);
519
518
 
520
519
  const scrollToIssue = (fieldSlug: string) => {
521
520
  const fieldEl = document.querySelector(`[data-field-path="${fieldSlug}"]`);
@@ -554,7 +553,6 @@
554
553
  fields={getFieldsFromConfig(collection)}
555
554
  getFormData={() => get(form.form)}
556
555
  onScrollToIssue={scrollToIssue}
557
- {translationStatus}
558
556
  />
559
557
 
560
558
  {#if validationErrors.length > 0}
@@ -620,7 +618,7 @@
620
618
  <button
621
619
  type="button"
622
620
  class="font-semibold text-[var(--primary)] hover:underline"
623
- onclick={() => goto(`?version=${entry.publishedVersion!.id}`)}
621
+ onclick={() => goto(`?version=${currentPublishedVersion!.id}`)}
624
622
  >
625
623
  {lang[interfaceLanguage.current].switchToPublished}
626
624
  </button>
@@ -198,7 +198,7 @@ function tryExtractDoc(val) {
198
198
  function extractDocs(data, fields) {
199
199
  const result = [];
200
200
  for (const field of fields) {
201
- if (field.type !== 'content' && field.type !== 'richtext')
201
+ if (field.type !== 'content')
202
202
  continue;
203
203
  const val = data[field.slug];
204
204
  const docs = tryExtractDoc(val);
@@ -212,7 +212,7 @@ function extractDocs(data, fields) {
212
212
  function extractDocsForLang(data, fields, language) {
213
213
  const result = [];
214
214
  for (const field of fields) {
215
- if (field.type !== 'content' && field.type !== 'richtext')
215
+ if (field.type !== 'content')
216
216
  continue;
217
217
  const val = data[field.slug];
218
218
  if (!val || typeof val !== 'object')
@@ -16,10 +16,9 @@
16
16
  import InfoCircle from '@tabler/icons-svelte/icons/info-circle';
17
17
  import Shield from '@tabler/icons-svelte/icons/shield';
18
18
  import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
19
- import { getEntryStatus } from '../utils.js';
19
+ import { getEntryStatusForLang } from '../utils.js';
20
20
  import { validateA11y, a11yLangPl, a11yLangEn, type A11yIssue } from './a11y-validator.js';
21
21
  import { getContentLanguage } from '../../../state/content-language.svelte.js';
22
- import { computeTranslationStatus, type LangStatus } from '../../../utils/translationStatus.js';
23
22
  import LanguageIcon from '@tabler/icons-svelte/icons/language';
24
23
 
25
24
  const lang: Record<
@@ -131,19 +130,13 @@
131
130
  let dateValue = $state('');
132
131
  let timeValue = $state('');
133
132
  let a11yIssues = $state<A11yIssue[]>([]);
134
- let translationStatus = $state<Record<string, LangStatus> | null>(null);
135
133
 
136
- const entryStatus = $derived(getEntryStatus(entry));
134
+ // Status is per current language
135
+ const entryStatus = $derived(getEntryStatusForLang(entry, contentLanguage.current));
137
136
  const t = $derived(lang[interfaceLanguage.current]);
138
137
 
139
138
  const hasA11yWarnings = $derived(a11yIssues.some((i) => i.type === 'warning'));
140
- const hasIncompleteTranslations = $derived(
141
- translationStatus != null &&
142
- Object.values(translationStatus).some((s) => s.status !== 'complete')
143
- );
144
- const showTranslationSection = $derived(
145
- contentLanguage.all.length > 1 && translationStatus != null
146
- );
139
+ const showLangStatusSection = $derived(contentLanguage.all.length > 1);
147
140
 
148
141
  const scheduleLabel = $derived.by(() => {
149
142
  switch (entryStatus) {
@@ -173,8 +166,9 @@
173
166
  }
174
167
 
175
168
  function setDefaultValues() {
176
- if (entryStatus === 'scheduled' && entry.publishedAt) {
177
- const scheduled = new Date(entry.publishedAt);
169
+ const scheduledVersion = entry.scheduledVersions[contentLanguage.current];
170
+ if (entryStatus === 'scheduled' && scheduledVersion?.publishedAt) {
171
+ const scheduled = new Date(scheduledVersion.publishedAt);
178
172
  dateValue = scheduled.toISOString().slice(0, 10);
179
173
  timeValue = `${String(scheduled.getHours()).padStart(2, '0')}:${String(scheduled.getMinutes()).padStart(2, '0')}`;
180
174
  } else {
@@ -229,20 +223,10 @@
229
223
  a11yIssues = validateA11y(data, fields, a11yLang, contentLanguage.all);
230
224
  }
231
225
 
232
- function runTranslationCheck() {
233
- if (contentLanguage.all.length <= 1 || !fields.length) {
234
- translationStatus = null;
235
- return;
236
- }
237
- const data = getFormData ? getFormData() : version.data;
238
- translationStatus = computeTranslationStatus(data, fields, contentLanguage.all);
239
- }
240
-
241
226
  $effect(() => {
242
227
  if (open) {
243
228
  setDefaultValues();
244
229
  runA11yValidation();
245
- runTranslationCheck();
246
230
  }
247
231
  });
248
232
  </script>
@@ -284,92 +268,56 @@
284
268
  </div>
285
269
  </div>
286
270
 
287
- {#if entryStatus === 'published' && entry.publishedAt}
271
+ {#if entryStatus === 'published' && entry.publishedVersions[contentLanguage.current]?.publishedAt}
288
272
  <div class="publish-detail-row">
289
273
  <span>{t.publishedAt}</span>
290
- <span class="tabular-nums">{formatDateTime(entry.publishedAt)}</span>
274
+ <span class="tabular-nums">{formatDateTime(entry.publishedVersions[contentLanguage.current]?.publishedAt)}</span>
291
275
  </div>
292
276
  {/if}
293
277
 
294
- {#if entryStatus === 'scheduled' && entry.publishedAt}
278
+ {#if entryStatus === 'scheduled' && entry.publishedVersions[contentLanguage.current]?.publishedAt}
295
279
  <div class="publish-detail-row">
296
280
  <span>{t.scheduledFor}</span>
297
- <span class="tabular-nums">{formatDateTime(entry.publishedAt)}</span>
281
+ <span class="tabular-nums">{formatDateTime(entry.publishedVersions[contentLanguage.current]?.publishedAt)}</span>
298
282
  </div>
299
283
  {/if}
300
284
 
301
- {#if entry.publishedVersion && entry.publishedVersion.id !== version.id}
285
+ {#if entry.publishedVersions[contentLanguage.current] && entry.publishedVersions[contentLanguage.current].id !== version.id}
302
286
  <div class="publish-detail-row">
303
287
  <span>{t.publishedVersion}</span>
304
- <span class="tabular-nums">{formatDateTime(entry.publishedVersion.createdAt)}</span>
288
+ <span class="tabular-nums">{formatDateTime(entry.publishedVersions[contentLanguage.current].createdAt)}</span>
305
289
  </div>
306
290
  {/if}
307
291
  </div>
308
292
 
309
- <!-- Translation section -->
310
- {#if showTranslationSection && translationStatus}
293
+ <!-- Per-language status section -->
294
+ {#if showLangStatusSection}
311
295
  <div class="sheet-section">
312
296
  <div class="sheet-section-title">
313
297
  <LanguageIcon class="size-3.5" />
314
298
  {t.translations}
315
299
  </div>
316
300
 
317
- {#each contentLanguage.all as lang}
318
- {@const status = translationStatus[lang]}
319
- {#if status}
320
- <div class="translation-lang-row">
321
- <div class="translation-lang-header">
322
- <span class="translation-lang-label">{lang.toUpperCase()}</span>
323
- <span class="translation-lang-pct {status.status === 'complete' ? 'complete' : status.status === 'partial' ? 'partial' : 'empty'}">
324
- {#if status.status === 'complete'}&#10003;{:else}{status.percentage}%{/if}
325
- </span>
326
- </div>
327
- <div class="translation-progress-track">
328
- <div
329
- class="translation-progress-fill {status.status === 'complete' ? 'complete' : status.status === 'partial' ? 'partial' : ''}"
330
- style="width: {status.percentage}%"
331
- ></div>
332
- </div>
301
+ {#each contentLanguage.all as langCode}
302
+ {@const langPub = entry.publishedVersions[langCode]}
303
+ {@const langSch = entry.scheduledVersions[langCode]}
304
+ {@const langDraft = entry.draftVersions[langCode]}
305
+ {@const langStatus = langPub ? 'published' : langSch ? 'scheduled' : 'draft'}
306
+ <div class="translation-lang-row">
307
+ <div class="translation-lang-header">
308
+ <span class="translation-lang-label">{langCode.toUpperCase()}</span>
309
+ <span class="translation-lang-pct {langStatus === 'published' ? 'complete' : langStatus === 'scheduled' ? 'partial' : 'empty'}">
310
+ {t.statusLabels[langStatus]}
311
+ </span>
333
312
  </div>
334
- {/if}
313
+ <div class="translation-progress-track">
314
+ <div
315
+ class="translation-progress-fill {langStatus === 'published' ? 'complete' : langStatus === 'scheduled' ? 'partial' : ''}"
316
+ style="width: {langStatus === 'published' ? 100 : langStatus === 'scheduled' ? 66 : langDraft ? 33 : 0}%"
317
+ ></div>
318
+ </div>
319
+ </div>
335
320
  {/each}
336
-
337
- {#if hasIncompleteTranslations}
338
- {#each contentLanguage.all as lang}
339
- {@const status = translationStatus[lang]}
340
- {#if status && status.missingFields.length > 0}
341
- <div class="translation-missing">
342
- <div class="translation-missing-title">{t.missingFieldsLabel(lang)}</div>
343
- {#each status.missingFields as field}
344
- {#if onScrollToIssue}
345
- <button
346
- type="button"
347
- class="a11y-item warning a11y-item-clickable"
348
- onclick={() => {
349
- contentLanguage.current = lang;
350
- open = false;
351
- setTimeout(() => onScrollToIssue!(field.slug, 0), 200);
352
- }}
353
- >
354
- <div class="a11y-item-icon">
355
- <AlertTriangle class="size-4" />
356
- </div>
357
- <span class="a11y-item-text">{field.label}</span>
358
- </button>
359
- {:else}
360
- <div class="a11y-item warning">
361
- <div class="a11y-item-icon">
362
- <AlertTriangle class="size-4" />
363
- </div>
364
- <span class="a11y-item-text">{field.label}</span>
365
- </div>
366
- {/if}
367
- {/each}
368
- </div>
369
- {/if}
370
- {/each}
371
- <p class="a11y-hint">{t.translationsHint}</p>
372
- {/if}
373
321
  </div>
374
322
  {/if}
375
323
 
@@ -45,8 +45,8 @@
45
45
 
46
46
  let { entry, version }: Props = $props();
47
47
 
48
- const isPublishedVersion = $derived(entry.publishedVersion?.id === version.id);
49
- const isScheduledVersion = $derived(entry.scheduledVersion?.id === version.id);
48
+ const isPublishedVersion = $derived(entry.publishedVersions[version.lang]?.id === version.id);
49
+ const isScheduledVersion = $derived(entry.scheduledVersions[version.lang]?.id === version.id);
50
50
 
51
51
  const currentStatus = $derived.by((): EntryVersionStatus => {
52
52
  if (isPublishedVersion) return 'published';
@@ -89,8 +89,8 @@
89
89
  }
90
90
 
91
91
  function getDotClass(version: DbEntryVersion): string {
92
- const isPublished = entry.publishedVersionId === version.id && entryStatus === 'published';
93
- const isScheduled = entry.publishedVersionId === version.id && entryStatus === 'scheduled';
92
+ const isPublished = entry.publishedVersions[version.lang]?.id === version.id && entryStatus === 'published';
93
+ const isScheduled = entry.publishedVersions[version.lang]?.id === version.id && entryStatus === 'scheduled';
94
94
  const isCurrent = version.id === currentVersionId;
95
95
 
96
96
  if (isPublished) return 'dot-published';
@@ -109,7 +109,7 @@
109
109
  // Always keep current version
110
110
  if (version.id === currentVersionId) return true;
111
111
  // Always keep published/scheduled
112
- if (entry.publishedVersionId === version.id) return true;
112
+ if (entry.publishedVersions[version.lang]?.id === version.id) return true;
113
113
  // Keep versions with actual data changes vs previous
114
114
  const prevVersion = sortedVersions[idx + 1];
115
115
  if (!prevVersion) return true; // first version
@@ -132,8 +132,8 @@
132
132
  const groups: Map<string, DbEntryVersion[]> = new Map();
133
133
 
134
134
  for (const version of timelineVersions) {
135
- const isPublished = entry.publishedVersionId === version.id && entryStatus === 'published';
136
- const isScheduled = entry.publishedVersionId === version.id && entryStatus === 'scheduled';
135
+ const isPublished = entry.publishedVersions[version.lang]?.id === version.id && entryStatus === 'published';
136
+ const isScheduled = entry.publishedVersions[version.lang]?.id === version.id && entryStatus === 'scheduled';
137
137
 
138
138
  let key: string;
139
139
  if (isPublished) {
@@ -193,12 +193,12 @@
193
193
  <div class="vh-item-left">
194
194
  <span class="vh-time">{formatTime(currentVersion.createdAt)}</span>
195
195
  <Badge class="vh-badge-editing">{t.editing}</Badge>
196
- {#if entry.publishedVersionId === currentVersion.id && entryStatus === 'published'}
196
+ {#if entry.publishedVersions[currentVersion.lang]?.id === currentVersion.id && entryStatus === 'published'}
197
197
  <Badge class="vh-badge-published">
198
198
  <SquareCheckFilled class="size-2.5 mr-0.5" />
199
199
  {t.published}
200
200
  </Badge>
201
- {:else if entry.publishedVersionId === currentVersion.id && entryStatus === 'scheduled'}
201
+ {:else if entry.publishedVersions[currentVersion.lang]?.id === currentVersion.id && entryStatus === 'scheduled'}
202
202
  <Badge class="vh-badge-scheduled">
203
203
  {t.scheduled}
204
204
  </Badge>
@@ -233,8 +233,8 @@
233
233
  <div class="vh-timeline-line"></div>
234
234
 
235
235
  {#each versions as version}
236
- {@const isPublished = entry.publishedVersionId === version.id && entryStatus === 'published'}
237
- {@const isScheduled = entry.publishedVersionId === version.id && entryStatus === 'scheduled'}
236
+ {@const isPublished = entry.publishedVersions[version.lang]?.id === version.id && entryStatus === 'published'}
237
+ {@const isScheduled = entry.publishedVersions[version.lang]?.id === version.id && entryStatus === 'scheduled'}
238
238
  {@const prevVersion = sortedVersions.find(
239
239
  (v) => v.versionNumber === version.versionNumber - 1
240
240
  )}
@@ -1,11 +1,12 @@
1
1
  <script lang="ts">
2
+ import { getContentLanguage } from '../../../state/content-language.svelte.js';
2
3
  import { useInterfaceLanguage } from '../../../state/interface-language.svelte.js';
3
4
  import type { RawEntry } from '../../../../types/entries.js';
4
5
  import type { InterfaceLanguage } from '../../../../types/languages.js';
5
6
  import ClockFilled from '@tabler/icons-svelte/icons/clock-filled';
6
7
  import FileFilled from '@tabler/icons-svelte/icons/file-filled';
7
8
  import SquareCheckFilled from '@tabler/icons-svelte/icons/square-check-filled';
8
- import { getEntryStatus } from '../utils.js';
9
+ import { getEntryStatusForLang } from '../utils.js';
9
10
 
10
11
  const lang: Record<
11
12
  InterfaceLanguage,
@@ -34,6 +35,7 @@
34
35
  };
35
36
 
36
37
  const interfaceLanguage = useInterfaceLanguage();
38
+ const contentLanguage = getContentLanguage();
37
39
 
38
40
  type Props = {
39
41
  entry: RawEntry;
@@ -41,34 +43,38 @@
41
43
 
42
44
  let { entry }: Props = $props();
43
45
 
44
- const entryStatus = $derived(getEntryStatus(entry));
46
+ const currentLang = $derived(contentLanguage.current);
47
+ const entryStatus = $derived(getEntryStatusForLang(entry, currentLang));
48
+ const publishedVersion = $derived(entry.publishedVersions[currentLang]);
49
+ const scheduledVersion = $derived(entry.scheduledVersions[currentLang]);
50
+ const draftVersion = $derived(entry.draftVersions[currentLang]);
45
51
  </script>
46
52
 
47
53
  <div class="flex items-center gap-1 text-sm">
48
54
  <span class="text-muted-foreground"> {lang[interfaceLanguage.current].status}:</span>
49
55
 
50
- {#if entryStatus === 'published' && entry.publishedVersion}
56
+ {#if entryStatus === 'published' && publishedVersion}
51
57
  <span class="flex items-center gap-1">
52
58
  <SquareCheckFilled />
53
- {lang[interfaceLanguage.current].published} (v{entry.publishedVersion.versionNumber}, {entry.publishedAt?.toLocaleString(
59
+ {lang[interfaceLanguage.current].published} (v{publishedVersion.versionNumber}, {publishedVersion.publishedAt ? new Date(publishedVersion.publishedAt).toLocaleString(
54
60
  interfaceLanguage.current
55
- )})</span
61
+ ) : ''})</span
56
62
  >
57
63
  {/if}
58
64
 
59
- {#if entryStatus === 'scheduled' && entry.scheduledVersion}
65
+ {#if entryStatus === 'scheduled' && scheduledVersion}
60
66
  <span class="flex items-center gap-1">
61
67
  <ClockFilled />
62
- {lang[interfaceLanguage.current].scheduled} v{entry.scheduledVersion.versionNumber}
68
+ {lang[interfaceLanguage.current].scheduled} v{scheduledVersion.versionNumber}
63
69
  {lang[interfaceLanguage.current].on}
64
- {entry.publishedAt?.toLocaleString(interfaceLanguage.current)})</span
70
+ {scheduledVersion.publishedAt ? new Date(scheduledVersion.publishedAt).toLocaleString(interfaceLanguage.current) : ''})</span
65
71
  >
66
72
  {/if}
67
73
 
68
- {#if entryStatus === 'draft' && entry.draftVersion}
74
+ {#if entryStatus === 'draft' && draftVersion}
69
75
  <span class="flex items-center gap-1">
70
76
  <FileFilled />
71
- {lang[interfaceLanguage.current].draft} (v{entry.draftVersion.versionNumber})</span
77
+ {lang[interfaceLanguage.current].draft} (v{draftVersion.versionNumber})</span
72
78
  >
73
79
  {/if}
74
80
  </div>
@@ -1,3 +1,6 @@
1
1
  import type { DbEntryVersion, EntryStatus, EntryVersionStatus, RawEntry } from '../../../types/entries.js';
2
2
  export declare function getEntryVersionStatus(version: DbEntryVersion): EntryVersionStatus;
3
+ /** Get entry status for a specific language */
4
+ export declare function getEntryStatusForLang(entry: RawEntry, lang: string): EntryStatus;
5
+ /** Get overall entry status — published if any lang is published */
3
6
  export declare function getEntryStatus(entry: RawEntry): EntryStatus;