@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,340 @@
|
|
|
1
|
+
---
|
|
2
|
+
import "virtual:kide/admin-css";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
ExternalLink,
|
|
6
|
+
Clock,
|
|
7
|
+
FileText,
|
|
8
|
+
FolderTree,
|
|
9
|
+
Image,
|
|
10
|
+
Layers,
|
|
11
|
+
LayoutGrid,
|
|
12
|
+
Menu,
|
|
13
|
+
PencilRuler,
|
|
14
|
+
Plus,
|
|
15
|
+
Users,
|
|
16
|
+
BarChart,
|
|
17
|
+
Bell,
|
|
18
|
+
Bookmark,
|
|
19
|
+
Calendar,
|
|
20
|
+
Database,
|
|
21
|
+
Globe,
|
|
22
|
+
Home,
|
|
23
|
+
Key,
|
|
24
|
+
Link,
|
|
25
|
+
Lock,
|
|
26
|
+
Mail,
|
|
27
|
+
MessageSquare,
|
|
28
|
+
Package,
|
|
29
|
+
Palette,
|
|
30
|
+
Search,
|
|
31
|
+
Settings,
|
|
32
|
+
Shield,
|
|
33
|
+
Star,
|
|
34
|
+
Tag,
|
|
35
|
+
Terminal,
|
|
36
|
+
Zap,
|
|
37
|
+
} from "lucide-react";
|
|
38
|
+
|
|
39
|
+
const navIconMap: Record<string, any> = {
|
|
40
|
+
BarChart, Bell, Bookmark, Calendar, Clock, Database, FileText, FolderTree,
|
|
41
|
+
Globe, Home, Image, Key, Layers, LayoutGrid, Link, Lock, Mail,
|
|
42
|
+
Menu, MessageSquare, Package, Palette, PencilRuler, Search, Settings,
|
|
43
|
+
Shield, Star, Tag, Terminal, Users, Zap,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
import MobileSidebar from "../components/MobileSidebar";
|
|
47
|
+
import SidebarUserMenu from "../components/SidebarUserMenu";
|
|
48
|
+
import Toast from "../components/Toast.astro";
|
|
49
|
+
import { Separator } from "../components/ui/separator";
|
|
50
|
+
import { cn } from "../lib/utils";
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
title = "Admin",
|
|
54
|
+
collections = [],
|
|
55
|
+
activeCollection = null,
|
|
56
|
+
embed = false,
|
|
57
|
+
customNav = [],
|
|
58
|
+
} = Astro.props as {
|
|
59
|
+
title?: string;
|
|
60
|
+
collections?: Array<{ slug: string; labels: { singular: string; plural: string }; singleton?: boolean }>;
|
|
61
|
+
activeCollection?: string | null;
|
|
62
|
+
embed?: boolean;
|
|
63
|
+
customNav?: Array<{ label: string; href: string; icon?: string }>;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const user = Astro.locals.user;
|
|
67
|
+
|
|
68
|
+
// Server-side toast: read _toast/_msg params set by API redirect
|
|
69
|
+
const toastStatus = Astro.url.searchParams.get("_toast") as "success" | "error" | null;
|
|
70
|
+
const toastMessage =
|
|
71
|
+
Astro.url.searchParams.get("_msg") ?? (toastStatus === "success" ? "Saved successfully" : "Something went wrong");
|
|
72
|
+
|
|
73
|
+
// Build sidebar nav in explicit order:
|
|
74
|
+
// 1. Content collections (alphabetical, shared icon)
|
|
75
|
+
// 2. Singles
|
|
76
|
+
// 3. Taxonomies, Menus, Authors, Users (pinned utility collections)
|
|
77
|
+
// 4. Assets
|
|
78
|
+
|
|
79
|
+
const pinnedOrder = ["taxonomies", "menus", "authors", "users"];
|
|
80
|
+
const pinnedSlugs = new Set(pinnedOrder);
|
|
81
|
+
const pinnedIcons: Record<string, any> = {
|
|
82
|
+
taxonomies: FolderTree,
|
|
83
|
+
menus: Menu,
|
|
84
|
+
authors: PencilRuler,
|
|
85
|
+
users: Users,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const hasSingletons = collections.some((c) => c.singleton);
|
|
89
|
+
const singletonSlugs = new Set(collections.filter((c) => c.singleton).map((c) => c.slug));
|
|
90
|
+
const isActiveSingles =
|
|
91
|
+
activeCollection === "singles" || (activeCollection !== null && singletonSlugs.has(activeCollection));
|
|
92
|
+
|
|
93
|
+
type NavItem = { href: string; label: string; singularLabel?: string; Icon: any; active: boolean; newHref?: string; weight: number };
|
|
94
|
+
const navItems: NavItem[] = [];
|
|
95
|
+
|
|
96
|
+
// Built-in nav items with default weights (spaced by 10 for easy interleaving)
|
|
97
|
+
navItems.push({
|
|
98
|
+
href: "/admin/recent",
|
|
99
|
+
label: "Recent",
|
|
100
|
+
Icon: Clock,
|
|
101
|
+
active: activeCollection === "recent",
|
|
102
|
+
weight: 0,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const contentCollections = collections
|
|
106
|
+
.filter((c) => !c.singleton && !pinnedSlugs.has(c.slug))
|
|
107
|
+
.sort((a, b) => a.labels.plural.localeCompare(b.labels.plural));
|
|
108
|
+
for (const c of contentCollections) {
|
|
109
|
+
navItems.push({
|
|
110
|
+
href: `/admin/${c.slug}`,
|
|
111
|
+
label: c.labels.plural,
|
|
112
|
+
singularLabel: c.labels.singular,
|
|
113
|
+
Icon: Layers,
|
|
114
|
+
active: activeCollection === c.slug,
|
|
115
|
+
newHref: `/admin/${c.slug}/new`,
|
|
116
|
+
weight: 10,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (hasSingletons) {
|
|
121
|
+
navItems.push({ href: "/admin/singles", label: "Singles", Icon: FileText, active: isActiveSingles, weight: 20 });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const slug of pinnedOrder) {
|
|
125
|
+
const c = collections.find((col) => col.slug === slug);
|
|
126
|
+
if (c) {
|
|
127
|
+
navItems.push({
|
|
128
|
+
href: `/admin/${c.slug}`,
|
|
129
|
+
label: c.labels.plural,
|
|
130
|
+
singularLabel: c.labels.singular,
|
|
131
|
+
Icon: pinnedIcons[slug] ?? LayoutGrid,
|
|
132
|
+
active: activeCollection === c.slug,
|
|
133
|
+
newHref: `/admin/${c.slug}/new`,
|
|
134
|
+
weight: 30,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
navItems.push({
|
|
140
|
+
href: "/admin/assets",
|
|
141
|
+
label: "Assets",
|
|
142
|
+
Icon: Image,
|
|
143
|
+
active: activeCollection === "assets",
|
|
144
|
+
weight: 40,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Custom nav items from admin config
|
|
148
|
+
const currentPath = Astro.url.pathname;
|
|
149
|
+
for (const item of customNav) {
|
|
150
|
+
navItems.push({
|
|
151
|
+
href: item.href,
|
|
152
|
+
label: item.label,
|
|
153
|
+
Icon: navIconMap[item.icon ?? ""] ?? LayoutGrid,
|
|
154
|
+
active: currentPath === item.href || currentPath.startsWith(item.href + "/"),
|
|
155
|
+
weight: item.weight ?? 50,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Sort all items by weight (stable sort preserves order within same weight)
|
|
160
|
+
navItems.sort((a, b) => a.weight - b.weight);
|
|
161
|
+
---
|
|
162
|
+
|
|
163
|
+
<!doctype html>
|
|
164
|
+
<html lang="en">
|
|
165
|
+
<head>
|
|
166
|
+
<meta charset="utf-8" />
|
|
167
|
+
<meta name="viewport" content="width=device-width" />
|
|
168
|
+
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
|
169
|
+
<link rel="icon" href="/favicon.ico" />
|
|
170
|
+
<title>{title}</title>
|
|
171
|
+
<script is:inline>
|
|
172
|
+
if (!document.cookie.includes("tz="))
|
|
173
|
+
document.cookie = "tz=" + Intl.DateTimeFormat().resolvedOptions().timeZone + ";path=/;max-age=31536000";
|
|
174
|
+
</script>
|
|
175
|
+
<script is:inline>
|
|
176
|
+
(function () {
|
|
177
|
+
var t = localStorage.getItem("admin-theme") || "system";
|
|
178
|
+
var dark = t === "dark" || (t === "system" && matchMedia("(prefers-color-scheme: dark)").matches);
|
|
179
|
+
if (dark) document.documentElement.classList.add("dark");
|
|
180
|
+
})();
|
|
181
|
+
</script>
|
|
182
|
+
</head>
|
|
183
|
+
<body class="bg-background text-foreground min-h-screen antialiased">
|
|
184
|
+
{
|
|
185
|
+
embed ? (
|
|
186
|
+
<main class="mx-auto w-full px-4 py-6 lg:px-8 lg:py-8">
|
|
187
|
+
<slot />
|
|
188
|
+
</main>
|
|
189
|
+
) : (
|
|
190
|
+
<div
|
|
191
|
+
class="min-h-screen lg:grid"
|
|
192
|
+
style="grid-template-columns: 17rem 1fr; transition: grid-template-columns 150ms ease;"
|
|
193
|
+
>
|
|
194
|
+
{/* ── Desktop sidebar ──────────────────────────────────────────── */}
|
|
195
|
+
<aside class="bg-sidebar/40 sticky top-0 mt-1 hidden h-screen overflow-hidden border-r backdrop-blur transition-transform duration-150 ease-in-out lg:flex lg:flex-col">
|
|
196
|
+
<div class="px-3 pt-3 pb-2">
|
|
197
|
+
{user && (
|
|
198
|
+
<SidebarUserMenu
|
|
199
|
+
client:load
|
|
200
|
+
userName={user.name}
|
|
201
|
+
userEmail={user.email}
|
|
202
|
+
logoutAction="/api/cms/auth/logout"
|
|
203
|
+
/>
|
|
204
|
+
)}
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<nav class="flex-1 space-y-1 overflow-y-auto px-3 py-3">
|
|
208
|
+
{navItems.map((item) => (
|
|
209
|
+
<div
|
|
210
|
+
class={cn(
|
|
211
|
+
"group relative rounded-lg",
|
|
212
|
+
item.active ? "bg-foreground/10 dark:bg-accent" : "hover:bg-foreground/5 dark:hover:bg-accent/60",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<a
|
|
216
|
+
href={item.href}
|
|
217
|
+
class={cn(
|
|
218
|
+
"flex items-center gap-3 px-3 py-2.5 text-sm",
|
|
219
|
+
item.newHref && "pr-8",
|
|
220
|
+
item.active ? "text-accent-foreground" : "text-foreground/70 group-hover:text-foreground",
|
|
221
|
+
)}
|
|
222
|
+
>
|
|
223
|
+
<item.Icon className="size-4 shrink-0 stroke-1" />
|
|
224
|
+
<span>{item.label}</span>
|
|
225
|
+
</a>
|
|
226
|
+
{item.newHref && (
|
|
227
|
+
<a
|
|
228
|
+
href={item.newHref}
|
|
229
|
+
title={`Add new ${(item.singularLabel ?? item.label).toLowerCase()}`}
|
|
230
|
+
class="text-foreground/40 hover:text-foreground/70 absolute top-1/2 right-1.5 hidden -translate-y-1/2 rounded p-1 group-hover:block"
|
|
231
|
+
>
|
|
232
|
+
<Plus className="size-3.5" />
|
|
233
|
+
</a>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
))}
|
|
237
|
+
</nav>
|
|
238
|
+
<div class="border-t px-3 pt-2.5 pb-3">
|
|
239
|
+
<a
|
|
240
|
+
href="/"
|
|
241
|
+
target="_blank"
|
|
242
|
+
class="text-foreground/70 hover:text-foreground hover:bg-foreground/5 dark:hover:bg-accent/60 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm"
|
|
243
|
+
>
|
|
244
|
+
<ExternalLink className="size-4 shrink-0 stroke-1" />
|
|
245
|
+
<span>View site</span>
|
|
246
|
+
</a>
|
|
247
|
+
</div>
|
|
248
|
+
</aside>
|
|
249
|
+
|
|
250
|
+
<div class="min-w-0">
|
|
251
|
+
{/* ── Mobile header ────────────────────────────────────────── */}
|
|
252
|
+
<header class="bg-background/95 supports-backdrop-filter:bg-background/75 flex items-center gap-3 border-b px-4 py-3 backdrop-blur lg:hidden">
|
|
253
|
+
<MobileSidebar client:load>
|
|
254
|
+
<div class="px-3 pt-3 pb-1">
|
|
255
|
+
{user && (
|
|
256
|
+
<SidebarUserMenu
|
|
257
|
+
client:load
|
|
258
|
+
userName={user.name}
|
|
259
|
+
userEmail={user.email}
|
|
260
|
+
logoutAction="/api/cms/auth/logout"
|
|
261
|
+
/>
|
|
262
|
+
)}
|
|
263
|
+
<Separator className="mt-3" />
|
|
264
|
+
</div>
|
|
265
|
+
|
|
266
|
+
<nav class="flex-1 space-y-1 overflow-y-auto px-3 py-3">
|
|
267
|
+
{navItems.map((item) => (
|
|
268
|
+
<div
|
|
269
|
+
class={cn(
|
|
270
|
+
"group relative rounded-lg",
|
|
271
|
+
item.active
|
|
272
|
+
? "bg-foreground/10 dark:bg-accent"
|
|
273
|
+
: "hover:bg-foreground/5 dark:hover:bg-accent/60",
|
|
274
|
+
)}
|
|
275
|
+
>
|
|
276
|
+
<a
|
|
277
|
+
href={item.href}
|
|
278
|
+
class={cn(
|
|
279
|
+
"flex items-center gap-3 px-3 py-2.5 text-sm",
|
|
280
|
+
item.newHref && "pr-8",
|
|
281
|
+
item.active ? "text-accent-foreground" : "text-foreground/70 group-hover:text-foreground",
|
|
282
|
+
)}
|
|
283
|
+
>
|
|
284
|
+
<item.Icon className="size-4 shrink-0 stroke-1" />
|
|
285
|
+
<span>{item.label}</span>
|
|
286
|
+
</a>
|
|
287
|
+
{item.newHref && (
|
|
288
|
+
<a
|
|
289
|
+
href={item.newHref}
|
|
290
|
+
title={`Add new ${(item.singularLabel ?? item.label).toLowerCase()}`}
|
|
291
|
+
class="text-foreground/40 hover:text-foreground/70 absolute top-1/2 right-1.5 hidden -translate-y-1/2 rounded p-1 group-hover:block"
|
|
292
|
+
>
|
|
293
|
+
<Plus className="size-3.5" />
|
|
294
|
+
</a>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
))}
|
|
298
|
+
</nav>
|
|
299
|
+
<div class="border-t px-3 py-3">
|
|
300
|
+
<a
|
|
301
|
+
href="/"
|
|
302
|
+
target="_blank"
|
|
303
|
+
class="text-foreground/70 hover:text-foreground hover:bg-foreground/5 dark:hover:bg-accent/60 flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm"
|
|
304
|
+
>
|
|
305
|
+
<ExternalLink className="size-4 shrink-0 stroke-1" />
|
|
306
|
+
<span>View site</span>
|
|
307
|
+
</a>
|
|
308
|
+
</div>
|
|
309
|
+
</MobileSidebar>
|
|
310
|
+
</header>
|
|
311
|
+
|
|
312
|
+
<main class="mx-auto w-full max-w-[1900px] px-4 py-6 lg:px-8 lg:py-8">
|
|
313
|
+
<slot />
|
|
314
|
+
</main>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
{toastStatus && <Toast status={toastStatus} message={toastMessage} />}
|
|
321
|
+
|
|
322
|
+
{/* Clean toast params from URL and remove toast element after animation */}
|
|
323
|
+
<script is:inline>
|
|
324
|
+
(function () {
|
|
325
|
+
var p = new URLSearchParams(location.search);
|
|
326
|
+
var status = p.get("_toast");
|
|
327
|
+
if (!status) return;
|
|
328
|
+
p.delete("_toast");
|
|
329
|
+
p.delete("_msg");
|
|
330
|
+
history.replaceState({}, "", location.pathname + (p.size ? "?" + p : ""));
|
|
331
|
+
// Error toasts stay longer (5s) than success toasts (3s)
|
|
332
|
+
var delay = status === "error" ? 5200 : 3200;
|
|
333
|
+
setTimeout(function () {
|
|
334
|
+
var el = document.getElementById("admin-toast");
|
|
335
|
+
if (el) el.remove();
|
|
336
|
+
}, delay);
|
|
337
|
+
})();
|
|
338
|
+
</script>
|
|
339
|
+
</body>
|
|
340
|
+
</html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { clsx, type ClassValue } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const ALLOWED_WIDTHS = [320, 480, 640, 768, 960, 1024, 1280, 1536, 1920];
|
|
9
|
+
|
|
10
|
+
function clampWidth(width: number): number {
|
|
11
|
+
return ALLOWED_WIDTHS.reduce((prev, curr) => (Math.abs(curr - width) < Math.abs(prev - width) ? curr : prev));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Generate a thumbnail URL for an upload path. Works on both local (Sharp) and Cloudflare (Image Transformations). */
|
|
15
|
+
export function thumbnail(src: string, width: number = 480): string {
|
|
16
|
+
if (!src || !src.startsWith("/uploads/")) return src;
|
|
17
|
+
const w = clampWidth(width);
|
|
18
|
+
return `/api/cms/img${src}?w=${w}`;
|
|
19
|
+
}
|
package/dist/admin.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { richTextToPlainText } from "./values";
|
|
2
|
+
const DEFAULT_DATE_FORMAT = "en-US";
|
|
3
|
+
let dateLocale = DEFAULT_DATE_FORMAT;
|
|
4
|
+
let timeZone;
|
|
5
|
+
export const initDateFormat = (config, nextTimeZone) => {
|
|
6
|
+
dateLocale = config.admin?.dateFormat ?? DEFAULT_DATE_FORMAT;
|
|
7
|
+
timeZone = nextTimeZone;
|
|
8
|
+
};
|
|
9
|
+
export const formatDate = (value) => {
|
|
10
|
+
if (!value)
|
|
11
|
+
return "—";
|
|
12
|
+
const date = new Date(String(value));
|
|
13
|
+
if (Number.isNaN(date.getTime()))
|
|
14
|
+
return String(value);
|
|
15
|
+
return date.toLocaleString(dateLocale, {
|
|
16
|
+
year: "numeric",
|
|
17
|
+
month: "numeric",
|
|
18
|
+
day: "numeric",
|
|
19
|
+
hour: "2-digit",
|
|
20
|
+
minute: "2-digit",
|
|
21
|
+
timeZone,
|
|
22
|
+
});
|
|
23
|
+
};
|
|
24
|
+
export const resolveAdminRoute = (path) => {
|
|
25
|
+
const segments = (path ?? "").split("/").filter(Boolean);
|
|
26
|
+
if (segments.length === 0)
|
|
27
|
+
return { kind: "dashboard" };
|
|
28
|
+
if (segments[0] === "recent" && segments.length === 1)
|
|
29
|
+
return { kind: "recent" };
|
|
30
|
+
if (segments[0] === "singles" && segments.length === 1)
|
|
31
|
+
return { kind: "singles" };
|
|
32
|
+
if (segments.length === 1)
|
|
33
|
+
return { kind: "list", collectionSlug: segments[0] };
|
|
34
|
+
if (segments[1] === "new")
|
|
35
|
+
return { kind: "new", collectionSlug: segments[0] };
|
|
36
|
+
return {
|
|
37
|
+
kind: "edit",
|
|
38
|
+
collectionSlug: segments[0],
|
|
39
|
+
documentId: segments[1],
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
export const humanize = (value) => value
|
|
43
|
+
.replace(/^_+/, "")
|
|
44
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
45
|
+
.replace(/[-_]/g, " ")
|
|
46
|
+
.replace(/\s+/g, " ")
|
|
47
|
+
.trim()
|
|
48
|
+
.replace(/^\w/, (char) => char.toUpperCase());
|
|
49
|
+
export const formatFieldValue = (field, value, relationLabels = {}) => {
|
|
50
|
+
if (value === undefined || value === null || value === "")
|
|
51
|
+
return "—";
|
|
52
|
+
if (!field)
|
|
53
|
+
return String(value);
|
|
54
|
+
if (field.type === "richText") {
|
|
55
|
+
const text = richTextToPlainText(value);
|
|
56
|
+
return text.length > 120 ? `${text.slice(0, 117)}...` : text;
|
|
57
|
+
}
|
|
58
|
+
if (field.type === "array") {
|
|
59
|
+
return Array.isArray(value) ? value.join(", ") : "—";
|
|
60
|
+
}
|
|
61
|
+
if (field.type === "json" || field.type === "blocks") {
|
|
62
|
+
return Array.isArray(value) ? `${value.length} items` : "JSON";
|
|
63
|
+
}
|
|
64
|
+
if (field.type === "boolean")
|
|
65
|
+
return value ? "Yes" : "No";
|
|
66
|
+
if (field.type === "relation") {
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return value.map((entry) => relationLabels[String(entry)] ?? String(entry)).join(", ");
|
|
69
|
+
}
|
|
70
|
+
return relationLabels[String(value)] ?? String(value);
|
|
71
|
+
}
|
|
72
|
+
return String(value);
|
|
73
|
+
};
|
|
74
|
+
export const getListColumns = (collection, viewConfig) => {
|
|
75
|
+
if (viewConfig?.columns?.length) {
|
|
76
|
+
return collection.drafts ? viewConfig.columns : viewConfig.columns.filter((column) => column !== "_status");
|
|
77
|
+
}
|
|
78
|
+
const firstField = "title" in collection.fields ? "title" : Object.keys(collection.fields)[0];
|
|
79
|
+
return collection.drafts ? [firstField, "_status", "_updatedAt"] : [firstField, "_updatedAt"];
|
|
80
|
+
};
|
|
81
|
+
export const getFieldSets = (collection) => {
|
|
82
|
+
const allFields = Object.keys(collection.fields).filter((fieldName) => !collection.fields[fieldName].admin?.hidden);
|
|
83
|
+
const contentFields = allFields.filter((fieldName) => collection.fields[fieldName].admin?.position !== "sidebar");
|
|
84
|
+
const sidebarFields = allFields.filter((fieldName) => collection.fields[fieldName].admin?.position === "sidebar");
|
|
85
|
+
if (sidebarFields.length > 0) {
|
|
86
|
+
return [
|
|
87
|
+
{ fields: contentFields, position: "content" },
|
|
88
|
+
{ fields: sidebarFields, position: "sidebar" },
|
|
89
|
+
];
|
|
90
|
+
}
|
|
91
|
+
return [{ fields: contentFields, position: "content" }];
|
|
92
|
+
};
|
package/dist/ai.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { streamText } from "ai";
|
|
2
|
+
import { getStorage, readEnv } from "./runtime";
|
|
3
|
+
export function isAiEnabled() {
|
|
4
|
+
return !!(readEnv("AI_PROVIDER") && readEnv("AI_API_KEY"));
|
|
5
|
+
}
|
|
6
|
+
export async function getAiModel() {
|
|
7
|
+
const provider = readEnv("AI_PROVIDER") || "openai";
|
|
8
|
+
const modelName = readEnv("AI_MODEL") || "gpt-4o-mini";
|
|
9
|
+
if (provider === "openai") {
|
|
10
|
+
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
11
|
+
const openai = createOpenAI({ apiKey: readEnv("AI_API_KEY") });
|
|
12
|
+
return openai(modelName);
|
|
13
|
+
}
|
|
14
|
+
throw new Error(`Unsupported AI provider: ${provider}`);
|
|
15
|
+
}
|
|
16
|
+
export async function streamAltText(imageUrl, filename) {
|
|
17
|
+
const model = await getAiModel();
|
|
18
|
+
const image = await getStorage().getFile(imageUrl);
|
|
19
|
+
if (!image) {
|
|
20
|
+
throw new Error(`Image not found: ${imageUrl}`);
|
|
21
|
+
}
|
|
22
|
+
return streamText({
|
|
23
|
+
model,
|
|
24
|
+
messages: [
|
|
25
|
+
{
|
|
26
|
+
role: "user",
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "image",
|
|
30
|
+
image: new Uint8Array(image),
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
type: "text",
|
|
34
|
+
text: `Generate a concise, descriptive alt text for this image (filename: ${filename}). The alt text should be suitable for accessibility purposes. Return only the alt text, no quotes or extra formatting.`,
|
|
35
|
+
},
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
export async function streamSeoDescription(content) {
|
|
42
|
+
const model = await getAiModel();
|
|
43
|
+
const prompt = `Generate an SEO-optimized meta description (max 155 characters) for a page with the following content. Return only the description, no quotes or extra formatting.
|
|
44
|
+
|
|
45
|
+
Title: ${content.title}
|
|
46
|
+
${content.excerpt ? `Excerpt: ${content.excerpt}` : ""}
|
|
47
|
+
${content.body ? `Body preview: ${content.body.substring(0, 500)}` : ""}`;
|
|
48
|
+
return streamText({ model, prompt });
|
|
49
|
+
}
|
|
50
|
+
export async function streamTranslation(content) {
|
|
51
|
+
const model = await getAiModel();
|
|
52
|
+
let prompt;
|
|
53
|
+
if (content.fieldType === "richText") {
|
|
54
|
+
prompt = `Translate the following rich text JSON AST from ${content.sourceLocale} to ${content.targetLocale}. Keep the exact same JSON structure, only translate the text values. Return only the valid JSON, no extra formatting or code blocks.
|
|
55
|
+
|
|
56
|
+
${content.text}`;
|
|
57
|
+
}
|
|
58
|
+
else if (content.fieldType === "slug") {
|
|
59
|
+
prompt = `Translate and create a URL-friendly slug in ${content.targetLocale} based on this ${content.sourceLocale} text: "${content.text}". Return only the slug (lowercase, hyphens instead of spaces, no special characters).`;
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
prompt = `Translate the following text from ${content.sourceLocale} to ${content.targetLocale}. Return only the translated text, no quotes or extra formatting.
|
|
63
|
+
|
|
64
|
+
${content.text}`;
|
|
65
|
+
}
|
|
66
|
+
return streamText({ model, prompt });
|
|
67
|
+
}
|