@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.
- package/README.md +28 -0
- package/admin/components/AdminCard.astro +25 -0
- package/admin/components/AiGenerateButton.tsx +102 -0
- package/admin/components/AssetsGrid.tsx +711 -0
- package/admin/components/BlockEditor.tsx +996 -0
- package/admin/components/CheckboxField.tsx +31 -0
- package/admin/components/DocumentActions.tsx +317 -0
- package/admin/components/DocumentLock.tsx +54 -0
- package/admin/components/DocumentsDataTable.tsx +804 -0
- package/admin/components/FieldControl.astro +397 -0
- package/admin/components/FocalPointSelector.tsx +100 -0
- package/admin/components/ImageBrowseDialog.tsx +176 -0
- package/admin/components/ImagePicker.tsx +149 -0
- package/admin/components/InternalLinkPicker.tsx +80 -0
- package/admin/components/LiveHeading.tsx +17 -0
- package/admin/components/MobileSidebar.tsx +29 -0
- package/admin/components/RelationField.tsx +204 -0
- package/admin/components/RichTextEditor.tsx +685 -0
- package/admin/components/SelectField.tsx +65 -0
- package/admin/components/SidebarUserMenu.tsx +99 -0
- package/admin/components/SlugField.tsx +77 -0
- package/admin/components/TaxonomySelect.tsx +52 -0
- package/admin/components/Toast.astro +40 -0
- package/admin/components/TreeItemsEditor.tsx +790 -0
- package/admin/components/TreeSelect.tsx +166 -0
- package/admin/components/UnsavedGuard.tsx +181 -0
- package/admin/components/tree-utils.ts +86 -0
- package/admin/components/ui/alert-dialog.tsx +92 -0
- package/admin/components/ui/badge.tsx +83 -0
- package/admin/components/ui/button.tsx +53 -0
- package/admin/components/ui/card.tsx +70 -0
- package/admin/components/ui/checkbox.tsx +28 -0
- package/admin/components/ui/collapsible.tsx +26 -0
- package/admin/components/ui/command.tsx +88 -0
- package/admin/components/ui/dialog.tsx +92 -0
- package/admin/components/ui/dropdown-menu.tsx +259 -0
- package/admin/components/ui/input.tsx +20 -0
- package/admin/components/ui/label.tsx +20 -0
- package/admin/components/ui/popover.tsx +42 -0
- package/admin/components/ui/select.tsx +165 -0
- package/admin/components/ui/separator.tsx +21 -0
- package/admin/components/ui/sheet.tsx +104 -0
- package/admin/components/ui/skeleton.tsx +7 -0
- package/admin/components/ui/table.tsx +74 -0
- package/admin/components/ui/textarea.tsx +18 -0
- package/admin/components/ui/tooltip.tsx +52 -0
- package/admin/layouts/AdminLayout.astro +340 -0
- package/admin/lib/utils.ts +19 -0
- package/dist/admin.js +92 -0
- package/dist/ai.js +67 -0
- package/dist/api.js +827 -0
- package/dist/assets.js +163 -0
- package/dist/auth.js +132 -0
- package/dist/blocks.js +110 -0
- package/dist/content.js +29 -0
- package/dist/create-admin.js +23 -0
- package/dist/define.js +36 -0
- package/dist/generator.js +370 -0
- package/dist/image.js +69 -0
- package/dist/index.js +16 -0
- package/dist/integration.js +256 -0
- package/dist/locks.js +37 -0
- package/dist/richtext.js +1 -0
- package/dist/runtime.js +26 -0
- package/dist/schema.js +13 -0
- package/dist/seed.js +84 -0
- package/dist/values.js +102 -0
- package/middleware/auth.ts +100 -0
- package/package.json +102 -0
- package/routes/api/cms/[collection]/[...path].ts +366 -0
- package/routes/api/cms/ai/alt-text.ts +25 -0
- package/routes/api/cms/ai/seo.ts +25 -0
- package/routes/api/cms/ai/translate.ts +31 -0
- package/routes/api/cms/assets/[id].ts +82 -0
- package/routes/api/cms/assets/folders.ts +81 -0
- package/routes/api/cms/assets/index.ts +23 -0
- package/routes/api/cms/assets/upload.ts +112 -0
- package/routes/api/cms/auth/invite.ts +166 -0
- package/routes/api/cms/auth/login.ts +124 -0
- package/routes/api/cms/auth/logout.ts +33 -0
- package/routes/api/cms/auth/setup.ts +77 -0
- package/routes/api/cms/cron/publish.ts +33 -0
- package/routes/api/cms/img/[...path].ts +24 -0
- package/routes/api/cms/locks/[...path].ts +37 -0
- package/routes/api/cms/preview/render.ts +36 -0
- package/routes/api/cms/references/[collection]/[id].ts +60 -0
- package/routes/pages/admin/[...path].astro +1104 -0
- package/routes/pages/admin/assets/[id].astro +183 -0
- package/routes/pages/admin/assets/index.astro +58 -0
- package/routes/pages/admin/invite.astro +116 -0
- package/routes/pages/admin/login.astro +57 -0
- package/routes/pages/admin/setup.astro +91 -0
- 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>
|