includio-cms 0.36.6 → 0.36.8

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 (68) hide show
  1. package/API.md +8 -2
  2. package/CHANGELOG.md +30 -0
  3. package/DOCS.md +1 -1
  4. package/dist/admin/api/handler.js +2 -0
  5. package/dist/admin/api/media-jobs.d.ts +5 -0
  6. package/dist/admin/api/media-jobs.js +33 -0
  7. package/dist/admin/client/maintenance/maintenance-page.svelte +9 -0
  8. package/dist/admin/client/maintenance/media-jobs-panel.svelte +310 -0
  9. package/dist/admin/client/maintenance/media-jobs-panel.svelte.d.ts +3 -0
  10. package/dist/core/cms.js +7 -0
  11. package/dist/core/server/fields/utils/imageStyles.js +41 -4
  12. package/dist/core/server/index.d.ts +3 -0
  13. package/dist/core/server/index.js +3 -0
  14. package/dist/core/server/media/jobs/enqueue.d.ts +6 -0
  15. package/dist/core/server/media/jobs/enqueue.js +20 -0
  16. package/dist/core/server/media/jobs/processImageStyleJob.d.ts +19 -0
  17. package/dist/core/server/media/jobs/processImageStyleJob.js +23 -0
  18. package/dist/core/server/media/jobs/startWorker.d.ts +2 -0
  19. package/dist/core/server/media/jobs/startWorker.js +29 -0
  20. package/dist/core/server/media/jobs/worker.d.ts +29 -0
  21. package/dist/core/server/media/jobs/worker.js +54 -0
  22. package/dist/core/server/media/operations/replaceFile.js +2 -2
  23. package/dist/core/server/media/operations/uploadFile.js +10 -3
  24. package/dist/core/server/media/styles/buildStyleSpecs.d.ts +12 -0
  25. package/dist/core/server/media/styles/buildStyleSpecs.js +36 -0
  26. package/dist/core/server/media/styles/imagesConfig.d.ts +3 -0
  27. package/dist/core/server/media/styles/imagesConfig.js +16 -0
  28. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +19 -41
  29. package/dist/core/server/media/styles/operations/generateFallbackVariant.d.ts +10 -0
  30. package/dist/core/server/media/styles/operations/generateFallbackVariant.js +21 -0
  31. package/dist/core/server/media/styles/operations/getImageStyle.js +12 -8
  32. package/dist/core/server/media/styles/sharp/sharpConfig.d.ts +7 -0
  33. package/dist/core/server/media/styles/sharp/sharpConfig.js +12 -0
  34. package/dist/db-postgres/index.js +113 -1
  35. package/dist/db-postgres/schema/imageStyle.js +1 -1
  36. package/dist/db-postgres/schema/index.d.ts +1 -0
  37. package/dist/db-postgres/schema/index.js +1 -0
  38. package/dist/db-postgres/schema/mediaFile.d.ts +17 -0
  39. package/dist/db-postgres/schema/mediaFile.js +1 -0
  40. package/dist/db-postgres/schema/mediaJob.d.ts +279 -0
  41. package/dist/db-postgres/schema/mediaJob.js +23 -0
  42. package/dist/paraglide/messages/_index.d.ts +36 -3
  43. package/dist/paraglide/messages/_index.js +71 -3
  44. package/dist/paraglide/messages/en.d.ts +5 -0
  45. package/dist/paraglide/messages/en.js +14 -0
  46. package/dist/paraglide/messages/pl.d.ts +5 -0
  47. package/dist/paraglide/messages/pl.js +14 -0
  48. package/dist/shop/adapters/fakturownia/payload.d.ts +2 -0
  49. package/dist/shop/adapters/fakturownia/payload.js +1 -0
  50. package/dist/shop/server/invoices.d.ts +16 -2
  51. package/dist/shop/server/invoices.js +86 -9
  52. package/dist/shop/types.d.ts +5 -0
  53. package/dist/sveltekit/components/image.svelte +1 -1
  54. package/dist/types/adapters/db.d.ts +51 -0
  55. package/dist/types/cms.d.ts +46 -0
  56. package/dist/types/media.d.ts +1 -0
  57. package/dist/updates/0.36.7/index.d.ts +2 -0
  58. package/dist/updates/0.36.7/index.js +13 -0
  59. package/dist/updates/0.36.8/index.d.ts +2 -0
  60. package/dist/updates/0.36.8/index.js +20 -0
  61. package/dist/updates/index.js +5 -1
  62. package/package.json +1 -1
  63. package/dist/paraglide/messages/hello_world.d.ts +0 -5
  64. package/dist/paraglide/messages/hello_world.js +0 -33
  65. package/dist/paraglide/messages/login_hello.d.ts +0 -16
  66. package/dist/paraglide/messages/login_hello.js +0 -34
  67. package/dist/paraglide/messages/login_please_login.d.ts +0 -16
  68. package/dist/paraglide/messages/login_please_login.js +0 -34
package/API.md CHANGED
@@ -1,8 +1,8 @@
1
- # includio-cms — Public API v0.36.6
1
+ # includio-cms — Public API v0.36.8
2
2
 
3
3
  > Auto-generated by `scripts/generate-api-md.ts`. Do not edit by hand.
4
4
 
5
- Entry points: **19** · Stable: **495** · Experimental: **4**
5
+ Entry points: **19** · Stable: **501** · Experimental: **4**
6
6
 
7
7
  Tags:
8
8
  - `@public` — frozen for v1.0; semver-protected.
@@ -28,10 +28,15 @@ Tags:
28
28
  ### `includio-cms/core/server`
29
29
 
30
30
  - `createEntityAPI(cms: CMS, opts?: EntityAPIOptions): <inferred>` — Creates a high-level Entity API (CRUD + publish/archive) bound to a CMS
31
+ - `enqueueStyleSpecs(mediaFile: Pick<MediaFile, 'id' | 'width' | 'height'>, fieldStyles: ImageFieldStyle[] | undefined, opts: { priority?:...): Promise<void>` — Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey.
31
32
  - `getAuth(): <inferred>` — Returns the underlying `better-auth` instance from the initialized CMS.
32
33
  - `getCMS(): CMS` — Returns the singleton CMS instance. Must be called after `includioCMS()`
34
+ - `interface MediaJob`
35
+ - `type MediaJobStatus = 'pending' | 'processing' | 'done' | 'failed' | 'skipped'`
33
36
  - `interface ResolvedMedia` — Resolved media file plus its image styles + blur placeholder. Returned by
34
37
  - `resolveMediaWithStyles(mediaIds: string[], styles?: ImageFieldStyle[]): Promise<Record<string, ResolvedMedia>>` — Resolve media files by IDs and generate image styles. Useful for plugins or
38
+ - `startMediaWorker(cfg: WorkerConfig, deps: RunOnceDeps): void` — Startuje ciągłą pętlę workera (idempotentne). Concurrency=1: jeden job naraz.
39
+ - `stopMediaWorker(): void`
35
40
 
36
41
  ### `includio-cms/types`
37
42
 
@@ -356,6 +361,7 @@ Tags:
356
361
  - `const invitation: <inferred>`
357
362
  - `const mediaFilesTable: <inferred>`
358
363
  - `const mediaFileTagsTable: <inferred>`
364
+ - `const mediaJobTable: <inferred>`
359
365
  - `const mediaTagsTable: <inferred>`
360
366
  - `pg(config: Config): DatabaseAdapterWithDrizzle` — Postgres database adapter (drizzle + `postgres`).
361
367
  - `const session: <inferred>`
package/CHANGELOG.md CHANGED
@@ -3,6 +3,36 @@
3
3
  All notable changes to includio-cms are documented here.
4
4
  Generated from `src/lib/updates/` — do not edit manually.
5
5
 
6
+ ## 0.36.8 — 2026-07-02
7
+
8
+ Optymalizacja obrazów przeniesiona do tła: render nigdy nie odpala sharpa. Warianty generuje in-process worker zasilany trwałą kolejką media_job (concurrency=1, twarde limity sharp, retry/backoff, circuit-breaker). Lżejsza, konfigurowalna matryca (avif+webp, bez pełno-res bazy, cap 2560) tnie ~połowę plików/jobów i eliminuje bombę CPU pełno-res AVIF, która OOM-owała VPS.
9
+
10
+ ### Added
11
+ - Kolejka `media_job` + in-process worker: claim `FOR UPDATE SKIP LOCKED`, backoff wykładniczy, circuit-breaker po N próbach, poison-guard `limitInputPixels`, reset stale-locków. Start w `initCMS`, wyłączalny `media.optimization.worker=false`.
12
+ - Render kolejkuje brakujące style zamiast generować synchronicznie — odpowiedź HTTP nigdy nie czeka na sharpa; frontend serwuje lekki fallback + blur do czasu gotowości wariantów.
13
+ - Konfigurowalna matryca `mediaConfig.images`: `formats` (domyślnie avif+webp), `widths` [640,1024,1536,2560], `quality`, `maxDimension` (2560), `fallback` (jpeg ~1280). Bez wariantu bazowego pełno-res i bez martwej drabiny w formacie oryginalnym (~15 → ~8 wariantów/obraz).
14
+ - Upload generuje synchronicznie lekki fallback (`media_file.fallback_url`) i kolejkuje warianty; sweep oraz `replaceFile` też kolejkują (nie generują inline).
15
+ - Admin `/admin/maintenance`: sekcja „Kolejka mediów / Logi" — liczniki pending/processing/failed, lista failed z akcjami Ponów/Pomiń.
16
+ - Globalne limity sharp (`sharp.concurrency`/`cache`) ustawiane raz przy init. Deduplikacja zadań po efektywnej specyfikacji (format+wymiary+crop+quality).
17
+ - Public API: `enqueueStyleSpecs`, `startMediaWorker`, `stopMediaWorker`, typy `MediaJob`/`MediaJobStatus`.
18
+
19
+ ### Fixed
20
+ - `image_styles`: dodany `crop` do klucza unikalnego (koniec cichego nadpisywania wariantów różniących się tylko cropem) + spójny `ON CONFLICT`.
21
+
22
+ ### Breaking
23
+ - Nowa tabela `media_job` i kolumna `media_file.fallback_url` — wymagana migracja (`drizzle-kit push`). Render nie generuje styli synchronicznie (dogeneruje worker w tle). Konsumenci, którzy wyłączyli auto-maintenance z powodu OOM, mogą usunąć `media.maintenance.autoRun=false` — nowy worker jest bezpieczny by-design.
24
+
25
+ ## 0.36.7 — 2026-06-24
26
+
27
+ Faktura uwzględnia rabat z kuponu i koszt wysyłki. Pozycje są wystawiane w cenach po rabacie (finalna kwota = kwota zapłacona), a użyty kod rabatowy trafia do uwag faktury. Wcześniej faktura zawierała pełne ceny katalogowe i pomijała rabat.
28
+
29
+ ### Added
30
+ - Rabat z kuponu na fakturze: ceny pozycji obniżone proporcjonalnie do udziału brutto (reszta zaokrąglenia do ostatniej pozycji), suma = `order.totalGross`. Kod rabatowy dopisywany do uwag faktury (Fakturownia: pole `description`), zlokalizowany PL/EN wg języka zamówienia.
31
+ - Koszt wysyłki dodawany jako pozycja „Wysyłka" gdy > 0 (VAT rekonstruowany z net/gross, fallback 23%).
32
+
33
+ ### Fixed
34
+ - Faktury z kuponem nie pomijają już rabatu — wartość faktury zgadza się z kwotą zapłaconą przez klienta.
35
+
6
36
  ## 0.36.5 — 2026-06-11
7
37
 
8
38
  Zależności tiptap przypięte do exact `3.26.1` (wcześniej `^3.17.1`). Rozjazd zakresów powodował, że konsument mógł rozwiązać rdzeń `@tiptap/core` na inną wersję niż rozszerzenia (np. core 3.22.3 vs extension-highlight 3.26.1), co wywalało produkcyjny build („getStyleProperty is not exported by @tiptap/core"). Bez potrzeby override po stronie projektu.
package/DOCS.md CHANGED
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.36.6)
1
+ # Includio CMS Documentation (v0.36.8)
2
2
 
3
3
  > This file is auto-generated from the docs site. For the latest version, update the package.
4
4
 
@@ -11,6 +11,7 @@ import * as transcodeVideosHandlers from './transcode-videos.js';
11
11
  import * as uploadLimitHandlers from './upload-limit.js';
12
12
  import * as systemInfoHandlers from './system-info.js';
13
13
  import * as maintenanceStatusHandlers from './maintenance-status.js';
14
+ import * as mediaJobsHandlers from './media-jobs.js';
14
15
  import * as integrationsHandlers from './integrations.js';
15
16
  import * as testEmailHandlers from './test-email.js';
16
17
  import { requireAuth } from '../remote/middleware/auth.js';
@@ -30,6 +31,7 @@ export function createAdminApiHandler(options) {
30
31
  'upload-limit': uploadLimitHandlers,
31
32
  'system-info': systemInfoHandlers,
32
33
  'maintenance-status': maintenanceStatusHandlers,
34
+ 'media-jobs': mediaJobsHandlers,
33
35
  integrations: integrationsHandlers,
34
36
  'test-email': testEmailHandlers,
35
37
  ...options?.extraRoutes
@@ -0,0 +1,5 @@
1
+ import { type RequestHandler } from '@sveltejs/kit';
2
+ /** GET /admin/api/media-jobs?status=&type=&limit=&offset= → { counts, jobs } */
3
+ export declare const GET: RequestHandler;
4
+ /** POST /admin/api/media-jobs { id, action: 'retry' | 'skip' } */
5
+ export declare const POST: RequestHandler;
@@ -0,0 +1,33 @@
1
+ import { requireRole } from '../remote/middleware/auth.js';
2
+ import { getCMS } from '../../core/cms.js';
3
+ import { json } from '@sveltejs/kit';
4
+ /** GET /admin/api/media-jobs?status=&type=&limit=&offset= → { counts, jobs } */
5
+ export const GET = async ({ url }) => {
6
+ requireRole('admin');
7
+ const status = url.searchParams.get('status') ?? undefined;
8
+ const type = url.searchParams.get('type') ?? undefined;
9
+ const limit = Number(url.searchParams.get('limit') ?? 50);
10
+ const offset = Number(url.searchParams.get('offset') ?? 0);
11
+ const db = getCMS().databaseAdapter;
12
+ const [counts, jobs] = await Promise.all([
13
+ db.countMediaJobs(),
14
+ db.listMediaJobs({ status, type, limit, offset })
15
+ ]);
16
+ return json({ counts, jobs });
17
+ };
18
+ /** POST /admin/api/media-jobs { id, action: 'retry' | 'skip' } */
19
+ export const POST = async ({ request }) => {
20
+ requireRole('admin');
21
+ const body = (await request.json());
22
+ if (!body.id || (body.action !== 'retry' && body.action !== 'skip')) {
23
+ return json({ error: 'Invalid request' }, { status: 400 });
24
+ }
25
+ const db = getCMS().databaseAdapter;
26
+ if (body.action === 'retry') {
27
+ await db.retryMediaJob(body.id);
28
+ }
29
+ else {
30
+ await db.failMediaJob(body.id, 'skipped by admin', { retry: false });
31
+ }
32
+ return json({ ok: true });
33
+ };
@@ -26,6 +26,7 @@
26
26
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
27
27
  import { sidebarLang } from '../../components/layout/lang.js';
28
28
  import PageHeader from '../../components/layout/page-header.svelte';
29
+ import MediaJobsPanel from './media-jobs-panel.svelte';
29
30
 
30
31
  const breadcrumbs = getBreadcrumbs();
31
32
  const interfaceLanguage = useInterfaceLanguage();
@@ -695,6 +696,14 @@
695
696
  </div>
696
697
  </div>
697
698
 
699
+ <!-- Section: Media jobs / Logs -->
700
+ <div class="mb-8">
701
+ <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
702
+ Kolejka mediów / Logi
703
+ </h2>
704
+ <MediaJobsPanel />
705
+ </div>
706
+
698
707
  <!-- Section: File Integrity -->
699
708
  <div class="mb-8">
700
709
  <h2 class="mb-3 text-sm font-semibold uppercase tracking-wider" style="color: var(--muted-foreground);">
@@ -0,0 +1,310 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import Loader2 from '@tabler/icons-svelte/icons/loader-2';
4
+ import Refresh from '@tabler/icons-svelte/icons/refresh';
5
+ import PlayerSkipForward from '@tabler/icons-svelte/icons/player-skip-forward';
6
+ import ListDetails from '@tabler/icons-svelte/icons/list-details';
7
+ import AlertTriangle from '@tabler/icons-svelte/icons/alert-triangle';
8
+ import Button from '../../../components/ui/button/button.svelte';
9
+ import * as Card from '../../../components/ui/card/index.js';
10
+ import { toast } from 'svelte-sonner';
11
+
12
+ type JobStatus = 'pending' | 'processing' | 'done' | 'failed' | 'skipped';
13
+ type StatusFilter = 'all' | 'failed' | 'pending' | 'processing';
14
+
15
+ interface JobCounts {
16
+ pending: number;
17
+ processing: number;
18
+ done: number;
19
+ failed: number;
20
+ skipped: number;
21
+ }
22
+
23
+ interface MediaJob {
24
+ id: string;
25
+ type: string;
26
+ mediaFileId: string;
27
+ payload: string;
28
+ status: JobStatus;
29
+ priority: number;
30
+ attempts: number;
31
+ maxAttempts: number;
32
+ lastError: string | null;
33
+ runAfter: string;
34
+ updatedAt: string;
35
+ completedAt: string | null;
36
+ }
37
+
38
+ interface MediaJobsResponse {
39
+ counts: JobCounts;
40
+ jobs: MediaJob[];
41
+ }
42
+
43
+ const filters: { value: StatusFilter; label: string }[] = [
44
+ { value: 'all', label: 'Wszystkie' },
45
+ { value: 'failed', label: 'Błędy' },
46
+ { value: 'pending', label: 'Oczekujące' },
47
+ { value: 'processing', label: 'W trakcie' }
48
+ ];
49
+
50
+ const statusLabels: Record<JobStatus, string> = {
51
+ pending: 'Oczekuje',
52
+ processing: 'W trakcie',
53
+ done: 'Gotowe',
54
+ failed: 'Błąd',
55
+ skipped: 'Pominięte'
56
+ };
57
+
58
+ function statusColor(status: JobStatus): string {
59
+ switch (status) {
60
+ case 'done':
61
+ return 'var(--success, #3A8A5C)';
62
+ case 'failed':
63
+ return 'var(--error, #C44B4B)';
64
+ case 'processing':
65
+ return 'var(--primary)';
66
+ case 'pending':
67
+ return 'var(--warning, #C4893A)';
68
+ default:
69
+ return 'var(--muted-foreground)';
70
+ }
71
+ }
72
+
73
+ function styleLabel(payload: string): string {
74
+ try {
75
+ const p = JSON.parse(payload) as { name?: string; format?: string; width?: number };
76
+ if (p.name) return p.name;
77
+ if (p.format && p.width) return `${p.format} · ${p.width}w`;
78
+ if (p.format) return p.format;
79
+ } catch {
80
+ // fall through to raw payload
81
+ }
82
+ return payload;
83
+ }
84
+
85
+ function truncate(text: string, max = 60): string {
86
+ return text.length > max ? `${text.slice(0, max)}…` : text;
87
+ }
88
+
89
+ function timeAgo(dateStr: string): string {
90
+ const diff = Date.now() - new Date(dateStr).getTime();
91
+ const mins = Math.floor(diff / 60000);
92
+ if (mins < 1) return 'przed chwilą';
93
+ if (mins < 60) return `${mins} min temu`;
94
+ const hours = Math.floor(mins / 60);
95
+ if (hours < 24) return `${hours}h temu`;
96
+ return `${Math.floor(hours / 24)}d temu`;
97
+ }
98
+
99
+ let counts = $state<JobCounts>({ pending: 0, processing: 0, done: 0, failed: 0, skipped: 0 });
100
+ let jobs = $state<MediaJob[]>([]);
101
+ let statusFilter = $state<StatusFilter>('all');
102
+ let loading = $state(true);
103
+ let error = $state<string | null>(null);
104
+ let actioningId = $state<string | null>(null);
105
+
106
+ let counterItems = $derived([
107
+ { label: 'Oczekujące', value: counts.pending, color: 'var(--warning, #C4893A)' },
108
+ { label: 'W trakcie', value: counts.processing, color: 'var(--primary)' },
109
+ { label: 'Gotowe', value: counts.done, color: 'var(--success, #3A8A5C)' },
110
+ { label: 'Błędy', value: counts.failed, color: 'var(--error, #C44B4B)' },
111
+ { label: 'Pominięte', value: counts.skipped, color: 'var(--muted-foreground)' }
112
+ ]);
113
+
114
+ async function loadJobs(filter: StatusFilter, showLoading = false) {
115
+ if (showLoading) loading = true;
116
+ try {
117
+ const params = new URLSearchParams({ limit: '50' });
118
+ if (filter !== 'all') params.set('status', filter);
119
+ const res = await fetch(`/admin/api/media-jobs?${params.toString()}`);
120
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
121
+ const data = (await res.json()) as MediaJobsResponse;
122
+ counts = data.counts;
123
+ jobs = data.jobs;
124
+ error = null;
125
+ } catch {
126
+ error = 'Nie udało się załadować kolejki mediów';
127
+ } finally {
128
+ loading = false;
129
+ }
130
+ }
131
+
132
+ async function doAction(id: string, action: 'retry' | 'skip') {
133
+ actioningId = id;
134
+ try {
135
+ const res = await fetch('/admin/api/media-jobs', {
136
+ method: 'POST',
137
+ headers: { 'Content-Type': 'application/json' },
138
+ body: JSON.stringify({ id, action })
139
+ });
140
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
141
+ toast.success(action === 'retry' ? 'Zadanie ponowione' : 'Zadanie pominięte');
142
+ await loadJobs(statusFilter);
143
+ } catch {
144
+ toast.error('Nie udało się wykonać akcji');
145
+ } finally {
146
+ actioningId = null;
147
+ }
148
+ }
149
+
150
+ // Refetch on mount and whenever the status filter changes.
151
+ $effect(() => {
152
+ loadJobs(statusFilter, true);
153
+ });
154
+
155
+ // Poll every 5s; interval cleaned up on unmount.
156
+ onMount(() => {
157
+ const id = setInterval(() => loadJobs(statusFilter), 5000);
158
+ return () => clearInterval(id);
159
+ });
160
+ </script>
161
+
162
+ <Card.Root>
163
+ <Card.Header class="pb-3">
164
+ <div class="flex flex-wrap items-center justify-between gap-3">
165
+ <div class="flex items-center gap-2">
166
+ <ListDetails class="size-5" style="color: var(--primary);" />
167
+ <Card.Title class="text-base">Kolejka zadań</Card.Title>
168
+ </div>
169
+ <div class="flex flex-wrap gap-1.5">
170
+ {#each filters as f (f.value)}
171
+ <Button
172
+ variant={statusFilter === f.value ? 'default' : 'outline'}
173
+ size="sm"
174
+ onclick={() => (statusFilter = f.value)}
175
+ >
176
+ {f.label}
177
+ </Button>
178
+ {/each}
179
+ </div>
180
+ </div>
181
+
182
+ <div class="mt-3 flex flex-wrap gap-2">
183
+ {#each counterItems as c (c.label)}
184
+ <div
185
+ class="flex items-center gap-1.5 rounded-md px-2.5 py-1 text-xs"
186
+ style="background: color-mix(in srgb, {c.color} 12%, transparent);"
187
+ >
188
+ <span class="font-semibold" style="color: {c.color};">{c.value}</span>
189
+ <span style="color: var(--muted-foreground);">{c.label}</span>
190
+ </div>
191
+ {/each}
192
+ </div>
193
+ </Card.Header>
194
+
195
+ <Card.Content>
196
+ {#if loading && jobs.length === 0}
197
+ <div class="flex items-center justify-center py-12">
198
+ <Loader2 class="size-6 animate-spin" style="color: var(--muted-foreground);" />
199
+ </div>
200
+ {:else if error && jobs.length === 0}
201
+ <div
202
+ class="flex items-center gap-2 rounded-md px-3 py-3 text-sm"
203
+ style="background: color-mix(in srgb, var(--error, #C44B4B) 12%, transparent); color: var(--error, #C44B4B);"
204
+ >
205
+ <AlertTriangle class="size-4 shrink-0" />
206
+ {error}
207
+ </div>
208
+ {:else if jobs.length === 0}
209
+ <p class="py-8 text-center text-sm" style="color: var(--muted-foreground);">Brak zadań</p>
210
+ {:else}
211
+ {#if error}
212
+ <p class="mb-3 flex items-center gap-1.5 text-xs" style="color: var(--error, #C44B4B);">
213
+ <AlertTriangle class="size-3.5 shrink-0" />
214
+ {error} — pokazano ostatnie dane
215
+ </p>
216
+ {/if}
217
+ <div class="overflow-x-auto">
218
+ <table class="w-full text-sm">
219
+ <thead>
220
+ <tr class="border-b text-left" style="border-color: var(--border);">
221
+ <th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Media</th>
222
+ <th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Styl</th>
223
+ <th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Status</th>
224
+ <th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Prób</th>
225
+ <th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Błąd</th>
226
+ <th class="px-3 py-2 font-medium" style="color: var(--muted-foreground);">Zaktualizowano</th>
227
+ <th class="px-3 py-2"></th>
228
+ </tr>
229
+ </thead>
230
+ <tbody>
231
+ {#each jobs as job (job.id)}
232
+ <tr class="border-b" style="border-color: var(--border);">
233
+ <td class="px-3 py-2">
234
+ <span class="font-mono text-xs" title={job.mediaFileId}
235
+ >{job.mediaFileId.slice(0, 8)}</span
236
+ >
237
+ </td>
238
+ <td class="px-3 py-2">
239
+ <span title={job.payload}>{styleLabel(job.payload)}</span>
240
+ </td>
241
+ <td class="px-3 py-2">
242
+ <span
243
+ class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
244
+ style="background: color-mix(in srgb, {statusColor(
245
+ job.status
246
+ )} 15%, transparent); color: {statusColor(job.status)};"
247
+ >
248
+ {#if job.status === 'processing'}
249
+ <Loader2 class="size-3 animate-spin" />
250
+ {/if}
251
+ {statusLabels[job.status]}
252
+ </span>
253
+ </td>
254
+ <td class="px-3 py-2 whitespace-nowrap" style="color: var(--muted-foreground);">
255
+ {job.attempts}/{job.maxAttempts}
256
+ </td>
257
+ <td class="px-3 py-2">
258
+ {#if job.lastError}
259
+ <span
260
+ class="text-xs"
261
+ style="color: var(--error, #C44B4B);"
262
+ title={job.lastError}>{truncate(job.lastError)}</span
263
+ >
264
+ {:else}
265
+ <span style="color: var(--muted-foreground);">—</span>
266
+ {/if}
267
+ </td>
268
+ <td
269
+ class="px-3 py-2 whitespace-nowrap"
270
+ style="color: var(--muted-foreground);"
271
+ title={new Date(job.updatedAt).toLocaleString('pl-PL')}
272
+ >
273
+ {timeAgo(job.updatedAt)}
274
+ </td>
275
+ <td class="px-3 py-2">
276
+ {#if job.status === 'failed'}
277
+ <div class="flex justify-end gap-1.5">
278
+ <Button
279
+ variant="outline"
280
+ size="sm"
281
+ onclick={() => doAction(job.id, 'retry')}
282
+ disabled={actioningId === job.id}
283
+ >
284
+ {#if actioningId === job.id}
285
+ <Loader2 class="size-3.5 animate-spin" />
286
+ {:else}
287
+ <Refresh class="size-3.5" />
288
+ {/if}
289
+ Ponów
290
+ </Button>
291
+ <Button
292
+ variant="ghost"
293
+ size="sm"
294
+ onclick={() => doAction(job.id, 'skip')}
295
+ disabled={actioningId === job.id}
296
+ >
297
+ <PlayerSkipForward class="size-3.5" />
298
+ Pomiń
299
+ </Button>
300
+ </div>
301
+ {/if}
302
+ </td>
303
+ </tr>
304
+ {/each}
305
+ </tbody>
306
+ </table>
307
+ </div>
308
+ {/if}
309
+ </Card.Content>
310
+ </Card.Root>
@@ -0,0 +1,3 @@
1
+ declare const MediaJobsPanel: import("svelte").Component<Record<string, never>, {}, "">;
2
+ type MediaJobsPanel = ReturnType<typeof MediaJobsPanel>;
3
+ export default MediaJobsPanel;
package/dist/core/cms.js CHANGED
@@ -178,6 +178,13 @@ export function initCMS(config) {
178
178
  import('./server/media/operations/backgroundMaintenance.js')
179
179
  .then((m) => m.startBackgroundMaintenance())
180
180
  .catch((e) => console.warn('[cms] Failed to start background maintenance:', e));
181
+ // Start in-process media optimization worker (generuje warianty poza ścieżką renderu).
182
+ // Wyłączalny przez media.optimization.worker = false.
183
+ if (cms.mediaConfig.optimization?.worker !== false) {
184
+ import('./server/media/jobs/startWorker.js')
185
+ .then((m) => m.startMediaOptimizationWorker())
186
+ .catch((e) => console.warn('[cms] Failed to start media worker:', e));
187
+ }
181
188
  // Apply shop variantAttribute GIN indexes (idempotent CREATE INDEX IF NOT EXISTS).
182
189
  // Pass shop + drizzle explicitly so the dynamic import doesn't depend on a
183
190
  // shared CMS singleton (vitest can give the dynamic module a fresh instance).
@@ -2,6 +2,8 @@ import { getImageStylesBatch } from '../../media/styles/operations/getImageStyle
2
2
  import { imageStyleKey } from '../../../../types/adapters/db.js';
3
3
  import { getCMS } from '../../../cms.js';
4
4
  import { generateBlurDataUrl } from '../../media/utils/generateBlurDataUrl.js';
5
+ import { buildStyleSpecs } from '../../media/styles/buildStyleSpecs.js';
6
+ import { resolveImagesConfig } from '../../media/styles/imagesConfig.js';
5
7
  export const defaultStyles = [
6
8
  { name: 'default', srcset: [640, 1024, 1920, 2560] }
7
9
  ];
@@ -72,10 +74,45 @@ function variantStyleFor(style, w) {
72
74
  }
73
75
  export async function getImageStyles(field, val) {
74
76
  const hasCustomStyles = field.styles && field.styles.length > 0;
75
- const rawStyles = [...(isProcessableImage(val) && !hasCustomStyles ? defaultStyles : []), ...(field.styles || [])];
76
- const originalFormat = getOriginalFormat(val);
77
- const stylesArr = expandStyleFormats(rawStyles, originalFormat);
78
- // Collect every (mediaFileId, style) request — base styles + srcset variants — into one batch.
77
+ // Custom field.styles: zachowaj dotychczasową semantykę (expand formatów + baza pełno-res + srcset).
78
+ if (hasCustomStyles) {
79
+ return resolveLegacyStyles(expandStyleFormats(field.styles, getOriginalFormat(val)), val);
80
+ }
81
+ // Domyślnie: nowa lekka matryca z configu (avif+webp × szerokości, BEZ bazy pełno-res).
82
+ // Grupujemy warianty po formacie → jeden <source> per format ze srcset (bez bramki bazy).
83
+ const specs = isProcessableImage(val)
84
+ ? buildStyleSpecs(val, undefined, resolveImagesConfig(getCMS().mediaConfig.images))
85
+ : [];
86
+ const requests = specs.map((style) => ({ mediaFileId: val.id, style }));
87
+ const [styleMap, blurDataUrl] = await Promise.all([
88
+ getImageStylesBatch(requests),
89
+ ensureBlurDataUrl(val)
90
+ ]);
91
+ const byFormat = new Map();
92
+ for (const style of specs) {
93
+ const data = styleMap.get(imageStyleKey(val.id, style));
94
+ if (!data || typeof style.width !== 'number')
95
+ continue;
96
+ const fmt = style.format ?? 'orig';
97
+ const group = byFormat.get(fmt) ?? { media: data.media, mimeType: data.mimeType, variants: [] };
98
+ group.variants.push({ url: data.url, w: style.width });
99
+ byFormat.set(fmt, group);
100
+ }
101
+ const styles = {};
102
+ for (const [fmt, group] of byFormat) {
103
+ group.variants.sort((a, b) => a.w - b.w);
104
+ const largest = group.variants[group.variants.length - 1];
105
+ styles[`default_${fmt}`] = {
106
+ url: largest.url,
107
+ media: group.media,
108
+ mimeType: group.mimeType,
109
+ srcset: group.variants.map((v) => `${v.url} ${v.w}w`).join(', ')
110
+ };
111
+ }
112
+ return { styles, blurDataUrl };
113
+ }
114
+ /** Stara ścieżka dla custom field.styles: styl bazowy pełno-res (bramka istnienia) + srcset wariantów. */
115
+ async function resolveLegacyStyles(stylesArr, val) {
79
116
  const requests = [];
80
117
  for (const style of stylesArr) {
81
118
  requests.push({ mediaFileId: val.id, style });
@@ -2,3 +2,6 @@ export { getCMS } from '../cms.js';
2
2
  export { resolveMediaWithStyles, type ResolvedMedia } from './fields/utils/resolveMedia.js';
3
3
  export { createEntityAPI } from '../../entity/index.js';
4
4
  export { getAuth } from '../../server/auth.js';
5
+ export { enqueueStyleSpecs } from './media/jobs/enqueue.js';
6
+ export { startMediaWorker, stopMediaWorker } from './media/jobs/worker.js';
7
+ export type { MediaJob, MediaJobStatus } from '../../types/adapters/db.js';
@@ -2,3 +2,6 @@ export { getCMS } from '../cms.js';
2
2
  export { resolveMediaWithStyles } from './fields/utils/resolveMedia.js';
3
3
  export { createEntityAPI } from '../../entity/index.js';
4
4
  export { getAuth } from '../../server/auth.js';
5
+ // Optymalizacja obrazów w tle (media_job)
6
+ export { enqueueStyleSpecs } from './media/jobs/enqueue.js';
7
+ export { startMediaWorker, stopMediaWorker } from './media/jobs/worker.js';
@@ -0,0 +1,6 @@
1
+ import type { ImageFieldStyle } from '../../../../types/fields.js';
2
+ import type { MediaFile } from '../../../../types/media.js';
3
+ /** Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey. */
4
+ export declare function enqueueStyleSpecs(mediaFile: Pick<MediaFile, 'id' | 'width' | 'height'>, fieldStyles: ImageFieldStyle[] | undefined, opts?: {
5
+ priority?: number;
6
+ }): Promise<void>;
@@ -0,0 +1,20 @@
1
+ import { getCMS } from '../../../cms.js';
2
+ import { resolveImagesConfig } from '../styles/imagesConfig.js';
3
+ import { buildStyleSpecs, styleDedupeKey } from '../styles/buildStyleSpecs.js';
4
+ /** Kolejkuje generację wszystkich wariantów dla media (upload / render-miss / sweep). Idempotentne po dedupeKey. */
5
+ export async function enqueueStyleSpecs(mediaFile, fieldStyles, opts = {}) {
6
+ const cms = getCMS();
7
+ const cfg = resolveImagesConfig(cms.mediaConfig.images);
8
+ const specs = buildStyleSpecs(mediaFile, fieldStyles, cfg);
9
+ for (const style of specs) {
10
+ await cms.databaseAdapter
11
+ .enqueueMediaJob({
12
+ type: 'image_style',
13
+ mediaFileId: mediaFile.id,
14
+ payload: JSON.stringify(style),
15
+ dedupeKey: styleDedupeKey(mediaFile.id, style),
16
+ priority: opts.priority ?? 0
17
+ })
18
+ .catch((e) => console.warn('[media] enqueue failed:', e));
19
+ }
20
+ }
@@ -0,0 +1,19 @@
1
+ import type { MediaJob } from '../../../../types/adapters/db.js';
2
+ import type { MediaFile, ImageStyle } from '../../../../types/media.js';
3
+ import type { ImageFieldStyle } from '../../../../types/fields.js';
4
+ export type JobResult = {
5
+ ok: true;
6
+ } | {
7
+ ok: false;
8
+ retry: boolean;
9
+ error: string;
10
+ };
11
+ type CreateImageStyleFn = (mediaFileId: string, style: ImageFieldStyle) => Promise<ImageStyle>;
12
+ export interface ProcessImageStyleDeps {
13
+ createImageStyle: CreateImageStyleFn;
14
+ getMediaFile: (id: string) => Promise<MediaFile | null>;
15
+ limitPixels: number;
16
+ }
17
+ /** Przetwarza pojedynczy job typu image_style. Klasyfikuje błędy: poison/not-found → retry:false. */
18
+ export declare function processImageStyleJob(job: MediaJob, deps: ProcessImageStyleDeps): Promise<JobResult>;
19
+ export {};
@@ -0,0 +1,23 @@
1
+ import { assertWithinPixelLimit } from '../styles/sharp/sharpConfig.js';
2
+ /** Przetwarza pojedynczy job typu image_style. Klasyfikuje błędy: poison/not-found → retry:false. */
3
+ export async function processImageStyleJob(job, deps) {
4
+ const style = JSON.parse(job.payload);
5
+ const media = await deps.getMediaFile(job.mediaFileId);
6
+ if (!media)
7
+ return { ok: false, retry: false, error: 'media file not found' };
8
+ try {
9
+ if (media.width && media.height) {
10
+ assertWithinPixelLimit(media.width, media.height, deps.limitPixels);
11
+ }
12
+ }
13
+ catch (e) {
14
+ return { ok: false, retry: false, error: e.message };
15
+ }
16
+ try {
17
+ await deps.createImageStyle(job.mediaFileId, style);
18
+ return { ok: true };
19
+ }
20
+ catch (e) {
21
+ return { ok: false, retry: true, error: e.message };
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ /** Składa zależności i startuje in-process worker generacji wariantów. */
2
+ export declare function startMediaOptimizationWorker(): void;