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.
- package/CHANGELOG.md +31 -12
- package/ROADMAP.md +12 -0
- package/dist/admin/client/admin/dashboard-page.svelte +18 -64
- package/dist/admin/client/collection/bulk-actions-bar.svelte +18 -1
- package/dist/admin/client/collection/bulk-actions-bar.svelte.d.ts +1 -0
- package/dist/admin/client/collection/collection-entries.svelte +25 -1
- package/dist/admin/client/collection/row-actions.svelte +13 -4
- package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -0
- package/dist/admin/client/entry/entry-header.svelte +51 -4
- package/dist/admin/client/entry/entry-header.svelte.d.ts +3 -0
- package/dist/admin/client/entry/entry.svelte +106 -6
- package/dist/admin/client/entry/header/a11y-validator.d.ts +3 -2
- package/dist/admin/client/entry/header/a11y-validator.js +50 -9
- package/dist/admin/client/entry/header/publish-panel.svelte +164 -4
- package/dist/admin/client/entry/header/version-history-sheet.svelte +9 -1
- package/dist/admin/components/dashboard/changelog-dialog.svelte +167 -0
- package/dist/admin/components/dashboard/changelog-dialog.svelte.d.ts +6 -0
- package/dist/admin/components/dashboard/orphaned-entries-notice.svelte +240 -0
- package/dist/admin/components/dashboard/orphaned-entries-notice.svelte.d.ts +13 -0
- package/dist/admin/components/fields/text-field-wrapper.svelte +134 -2
- package/dist/admin/components/layout/nav-footer.svelte +11 -4
- package/dist/admin/components/layout/nav-footer.svelte.d.ts +2 -17
- package/dist/admin/remote/entry.remote.d.ts +1 -0
- package/dist/admin/remote/entry.remote.js +5 -4
- package/dist/admin/state/content-language.svelte.d.ts +3 -0
- package/dist/admin/state/content-language.svelte.js +8 -0
- package/dist/admin/utils/translationStatus.d.ts +17 -0
- package/dist/admin/utils/translationStatus.js +134 -0
- package/dist/core/server/entries/operations/get.js +2 -1
- package/dist/db-postgres/index.js +10 -6
- package/dist/types/entries.d.ts +3 -0
- package/dist/updates/0.0.65/index.js +1 -1
- package/dist/updates/0.0.67/index.js +1 -1
- package/dist/updates/0.1.2/index.js +1 -1
- package/dist/updates/0.1.5/index.js +1 -1
- package/dist/updates/0.2.0/index.js +1 -1
- package/dist/updates/0.2.2/index.js +1 -1
- package/dist/updates/0.5.0/index.js +1 -1
- package/dist/updates/0.5.1/index.d.ts +2 -0
- package/dist/updates/0.5.1/index.js +17 -0
- package/dist/updates/0.5.2/index.d.ts +2 -0
- package/dist/updates/0.5.2/index.js +14 -0
- package/dist/updates/index.d.ts +2 -1
- package/dist/updates/index.js +3 -1
- package/package.json +1 -1
- package/dist/admin/components/dashboard/updates-banner.svelte +0 -170
- package/dist/admin/components/dashboard/updates-banner.svelte.d.ts +0 -3
- package/dist/updates/0.0.65/migration.sql +0 -55
- 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(
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
228
|
-
|
|
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
|
-
<!--
|
|
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'}✓{: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>
|