@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
@@ -0,0 +1,1104 @@
1
+ ---
2
+ import { ArrowUpRight, Dot, Plus } from "lucide-react";
3
+
4
+ import AdminCard from "@kidecms/core/admin/components/AdminCard.astro";
5
+ import AiGenerateButton from "@kidecms/core/admin/components/AiGenerateButton";
6
+ import DocumentActions from "@kidecms/core/admin/components/DocumentActions";
7
+ import DocumentLock from "@kidecms/core/admin/components/DocumentLock";
8
+ import DocumentsDataTable from "@kidecms/core/admin/components/DocumentsDataTable";
9
+ import LiveHeading from "@kidecms/core/admin/components/LiveHeading";
10
+ import UnsavedGuard from "@kidecms/core/admin/components/UnsavedGuard";
11
+ import FieldControl from "@kidecms/core/admin/components/FieldControl.astro";
12
+ import { StatusBadge } from "@kidecms/core/admin/components/ui/badge";
13
+ import { buttonVariants } from "@kidecms/core/admin/components/ui/button";
14
+ import config from "virtual:kide/config";
15
+ import { cms } from "virtual:kide/api";
16
+ import { isAiEnabled } from "virtual:kide/runtime";
17
+ import { getLabelField } from "@kidecms/core";
18
+ import {
19
+ formatDate,
20
+ formatFieldValue,
21
+ getFieldSets,
22
+ getListColumns,
23
+ humanize,
24
+ initDateFormat,
25
+ resolveAdminRoute,
26
+ } from "@kidecms/core";
27
+ import AdminLayout from "@kidecms/core/admin/layouts/AdminLayout.astro";
28
+ export const prerender = false;
29
+
30
+ initDateFormat(config, Astro.cookies.get("tz")?.value);
31
+ const cmsRuntime = cms as Record<string, any> & { meta: typeof cms.meta };
32
+ const route = resolveAdminRoute(Astro.params.path);
33
+ const user = Astro.locals.user;
34
+ const runtimeContext = user ? { user: { id: user.id, role: user.role, email: user.email } } : {};
35
+ const localeConfig = config.locales;
36
+ const locales = localeConfig?.supported ?? [];
37
+ const defaultLocale = localeConfig?.default ?? "en";
38
+ const requestedLocale = Astro.url.searchParams.get("locale") ?? defaultLocale;
39
+ const isEmbed = Astro.url.searchParams.get("_embed") === "1";
40
+
41
+ // Filter collections the user can access
42
+ const canRead = (slug: string) => {
43
+ const c = config.collections.find((col) => col.slug === slug);
44
+ const rule = c?.access?.read;
45
+ if (!rule) return true;
46
+ return rule({ user: user ?? null, doc: null, operation: "read", collection: slug });
47
+ };
48
+ const canWrite = (slug: string) => {
49
+ const c = config.collections.find((col) => col.slug === slug);
50
+ const rule = c?.access?.create;
51
+ if (!rule) return true;
52
+ return rule({ user: user ?? null, doc: null, operation: "create", collection: slug });
53
+ };
54
+ const accessibleCollections = config.collections.filter((c) => canRead(c.slug));
55
+
56
+ // Check if user can access the current collection
57
+ const canAccessCurrent = (() => {
58
+ if (route.kind === "dashboard" || route.kind === "recent" || route.kind === "singles") return true;
59
+ const slug = "collectionSlug" in route ? route.collectionSlug : null;
60
+ return !slug || canRead(slug);
61
+ })();
62
+
63
+ const isFieldHidden = (fieldName: string) => {
64
+ if (!collection) return false;
65
+ const field = collection.fields[fieldName];
66
+ if (!field?.access?.read) return false;
67
+ return !field.access.read({ user: user ?? null, doc: null, operation: "read", collection: collection.slug });
68
+ };
69
+ const isFieldReadOnly = (fieldName: string) => {
70
+ if (!collection) return false;
71
+ const field = collection.fields[fieldName];
72
+ if (!field?.access?.update) return false;
73
+ return !field.access.update({ user: user ?? null, doc: null, operation: "update", collection: collection.slug });
74
+ };
75
+
76
+ // Redirect /admin to recent
77
+ if (route.kind === "dashboard") {
78
+ return Astro.redirect("/admin/recent");
79
+ }
80
+
81
+ // Derive visual status: "draft" | "published" | "scheduled" | "changed"
82
+ const getVisualStatus = (d: Record<string, unknown>): string => {
83
+ const status = String(d._status ?? "draft");
84
+ if (status === "scheduled") return "scheduled";
85
+ if (status === "published" && d._published) return "changed";
86
+ return status;
87
+ };
88
+
89
+ // Recent entries page — all content collections (non-utility) sorted by updatedAt
90
+ const pinnedSlugs = new Set(["taxonomies", "menus", "authors", "users"]);
91
+ const contentCollections = config.collections.filter((c) => !c.auth && !pinnedSlugs.has(c.slug) && canRead(c.slug));
92
+ let recentRows: Array<{
93
+ id: string;
94
+ editHref: string;
95
+ status: string;
96
+ singleton?: boolean;
97
+ locales: string[];
98
+ searchText: string;
99
+ values: Record<string, string>;
100
+ }> = [];
101
+ if (route.kind === "recent") {
102
+ const allEntries: Array<{
103
+ doc: Record<string, unknown>;
104
+ collection: (typeof contentCollections)[0];
105
+ }> = [];
106
+
107
+ for (const cc of contentCollections) {
108
+ const api = cmsRuntime[cc.slug];
109
+ const docs = await api.find(
110
+ { status: "any", locale: defaultLocale, sort: { field: "_updatedAt", direction: "desc" }, limit: 50 },
111
+ runtimeContext,
112
+ );
113
+ for (const doc of docs) {
114
+ allEntries.push({ doc, collection: cc });
115
+ }
116
+ }
117
+
118
+ allEntries.sort((a, b) => String(b.doc._updatedAt ?? "").localeCompare(String(a.doc._updatedAt ?? "")));
119
+ const topEntries = allEntries.slice(0, 50);
120
+
121
+ recentRows = topEntries.map(({ doc, collection: cc }) => {
122
+ const displayTitle = cc.singleton
123
+ ? cc.labels.singular
124
+ : String(doc[getLabelField(cc)] ?? doc.slug ?? cc.labels.singular);
125
+ return {
126
+ id: String(doc._id),
127
+ editHref: `/admin/${cc.slug}/${doc._id}`,
128
+ status: getVisualStatus(doc),
129
+ singleton: cc.singleton,
130
+ locales: [
131
+ defaultLocale,
132
+ ...(Array.isArray(doc._availableLocales)
133
+ ? doc._availableLocales.map((l) => String(l)).filter((l) => l !== defaultLocale)
134
+ : []),
135
+ ],
136
+ searchText: displayTitle,
137
+ values: {
138
+ title: displayTitle,
139
+ collection: cc.labels.singular,
140
+ _status: getVisualStatus(doc),
141
+ _updatedAt: formatDate(doc._updatedAt),
142
+ },
143
+ };
144
+ });
145
+ }
146
+
147
+ // Singles listing page
148
+ const singletonCollections = config.collections.filter((c) => c.singleton && canRead(c.slug));
149
+ let singletonsData: Array<{
150
+ id: string;
151
+ collectionSlug: string;
152
+ label: string;
153
+ status: string;
154
+ locales: string[];
155
+ updatedAt: string;
156
+ editHref: string;
157
+ }> = [];
158
+ if (route.kind === "singles") {
159
+ for (const sc of singletonCollections) {
160
+ const api = cmsRuntime[sc.slug];
161
+ const singleDocs = await api.find({ status: "any", limit: 1, locale: defaultLocale }, runtimeContext);
162
+ const singleDoc = singleDocs[0] ?? null;
163
+ singletonsData.push({
164
+ id: singleDoc ? String(singleDoc._id) : "",
165
+ collectionSlug: sc.slug,
166
+ label: sc.labels.singular,
167
+ status: singleDoc && sc.drafts ? String(singleDoc._status ?? "draft") : "",
168
+ locales: singleDoc
169
+ ? [
170
+ defaultLocale,
171
+ ...(Array.isArray(singleDoc._availableLocales)
172
+ ? singleDoc._availableLocales.filter((l: string) => l !== defaultLocale)
173
+ : []),
174
+ ]
175
+ : [],
176
+ updatedAt: singleDoc ? formatDate(singleDoc._updatedAt) : "—",
177
+ editHref: `/admin/${sc.slug}`,
178
+ });
179
+ }
180
+ }
181
+
182
+ const getCollectionView = (slug: string) => {
183
+ const c = config.collections.find((col) => col.slug === slug);
184
+ return c?.views ?? {};
185
+ };
186
+ const getFormAction = (collectionSlug: string, documentId?: string) => {
187
+ if (collectionSlug === "users" && !documentId) return "/api/cms/auth/invite";
188
+ return documentId ? `/api/cms/${collectionSlug}/${documentId}` : `/api/cms/${collectionSlug}`;
189
+ };
190
+
191
+ const getFieldSetTitle = (position: string, index: number) => {
192
+ if (position === "sidebar") return "Details";
193
+ return index === 0 ? "Content" : "Content";
194
+ };
195
+
196
+ let collectionApi: any = null;
197
+ let viewConfig: Record<string, any> = {};
198
+ let docs: Array<Record<string, unknown>> = [];
199
+ let doc: Record<string, unknown> | null = null;
200
+ let baseDoc: Record<string, unknown> | null = null;
201
+ let relationOptionsByField: Record<string, Array<{ value: string; label: string }>> = {};
202
+ let relationMetaByField: Record<
203
+ string,
204
+ {
205
+ collectionSlug: string;
206
+ collectionLabel: string;
207
+ hasMany: boolean;
208
+ labelField?: string;
209
+ }
210
+ > = {};
211
+ let versions: Array<{ version: number; createdAt: string }> = [];
212
+ let menuLinkOptions: Array<{
213
+ collection: string;
214
+ label: string;
215
+ items: Array<{ id: string; label: string; href: string }>;
216
+ }> = [];
217
+
218
+ const collectionSlug = "collectionSlug" in route ? route.collectionSlug : null;
219
+ const collection = collectionSlug ? (cms.meta.getCollection(collectionSlug) as any) : null;
220
+ const isSingleton = collection?.singleton === true;
221
+
222
+ if (collection && collectionSlug && canAccessCurrent) {
223
+ collectionApi = cmsRuntime[collectionSlug];
224
+ viewConfig = getCollectionView(collectionSlug);
225
+
226
+ // Singleton collections: auto-create the single document if missing, then redirect to edit
227
+ if (isSingleton && (route.kind === "list" || route.kind === "new")) {
228
+ const singleDocs = await collectionApi.find({ status: "any", limit: 1 }, runtimeContext);
229
+ if (singleDocs.length > 0) {
230
+ return Astro.redirect(`/admin/${collectionSlug}/${singleDocs[0]._id}`);
231
+ }
232
+ return Astro.redirect(`/admin/${collectionSlug}/${(await collectionApi.create({}, runtimeContext))._id}`);
233
+ }
234
+
235
+ for (const [fieldName, field] of Object.entries(collection.fields) as [string, import("@kidecms/core").FieldConfig][]) {
236
+ if (field.type === "relation" && canRead(field.collection)) {
237
+ const relatedDocs = await cmsRuntime[field.collection].find(
238
+ {
239
+ status: "any",
240
+ limit: 100,
241
+ sort: { field: "_updatedAt", direction: "desc" },
242
+ locale: defaultLocale,
243
+ },
244
+ runtimeContext,
245
+ );
246
+
247
+ const relatedCollection = config.collections.find((c) => c.slug === field.collection);
248
+ const relLabelField = relatedCollection ? getLabelField(relatedCollection) : "title";
249
+ relationOptionsByField[fieldName] = relatedDocs.map((item: Record<string, unknown>) => ({
250
+ value: String(item._id),
251
+ label: String(item[relLabelField] ?? item.slug ?? item._id),
252
+ }));
253
+ if (relatedCollection) {
254
+ relationMetaByField[fieldName] = {
255
+ collectionSlug: field.collection,
256
+ collectionLabel: relatedCollection.labels.singular,
257
+ hasMany: field.hasMany ?? false,
258
+ labelField: getLabelField(relatedCollection),
259
+ };
260
+ }
261
+ }
262
+
263
+ // Also fetch relation options for relation fields inside blocks
264
+ if (field.type === "blocks" && field.types) {
265
+ for (const [typeName, typeFields] of Object.entries(field.types)) {
266
+ for (const [subFieldName, subField] of Object.entries(typeFields) as [
267
+ string,
268
+ import("@kidecms/core").FieldConfig,
269
+ ][]) {
270
+ if (subField.type === "relation" && canRead(subField.collection)) {
271
+ const key = `block:${typeName}:${subFieldName}`;
272
+ if (!relationOptionsByField[key]) {
273
+ const relatedDocs = await cmsRuntime[subField.collection].find(
274
+ { status: "any", limit: 100, sort: { field: "_updatedAt", direction: "desc" }, locale: defaultLocale },
275
+ runtimeContext,
276
+ );
277
+ const blkRelCol = config.collections.find((c) => c.slug === subField.collection);
278
+ const blkLabelField = blkRelCol ? getLabelField(blkRelCol) : "title";
279
+ relationOptionsByField[key] = relatedDocs.map((item: Record<string, unknown>) => ({
280
+ value: String(item._id),
281
+ label: String(item[blkLabelField] ?? item.slug ?? item._id),
282
+ }));
283
+ }
284
+ }
285
+ }
286
+ }
287
+ }
288
+ }
289
+
290
+ // Build link options for menu editors: fetch linkable documents from content collections
291
+ if (collectionSlug === "menus" && (route.kind === "edit" || route.kind === "new")) {
292
+ const linkableCollections = config.collections.filter(
293
+ (c) =>
294
+ !c.singleton &&
295
+ !["users", "menus", "taxonomies", "authors"].includes(c.slug) &&
296
+ c.fields.slug &&
297
+ canRead(c.slug),
298
+ );
299
+ for (const lc of linkableCollections) {
300
+ const lcApi = cmsRuntime[lc.slug];
301
+ const lcDocs = await lcApi.find(
302
+ { status: "published", limit: 200, sort: { field: "_updatedAt", direction: "desc" }, locale: defaultLocale },
303
+ runtimeContext,
304
+ );
305
+ menuLinkOptions.push({
306
+ collection: lc.slug,
307
+ label: lc.labels.plural,
308
+ items: lcDocs.map((d: Record<string, unknown>) => ({
309
+ id: String(d._id),
310
+ label: String(d[getLabelField(lc)] ?? d.slug ?? d._id),
311
+ href: cms.meta.getRouteForDocument(lc.slug, d),
312
+ })),
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ const PAGE_SIZE = config.admin?.pageSize ?? 20;
319
+ let totalDocs = 0;
320
+ let totalPages = 1;
321
+ let currentPage = 1;
322
+ let currentSort = viewConfig.list?.defaultSort ?? { field: "_updatedAt", direction: "desc" };
323
+ const searchParam = Astro.url.searchParams.get("q") ?? "";
324
+
325
+ if (route.kind === "list" && canAccessCurrent) {
326
+ const sortParam = Astro.url.searchParams.get("sort");
327
+ const sortDirParam = Astro.url.searchParams.get("dir");
328
+ if (sortParam) {
329
+ currentSort = { field: sortParam, direction: (sortDirParam === "asc" ? "asc" : "desc") as "asc" | "desc" };
330
+ }
331
+ currentPage = Math.max(1, Number(Astro.url.searchParams.get("page") ?? "1"));
332
+ const offset = (currentPage - 1) * PAGE_SIZE;
333
+
334
+ const findOptions = {
335
+ status: "any" as const,
336
+ locale: defaultLocale,
337
+ sort: currentSort,
338
+ limit: PAGE_SIZE,
339
+ offset,
340
+ search: searchParam || undefined,
341
+ };
342
+
343
+ [docs, totalDocs] = await Promise.all([
344
+ collectionApi.find(findOptions, runtimeContext),
345
+ collectionApi.count({ status: "any", locale: defaultLocale, search: searchParam || undefined }, runtimeContext),
346
+ ]);
347
+
348
+ totalPages = Math.max(1, Math.ceil(totalDocs / PAGE_SIZE));
349
+ }
350
+
351
+ if (route.kind === "edit") {
352
+ try {
353
+ doc = await collectionApi.findById(route.documentId, { status: "any", locale: requestedLocale }, runtimeContext);
354
+ } catch {
355
+ return Astro.redirect(`/admin/${route.collectionSlug}?_toast=error&_msg=Access+denied`);
356
+ }
357
+ if (!doc) {
358
+ return Astro.redirect(`/admin/${route.collectionSlug}`);
359
+ }
360
+ baseDoc =
361
+ requestedLocale === defaultLocale
362
+ ? doc
363
+ : await collectionApi.findById(route.documentId, { status: "any", locale: defaultLocale }, runtimeContext);
364
+ versions = await collectionApi.versions(route.documentId);
365
+ }
366
+
367
+ // Find reverse references — documents in other collections that link to this document
368
+ type ReverseRef = { collectionLabel: string; collectionSlug: string; docs: Array<{ _id: string; label: string }> };
369
+ const reverseRefs: ReverseRef[] = [];
370
+ if (route.kind === "edit" && collection && route.documentId) {
371
+ for (const otherCollection of config.collections) {
372
+ if (otherCollection.slug === collection.slug || !canRead(otherCollection.slug)) continue;
373
+ const relationFields = Object.entries(otherCollection.fields).filter(
374
+ ([, f]) => f.type === "relation" && (f as any).collection === collection.slug,
375
+ );
376
+ if (relationFields.length === 0) continue;
377
+ const otherApi = cmsRuntime[otherCollection.slug];
378
+ if (!otherApi) continue;
379
+ const otherLabelField = getLabelField(otherCollection);
380
+ for (const [fieldName, field] of relationFields) {
381
+ try {
382
+ const isMany = (field as any).hasMany;
383
+ if (isMany) {
384
+ // hasMany stores JSON array — fetch all and filter in JS
385
+ const allDocs = await otherApi.find({ status: "any", limit: 200, locale: defaultLocale }, runtimeContext);
386
+ const matched = allDocs.filter((r: Record<string, unknown>) => {
387
+ const val = r[fieldName];
388
+ if (Array.isArray(val)) return val.includes(route.documentId);
389
+ if (typeof val === "string") return val.includes(route.documentId);
390
+ return false;
391
+ });
392
+ if (matched.length > 0) {
393
+ reverseRefs.push({
394
+ collectionLabel: otherCollection.labels.plural,
395
+ collectionSlug: otherCollection.slug,
396
+ docs: matched.map((r: Record<string, unknown>) => ({
397
+ _id: String(r._id),
398
+ label: String(r[otherLabelField] ?? r.slug ?? r._id),
399
+ })),
400
+ });
401
+ }
402
+ } else {
403
+ const refs = await otherApi.find(
404
+ { status: "any", where: { [fieldName]: route.documentId }, limit: 50, locale: defaultLocale },
405
+ runtimeContext,
406
+ );
407
+ if (refs.length > 0) {
408
+ reverseRefs.push({
409
+ collectionLabel: otherCollection.labels.plural,
410
+ collectionSlug: otherCollection.slug,
411
+ docs: refs.map((r: Record<string, unknown>) => ({
412
+ _id: String(r._id),
413
+ label: String(r[otherLabelField] ?? r.slug ?? r._id),
414
+ })),
415
+ });
416
+ }
417
+ }
418
+ } catch {}
419
+ }
420
+ }
421
+ }
422
+
423
+ const selectedLocale = route.kind === "new" ? defaultLocale : requestedLocale;
424
+ const translatableFields = collection
425
+ ? Object.entries(collection.fields)
426
+ .filter(([, field]: [string, any]) => field.translatable)
427
+ .map(([fieldName]) => fieldName)
428
+ : [];
429
+ const translationMode =
430
+ !!collection && route.kind === "edit" && selectedLocale !== defaultLocale && translatableFields.length > 0;
431
+ const fieldSets = collection ? getFieldSets(collection) : [];
432
+ const primaryFieldSets = fieldSets.filter((set: { position?: string }) => set.position !== "sidebar");
433
+ const sidebarFieldSets = fieldSets.filter((set: { position?: string }) => set.position === "sidebar");
434
+ const mainFieldSets = primaryFieldSets.length > 0 ? primaryFieldSets : fieldSets.slice(0, 1);
435
+ const effectiveSidebarFieldSets =
436
+ sidebarFieldSets.length > 0 ? sidebarFieldSets : fieldSets.slice(mainFieldSets.length);
437
+ const listColumns = collection ? getListColumns(collection, viewConfig.list) : [];
438
+ const hasPreview = !!(
439
+ collection &&
440
+ route.kind === "edit" &&
441
+ (collection.preview || (collection.pathPrefix && doc?.slug))
442
+ );
443
+ const previewUrl = hasPreview
444
+ ? typeof collection.preview === "string"
445
+ ? `${collection.preview}?preview=true`
446
+ : `${cms.meta.getRouteForDocument(collection.slug, doc)}?preview=true`
447
+ : null;
448
+ const getCurrentValue = (fieldName: string) => (doc ? doc[fieldName] : undefined);
449
+ const getSharedValue = (fieldName: string) => (baseDoc ? baseDoc[fieldName] : undefined);
450
+ const getBlockRelationOptions = (_fieldName: string): Record<string, Array<{ value: string; label: string }>> => {
451
+ const result: Record<string, Array<{ value: string; label: string }>> = {};
452
+ for (const [key, opts] of Object.entries(relationOptionsByField)) {
453
+ if (key.startsWith("block:")) result[key] = opts;
454
+ }
455
+ return result;
456
+ };
457
+ const aiEnabled = isAiEnabled();
458
+
459
+ const tableRows =
460
+ collection && route.kind === "list"
461
+ ? docs.map((entry) => ({
462
+ id: String(entry._id),
463
+ editHref: `/admin/${collection.slug}/${entry._id}`,
464
+ status: getVisualStatus(entry),
465
+ locales: [
466
+ defaultLocale,
467
+ ...(Array.isArray(entry._availableLocales)
468
+ ? entry._availableLocales.map((l) => String(l)).filter((l) => l !== defaultLocale)
469
+ : []),
470
+ ],
471
+ searchText: String(entry[getLabelField(collection)] ?? entry.slug ?? entry._id ?? ""),
472
+ values: Object.fromEntries(
473
+ listColumns.map((column) => [
474
+ column,
475
+ column === "_status"
476
+ ? getVisualStatus(entry)
477
+ : column === "_updatedAt" || column === "_createdAt"
478
+ ? formatDate(entry[column])
479
+ : column.startsWith("_")
480
+ ? String(entry[column] ?? "—")
481
+ : formatFieldValue(collection.fields[column], entry[column]),
482
+ ]),
483
+ ),
484
+ }))
485
+ : [];
486
+ ---
487
+
488
+ <AdminLayout
489
+ title={route.kind === "recent"
490
+ ? "Recent | Kide CMS"
491
+ : route.kind === "singles"
492
+ ? "Singles | Kide CMS"
493
+ : `${collection?.labels?.plural ?? "Admin"} | Kide CMS`}
494
+ collections={accessibleCollections}
495
+ activeCollection={route.kind === "recent" ? "recent" : (collectionSlug ?? "singles")}
496
+ embed={isEmbed}
497
+ customNav={config.admin?.nav}
498
+ >
499
+ {
500
+ route.kind === "recent" && (
501
+ <section class="space-y-6">
502
+ <div class="flex items-start justify-between gap-4">
503
+ <h1 class="text-2xl font-semibold tracking-tight">Recent</h1>
504
+ </div>
505
+ <DocumentsDataTable
506
+ client:load
507
+ collectionSlug="recent"
508
+ draftsEnabled={true}
509
+ title="Recent"
510
+ columns={[
511
+ { key: "title", label: "Title" },
512
+ { key: "collection", label: "Collection" },
513
+ { key: "_status", label: "Status" },
514
+ { key: "_updatedAt", label: "Updated" },
515
+ ]}
516
+ data={recentRows}
517
+ searchPlaceholder="Filter recent entries..."
518
+ />
519
+ </section>
520
+ )
521
+ }
522
+
523
+ {
524
+ route.kind === "singles" && (
525
+ <section class="space-y-6">
526
+ <div class="flex items-start justify-between gap-4">
527
+ <h1 class="text-2xl font-semibold tracking-tight">Singles</h1>
528
+ </div>
529
+ <DocumentsDataTable
530
+ client:load
531
+ collectionSlug="singles"
532
+ draftsEnabled={true}
533
+ duplicateEnabled={false}
534
+ title="Singles"
535
+ columns={[
536
+ { key: "name", label: "Name" },
537
+ ...(singletonCollections.some((c) => c.drafts) ? [{ key: "_status", label: "Status" }] : []),
538
+ { key: "updatedAt", label: "Last updated" },
539
+ ]}
540
+ data={singletonsData.map((s) => ({
541
+ id: s.id,
542
+ collectionSlug: s.collectionSlug,
543
+ editHref: s.editHref,
544
+ locales: s.locales,
545
+ searchText: s.label,
546
+ values: { name: s.label, _status: s.status, updatedAt: s.updatedAt },
547
+ }))}
548
+ searchPlaceholder="Filter singles..."
549
+ />
550
+ </section>
551
+ )
552
+ }
553
+
554
+ {
555
+ route.kind !== "singles" && !canAccessCurrent && (
556
+ <section class="space-y-6">
557
+ <h1 class="text-2xl font-semibold tracking-tight">Access denied</h1>
558
+ <p class="text-muted-foreground">You do not have permission to view this collection.</p>
559
+ </section>
560
+ )
561
+ }
562
+
563
+ {
564
+ route.kind !== "singles" && canAccessCurrent && (
565
+ <section class="space-y-6">
566
+ <div class="flex items-start justify-between gap-4">
567
+ <div>
568
+ <h1 class="text-2xl font-semibold tracking-tight">
569
+ {route.kind === "list" && collection.labels.plural}
570
+ {route.kind === "new" && <LiveHeading client:load initial={`New ${collection.labels.singular}`} />}
571
+ {route.kind === "edit" && (
572
+ <LiveHeading
573
+ client:load
574
+ initial={
575
+ collection.singleton
576
+ ? collection.labels.singular
577
+ : String(doc?.[getLabelField(collection)] ?? doc?.slug ?? collection.labels.singular)
578
+ }
579
+ />
580
+ )}
581
+ </h1>
582
+ {route.kind === "edit" && doc && collection.drafts && (
583
+ <div class="text-muted-foreground mt-1 flex flex-col gap-0.5 text-xs sm:flex-row sm:items-center sm:gap-0">
584
+ <StatusBadge status={getVisualStatus(doc)} styled />
585
+ {doc._status === "scheduled" && doc._publishAt && (
586
+ <>
587
+ <Dot className="hidden size-6 text-muted-foreground/70 sm:block" />
588
+ <span>Publishes {formatDate(doc._publishAt)}</span>
589
+ {doc._unpublishAt && (
590
+ <>
591
+ <Dot className="hidden size-6 text-muted-foreground/70 sm:block" />
592
+ <span>Unpublishes {formatDate(doc._unpublishAt)}</span>
593
+ </>
594
+ )}
595
+ </>
596
+ )}
597
+ <Dot className="hidden size-6 text-muted-foreground/70 sm:block" />
598
+ <span>Created {formatDate(doc._createdAt)}</span>
599
+ <Dot className="hidden size-6 text-muted-foreground/70 sm:block" />
600
+ <span>Updated {formatDate(doc._updatedAt)}</span>
601
+ </div>
602
+ )}
603
+ </div>
604
+
605
+ <div class="flex flex-wrap items-center gap-2">
606
+ {hasPreview && previewUrl && !translationMode && (
607
+ <a
608
+ href={previewUrl}
609
+ target="_blank"
610
+ class="text-foreground/70 hover:text-foreground flex items-center gap-1 text-sm transition-colors"
611
+ >
612
+ Preview
613
+ <ArrowUpRight className="size-4" />
614
+ </a>
615
+ )}
616
+ {route.kind === "list" && canWrite(collectionSlug!) && (
617
+ <a href={`/admin/${collection.slug}/new`} class={buttonVariants({})}>
618
+ <Plus className="size-4" />
619
+ {collection.slug === "users" ? "Invite user" : `New ${collection.labels.singular}`}
620
+ </a>
621
+ )}
622
+ </div>
623
+ </div>
624
+
625
+ {route.kind === "list" && (
626
+ <DocumentsDataTable
627
+ client:load
628
+ collectionSlug={collection.slug}
629
+ draftsEnabled={collection.drafts}
630
+ defaultLocale={locales.length > 1 ? defaultLocale : undefined}
631
+ labelField={getLabelField(collection)}
632
+ newHref={`/admin/${collection.slug}/new`}
633
+ title={collection.labels.plural}
634
+ columns={listColumns.map((column) => ({ key: column, label: humanize(column) }))}
635
+ data={tableRows}
636
+ searchPlaceholder={`Filter ${collection.labels.plural.toLowerCase()}...`}
637
+ serverPagination={{
638
+ totalDocs,
639
+ totalPages,
640
+ currentPage,
641
+ pageSize: PAGE_SIZE,
642
+ currentSort,
643
+ currentSearch: searchParam,
644
+ }}
645
+ />
646
+ )}
647
+
648
+ {(route.kind === "new" || route.kind === "edit") && (
649
+ <>
650
+ {route.kind === "edit" && (
651
+ <DocumentLock client:load collection={route.collectionSlug} documentId={route.documentId} />
652
+ )}
653
+ <UnsavedGuard
654
+ client:load
655
+ formId={translationMode ? "translation-form" : "document-form"}
656
+ isNew={route.kind === "new"}
657
+ isDraft={
658
+ !translationMode &&
659
+ collection.drafts &&
660
+ (doc?._status === "draft" ||
661
+ doc?._status === "scheduled" ||
662
+ (doc && getVisualStatus(doc) === "changed"))
663
+ }
664
+ />
665
+ <div id="document-edit-area" class="space-y-6">
666
+ {collection.slug === "users" && route.kind === "edit" && Astro.url.searchParams.get("inviteToken") && (
667
+ <AdminCard
668
+ title={Astro.url.searchParams.get("emailSent") === "true" ? "Invitation sent" : "Invite link"}
669
+ >
670
+ {Astro.url.searchParams.get("emailSent") === "true" ? (
671
+ <p class="text-muted-foreground text-sm">
672
+ An invitation email has been sent. The user can also use this link:
673
+ </p>
674
+ ) : (
675
+ <p class="text-muted-foreground text-sm">
676
+ Email is not configured. Share this link with the user to complete their registration:
677
+ </p>
678
+ )}
679
+ <code class="bg-muted mt-2 block rounded-md px-3 py-2 text-sm break-all select-all">
680
+ {Astro.url.origin}/admin/invite?token={Astro.url.searchParams.get("inviteToken")}
681
+ </code>
682
+ </AdminCard>
683
+ )}
684
+ <div
685
+ class={
686
+ effectiveSidebarFieldSets.length === 0 || translationMode
687
+ ? "grid gap-6 2xl:grid-cols-[minmax(0,1.55fr)_minmax(380px,460px)]"
688
+ : ""
689
+ }
690
+ >
691
+ <div class="flex flex-wrap items-center justify-between gap-2">
692
+ <div class="flex flex-wrap items-center gap-2">
693
+ {locales.length > 1 &&
694
+ route.kind === "edit" &&
695
+ translatableFields.length > 0 &&
696
+ locales.map((locale) => {
697
+ const isActive = selectedLocale === locale;
698
+ return (
699
+ <a
700
+ href={`/admin/${collection.slug}/${route.documentId}?locale=${locale}`}
701
+ class:list={[
702
+ buttonVariants({ variant: "outline", size: "sm" }),
703
+ isActive
704
+ ? "border-primary/50 bg-background ring-ring/30 font-medium ring-2"
705
+ : "text-muted-foreground border-border/60",
706
+ ]}
707
+ >
708
+ {locale.toUpperCase()}
709
+ </a>
710
+ );
711
+ })}
712
+ </div>
713
+ <div class="flex flex-wrap items-center gap-2">
714
+ {translationMode ? (
715
+ <button
716
+ class={buttonVariants({ variant: "outline" })}
717
+ type="submit"
718
+ form="translation-form"
719
+ value="save"
720
+ disabled
721
+ >
722
+ Save translation
723
+ </button>
724
+ ) : (
725
+ <>
726
+ <button
727
+ class={buttonVariants({ variant: "outline" })}
728
+ type="submit"
729
+ form="document-form"
730
+ name="_intent"
731
+ value="save"
732
+ disabled={route.kind === "edit"}
733
+ >
734
+ {collection.slug === "users" && route.kind === "new"
735
+ ? "Send invite"
736
+ : collection?.drafts
737
+ ? "Save draft"
738
+ : "Save"}
739
+ </button>
740
+ {collection.drafts && (
741
+ <button
742
+ class={buttonVariants({ variant: "publish" })}
743
+ type="submit"
744
+ form="document-form"
745
+ name="_intent"
746
+ value="publish"
747
+ disabled={
748
+ route.kind === "edit" &&
749
+ !(
750
+ doc?._status === "draft" ||
751
+ doc?._status === "scheduled" ||
752
+ (doc && getVisualStatus(doc) === "changed")
753
+ )
754
+ }
755
+ >
756
+ Publish
757
+ </button>
758
+ )}
759
+ </>
760
+ )}
761
+ {route.kind === "edit" && !translationMode && (
762
+ <DocumentActions
763
+ client:load
764
+ formId="document-form"
765
+ collectionSlug={collection.slug}
766
+ documentId={route.documentId}
767
+ showUnpublish={collection.drafts && doc?._status === "published"}
768
+ showDiscardDraft={collection.drafts && doc?._status === "published" && !!doc?._published}
769
+ showDelete={!isSingleton}
770
+ showDuplicate={!isSingleton}
771
+ showSchedule={!!collection.drafts}
772
+ showCancelSchedule={collection.drafts && doc?._status === "scheduled"}
773
+ currentPublishAt={doc?._publishAt ? String(doc._publishAt) : undefined}
774
+ currentUnpublishAt={doc?._unpublishAt ? String(doc._unpublishAt) : undefined}
775
+ versions={versions}
776
+ restoreEndpoint={getFormAction(collection.slug, route.documentId)}
777
+ redirectTo={Astro.url.pathname + Astro.url.search}
778
+ />
779
+ )}
780
+ </div>
781
+ </div>
782
+ </div>
783
+
784
+ {!translationMode && (
785
+ <form
786
+ id="document-form"
787
+ method="post"
788
+ action={getFormAction(collection.slug, route.kind === "edit" ? route.documentId : undefined)}
789
+ class="grid gap-6 2xl:grid-cols-[minmax(0,1.55fr)_minmax(380px,460px)]"
790
+ >
791
+ <input type="hidden" name="_action" value={route.kind === "new" ? "create" : "update"} />
792
+ <input type="hidden" name="redirectTo" value={Astro.url.pathname + Astro.url.search} />
793
+
794
+ <div class="space-y-6">
795
+ {mainFieldSets.map((set, index) => (
796
+ <AdminCard title={getFieldSetTitle("content", index)}>
797
+ <div class="grid gap-5">
798
+ {set.fields
799
+ .filter((fieldName) => collection.fields[fieldName] && !isFieldHidden(fieldName))
800
+ .map((fieldName) => (
801
+ <FieldControl
802
+ name={fieldName}
803
+ field={collection.fields[fieldName]}
804
+ value={getCurrentValue(fieldName)}
805
+ readOnly={isFieldReadOnly(fieldName)}
806
+ relationOptions={relationOptionsByField[fieldName] ?? []}
807
+ relationMeta={relationMetaByField[fieldName]}
808
+ menuLinkOptions={menuLinkOptions}
809
+ blockRelationOptions={getBlockRelationOptions(fieldName)}
810
+ />
811
+ ))}
812
+ </div>
813
+ </AdminCard>
814
+ ))}
815
+ </div>
816
+
817
+ <div class="space-y-6 text-sm [&_button]:text-sm [&_input]:text-sm [&_select]:text-sm [&_textarea]:text-sm">
818
+ {effectiveSidebarFieldSets.map((set, index) => (
819
+ <AdminCard title={getFieldSetTitle("sidebar", index)}>
820
+ <div class="grid gap-5">
821
+ {set.fields
822
+ .filter((fieldName) => collection.fields[fieldName] && !isFieldHidden(fieldName))
823
+ .map((fieldName) => (
824
+ <div>
825
+ <FieldControl
826
+ name={fieldName}
827
+ field={collection.fields[fieldName]}
828
+ value={getCurrentValue(fieldName)}
829
+ readOnly={isFieldReadOnly(fieldName)}
830
+ relationOptions={relationOptionsByField[fieldName] ?? []}
831
+ relationMeta={relationMetaByField[fieldName]}
832
+ menuLinkOptions={menuLinkOptions}
833
+ />
834
+ {aiEnabled && fieldName === "seoDescription" && !isFieldReadOnly(fieldName) && (
835
+ <div class="mt-1.5">
836
+ <AiGenerateButton
837
+ client:load
838
+ endpoint="/api/cms/ai/seo"
839
+ payload={{
840
+ title: getCurrentValue("title") ?? "",
841
+ excerpt: getCurrentValue("excerpt") ?? "",
842
+ }}
843
+ targetField={fieldName}
844
+ label="Generate description"
845
+ />
846
+ </div>
847
+ )}
848
+ </div>
849
+ ))}
850
+ </div>
851
+ </AdminCard>
852
+ ))}
853
+ {reverseRefs.length > 0 &&
854
+ (() => {
855
+ const allDocs = reverseRefs.flatMap((ref) =>
856
+ ref.docs.map((d) => ({
857
+ ...d,
858
+ collectionSlug: ref.collectionSlug,
859
+ collectionLabel: ref.collectionLabel,
860
+ })),
861
+ );
862
+ const visible = allDocs.slice(0, 30);
863
+ const hidden = allDocs.slice(30);
864
+ return (
865
+ <AdminCard title="Referenced by">
866
+ <ul class="grid gap-1">
867
+ {visible.map((d) => (
868
+ <li class="flex min-w-0 items-baseline justify-between gap-2">
869
+ <a
870
+ href={`/admin/${d.collectionSlug}/${d._id}`}
871
+ target="_blank"
872
+ class="text-foreground/70 hover:text-foreground truncate text-sm transition-colors hover:underline"
873
+ >
874
+ {d.label}
875
+ </a>
876
+ <span class="text-muted-foreground shrink-0 text-xs">{d.collectionLabel}</span>
877
+ </li>
878
+ ))}
879
+ </ul>
880
+ {hidden.length > 0 && (
881
+ <details class="mt-2">
882
+ <summary class="text-muted-foreground hover:text-foreground cursor-pointer text-xs transition-colors">
883
+ Show {hidden.length} more
884
+ </summary>
885
+ <ul class="mt-1 grid gap-1">
886
+ {hidden.map((d) => (
887
+ <li class="flex min-w-0 items-baseline justify-between gap-2">
888
+ <a
889
+ href={`/admin/${d.collectionSlug}/${d._id}`}
890
+ class="text-foreground/70 hover:text-foreground truncate text-sm transition-colors hover:underline"
891
+ >
892
+ {d.label}
893
+ </a>
894
+ <span class="text-muted-foreground shrink-0 text-xs">{d.collectionLabel}</span>
895
+ </li>
896
+ ))}
897
+ </ul>
898
+ </details>
899
+ )}
900
+ </AdminCard>
901
+ );
902
+ })()}
903
+ </div>
904
+ </form>
905
+ )}
906
+
907
+ {translationMode && (
908
+ <div class="space-y-6">
909
+ <form
910
+ id="translation-form"
911
+ method="post"
912
+ action={getFormAction(collection.slug, route.documentId)}
913
+ class="grid gap-6 2xl:grid-cols-[minmax(0,1.55fr)_minmax(380px,460px)]"
914
+ >
915
+ <input type="hidden" name="_action" value="save-translation" />
916
+ <input type="hidden" name="locale" value={selectedLocale} />
917
+ <input type="hidden" name="redirectTo" value={Astro.url.pathname + Astro.url.search} />
918
+
919
+ <AdminCard title="Localized content">
920
+ <div class="grid gap-5">
921
+ {translatableFields
922
+ .filter((fn) => !isFieldHidden(fn))
923
+ .map((fieldName) => {
924
+ const fieldDef = collection.fields[fieldName];
925
+ const fieldType =
926
+ fieldDef.type === "richText" ? "richText" : fieldDef.type === "slug" ? "slug" : "text";
927
+ const sourceValue = getSharedValue(fieldName);
928
+ const sourceText =
929
+ typeof sourceValue === "object" && sourceValue !== null
930
+ ? JSON.stringify(sourceValue)
931
+ : String(sourceValue ?? "");
932
+ return (
933
+ <div>
934
+ <FieldControl
935
+ name={fieldName}
936
+ field={fieldDef}
937
+ value={getCurrentValue(fieldName)}
938
+ readOnly={isFieldReadOnly(fieldName)}
939
+ relationOptions={relationOptionsByField[fieldName] ?? []}
940
+ relationMeta={relationMetaByField[fieldName]}
941
+ menuLinkOptions={menuLinkOptions}
942
+ />
943
+ {aiEnabled && sourceText && fieldDef.type !== "slug" && fieldDef.type !== "blocks" && (
944
+ <div class="mt-1.5">
945
+ <AiGenerateButton
946
+ client:load
947
+ endpoint="/api/cms/ai/translate"
948
+ payload={{
949
+ text: sourceText,
950
+ sourceLocale: defaultLocale,
951
+ targetLocale: selectedLocale,
952
+ fieldName,
953
+ fieldType,
954
+ }}
955
+ targetField={fieldName}
956
+ label={`Translate from ${defaultLocale.toUpperCase()}`}
957
+ />
958
+ </div>
959
+ )}
960
+ </div>
961
+ );
962
+ })}
963
+ </div>
964
+ </AdminCard>
965
+ </form>
966
+ </div>
967
+ )}
968
+ </div>
969
+ </>
970
+ )}
971
+ </section>
972
+ )
973
+ }
974
+ </AdminLayout>
975
+
976
+ <script is:inline>
977
+ (function () {
978
+ // --- Conditional fields ---
979
+ var conditionals = document.querySelectorAll("[data-condition-field]");
980
+ if (conditionals.length > 0) {
981
+ var matchesCondition = function (currentValue, expected) {
982
+ if (Array.isArray(expected)) {
983
+ return expected.some(function (v) {
984
+ return String(v) === String(currentValue);
985
+ });
986
+ }
987
+ if (typeof expected === "boolean") {
988
+ return expected
989
+ ? currentValue === "true" || currentValue === true
990
+ : currentValue === "false" || currentValue === false || currentValue === "";
991
+ }
992
+ return String(expected) === String(currentValue);
993
+ };
994
+
995
+ var getFieldValue = function (fieldName) {
996
+ // Radio buttons: find the checked one
997
+ var radio = document.querySelector('input[type="radio"][name="' + fieldName + '"]:checked');
998
+ if (radio) return radio.value;
999
+ // Check if radios exist but none checked
1000
+ if (document.querySelector('input[type="radio"][name="' + fieldName + '"]')) return "";
1001
+ // Checkboxes (boolean fields have a hidden "false" fallback that would match otherwise)
1002
+ var checkbox = document.querySelector('input[type="checkbox"][name="' + fieldName + '"]');
1003
+ if (checkbox) return checkbox.checked ? "true" : "false";
1004
+ // Hidden inputs (React select components)
1005
+ var hidden = document.querySelector('input[type="hidden"][name="' + fieldName + '"]');
1006
+ if (hidden) return hidden.value;
1007
+ // Other inputs
1008
+ var input = document.querySelector('[name="' + fieldName + '"]');
1009
+ if (input) return input.value;
1010
+ return "";
1011
+ };
1012
+
1013
+ var updateVisibility = function () {
1014
+ conditionals.forEach(function (el) {
1015
+ var depField = el.getAttribute("data-condition-field");
1016
+ var expected = JSON.parse(el.getAttribute("data-condition-value"));
1017
+ var currentValue = getFieldValue(depField);
1018
+ var visible = matchesCondition(currentValue, expected);
1019
+ // Hide the field and its parent wrapper (sidebar fields have an extra wrapping div)
1020
+ var target = el.parentElement && el.parentElement.childElementCount === 1 ? el.parentElement : el;
1021
+ target.style.display = visible ? "" : "none";
1022
+ // Disable hidden inputs so they don't submit stale values
1023
+ el.querySelectorAll("input, textarea, select").forEach(function (input) {
1024
+ input.disabled = !visible;
1025
+ });
1026
+ });
1027
+ };
1028
+
1029
+ // Run on load
1030
+ updateVisibility();
1031
+
1032
+ // Listen for changes on the form
1033
+ var form = document.getElementById("document-form");
1034
+ if (form) {
1035
+ form.addEventListener("input", updateVisibility);
1036
+ form.addEventListener("change", updateVisibility);
1037
+ }
1038
+ }
1039
+ })();
1040
+ </script>
1041
+
1042
+ <script is:inline>
1043
+ (function () {
1044
+ var form = document.getElementById("document-form");
1045
+ if (!form) return;
1046
+ // Autofocus first text input on new document pages
1047
+ if (location.pathname.endsWith("/new")) {
1048
+ var first = form.querySelector('input[type="text"], textarea');
1049
+ if (first) first.focus();
1050
+ }
1051
+ form.addEventListener("submit", function (e) {
1052
+ e.preventDefault();
1053
+ var f = e.target;
1054
+ var sub = e.submitter;
1055
+ var fd = new FormData(f);
1056
+ if (sub && sub.name && sub.value) fd.set(sub.name, sub.value);
1057
+ fetch(f.action, {
1058
+ method: f.method || "POST",
1059
+ body: new URLSearchParams(fd),
1060
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1061
+ })
1062
+ .then(function (res) {
1063
+ var u = new URL(res.url);
1064
+ if (u.searchParams.get("_toast") === "error") {
1065
+ // Show error toast without navigating — preserves form data
1066
+ var existing = document.getElementById("admin-toast");
1067
+ if (existing) existing.parentElement.remove();
1068
+ var msg = decodeURIComponent(u.searchParams.get("_msg") || "Something went wrong");
1069
+ var w = document.createElement("div");
1070
+ w.className = "fixed top-4 left-1/2 z-50 w-full max-w-sm -translate-x-1/2";
1071
+ w.innerHTML =
1072
+ '<div id="admin-toast" role="alert" class="bg-card flex items-center gap-2 rounded-lg border border-red-500/30 px-5 py-3 text-sm shadow-lg" style="animation:toast-in .25s ease-out,toast-out .3s ease-in 4.7s forwards">' +
1073
+ '<svg class="size-4 shrink-0 text-red-600 dark:text-red-400" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" x2="12" y1="8" y2="12"/><line x1="12" x2="12.01" y1="16" y2="16"/></svg>' +
1074
+ '<span class="text-red-700 dark:text-red-400">' +
1075
+ msg +
1076
+ "</span></div>";
1077
+ document.body.appendChild(w);
1078
+ setTimeout(function () {
1079
+ w.remove();
1080
+ }, 5200);
1081
+ return;
1082
+ }
1083
+ // Live preview: reload preview tab after successful save
1084
+ if (u.searchParams.get("_toast") === "success") {
1085
+ try {
1086
+ new BroadcastChannel("cms-preview").postMessage({ type: "reload" });
1087
+ } catch (e) {}
1088
+ }
1089
+ // If embedded in a relation field drawer, notify parent instead of navigating
1090
+ if (window.parent !== window && u.searchParams.get("_toast") === "success") {
1091
+ var idMatch = u.pathname.match(/\/admin\/[^/]+\/([^/]+)/);
1092
+ if (idMatch) {
1093
+ window.parent.postMessage({ type: "cms:created", id: idMatch[1] }, "*");
1094
+ return;
1095
+ }
1096
+ }
1097
+ window.location.assign(res.url);
1098
+ })
1099
+ .catch(function () {
1100
+ f.submit();
1101
+ });
1102
+ });
1103
+ })();
1104
+ </script>