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.
- package/API.md +9 -2
- package/CHANGELOG.md +29 -0
- package/DOCS.md +1 -1
- package/dist/admin/api/handler.js +2 -0
- package/dist/admin/api/media-jobs.d.ts +5 -0
- package/dist/admin/api/media-jobs.js +33 -0
- package/dist/admin/client/collection/collection-entries.svelte +20 -1
- package/dist/admin/client/collection/row-actions.svelte +12 -4
- package/dist/admin/client/collection/row-actions.svelte.d.ts +1 -0
- package/dist/admin/client/maintenance/maintenance-page.svelte +9 -0
- package/dist/admin/client/maintenance/media-jobs-panel.svelte +310 -0
- package/dist/admin/client/maintenance/media-jobs-panel.svelte.d.ts +3 -0
- package/dist/admin/remote/entry.remote.d.ts +3 -0
- package/dist/admin/remote/entry.remote.js +14 -3
- package/dist/core/cms.js +7 -0
- package/dist/core/fields/dotPath.d.ts +4 -0
- package/dist/core/fields/dotPath.js +18 -0
- package/dist/core/fields/slugify.d.ts +3 -0
- package/dist/core/fields/slugify.js +4 -0
- package/dist/core/server/entries/operations/duplicate.d.ts +10 -0
- package/dist/core/server/entries/operations/duplicate.js +73 -0
- package/dist/core/server/entries/operations/slugNormalize.d.ts +8 -0
- package/dist/core/server/entries/operations/slugNormalize.js +20 -0
- package/dist/core/server/entries/operations/slugUniqueness.d.ts +17 -0
- package/dist/core/server/entries/operations/slugUniqueness.js +39 -0
- package/dist/core/server/fields/utils/imageStyles.js +41 -4
- package/dist/core/server/index.d.ts +3 -0
- package/dist/core/server/index.js +3 -0
- package/dist/core/server/media/jobs/enqueue.d.ts +6 -0
- package/dist/core/server/media/jobs/enqueue.js +20 -0
- package/dist/core/server/media/jobs/processImageStyleJob.d.ts +19 -0
- package/dist/core/server/media/jobs/processImageStyleJob.js +23 -0
- package/dist/core/server/media/jobs/startWorker.d.ts +2 -0
- package/dist/core/server/media/jobs/startWorker.js +29 -0
- package/dist/core/server/media/jobs/worker.d.ts +29 -0
- package/dist/core/server/media/jobs/worker.js +54 -0
- package/dist/core/server/media/operations/replaceFile.js +2 -2
- package/dist/core/server/media/operations/uploadFile.js +10 -3
- package/dist/core/server/media/styles/buildStyleSpecs.d.ts +12 -0
- package/dist/core/server/media/styles/buildStyleSpecs.js +36 -0
- package/dist/core/server/media/styles/imagesConfig.d.ts +3 -0
- package/dist/core/server/media/styles/imagesConfig.js +16 -0
- package/dist/core/server/media/styles/operations/batchGenerateStyles.js +19 -41
- package/dist/core/server/media/styles/operations/generateFallbackVariant.d.ts +10 -0
- package/dist/core/server/media/styles/operations/generateFallbackVariant.js +21 -0
- package/dist/core/server/media/styles/operations/getImageStyle.js +12 -8
- package/dist/core/server/media/styles/sharp/sharpConfig.d.ts +7 -0
- package/dist/core/server/media/styles/sharp/sharpConfig.js +12 -0
- package/dist/db-postgres/index.js +113 -1
- package/dist/db-postgres/schema/imageStyle.js +1 -1
- package/dist/db-postgres/schema/index.d.ts +1 -0
- package/dist/db-postgres/schema/index.js +1 -0
- package/dist/db-postgres/schema/mediaFile.d.ts +17 -0
- package/dist/db-postgres/schema/mediaFile.js +1 -0
- package/dist/db-postgres/schema/mediaJob.d.ts +279 -0
- package/dist/db-postgres/schema/mediaJob.js +23 -0
- package/dist/sveltekit/components/image.svelte +1 -1
- package/dist/types/adapters/db.d.ts +51 -0
- package/dist/types/cms.d.ts +46 -0
- package/dist/types/media.d.ts +1 -0
- package/dist/updates/0.36.8/index.d.ts +2 -0
- package/dist/updates/0.36.8/index.js +20 -0
- package/dist/updates/0.36.9/index.d.ts +2 -0
- package/dist/updates/0.36.9/index.js +12 -0
- package/dist/updates/index.js +5 -1
- package/package.json +1 -1
package/API.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# includio-cms — Public API v0.36.
|
|
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: **
|
|
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
|
@@ -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
|
|
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" />
|
|
@@ -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>
|
|
@@ -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
|
|
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,
|
|
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()
|