@kidecms/core 0.1.0

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 (93) hide show
  1. package/README.md +28 -0
  2. package/admin/components/AdminCard.astro +25 -0
  3. package/admin/components/AiGenerateButton.tsx +102 -0
  4. package/admin/components/AssetsGrid.tsx +711 -0
  5. package/admin/components/BlockEditor.tsx +996 -0
  6. package/admin/components/CheckboxField.tsx +31 -0
  7. package/admin/components/DocumentActions.tsx +317 -0
  8. package/admin/components/DocumentLock.tsx +54 -0
  9. package/admin/components/DocumentsDataTable.tsx +804 -0
  10. package/admin/components/FieldControl.astro +397 -0
  11. package/admin/components/FocalPointSelector.tsx +100 -0
  12. package/admin/components/ImageBrowseDialog.tsx +176 -0
  13. package/admin/components/ImagePicker.tsx +149 -0
  14. package/admin/components/InternalLinkPicker.tsx +80 -0
  15. package/admin/components/LiveHeading.tsx +17 -0
  16. package/admin/components/MobileSidebar.tsx +29 -0
  17. package/admin/components/RelationField.tsx +204 -0
  18. package/admin/components/RichTextEditor.tsx +685 -0
  19. package/admin/components/SelectField.tsx +65 -0
  20. package/admin/components/SidebarUserMenu.tsx +99 -0
  21. package/admin/components/SlugField.tsx +77 -0
  22. package/admin/components/TaxonomySelect.tsx +52 -0
  23. package/admin/components/Toast.astro +40 -0
  24. package/admin/components/TreeItemsEditor.tsx +790 -0
  25. package/admin/components/TreeSelect.tsx +166 -0
  26. package/admin/components/UnsavedGuard.tsx +181 -0
  27. package/admin/components/tree-utils.ts +86 -0
  28. package/admin/components/ui/alert-dialog.tsx +92 -0
  29. package/admin/components/ui/badge.tsx +83 -0
  30. package/admin/components/ui/button.tsx +53 -0
  31. package/admin/components/ui/card.tsx +70 -0
  32. package/admin/components/ui/checkbox.tsx +28 -0
  33. package/admin/components/ui/collapsible.tsx +26 -0
  34. package/admin/components/ui/command.tsx +88 -0
  35. package/admin/components/ui/dialog.tsx +92 -0
  36. package/admin/components/ui/dropdown-menu.tsx +259 -0
  37. package/admin/components/ui/input.tsx +20 -0
  38. package/admin/components/ui/label.tsx +20 -0
  39. package/admin/components/ui/popover.tsx +42 -0
  40. package/admin/components/ui/select.tsx +165 -0
  41. package/admin/components/ui/separator.tsx +21 -0
  42. package/admin/components/ui/sheet.tsx +104 -0
  43. package/admin/components/ui/skeleton.tsx +7 -0
  44. package/admin/components/ui/table.tsx +74 -0
  45. package/admin/components/ui/textarea.tsx +18 -0
  46. package/admin/components/ui/tooltip.tsx +52 -0
  47. package/admin/layouts/AdminLayout.astro +340 -0
  48. package/admin/lib/utils.ts +19 -0
  49. package/dist/admin.js +92 -0
  50. package/dist/ai.js +67 -0
  51. package/dist/api.js +827 -0
  52. package/dist/assets.js +163 -0
  53. package/dist/auth.js +132 -0
  54. package/dist/blocks.js +110 -0
  55. package/dist/content.js +29 -0
  56. package/dist/create-admin.js +23 -0
  57. package/dist/define.js +36 -0
  58. package/dist/generator.js +370 -0
  59. package/dist/image.js +69 -0
  60. package/dist/index.js +16 -0
  61. package/dist/integration.js +256 -0
  62. package/dist/locks.js +37 -0
  63. package/dist/richtext.js +1 -0
  64. package/dist/runtime.js +26 -0
  65. package/dist/schema.js +13 -0
  66. package/dist/seed.js +84 -0
  67. package/dist/values.js +102 -0
  68. package/middleware/auth.ts +100 -0
  69. package/package.json +102 -0
  70. package/routes/api/cms/[collection]/[...path].ts +366 -0
  71. package/routes/api/cms/ai/alt-text.ts +25 -0
  72. package/routes/api/cms/ai/seo.ts +25 -0
  73. package/routes/api/cms/ai/translate.ts +31 -0
  74. package/routes/api/cms/assets/[id].ts +82 -0
  75. package/routes/api/cms/assets/folders.ts +81 -0
  76. package/routes/api/cms/assets/index.ts +23 -0
  77. package/routes/api/cms/assets/upload.ts +112 -0
  78. package/routes/api/cms/auth/invite.ts +166 -0
  79. package/routes/api/cms/auth/login.ts +124 -0
  80. package/routes/api/cms/auth/logout.ts +33 -0
  81. package/routes/api/cms/auth/setup.ts +77 -0
  82. package/routes/api/cms/cron/publish.ts +33 -0
  83. package/routes/api/cms/img/[...path].ts +24 -0
  84. package/routes/api/cms/locks/[...path].ts +37 -0
  85. package/routes/api/cms/preview/render.ts +36 -0
  86. package/routes/api/cms/references/[collection]/[id].ts +60 -0
  87. package/routes/pages/admin/[...path].astro +1104 -0
  88. package/routes/pages/admin/assets/[id].astro +183 -0
  89. package/routes/pages/admin/assets/index.astro +58 -0
  90. package/routes/pages/admin/invite.astro +116 -0
  91. package/routes/pages/admin/login.astro +57 -0
  92. package/routes/pages/admin/setup.astro +91 -0
  93. package/virtual.d.ts +61 -0
package/package.json ADDED
@@ -0,0 +1,102 @@
1
+ {
2
+ "name": "@kidecms/core",
3
+ "version": "0.1.0",
4
+ "description": "Code-first CMS framework built for Astro.",
5
+ "author": "Matti Hernesniemi",
6
+ "license": "MIT",
7
+ "type": "module",
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js"
14
+ },
15
+ "./integration": {
16
+ "import": "./dist/integration.js"
17
+ },
18
+ "./admin/*": "./admin/*",
19
+ "./routes/*": "./routes/*",
20
+ "./middleware/*": "./middleware/*"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "admin",
25
+ "routes",
26
+ "middleware",
27
+ "virtual.d.ts"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc -p tsconfig.json"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.12.0"
34
+ },
35
+ "dependencies": {
36
+ "@base-ui/react": "^1.3.0",
37
+ "@dnd-kit/core": "^6.3.1",
38
+ "@dnd-kit/sortable": "^10.0.0",
39
+ "@dnd-kit/utilities": "^3.2.2",
40
+ "@radix-ui/react-dropdown-menu": "^2.1.16",
41
+ "@radix-ui/react-popover": "^1.1.15",
42
+ "@tanstack/react-table": "^8.21.3",
43
+ "@tiptap/core": "^3.22.3",
44
+ "@tiptap/extension-image": "^3.22.3",
45
+ "@tiptap/extension-link": "^3.22.3",
46
+ "@tiptap/markdown": "^3.22.3",
47
+ "@tiptap/pm": "^3.22.3",
48
+ "@tiptap/react": "^3.22.3",
49
+ "@tiptap/starter-kit": "^3.22.3",
50
+ "class-variance-authority": "^0.7.1",
51
+ "clsx": "^2.1.1",
52
+ "cmdk": "^1.1.1",
53
+ "drizzle-orm": "^0.45.0",
54
+ "lucide-react": "^0.577.0",
55
+ "nanoid": "^5.1.6",
56
+ "tailwind-merge": "^3.5.0",
57
+ "tw-animate-css": "^1.4.0",
58
+ "zod": "^4.3.6"
59
+ },
60
+ "peerDependencies": {
61
+ "@ai-sdk/openai": "^3.0.0",
62
+ "@astrojs/react": "^5.0.0",
63
+ "ai": "^6.0.0",
64
+ "astro": "^6.0.0",
65
+ "react": "^19.0.0",
66
+ "react-dom": "^19.0.0",
67
+ "sharp": "^0.34.0",
68
+ "tailwindcss": "^4.0.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@ai-sdk/openai": {
72
+ "optional": true
73
+ },
74
+ "@astrojs/react": {
75
+ "optional": true
76
+ },
77
+ "react": {
78
+ "optional": true
79
+ },
80
+ "react-dom": {
81
+ "optional": true
82
+ },
83
+ "sharp": {
84
+ "optional": true
85
+ },
86
+ "ai": {
87
+ "optional": true
88
+ },
89
+ "astro": {
90
+ "optional": true
91
+ },
92
+ "tailwindcss": {
93
+ "optional": true
94
+ }
95
+ },
96
+ "devDependencies": {
97
+ "@types/node": "^24.0.0",
98
+ "@types/react": "^19.2.14",
99
+ "@types/react-dom": "^19.2.3",
100
+ "typescript": "^5.9.3"
101
+ }
102
+ }
@@ -0,0 +1,366 @@
1
+ import type { APIRoute } from "astro";
2
+
3
+ import config from "virtual:kide/config";
4
+ import { cms } from "virtual:kide/api";
5
+
6
+ export const prerender = false;
7
+
8
+ const cmsRuntime = cms as Record<string, any> & { meta: typeof cms.meta };
9
+
10
+ const getSegments = (path: string | undefined) => (path ?? "").split("/").filter(Boolean);
11
+
12
+ const isFormRequest = (request: Request) => {
13
+ const contentType = request.headers.get("content-type") ?? "";
14
+ return contentType.includes("application/x-www-form-urlencoded") || contentType.includes("multipart/form-data");
15
+ };
16
+
17
+ // Append _toast and _msg params so the layout can render a server-side toast
18
+ const redirect = (location: string, toast?: { status: "success" | "error"; msg: string }) => {
19
+ let target = location;
20
+ if (toast) {
21
+ const sep = target.includes("?") ? "&" : "?";
22
+ target += `${sep}_toast=${toast.status}&_msg=${encodeURIComponent(toast.msg)}`;
23
+ }
24
+ return new Response(null, { status: 303, headers: { Location: target } });
25
+ };
26
+
27
+ const stripToastParams = (url: string) => {
28
+ const [path, query] = url.split("?");
29
+ if (!query) return url;
30
+ const params = new URLSearchParams(query);
31
+ params.delete("_toast");
32
+ params.delete("_msg");
33
+ return params.size ? `${path}?${params}` : path;
34
+ };
35
+
36
+ const parseJsonQuery = (value: string | null) => {
37
+ if (!value) {
38
+ return undefined;
39
+ }
40
+
41
+ try {
42
+ return JSON.parse(value);
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ };
47
+
48
+ const getCollection = (slug: string) => {
49
+ const collection = config.collections.find((entry) => entry.slug === slug);
50
+ if (!collection) {
51
+ throw new Error(`Unknown collection "${slug}".`);
52
+ }
53
+
54
+ return collection;
55
+ };
56
+
57
+ const extractDataFromForm = async (request: Request) => {
58
+ const formData = await request.formData();
59
+ const entries = [...formData.entries()];
60
+
61
+ return {
62
+ action: String(formData.get("_action") ?? "create"),
63
+ intent: String(formData.get("_intent") ?? "save"),
64
+ // Strip stale toast params from redirectTo (they persist in the hidden input
65
+ // because the server renders the form before the client-side URL cleanup runs)
66
+ redirectTo: stripToastParams(String(formData.get("redirectTo") ?? "/admin")),
67
+ locale: formData.get("locale") ? String(formData.get("locale")) : undefined,
68
+ version: formData.get("version") ? Number(formData.get("version")) : undefined,
69
+ data: Object.fromEntries(
70
+ entries.filter(
71
+ ([key]) =>
72
+ (!key.startsWith("_") || key === "_publishAt" || key === "_unpublishAt") &&
73
+ key !== "redirectTo" &&
74
+ key !== "locale" &&
75
+ key !== "version",
76
+ ),
77
+ ),
78
+ };
79
+ };
80
+
81
+ const handleHtmlMutation = async (
82
+ collectionSlug: string,
83
+ documentId: string | undefined,
84
+ request: Request,
85
+ locals: App.Locals,
86
+ cache?: { invalidate: (opts: { tags: string[] }) => void | Promise<void> },
87
+ ) => {
88
+ const { action, data, intent, redirectTo, locale, version } = await extractDataFromForm(request);
89
+ const collectionApi = cmsRuntime[collectionSlug];
90
+ const collection = getCollection(collectionSlug);
91
+ const ctx = getRuntimeContext(locals, cache);
92
+
93
+ const name = collection.labels.singular;
94
+
95
+ try {
96
+ if (action === "create") {
97
+ const created = await collectionApi.create(data, ctx);
98
+ if (collection.drafts && intent === "publish") {
99
+ await collectionApi.publish(created._id, ctx);
100
+ } else if (collection.drafts && intent === "schedule" && data._publishAt) {
101
+ await collectionApi.schedule(
102
+ created._id,
103
+ String(data._publishAt),
104
+ data._unpublishAt ? String(data._unpublishAt) : null,
105
+ ctx,
106
+ );
107
+ }
108
+ const msg =
109
+ intent === "publish"
110
+ ? `${name} created and published`
111
+ : intent === "schedule"
112
+ ? `${name} scheduled`
113
+ : `${name} created`;
114
+ return redirect(`/admin/${collectionSlug}/${created._id}`, { status: "success", msg });
115
+ }
116
+
117
+ if (!documentId) {
118
+ throw new Error("A document id is required for this action.");
119
+ }
120
+
121
+ if (action === "update") {
122
+ await collectionApi.update(documentId, data, ctx);
123
+ if (collection.drafts && intent === "publish") {
124
+ await collectionApi.publish(documentId, ctx);
125
+ } else if (collection.drafts && intent === "unpublish") {
126
+ await collectionApi.unpublish(documentId, ctx);
127
+ } else if (collection.drafts && intent === "schedule" && data._publishAt) {
128
+ await collectionApi.schedule(
129
+ documentId,
130
+ String(data._publishAt),
131
+ data._unpublishAt ? String(data._unpublishAt) : null,
132
+ ctx,
133
+ );
134
+ }
135
+ const msg =
136
+ intent === "publish"
137
+ ? `${name} published`
138
+ : intent === "unpublish"
139
+ ? `${name} unpublished`
140
+ : intent === "schedule"
141
+ ? `${name} scheduled`
142
+ : collection.drafts
143
+ ? `${name} saved as draft`
144
+ : `${name} saved`;
145
+ return redirect(redirectTo, { status: "success", msg });
146
+ }
147
+
148
+ if (action === "delete") {
149
+ await collectionApi.delete(documentId, ctx);
150
+ return redirect(`/admin/${collectionSlug}`, { status: "success", msg: `${name} deleted` });
151
+ }
152
+
153
+ if (action === "publish") {
154
+ await collectionApi.publish(documentId, ctx);
155
+ return redirect(redirectTo, { status: "success", msg: `${name} published` });
156
+ }
157
+
158
+ if (action === "unpublish") {
159
+ await collectionApi.unpublish(documentId, ctx);
160
+ return redirect(redirectTo, { status: "success", msg: `${name} unpublished` });
161
+ }
162
+
163
+ if (action === "discard-draft") {
164
+ await collectionApi.discardDraft(documentId, ctx);
165
+ return redirect(redirectTo, { status: "success", msg: `Changes discarded` });
166
+ }
167
+
168
+ if (action === "restore" && version) {
169
+ await collectionApi.restore(documentId, version, ctx);
170
+ return redirect(redirectTo, { status: "success", msg: `Version ${version} restored` });
171
+ }
172
+
173
+ if (action === "save-translation" && locale) {
174
+ await collectionApi.upsertTranslation(documentId, locale, data, ctx);
175
+ return redirect(redirectTo, { status: "success", msg: `${locale} translation saved` });
176
+ }
177
+
178
+ return redirect(redirectTo);
179
+ } catch (error) {
180
+ let msg = error instanceof Error ? error.message : `Failed to ${action}`;
181
+ if (msg.toLowerCase().includes("unique constraint failed")) {
182
+ const match = msg.match(/unique constraint failed:\s*\S+\.(\w+)/i);
183
+ const field = match ? match[1] : "field";
184
+ msg = `A document with this ${field} already exists`;
185
+ } else if (msg.startsWith("Failed query:")) {
186
+ msg = `Failed to ${action} ${collection.labels.singular.toLowerCase()}`;
187
+ }
188
+ const fallback = documentId ? redirectTo : redirectTo || `/admin/${collectionSlug}/new`;
189
+ return redirect(fallback, { status: "error", msg });
190
+ }
191
+ };
192
+
193
+ const getRuntimeContext = (
194
+ locals: App.Locals,
195
+ cache?: { invalidate: (opts: { tags: string[] }) => void | Promise<void> },
196
+ ) => {
197
+ const user = locals.user;
198
+ return {
199
+ ...(user ? { user: { id: user.id, role: user.role, email: user.email } } : {}),
200
+ ...(cache ? { cache } : {}),
201
+ };
202
+ };
203
+
204
+ export const GET: APIRoute = async ({ params, url, locals }) => {
205
+ const collectionSlug = params.collection;
206
+ if (!collectionSlug) {
207
+ return Response.json({ error: "Collection is required." }, { status: 400 });
208
+ }
209
+
210
+ const ctx = getRuntimeContext(locals);
211
+ const pathSegments = getSegments(params.path);
212
+ const documentId = pathSegments[0];
213
+ const locale = url.searchParams.get("locale") ?? undefined;
214
+ const status = (url.searchParams.get("status") as "draft" | "published" | "any" | null) ?? undefined;
215
+
216
+ if (documentId) {
217
+ const doc = await cmsRuntime[collectionSlug].findById(documentId, { locale, status }, ctx);
218
+ if (!doc) {
219
+ return Response.json({ error: "Not found." }, { status: 404 });
220
+ }
221
+
222
+ return Response.json(doc);
223
+ }
224
+
225
+ const limit = url.searchParams.get("limit") ? Number(url.searchParams.get("limit")) : 20;
226
+ const offset = url.searchParams.get("offset") ? Number(url.searchParams.get("offset")) : 0;
227
+ const search = url.searchParams.get("search") ?? undefined;
228
+
229
+ const findOptions = {
230
+ where: parseJsonQuery(url.searchParams.get("where")),
231
+ sort: parseJsonQuery(url.searchParams.get("sort")),
232
+ limit,
233
+ offset,
234
+ locale,
235
+ status,
236
+ search,
237
+ };
238
+
239
+ const [docs, totalDocs] = await Promise.all([
240
+ cmsRuntime[collectionSlug].find(findOptions, ctx),
241
+ cmsRuntime[collectionSlug].count({ where: findOptions.where, status: findOptions.status, locale, search }, ctx),
242
+ ]);
243
+
244
+ const page = Math.floor(offset / limit) + 1;
245
+ const totalPages = Math.ceil(totalDocs / limit);
246
+
247
+ return Response.json({ docs, totalDocs, limit, offset, page, totalPages });
248
+ };
249
+
250
+ export const POST: APIRoute = async ({ params, request, locals, cache }) => {
251
+ const collectionSlug = params.collection;
252
+ if (!collectionSlug) {
253
+ return Response.json({ error: "Collection is required." }, { status: 400 });
254
+ }
255
+
256
+ const ctx = getRuntimeContext(locals, cache);
257
+ const pathSegments = getSegments(params.path);
258
+ const documentId = pathSegments[0];
259
+ const pathAction = pathSegments[1];
260
+
261
+ if (isFormRequest(request)) {
262
+ return handleHtmlMutation(collectionSlug, documentId, request, locals, cache);
263
+ }
264
+
265
+ const collectionApi = cmsRuntime[collectionSlug];
266
+
267
+ if (pathAction === "publish" && documentId) {
268
+ return Response.json(await collectionApi.publish(documentId, ctx));
269
+ }
270
+
271
+ if (pathAction === "unpublish" && documentId) {
272
+ return Response.json(await collectionApi.unpublish(documentId, ctx));
273
+ }
274
+
275
+ if (pathAction === "schedule" && documentId) {
276
+ const body = await request.json();
277
+ return Response.json(await collectionApi.schedule(documentId, body.publishAt, body.unpublishAt ?? null, ctx));
278
+ }
279
+
280
+ if (pathAction === "duplicate" && documentId) {
281
+ const original = await collectionApi.findById(documentId, { status: "any" }, ctx);
282
+ if (!original) {
283
+ return Response.json({ error: "Not found." }, { status: 404 });
284
+ }
285
+ const collection = getCollection(collectionSlug);
286
+
287
+ // Strip system fields and unique values that would conflict
288
+ const stripFields = (source: Record<string, any>) => {
289
+ const out: Record<string, any> = {};
290
+ for (const [key, value] of Object.entries(source)) {
291
+ if (key.startsWith("_")) continue;
292
+ const fieldDef = collection.fields[key];
293
+ if (!fieldDef) continue;
294
+ if ("unique" in fieldDef && fieldDef.unique) continue;
295
+ if (fieldDef.type === "slug") continue;
296
+ out[key] = value;
297
+ }
298
+ return out;
299
+ };
300
+
301
+ const data = stripFields(original);
302
+ if (typeof data.title === "string") data.title = `Copy of ${data.title}`;
303
+ else if (typeof data.name === "string") data.name = `Copy of ${data.name}`;
304
+
305
+ const created = await collectionApi.create(data, ctx);
306
+
307
+ // Copy translations if the collection supports them
308
+ if (collectionApi.getTranslations && collectionApi.upsertTranslation) {
309
+ try {
310
+ const translations = await collectionApi.getTranslations(documentId);
311
+ if (translations && typeof translations === "object") {
312
+ for (const [locale, translationData] of Object.entries(translations)) {
313
+ if (!translationData || typeof translationData !== "object") continue;
314
+ const translatedData = stripFields(translationData as Record<string, any>);
315
+ if (typeof translatedData.title === "string") translatedData.title = `Copy of ${translatedData.title}`;
316
+ else if (typeof translatedData.name === "string") translatedData.name = `Copy of ${translatedData.name}`;
317
+ await collectionApi.upsertTranslation(created._id, locale, translatedData, ctx);
318
+ }
319
+ }
320
+ } catch {
321
+ // Translations are optional — ignore errors
322
+ }
323
+ }
324
+
325
+ return Response.json(created, { status: 201 });
326
+ }
327
+
328
+ const body = await request.json();
329
+ const created = await collectionApi.create(body, ctx);
330
+ return Response.json(created, { status: 201 });
331
+ };
332
+
333
+ export const PATCH: APIRoute = async ({ params, request, locals, cache }) => {
334
+ const collectionSlug = params.collection;
335
+ if (!collectionSlug) {
336
+ return Response.json({ error: "Collection is required." }, { status: 400 });
337
+ }
338
+
339
+ const ctx = getRuntimeContext(locals, cache);
340
+ const pathSegments = getSegments(params.path);
341
+ const documentId = pathSegments[0];
342
+ if (!documentId) {
343
+ return Response.json({ error: "Document id is required." }, { status: 400 });
344
+ }
345
+
346
+ const body = await request.json();
347
+ const updated = await cmsRuntime[collectionSlug].update(documentId, body, ctx);
348
+ return Response.json(updated);
349
+ };
350
+
351
+ export const DELETE: APIRoute = async ({ params, locals, cache }) => {
352
+ const collectionSlug = params.collection;
353
+ if (!collectionSlug) {
354
+ return new Response(null, { status: 400 });
355
+ }
356
+
357
+ const ctx = getRuntimeContext(locals, cache);
358
+ const pathSegments = getSegments(params.path);
359
+ const documentId = pathSegments[0];
360
+ if (!documentId) {
361
+ return new Response(null, { status: 400 });
362
+ }
363
+
364
+ await cmsRuntime[collectionSlug].delete(documentId, ctx);
365
+ return new Response(null, { status: 204 });
366
+ };
@@ -0,0 +1,25 @@
1
+ import type { APIRoute } from "astro";
2
+ import { isAiEnabled, streamAltText } from "virtual:kide/runtime";
3
+
4
+ export const prerender = false;
5
+
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ if (!isAiEnabled()) {
8
+ return Response.json({ error: "AI features are not enabled." }, { status: 403 });
9
+ }
10
+
11
+ const body = await request.json();
12
+ const { imageUrl, filename } = body;
13
+
14
+ if (!imageUrl || !filename) {
15
+ return Response.json({ error: "imageUrl and filename are required." }, { status: 400 });
16
+ }
17
+
18
+ try {
19
+ const result = await streamAltText(imageUrl, filename);
20
+ return result.toTextStreamResponse();
21
+ } catch (e) {
22
+ const message = e instanceof Error ? e.message : "Generation failed";
23
+ return Response.json({ error: message }, { status: 500 });
24
+ }
25
+ };
@@ -0,0 +1,25 @@
1
+ import type { APIRoute } from "astro";
2
+ import { isAiEnabled, streamSeoDescription } from "virtual:kide/runtime";
3
+
4
+ export const prerender = false;
5
+
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ if (!isAiEnabled()) {
8
+ return Response.json({ error: "AI features are not enabled." }, { status: 403 });
9
+ }
10
+
11
+ const body = await request.json();
12
+ const { title, excerpt, body: pageBody } = body;
13
+
14
+ if (!title) {
15
+ return Response.json({ error: "title is required." }, { status: 400 });
16
+ }
17
+
18
+ try {
19
+ const result = await streamSeoDescription({ title, excerpt, body: pageBody });
20
+ return result.toTextStreamResponse();
21
+ } catch (e) {
22
+ const message = e instanceof Error ? e.message : "Generation failed";
23
+ return Response.json({ error: message }, { status: 500 });
24
+ }
25
+ };
@@ -0,0 +1,31 @@
1
+ import type { APIRoute } from "astro";
2
+ import { isAiEnabled, streamTranslation } from "virtual:kide/runtime";
3
+
4
+ export const prerender = false;
5
+
6
+ export const POST: APIRoute = async ({ request }) => {
7
+ if (!isAiEnabled()) {
8
+ return Response.json({ error: "AI features are not enabled." }, { status: 403 });
9
+ }
10
+
11
+ const body = await request.json();
12
+ const { text, sourceLocale, targetLocale, fieldName, fieldType } = body;
13
+
14
+ if (!text || !sourceLocale || !targetLocale || !fieldName) {
15
+ return Response.json({ error: "text, sourceLocale, targetLocale, and fieldName are required." }, { status: 400 });
16
+ }
17
+
18
+ try {
19
+ const result = await streamTranslation({
20
+ text,
21
+ sourceLocale,
22
+ targetLocale,
23
+ fieldName,
24
+ fieldType: fieldType || "text",
25
+ });
26
+ return result.toTextStreamResponse();
27
+ } catch (e) {
28
+ const message = e instanceof Error ? e.message : "Generation failed";
29
+ return Response.json({ error: message }, { status: 500 });
30
+ }
31
+ };
@@ -0,0 +1,82 @@
1
+ import type { APIRoute } from "astro";
2
+ import { assets } from "virtual:kide/runtime";
3
+
4
+ export const prerender = false;
5
+
6
+ export const GET: APIRoute = async ({ params }) => {
7
+ const id = params.id;
8
+ if (!id) return Response.json({ error: "Asset ID is required." }, { status: 400 });
9
+
10
+ const asset = await assets.findById(id);
11
+ if (!asset) return Response.json({ error: "Not found." }, { status: 404 });
12
+
13
+ return Response.json(asset);
14
+ };
15
+
16
+ export const PATCH: APIRoute = async ({ params, request }) => {
17
+ const id = params.id;
18
+ if (!id) return Response.json({ error: "Asset ID is required." }, { status: 400 });
19
+
20
+ const body = await request.json();
21
+ const data: {
22
+ alt?: string;
23
+ filename?: string;
24
+ folder?: string | null;
25
+ focalX?: number | null;
26
+ focalY?: number | null;
27
+ } = {};
28
+ if (typeof body.alt === "string") data.alt = body.alt;
29
+ if (typeof body.filename === "string") data.filename = body.filename;
30
+ if (body.folder === null || typeof body.folder === "string") data.folder = body.folder;
31
+ if (body.focalX === null || typeof body.focalX === "number") data.focalX = body.focalX;
32
+ if (body.focalY === null || typeof body.focalY === "number") data.focalY = body.focalY;
33
+
34
+ const result = await assets.update(id, data);
35
+ if (!result) return Response.json({ error: "Not found." }, { status: 404 });
36
+
37
+ return Response.json(result);
38
+ };
39
+
40
+ export const DELETE: APIRoute = async ({ params }) => {
41
+ const id = params.id;
42
+ if (!id) return new Response(null, { status: 400 });
43
+
44
+ await assets.delete(id);
45
+ return new Response(null, { status: 204 });
46
+ };
47
+
48
+ export const POST: APIRoute = async ({ params, request }) => {
49
+ const id = params.id;
50
+ if (!id) return new Response(null, { status: 400 });
51
+
52
+ const formData = new URLSearchParams(await request.text());
53
+ const method = formData.get("_method");
54
+
55
+ if (method === "DELETE") {
56
+ await assets.delete(id);
57
+ return new Response(null, {
58
+ status: 303,
59
+ headers: { Location: "/admin/assets?_toast=success&_msg=Asset+deleted" },
60
+ });
61
+ }
62
+
63
+ const action = formData.get("_action");
64
+ if (action === "update") {
65
+ const alt = formData.get("alt");
66
+ const folder = formData.get("folder");
67
+ const focalX = formData.get("focalX");
68
+ const focalY = formData.get("focalY");
69
+ await assets.update(id, {
70
+ alt: alt !== null ? alt : undefined,
71
+ folder: folder !== null ? (folder === "" ? null : folder) : undefined,
72
+ focalX: focalX !== null ? (focalX === "" ? null : Number(focalX)) : undefined,
73
+ focalY: focalY !== null ? (focalY === "" ? null : Number(focalY)) : undefined,
74
+ });
75
+ return new Response(null, {
76
+ status: 303,
77
+ headers: { Location: `/admin/assets/${id}?_toast=success&_msg=Asset+updated` },
78
+ });
79
+ }
80
+
81
+ return new Response(null, { status: 400 });
82
+ };