includio-cms 0.36.7 → 0.36.9

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 (66) hide show
  1. package/API.md +9 -2
  2. package/CHANGELOG.md +29 -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/collection/collection-entries.svelte +20 -1
  8. package/dist/admin/client/collection/row-actions.svelte +12 -4
  9. package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -0
  10. package/dist/admin/client/maintenance/maintenance-page.svelte +9 -0
  11. package/dist/admin/client/maintenance/media-jobs-panel.svelte +310 -0
  12. package/dist/admin/client/maintenance/media-jobs-panel.svelte.d.ts +3 -0
  13. package/dist/admin/remote/entry.remote.d.ts +3 -0
  14. package/dist/admin/remote/entry.remote.js +14 -3
  15. package/dist/core/cms.js +7 -0
  16. package/dist/core/fields/dotPath.d.ts +4 -0
  17. package/dist/core/fields/dotPath.js +18 -0
  18. package/dist/core/fields/slugify.d.ts +3 -0
  19. package/dist/core/fields/slugify.js +4 -0
  20. package/dist/core/server/entries/operations/duplicate.d.ts +10 -0
  21. package/dist/core/server/entries/operations/duplicate.js +73 -0
  22. package/dist/core/server/entries/operations/slugNormalize.d.ts +8 -0
  23. package/dist/core/server/entries/operations/slugNormalize.js +20 -0
  24. package/dist/core/server/entries/operations/slugUniqueness.d.ts +17 -0
  25. package/dist/core/server/entries/operations/slugUniqueness.js +39 -0
  26. package/dist/core/server/fields/utils/imageStyles.js +41 -4
  27. package/dist/core/server/index.d.ts +3 -0
  28. package/dist/core/server/index.js +3 -0
  29. package/dist/core/server/media/jobs/enqueue.d.ts +6 -0
  30. package/dist/core/server/media/jobs/enqueue.js +20 -0
  31. package/dist/core/server/media/jobs/processImageStyleJob.d.ts +19 -0
  32. package/dist/core/server/media/jobs/processImageStyleJob.js +23 -0
  33. package/dist/core/server/media/jobs/startWorker.d.ts +2 -0
  34. package/dist/core/server/media/jobs/startWorker.js +29 -0
  35. package/dist/core/server/media/jobs/worker.d.ts +29 -0
  36. package/dist/core/server/media/jobs/worker.js +54 -0
  37. package/dist/core/server/media/operations/replaceFile.js +2 -2
  38. package/dist/core/server/media/operations/uploadFile.js +10 -3
  39. package/dist/core/server/media/styles/buildStyleSpecs.d.ts +12 -0
  40. package/dist/core/server/media/styles/buildStyleSpecs.js +36 -0
  41. package/dist/core/server/media/styles/imagesConfig.d.ts +3 -0
  42. package/dist/core/server/media/styles/imagesConfig.js +16 -0
  43. package/dist/core/server/media/styles/operations/batchGenerateStyles.js +19 -41
  44. package/dist/core/server/media/styles/operations/generateFallbackVariant.d.ts +10 -0
  45. package/dist/core/server/media/styles/operations/generateFallbackVariant.js +21 -0
  46. package/dist/core/server/media/styles/operations/getImageStyle.js +12 -8
  47. package/dist/core/server/media/styles/sharp/sharpConfig.d.ts +7 -0
  48. package/dist/core/server/media/styles/sharp/sharpConfig.js +12 -0
  49. package/dist/db-postgres/index.js +113 -1
  50. package/dist/db-postgres/schema/imageStyle.js +1 -1
  51. package/dist/db-postgres/schema/index.d.ts +1 -0
  52. package/dist/db-postgres/schema/index.js +1 -0
  53. package/dist/db-postgres/schema/mediaFile.d.ts +17 -0
  54. package/dist/db-postgres/schema/mediaFile.js +1 -0
  55. package/dist/db-postgres/schema/mediaJob.d.ts +279 -0
  56. package/dist/db-postgres/schema/mediaJob.js +23 -0
  57. package/dist/sveltekit/components/image.svelte +1 -1
  58. package/dist/types/adapters/db.d.ts +51 -0
  59. package/dist/types/cms.d.ts +46 -0
  60. package/dist/types/media.d.ts +1 -0
  61. package/dist/updates/0.36.8/index.d.ts +2 -0
  62. package/dist/updates/0.36.8/index.js +20 -0
  63. package/dist/updates/0.36.9/index.d.ts +2 -0
  64. package/dist/updates/0.36.9/index.js +12 -0
  65. package/dist/updates/index.js +5 -1
  66. package/package.json +1 -1
package/API.md CHANGED
@@ -1,8 +1,8 @@
1
- # includio-cms — Public API v0.36.7
1
+ # includio-cms — Public API v0.36.9
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: **502** · 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
 
@@ -219,6 +224,7 @@ Tags:
219
224
  - `const deleteOrderCmd: <inferred>`
220
225
  - `const deleteShippingMethodCmd: <inferred>`
221
226
  - `const deleteShopDataForEntry: <inferred>`
227
+ - `const duplicateEntryCommand: <inferred>`
222
228
  - `const exportFormSubmissions: <inferred>`
223
229
  - `const exportOrdersCsv: <inferred>`
224
230
  - `const findMediaReferences: <inferred>`
@@ -356,6 +362,7 @@ Tags:
356
362
  - `const invitation: <inferred>`
357
363
  - `const mediaFilesTable: <inferred>`
358
364
  - `const mediaFileTagsTable: <inferred>`
365
+ - `const mediaJobTable: <inferred>`
359
366
  - `const mediaTagsTable: <inferred>`
360
367
  - `pg(config: Config): DatabaseAdapterWithDrizzle` — Postgres database adapter (drizzle + `postgres`).
361
368
  - `const session: <inferred>`
package/CHANGELOG.md CHANGED
@@ -3,6 +3,35 @@
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.9 — 2026-07-02
7
+
8
+ Duplikacja wpisu z tabeli kolekcji + serwerowa normalizacja pól slug przy zapisie. Menu wiersza zyskuje „Duplikuj" — tworzy nieopublikowany szkic z tą samą treścią (wszystkie języki), z prefiksem tytułu „Kopia — " i unikalnym slugiem („-kopia"), i przenosi do edytora duplikatu. Media i relacje są współdzielone przez referencje (bez głębokiego klonu). Niezależnie od duplikacji: każde pole typu slug (SlugField oraz slug w polu SEO) jest przy zapisie normalizowane przez slugify — „Singapur"/„Hello World" zapisują się jako „singapur"/„hello-world", transparentnie dla użytkownika. Spec: `docs/superpowers/specs/2026-07-02-duplicate-entry-and-slug-normalization-design.md`, plan: `docs/superpowers/plans/2026-07-02-duplicate-entry-and-slug-normalization.md`.
9
+
10
+ ### Added
11
+ - Duplikacja wpisu (`duplicateEntry` + remote `duplicateEntryCommand`, akcja „Duplikuj" w menu wiersza tabeli kolekcji `/admin/collections/[collection]`): kopiuje najświeższą treść per język (draft ?? published ?? scheduled) na nowy wpis jako wersje nieopublikowane (`publishedAt: null`), prefiksuje `entryAdminTitle` przez „Kopia — " i nadaje każdemu polu slug unikalny sufiks („-kopia", „-kopia-2", …) sprawdzany wobec published-or-scheduled slugów kolekcji, po czym przenosi do edytora duplikatu. Media/relacje = współdzielone referencje. Akcja ukryta dla wpisów archiwalnych.
12
+
13
+ ### Fixed
14
+ - Normalizacja slug przy zapisie (`normalizeSlugFields` wpięte w `updateEntryVersionCommand`, ścieżka draft i publish): wartości pól typu slug (SlugField oraz slug w polu SEO) są normalizowane przez `slugify({ lower, strict, trim })` przed zapisem i przed kontrolą unikalności. Nietechniczny użytkownik wpisujący np. „Singapur" czy „Hello World" dostaje poprawny „singapur"/„hello-world" bez ręcznej korekty. Puste/nie-string i pola nie-slug pozostają nietknięte.
15
+
16
+ ## 0.36.8 — 2026-07-02
17
+
18
+ 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.
19
+
20
+ ### Added
21
+ - 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`.
22
+ - 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.
23
+ - 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).
24
+ - Upload generuje synchronicznie lekki fallback (`media_file.fallback_url`) i kolejkuje warianty; sweep oraz `replaceFile` też kolejkują (nie generują inline).
25
+ - Admin `/admin/maintenance`: sekcja „Kolejka mediów / Logi" — liczniki pending/processing/failed, lista failed z akcjami Ponów/Pomiń.
26
+ - Globalne limity sharp (`sharp.concurrency`/`cache`) ustawiane raz przy init. Deduplikacja zadań po efektywnej specyfikacji (format+wymiary+crop+quality).
27
+ - Public API: `enqueueStyleSpecs`, `startMediaWorker`, `stopMediaWorker`, typy `MediaJob`/`MediaJobStatus`.
28
+
29
+ ### Fixed
30
+ - `image_styles`: dodany `crop` do klucza unikalnego (koniec cichego nadpisywania wariantów różniących się tylko cropem) + spójny `ON CONFLICT`.
31
+
32
+ ### Breaking
33
+ - 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.
34
+
6
35
  ## 0.36.7 — 2026-06-24
7
36
 
8
37
  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.
package/DOCS.md CHANGED
@@ -1,4 +1,4 @@
1
- # Includio CMS Documentation (v0.36.7)
1
+ # Includio CMS Documentation (v0.36.9)
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
+ };
@@ -4,6 +4,7 @@
4
4
  import { getEntryThumbnail, type MediaThumbnailLookup } from '../../utils/entryThumbnail.js';
5
5
  import { arrayMove } from '../../utils/arrayMove.js';
6
6
  import { getRemotes } from '../../../sveltekit/index.js';
7
+ import { goto } from '$app/navigation';
7
8
  import type { CollectionConfigWithType } from '../../../types/collections.js';
8
9
  import DataTable from './data-table.svelte';
9
10
  import type { ColumnDef, RowSelectionState, Table } from '@tanstack/table-core';
@@ -54,6 +55,8 @@
54
55
  entryRestored: string;
55
56
  entriesRestored: string;
56
57
  entryDeleted: string;
58
+ entryDuplicated: string;
59
+ entryDuplicateFailed: string;
57
60
  deleteConfirmTitle: string;
58
61
  deleteConfirmDescription: string;
59
62
  cancel: string;
@@ -74,6 +77,8 @@
74
77
  entryRestored: 'Entry restored',
75
78
  entriesRestored: 'Entries restored',
76
79
  entryDeleted: 'Entry permanently deleted',
80
+ entryDuplicated: 'Duplicate created (draft)',
81
+ entryDuplicateFailed: 'Failed to duplicate entry',
77
82
  deleteConfirmTitle: 'Delete entry permanently?',
78
83
  deleteConfirmDescription:
79
84
  'This action cannot be undone. The entry will be permanently deleted.',
@@ -94,6 +99,8 @@
94
99
  entryRestored: 'Wpis przywrócony',
95
100
  entriesRestored: 'Wpisy przywrócone',
96
101
  entryDeleted: 'Wpis trwale usunięty',
102
+ entryDuplicated: 'Utworzono duplikat (szkic)',
103
+ entryDuplicateFailed: 'Nie udało się zduplikować wpisu',
97
104
  deleteConfirmTitle: 'Usunąć wpis na stałe?',
98
105
  deleteConfirmDescription: 'Ta akcja jest nieodwracalna. Wpis zostanie trwale usunięty.',
99
106
  cancel: 'Anuluj',
@@ -374,7 +381,9 @@
374
381
  entryUrl: info.row.original.url,
375
382
  entryName: info.row.original.name,
376
383
  onArchive: () => handleArchiveSingle(info.row.original.id),
377
- ...(isArchivedFilter ? { onDelete: () => handleDelete(info.row.original.id), onRestore: () => handleRestoreSingle(info.row.original.id) } : {})
384
+ ...(isArchivedFilter
385
+ ? { onDelete: () => handleDelete(info.row.original.id), onRestore: () => handleRestoreSingle(info.row.original.id) }
386
+ : { onDuplicate: () => handleDuplicate(info.row.original.id) })
378
387
  }),
379
388
  enableSorting: false,
380
389
  size: 50
@@ -393,6 +402,16 @@
393
402
  override.release();
394
403
  }
395
404
 
405
+ async function handleDuplicate(id: string) {
406
+ try {
407
+ const { id: newId } = await remotes.duplicateEntryCommand(id);
408
+ toast.success(lang[interfaceLanguage.current].entryDuplicated);
409
+ await goto(`/admin/entries/${newId}`);
410
+ } catch {
411
+ toast.error(lang[interfaceLanguage.current].entryDuplicateFailed);
412
+ }
413
+ }
414
+
396
415
  async function handleRestoreSingle(id: string) {
397
416
  const override = entriesQuery.withOverride((old) => ({
398
417
  ...old,
@@ -4,6 +4,7 @@
4
4
  import Archive from '@tabler/icons-svelte/icons/archive';
5
5
  import ArchiveOff from '@tabler/icons-svelte/icons/archive-off';
6
6
  import Trash from '@tabler/icons-svelte/icons/trash';
7
+ import Copy from '@tabler/icons-svelte/icons/copy';
7
8
  import Button from '../../../components/ui/button/button.svelte';
8
9
  import * as DropdownMenu from '../../../components/ui/dropdown-menu/index.js';
9
10
  import { useInterfaceLanguage } from '../../state/interface-language.svelte.js';
@@ -14,17 +15,18 @@
14
15
  entryUrl: string;
15
16
  entryName: string;
16
17
  onArchive: () => void;
18
+ onDuplicate?: () => void;
17
19
  onDelete?: () => void;
18
20
  onRestore?: () => void;
19
21
  };
20
22
 
21
- let { entryUrl, entryName, onArchive, onDelete, onRestore }: Props = $props();
23
+ let { entryUrl, entryName, onArchive, onDuplicate, onDelete, onRestore }: Props = $props();
22
24
 
23
25
  const interfaceLanguage = useInterfaceLanguage();
24
26
 
25
- const lang: Record<InterfaceLanguage, { open: string; archive: string; restore: string; delete: string; moreActions: string }> = {
26
- en: { open: 'Open', archive: 'Archive', restore: 'Restore', delete: 'Delete', moreActions: 'More actions for' },
27
- pl: { open: 'Otwórz', archive: 'Archiwizuj', restore: 'Przywróć', delete: 'Usuń', moreActions: 'Więcej akcji dla' }
27
+ const lang: Record<InterfaceLanguage, { open: string; duplicate: string; archive: string; restore: string; delete: string; moreActions: string }> = {
28
+ en: { open: 'Open', duplicate: 'Duplicate', archive: 'Archive', restore: 'Restore', delete: 'Delete', moreActions: 'More actions for' },
29
+ pl: { open: 'Otwórz', duplicate: 'Duplikuj', archive: 'Archiwizuj', restore: 'Przywróć', delete: 'Usuń', moreActions: 'Więcej akcji dla' }
28
30
  };
29
31
 
30
32
  const t = $derived(lang[interfaceLanguage.current]);
@@ -51,6 +53,12 @@
51
53
  {t.open}
52
54
  </DropdownMenu.Item>
53
55
  <DropdownMenu.Separator />
56
+ {#if onDuplicate}
57
+ <DropdownMenu.Item onclick={onDuplicate} data-testid="row-action-duplicate">
58
+ <Copy class="mr-2 size-4" />
59
+ {t.duplicate}
60
+ </DropdownMenu.Item>
61
+ {/if}
54
62
  {#if onRestore}
55
63
  <DropdownMenu.Item onclick={onRestore} data-testid="row-action-restore">
56
64
  <ArchiveOff class="mr-2 size-4" />
@@ -2,6 +2,7 @@ type Props = {
2
2
  entryUrl: string;
3
3
  entryName: string;
4
4
  onArchive: () => void;
5
+ onDuplicate?: () => void;
5
6
  onDelete?: () => void;
6
7
  onRestore?: () => void;
7
8
  };
@@ -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;
@@ -90,6 +90,9 @@ export declare const updateEntryCommand: import("@sveltejs/kit").RemoteCommand<{
90
90
  export declare const archiveEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<import("../../types/entries.js").DbEntry>>;
91
91
  export declare const unarchiveEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<import("../../types/entries.js").DbEntry>>;
92
92
  export declare const deleteEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<void>>;
93
+ export declare const duplicateEntryCommand: import("@sveltejs/kit").RemoteCommand<string, Promise<{
94
+ id: string;
95
+ }>>;
93
96
  export declare const reorderEntriesCommand: import("@sveltejs/kit").RemoteCommand<{
94
97
  orderedIds: string[];
95
98
  collectionSlug: string;
@@ -6,11 +6,13 @@ import { db } from '../../server/db/index.js';
6
6
  import { user } from '../../server/db/schema/auth-schema.js';
7
7
  import { inArray } from 'drizzle-orm';
8
8
  import { createEntry as createEntryOperation, createEntrySchema, createEntryVersion } from '../../core/server/entries/operations/create.js';
9
- import { _getRawEntries as getRawEntriesOperation, _countRawEntries as countRawEntriesOperation, _getRawEntry as getRawEntryOperation, _getRawEntryOrThrow as getRawEntryOrThrow, _getDbEntry as getDbEntry, getEntryVersion as getEntryVersionOperation, getEntryLabels as getEntryLabelsOperation } from '../../core/server/entries/operations/get.js';
9
+ import { _getRawEntries as getRawEntriesOperation, _countRawEntries as countRawEntriesOperation, _getRawEntry as getRawEntryOperation, _getRawEntryOrThrow as getRawEntryOrThrow, _getDbEntry as getDbEntry, _getDbEntryOrThrow as getDbEntryOrThrow, getEntryVersion as getEntryVersionOperation, getEntryLabels as getEntryLabelsOperation } from '../../core/server/entries/operations/get.js';
10
10
  import { resolveEntries, resolveEntry } from '../../core/server/entries/operations/resolveEntry.js';
11
11
  import { getCMS } from '../../core/cms.js';
12
12
  import { getFieldsFromConfig } from '../../core/fields/layoutUtils.js';
13
13
  import { pruneOldDraftVersions, unpublishEntryLang, cancelScheduledEntryLang, upsertDraftVersion, updateEntry, updateEntrySchema, updateEntryVersionCommandTypes } from '../../core/server/entries/operations/update.js';
14
+ import { normalizeSlugFields } from '../../core/server/entries/operations/slugNormalize.js';
15
+ import { duplicateEntry } from '../../core/server/entries/operations/duplicate.js';
14
16
  import z from 'zod';
15
17
  import { requireAuth } from './middleware/auth.js';
16
18
  import { stableSlotReorder } from './reorder.js';
@@ -205,6 +207,11 @@ const updateEntryVersionCommandSchema = z.object({
205
207
  });
206
208
  export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema, async (input) => {
207
209
  const { user } = requireAuth();
210
+ // Normalize slug fields once, before any write path (covers draft in-place
211
+ // updates and publish). Keeps stored slugs valid regardless of raw input.
212
+ const entryForSlug = await getDbEntryOrThrow({ id: input.entryId });
213
+ const config = getCMS().getBySlug(entryForSlug.slug);
214
+ const data = normalizeSlugFields(config, input.data);
208
215
  let result;
209
216
  // Publish failures should reach the client with a structured body so the
210
217
  // editor can pin the message to the offending field. SvelteKit hides any
@@ -214,7 +221,7 @@ export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema
214
221
  return await createEntryVersion({
215
222
  entryId: input.entryId,
216
223
  lang: input.lang,
217
- data: input.data,
224
+ data,
218
225
  publishedAt,
219
226
  publishedBy: user.id
220
227
  });
@@ -232,7 +239,7 @@ export const updateEntryVersionCommand = command(updateEntryVersionCommandSchema
232
239
  };
233
240
  switch (input.type) {
234
241
  case 'draft':
235
- result = await upsertDraftVersion(input.entryId, input.data, input.lang, { skipValidation: true });
242
+ result = await upsertDraftVersion(input.entryId, data, input.lang, { skipValidation: true });
236
243
  break;
237
244
  case 'published-now': {
238
245
  result = await runPublish(new Date());
@@ -289,6 +296,10 @@ export const deleteEntryCommand = command(z.string().uuid(), async (id) => {
289
296
  }
290
297
  return getCMS().databaseAdapter.deleteEntry({ id });
291
298
  });
299
+ export const duplicateEntryCommand = command(z.string().uuid(), async (id) => {
300
+ requireAuth();
301
+ return duplicateEntry(id);
302
+ });
292
303
  export const reorderEntriesCommand = command(z.object({
293
304
  orderedIds: z.array(z.string().uuid()),
294
305
  collectionSlug: z.string()