includio-cms 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +31 -12
  2. package/ROADMAP.md +12 -0
  3. package/dist/admin/client/admin/dashboard-page.svelte +18 -64
  4. package/dist/admin/client/collection/bulk-actions-bar.svelte +18 -1
  5. package/dist/admin/client/collection/bulk-actions-bar.svelte.d.ts +1 -0
  6. package/dist/admin/client/collection/collection-entries.svelte +25 -1
  7. package/dist/admin/client/collection/row-actions.svelte +13 -4
  8. package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -0
  9. package/dist/admin/client/entry/entry-header.svelte +51 -4
  10. package/dist/admin/client/entry/entry-header.svelte.d.ts +3 -0
  11. package/dist/admin/client/entry/entry.svelte +106 -6
  12. package/dist/admin/client/entry/header/a11y-validator.d.ts +3 -2
  13. package/dist/admin/client/entry/header/a11y-validator.js +50 -9
  14. package/dist/admin/client/entry/header/publish-panel.svelte +164 -4
  15. package/dist/admin/client/entry/header/version-history-sheet.svelte +9 -1
  16. package/dist/admin/components/dashboard/changelog-dialog.svelte +167 -0
  17. package/dist/admin/components/dashboard/changelog-dialog.svelte.d.ts +6 -0
  18. package/dist/admin/components/dashboard/orphaned-entries-notice.svelte +240 -0
  19. package/dist/admin/components/dashboard/orphaned-entries-notice.svelte.d.ts +13 -0
  20. package/dist/admin/components/fields/text-field-wrapper.svelte +134 -2
  21. package/dist/admin/components/layout/nav-footer.svelte +11 -4
  22. package/dist/admin/components/layout/nav-footer.svelte.d.ts +2 -17
  23. package/dist/admin/remote/entry.remote.d.ts +1 -0
  24. package/dist/admin/remote/entry.remote.js +5 -4
  25. package/dist/admin/state/content-language.svelte.d.ts +3 -0
  26. package/dist/admin/state/content-language.svelte.js +8 -0
  27. package/dist/admin/utils/translationStatus.d.ts +17 -0
  28. package/dist/admin/utils/translationStatus.js +134 -0
  29. package/dist/core/server/entries/operations/get.js +2 -1
  30. package/dist/db-postgres/index.js +10 -6
  31. package/dist/types/entries.d.ts +3 -0
  32. package/dist/updates/0.0.65/index.js +1 -1
  33. package/dist/updates/0.0.67/index.js +1 -1
  34. package/dist/updates/0.1.2/index.js +1 -1
  35. package/dist/updates/0.1.5/index.js +1 -1
  36. package/dist/updates/0.2.0/index.js +1 -1
  37. package/dist/updates/0.2.2/index.js +1 -1
  38. package/dist/updates/0.5.0/index.js +1 -1
  39. package/dist/updates/0.5.1/index.d.ts +2 -0
  40. package/dist/updates/0.5.1/index.js +17 -0
  41. package/dist/updates/0.5.2/index.d.ts +2 -0
  42. package/dist/updates/0.5.2/index.js +14 -0
  43. package/dist/updates/index.d.ts +2 -1
  44. package/dist/updates/index.js +3 -1
  45. package/package.json +1 -1
  46. package/dist/admin/components/dashboard/updates-banner.svelte +0 -170
  47. package/dist/admin/components/dashboard/updates-banner.svelte.d.ts +0 -3
  48. package/dist/updates/0.0.65/migration.sql +0 -55
  49. package/dist/updates/0.0.67/migration.sql +0 -9
@@ -9,6 +9,10 @@
9
9
  import { getLocalizedLabel } from '../../utils/collectionLabel.js';
10
10
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
11
11
  import type { InterfaceLanguage } from '../../../types/languages.js';
12
+ import AlertCircle from '@tabler/icons-svelte/icons/alert-circle';
13
+ import ArchiveIcon from '@tabler/icons-svelte/icons/archive';
14
+ import XIcon from '@tabler/icons-svelte/icons/x';
15
+ import Button from '../../../components/ui/button/button.svelte';
12
16
  import { ElementSize, useDebounce } from 'runed';
13
17
  import { defaults, superForm, type SuperForm } from 'sveltekit-superforms';
14
18
  import { zod4, zod4Client } from 'sveltekit-superforms/adapters';
@@ -23,6 +27,7 @@
23
27
  import { createHybridContext } from './hybrid/hybrid-context.svelte.js';
24
28
  import { onMount } from 'svelte';
25
29
  import { get } from 'svelte/store';
30
+ import { computeTranslationStatus } from '../../utils/translationStatus.js';
26
31
 
27
32
  const contentLanguage = getContentLanguage();
28
33
  const remotes = getRemotes();
@@ -75,12 +80,16 @@
75
80
  InterfaceLanguage,
76
81
  {
77
82
  entryArchived: string;
83
+ entryRestored: string;
84
+ archivedBanner: string;
85
+ restore: string;
78
86
  saveToast: string;
79
87
  publishToast: string;
80
88
  scheduledToast: string;
81
89
  unpublishToast: string;
82
90
  saveFailed: string;
83
91
  cannotPublish: string;
92
+ validationHint: string;
84
93
  newerDraft: string;
85
94
  switchToDraft: string;
86
95
  editingDraft: string;
@@ -89,12 +98,16 @@
89
98
  > = {
90
99
  en: {
91
100
  entryArchived: 'Entry archived successfully',
101
+ entryRestored: 'Entry restored successfully',
102
+ archivedBanner: 'This entry is archived. Restore it to edit.',
103
+ restore: 'Restore',
92
104
  saveToast: 'Entry saved successfully',
93
105
  publishToast: 'Entry published successfully',
94
106
  scheduledToast: 'Entry scheduled successfully',
95
107
  unpublishToast: 'Publication withdrawn',
96
108
  saveFailed: 'Save failed',
97
109
  cannotPublish: 'Cannot publish',
110
+ validationHint: 'Fix the highlighted fields below to publish',
98
111
  newerDraft: 'A newer unpublished draft exists',
99
112
  switchToDraft: 'Switch to draft',
100
113
  editingDraft: 'You are editing an unpublished draft',
@@ -102,12 +115,16 @@
102
115
  },
103
116
  pl: {
104
117
  entryArchived: 'Wpis został zarchiwizowany pomyślnie',
118
+ entryRestored: 'Wpis został przywrócony',
119
+ archivedBanner: 'Ten wpis jest zarchiwizowany. Przywróć go, żeby edytować.',
120
+ restore: 'Przywróć',
105
121
  saveToast: 'Wpis został pomyślnie zapisany',
106
122
  publishToast: 'Wpis został pomyślnie opublikowany',
107
123
  scheduledToast: 'Wpis został zaplanowany pomyślnie',
108
124
  unpublishToast: 'Wycofano publikację',
109
125
  saveFailed: 'Błąd zapisu',
110
126
  cannotPublish: 'Nie można opublikować',
127
+ validationHint: 'Popraw wyróżnione pola, żeby opublikować',
111
128
  newerDraft: 'Istnieje nowszy nieopublikowany szkic',
112
129
  switchToDraft: 'Przejdź do szkicu',
113
130
  editingDraft: 'Edytujesz nieopublikowany szkic',
@@ -122,6 +139,7 @@
122
139
 
123
140
  let { entry, editingEntry }: Props = $props();
124
141
  let { collection } = entry;
142
+ const isArchived = $derived(!!entry.archivedAt);
125
143
 
126
144
  // Create form once at component level
127
145
  const collectionSchema = generateZodSchemaFromFields(
@@ -135,17 +153,29 @@
135
153
  resetForm: false
136
154
  });
137
155
 
156
+ let validationErrors = $state<string[]>([]);
157
+
138
158
  // Autosave state
139
159
  type SaveStatus = 'idle' | 'saving' | 'saved' | 'unsaved' | 'error';
140
160
  let saveStatus = $state<SaveStatus>('idle');
141
- let lastSavedData = $state<string>(JSON.stringify(editingEntry.data));
161
+ let lastSavedData = $state<string>(JSON.stringify(get(form.form)));
142
162
  let autosaveTimer: ReturnType<typeof setTimeout> | null = null;
143
163
  // Tracks draft version created via autosave while editing a published version
144
164
  let savedDraftVersionId = $state<string | null>(null);
145
165
 
146
166
  const AUTOSAVE_DELAY = 30000; // 30 seconds
147
167
 
168
+ // Reactive translation status — must live here where we can subscribe to form.form store
169
+ const formStore = form.form;
170
+ const collectionFields = getFieldsFromConfig(collection);
171
+ const translationStatus = $derived.by(() => {
172
+ if (contentLanguage.all.length <= 1) return null;
173
+ const data = $formStore;
174
+ return computeTranslationStatus(data, collectionFields, contentLanguage.all);
175
+ });
176
+
148
177
  function scheduleAutosave() {
178
+ if (isArchived) return;
149
179
  if (autosaveTimer) {
150
180
  clearTimeout(autosaveTimer);
151
181
  }
@@ -155,6 +185,7 @@
155
185
  }
156
186
 
157
187
  async function performAutosave() {
188
+ if (isArchived) return;
158
189
  const currentFormData = get(form.form);
159
190
  const currentData = JSON.stringify(currentFormData);
160
191
  if (currentData === lastSavedData) {
@@ -196,6 +227,7 @@
196
227
  function handleKeydown(e: KeyboardEvent) {
197
228
  if ((e.ctrlKey || e.metaKey) && e.key === 's') {
198
229
  e.preventDefault();
230
+ if (isArchived) return;
199
231
  if (autosaveTimer) {
200
232
  clearTimeout(autosaveTimer);
201
233
  autosaveTimer = null;
@@ -218,6 +250,12 @@
218
250
  goto(`/admin/collections/${collection.slug}`);
219
251
  }
220
252
 
253
+ async function onRestore() {
254
+ await remotes.unarchiveEntryCommand(entry.id);
255
+ toast.success(lang[interfaceLanguage.current].entryRestored);
256
+ goto(window.location.pathname, { invalidateAll: true });
257
+ }
258
+
221
259
  async function onSave(type: UpdateEntryVersionCommandType, scheduledAt?: Date) {
222
260
  // Cancel pending autosave
223
261
  if (autosaveTimer) {
@@ -251,9 +289,10 @@
251
289
  }
252
290
 
253
291
  // Publish requires validation
254
- const validatedForm = await form.validateForm();
292
+ const validatedForm = await form.validateForm({ update: true });
255
293
 
256
294
  if (validatedForm.valid) {
295
+ validationErrors = [];
257
296
  saveStatus = 'saving';
258
297
  try {
259
298
  await remotes.updateEntryVersionCommand({
@@ -284,10 +323,15 @@
284
323
  }
285
324
  } else {
286
325
  const errors = flattenErrors(validatedForm.errors, getFieldsFromConfig(collection));
287
- toast.error(lang[interfaceLanguage.current].cannotPublish, {
288
- description: errors.slice(0, 3).join('\n') + (errors.length > 3 ? '\n...' : ''),
289
- duration: 5000
290
- });
326
+ validationErrors = errors;
327
+
328
+ // Scroll to first errored field
329
+ const firstErrorKey = Object.keys(validatedForm.errors).find(
330
+ (k) => k !== '_errors'
331
+ );
332
+ if (firstErrorKey) {
333
+ scrollToIssue(firstErrorKey);
334
+ }
291
335
  }
292
336
  }
293
337
 
@@ -484,6 +528,18 @@
484
528
  }
485
529
  }
486
530
  };
531
+
532
+ // Auto-dismiss validation banner when user starts editing
533
+ onMount(() => {
534
+ const unsub = form.tainted.subscribe((tainted) => {
535
+ if (tainted && validationErrors.length > 0) {
536
+ validationErrors = [];
537
+ }
538
+ });
539
+ return () => unsub();
540
+ });
541
+
542
+ const t = $derived(lang[interfaceLanguage.current]);
487
543
  </script>
488
544
 
489
545
  <EntryHeader
@@ -493,11 +549,53 @@
493
549
  onSaveDraft={performAutosave}
494
550
  {onArchive}
495
551
  {saveStatus}
552
+ {isArchived}
496
553
  fields={getFieldsFromConfig(collection)}
497
554
  getFormData={() => get(form.form)}
498
555
  onScrollToIssue={scrollToIssue}
556
+ {translationStatus}
499
557
  />
500
558
 
559
+ {#if validationErrors.length > 0}
560
+ <div
561
+ role="alert"
562
+ aria-live="assertive"
563
+ class="flex items-start gap-3 border-b border-[var(--error)]/20 bg-[#FDF0F0] px-6 py-3 text-sm text-[var(--error)]"
564
+ >
565
+ <AlertCircle class="mt-0.5 size-4 shrink-0" />
566
+ <div class="min-w-0 flex-1">
567
+ <p class="font-semibold">{t.cannotPublish}</p>
568
+ <p class="text-xs opacity-80">{t.validationHint}</p>
569
+ <ul class="mt-1 text-xs">
570
+ {#each validationErrors.slice(0, 5) as error}
571
+ <li>— {error}</li>
572
+ {/each}
573
+ </ul>
574
+ </div>
575
+ <button
576
+ type="button"
577
+ onclick={() => (validationErrors = [])}
578
+ class="shrink-0 rounded p-0.5 opacity-60 hover:opacity-100"
579
+ aria-label="Zamknij"
580
+ >
581
+ <XIcon class="size-4" />
582
+ </button>
583
+ </div>
584
+ {/if}
585
+
586
+ {#if isArchived}
587
+ <div
588
+ role="alert"
589
+ class="flex items-center justify-between gap-3 border-b border-[var(--warning)]/20 bg-[#FDF6EC] px-6 py-3 text-sm"
590
+ >
591
+ <div class="flex items-center gap-2 text-[var(--warning)]">
592
+ <ArchiveIcon class="size-4 shrink-0" />
593
+ <span>{t.archivedBanner}</span>
594
+ </div>
595
+ <Button size="sm" onclick={onRestore}>{t.restore}</Button>
596
+ </div>
597
+ {/if}
598
+
501
599
  {#if showDraftBanner}
502
600
  <div
503
601
  class="flex items-center justify-between border-b bg-[var(--lavender-lighter)] px-6 py-2 text-sm"
@@ -526,6 +624,7 @@
526
624
  </div>
527
625
  {/if}
528
626
 
627
+ <div class={isArchived ? 'pointer-events-none opacity-60' : ''}>
529
628
  {#if hybridContext.mode === 'hybrid' && collection.previewUrl}
530
629
  <div class="flex min-h-0 flex-1 overflow-hidden">
531
630
  {#await import('./hybrid/hybrid-layout.svelte')}
@@ -574,3 +673,4 @@
574
673
  </div>
575
674
  </div>
576
675
  {/if}
676
+ </div>
@@ -42,5 +42,6 @@ export interface A11yLang {
42
42
  }
43
43
  export declare const a11yLangPl: A11yLang;
44
44
  export declare const a11yLangEn: A11yLang;
45
- /** Run a11y validation on entry data and return issues list. */
46
- export declare function validateA11y(data: Record<string, unknown>, fields: Field[], lang?: A11yLang): A11yIssue[];
45
+ /** Run a11y validation on entry data and return issues list.
46
+ * When `languages` is provided, validates each language separately with prefix tags. */
47
+ export declare function validateA11y(data: Record<string, unknown>, fields: Field[], lang?: A11yLang, languages?: string[]): A11yIssue[];
@@ -208,6 +208,32 @@ function extractDocs(data, fields) {
208
208
  }
209
209
  return result;
210
210
  }
211
+ /** Extract docs for a specific language from localized fields. */
212
+ function extractDocsForLang(data, fields, language) {
213
+ const result = [];
214
+ for (const field of fields) {
215
+ if (field.type !== 'content' && field.type !== 'richtext')
216
+ continue;
217
+ const val = data[field.slug];
218
+ if (!val || typeof val !== 'object')
219
+ continue;
220
+ const obj = val;
221
+ // Direct doc (non-localized)
222
+ if (obj.type === 'doc' && Array.isArray(obj.content)) {
223
+ result.push({ doc: obj, fieldSlug: field.slug });
224
+ continue;
225
+ }
226
+ // Language-keyed
227
+ const langVal = obj[language];
228
+ if (langVal && typeof langVal === 'object') {
229
+ const langObj = langVal;
230
+ if (langObj.type === 'doc' && Array.isArray(langObj.content)) {
231
+ result.push({ doc: langObj, fieldSlug: field.slug });
232
+ }
233
+ }
234
+ }
235
+ return result;
236
+ }
211
237
  export const a11yLangPl = {
212
238
  imagesWithoutAlt: (n) => `${n} ${n === 1 ? 'obraz' : n < 5 ? 'obrazy' : 'obrazów'} bez opisu alternatywnego`,
213
239
  headingsOk: 'Hierarchia nagłówków poprawna',
@@ -224,10 +250,10 @@ export const a11yLangEn = {
224
250
  allImagesHaveAlt: 'All images have alt text',
225
251
  noGenericLinks: 'Links have descriptive text'
226
252
  };
227
- /** Run a11y validation on entry data and return issues list. */
228
- export function validateA11y(data, fields, lang = a11yLangPl) {
229
- const entries = extractDocs(data, fields);
253
+ /** Validate a11y for a single set of docs and return issues. */
254
+ function validateDocsA11y(entries, lang, langPrefix) {
230
255
  const issues = [];
256
+ const prefix = langPrefix ? `[${langPrefix.toUpperCase()}] ` : '';
231
257
  // Images without alt
232
258
  let totalMissingAlt = 0;
233
259
  let firstMissingAltPos = null;
@@ -245,13 +271,13 @@ export function validateA11y(data, fields, lang = a11yLangPl) {
245
271
  if (totalMissingAlt > 0) {
246
272
  issues.push({
247
273
  type: 'warning',
248
- message: lang.imagesWithoutAlt(totalMissingAlt),
274
+ message: prefix + lang.imagesWithoutAlt(totalMissingAlt),
249
275
  fieldSlug: firstMissingAltField,
250
276
  firstNodePos: firstMissingAltPos ?? undefined
251
277
  });
252
278
  }
253
279
  else if (entries.length > 0) {
254
- issues.push({ type: 'success', message: lang.allImagesHaveAlt });
280
+ issues.push({ type: 'success', message: prefix + lang.allImagesHaveAlt });
255
281
  }
256
282
  // Heading hierarchy
257
283
  if (entries.length > 0) {
@@ -271,12 +297,12 @@ export function validateA11y(data, fields, lang = a11yLangPl) {
271
297
  }
272
298
  }
273
299
  if (allHeadingsOk) {
274
- issues.push({ type: 'success', message: lang.headingsOk });
300
+ issues.push({ type: 'success', message: prefix + lang.headingsOk });
275
301
  }
276
302
  else {
277
303
  issues.push({
278
304
  type: 'warning',
279
- message: lang.headingsSkipped,
305
+ message: prefix + lang.headingsSkipped,
280
306
  fieldSlug: firstHeadingIssueField,
281
307
  firstNodePos: firstHeadingIssuePos ?? undefined
282
308
  });
@@ -299,13 +325,28 @@ export function validateA11y(data, fields, lang = a11yLangPl) {
299
325
  if (totalGenericLinks > 0) {
300
326
  issues.push({
301
327
  type: 'warning',
302
- message: lang.genericLinks(totalGenericLinks),
328
+ message: prefix + lang.genericLinks(totalGenericLinks),
303
329
  fieldSlug: firstGenericLinkField,
304
330
  firstNodePos: firstGenericLinkPos ?? undefined
305
331
  });
306
332
  }
307
333
  else if (entries.length > 0) {
308
- issues.push({ type: 'success', message: lang.noGenericLinks });
334
+ issues.push({ type: 'success', message: prefix + lang.noGenericLinks });
309
335
  }
310
336
  return issues;
311
337
  }
338
+ /** Run a11y validation on entry data and return issues list.
339
+ * When `languages` is provided, validates each language separately with prefix tags. */
340
+ export function validateA11y(data, fields, lang = a11yLangPl, languages) {
341
+ if (languages && languages.length > 1) {
342
+ const issues = [];
343
+ for (const language of languages) {
344
+ const entries = extractDocsForLang(data, fields, language);
345
+ issues.push(...validateDocsA11y(entries, lang, language));
346
+ }
347
+ return issues;
348
+ }
349
+ // Single-language fallback (backward compatible)
350
+ const entries = extractDocs(data, fields);
351
+ return validateDocsA11y(entries, lang);
352
+ }
@@ -18,6 +18,9 @@
18
18
  import CircleCheck from '@tabler/icons-svelte/icons/circle-check';
19
19
  import { getEntryStatus } from '../utils.js';
20
20
  import { validateA11y, a11yLangPl, a11yLangEn, type A11yIssue } from './a11y-validator.js';
21
+ import { getContentLanguage } from '../../../state/content-language.svelte.js';
22
+ import { computeTranslationStatus, type LangStatus } from '../../../utils/translationStatus.js';
23
+ import LanguageIcon from '@tabler/icons-svelte/icons/language';
21
24
 
22
25
  const lang: Record<
23
26
  InterfaceLanguage,
@@ -42,6 +45,9 @@
42
45
  publishedVersion: string;
43
46
  accessibility: string;
44
47
  a11yHint: string;
48
+ translations: string;
49
+ translationsHint: string;
50
+ missingFieldsLabel: (lang: string) => string;
45
51
  }
46
52
  > = {
47
53
  en: {
@@ -69,7 +75,10 @@
69
75
  time: 'Time',
70
76
  publishedVersion: 'Published version',
71
77
  accessibility: 'Accessibility',
72
- a11yHint: 'Resolve issues before publishing'
78
+ a11yHint: 'Resolve issues before publishing',
79
+ translations: 'Translations',
80
+ translationsHint: 'Fill in missing fields to publish',
81
+ missingFieldsLabel: (lang: string) => `Missing fields (${lang.toUpperCase()})`
73
82
  },
74
83
  pl: {
75
84
  title: 'Publikacja',
@@ -96,11 +105,15 @@
96
105
  time: 'Czas',
97
106
  publishedVersion: 'Opublikowana wersja',
98
107
  accessibility: 'Dostępność',
99
- a11yHint: 'Rozwiąż problemy przed publikacją'
108
+ a11yHint: 'Rozwiąż problemy przed publikacją',
109
+ translations: 'Tłumaczenia',
110
+ translationsHint: 'Uzupełnij brakujące pola, żeby opublikować',
111
+ missingFieldsLabel: (lang: string) => `Brakujące pola (${lang.toUpperCase()})`
100
112
  }
101
113
  };
102
114
 
103
115
  const interfaceLanguage = useInterfaceLanguage();
116
+ const contentLanguage = getContentLanguage();
104
117
 
105
118
  type Props = {
106
119
  entry: RawEntry;
@@ -118,11 +131,19 @@
118
131
  let dateValue = $state('');
119
132
  let timeValue = $state('');
120
133
  let a11yIssues = $state<A11yIssue[]>([]);
134
+ let translationStatus = $state<Record<string, LangStatus> | null>(null);
121
135
 
122
136
  const entryStatus = $derived(getEntryStatus(entry));
123
137
  const t = $derived(lang[interfaceLanguage.current]);
124
138
 
125
139
  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
+ );
126
147
 
127
148
  const scheduleLabel = $derived.by(() => {
128
149
  switch (entryStatus) {
@@ -205,13 +226,23 @@
205
226
  }
206
227
  const data = getFormData ? getFormData() : version.data;
207
228
  const a11yLang = interfaceLanguage.current === 'pl' ? a11yLangPl : a11yLangEn;
208
- a11yIssues = validateA11y(data, fields, a11yLang);
229
+ a11yIssues = validateA11y(data, fields, a11yLang, contentLanguage.all);
230
+ }
231
+
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);
209
239
  }
210
240
 
211
241
  $effect(() => {
212
242
  if (open) {
213
243
  setDefaultValues();
214
244
  runA11yValidation();
245
+ runTranslationCheck();
215
246
  }
216
247
  });
217
248
  </script>
@@ -275,7 +306,74 @@
275
306
  {/if}
276
307
  </div>
277
308
 
278
- <!-- Schedule section -->
309
+ <!-- Translation section -->
310
+ {#if showTranslationSection && translationStatus}
311
+ <div class="sheet-section">
312
+ <div class="sheet-section-title">
313
+ <LanguageIcon class="size-3.5" />
314
+ {t.translations}
315
+ </div>
316
+
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>
333
+ </div>
334
+ {/if}
335
+ {/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
+ </div>
374
+ {/if}
375
+
376
+ <!-- Schedule section -->
279
377
  <div class="sheet-section">
280
378
  <div class="sheet-section-title">
281
379
  <CalendarClock class="size-3.5" />
@@ -386,6 +484,9 @@
386
484
  <style>
387
485
  .sheet-body {
388
486
  padding: 18px 20px;
487
+ overflow-y: auto;
488
+ flex: 1;
489
+ min-height: 0;
389
490
  }
390
491
 
391
492
  .sheet-section {
@@ -592,4 +693,63 @@
592
693
  color: var(--foreground);
593
694
  background: var(--muted);
594
695
  }
696
+
697
+ /* Translation section */
698
+ .translation-lang-row {
699
+ margin-bottom: 8px;
700
+ }
701
+ .translation-lang-header {
702
+ display: flex;
703
+ align-items: center;
704
+ justify-content: space-between;
705
+ margin-bottom: 4px;
706
+ }
707
+ .translation-lang-label {
708
+ font-size: 13px;
709
+ font-weight: 600;
710
+ color: var(--foreground);
711
+ }
712
+ .translation-lang-pct {
713
+ font-size: 12px;
714
+ font-weight: 600;
715
+ }
716
+ .translation-lang-pct.complete {
717
+ color: var(--success);
718
+ }
719
+ .translation-lang-pct.partial {
720
+ color: var(--warning);
721
+ }
722
+ .translation-lang-pct.empty {
723
+ color: var(--text-light);
724
+ }
725
+ .translation-progress-track {
726
+ height: 6px;
727
+ border-radius: 3px;
728
+ background: var(--muted);
729
+ overflow: hidden;
730
+ }
731
+ .translation-progress-fill {
732
+ height: 100%;
733
+ border-radius: 3px;
734
+ background: var(--text-light);
735
+ transition: width 0.3s ease;
736
+ }
737
+ .translation-progress-fill.complete {
738
+ background: var(--success);
739
+ }
740
+ .translation-progress-fill.partial {
741
+ background: var(--warning);
742
+ }
743
+ .translation-missing {
744
+ margin-top: 10px;
745
+ display: flex;
746
+ flex-direction: column;
747
+ gap: 4px;
748
+ }
749
+ .translation-missing-title {
750
+ font-size: 12px;
751
+ font-weight: 600;
752
+ color: var(--muted-foreground);
753
+ margin-bottom: 2px;
754
+ }
595
755
  </style>
@@ -9,6 +9,8 @@
9
9
  import HistoryIcon from '@tabler/icons-svelte/icons/history';
10
10
  import SquareCheckFilled from '@tabler/icons-svelte/icons/square-check-filled';
11
11
  import ClockFilled from '@tabler/icons-svelte/icons/clock-filled';
12
+ import { getLocalizedLabel } from '../../../utils/collectionLabel.js';
13
+ import { getFieldsFromConfig } from '../../../../core/fields/layoutUtils.js';
12
14
  import { getEntryStatus } from '../utils.js';
13
15
 
14
16
  const lang: Record<
@@ -157,6 +159,12 @@
157
159
  }
158
160
 
159
161
  const t = $derived(lang[interfaceLanguage.current]);
162
+
163
+ function getFieldLabel(slug: string): string {
164
+ const field = getFieldsFromConfig(entry.collection).find((f) => f.slug === slug);
165
+ if (!field) return slug.charAt(0).toUpperCase() + slug.slice(1).replace(/[-_]/g, ' ');
166
+ return getLocalizedLabel(field.label, interfaceLanguage.current) || field.slug;
167
+ }
160
168
  </script>
161
169
 
162
170
  <Sheet.Root bind:open>
@@ -264,7 +272,7 @@
264
272
  {#if changedFields.length > 0}
265
273
  <div class="vh-changed-fields">
266
274
  {#each changedFields.slice(0, 4) as field}
267
- <span class="vh-field-badge">{field}</span>
275
+ <span class="vh-field-badge">{getFieldLabel(field)}</span>
268
276
  {/each}
269
277
  {#if changedFields.length > 4}
270
278
  <span class="vh-field-badge">+{changedFields.length - 4}</span>