litecms 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/LICENSE +21 -0
- package/README.md +1387 -0
- package/dist/admin/CmsAdminLayout.d.ts +27 -0
- package/dist/admin/CmsAdminLayout.d.ts.map +1 -0
- package/dist/admin/CmsAdminPage.d.ts +31 -0
- package/dist/admin/CmsAdminPage.d.ts.map +1 -0
- package/dist/admin/config.d.ts +83 -0
- package/dist/admin/config.d.ts.map +1 -0
- package/dist/admin/config.js +53 -0
- package/dist/admin/exports.d.ts +7 -0
- package/dist/admin/exports.d.ts.map +1 -0
- package/dist/admin/exports.js +452 -0
- package/dist/admin/index.d.ts +147 -0
- package/dist/admin/index.d.ts.map +1 -0
- package/dist/components/CmsAutoForm.d.ts +73 -0
- package/dist/components/CmsAutoForm.d.ts.map +1 -0
- package/dist/components/CmsField.d.ts +50 -0
- package/dist/components/CmsField.d.ts.map +1 -0
- package/dist/components/CmsForm.d.ts +74 -0
- package/dist/components/CmsForm.d.ts.map +1 -0
- package/dist/components/CmsImageField.d.ts +33 -0
- package/dist/components/CmsImageField.d.ts.map +1 -0
- package/dist/components/CmsNavSection.d.ts +7 -0
- package/dist/components/CmsNavSection.d.ts.map +1 -0
- package/dist/components/CmsSimpleForm.d.ts +54 -0
- package/dist/components/CmsSimpleForm.d.ts.map +1 -0
- package/dist/components/index.d.ts +43 -0
- package/dist/components/index.d.ts.map +1 -0
- package/dist/components/index.js +619 -0
- package/dist/domain/index.d.ts +1 -0
- package/dist/domain/index.d.ts.map +1 -0
- package/dist/index-8zcd33mx.js +39 -0
- package/dist/index-pmb5m3ek.js +4135 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +32 -0
- package/dist/schema/index.d.ts +80 -0
- package/dist/schema/index.d.ts.map +1 -0
- package/dist/schema/index.js +46 -0
- package/dist/server/index.d.ts +79 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +117 -0
- package/dist/shared/utils.d.ts +23 -0
- package/dist/shared/utils.d.ts.map +1 -0
- package/dist/storage/index.d.ts +86 -0
- package/dist/storage/index.d.ts.map +1 -0
- package/dist/storage/index.js +86 -0
- package/dist/stores/index.d.ts +1 -0
- package/dist/stores/index.d.ts.map +1 -0
- package/package.json +90 -0
package/README.md
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
# litecms
|
|
2
|
+
|
|
3
|
+
A super lightweight and type-safe CMS for Next.js websites. Auto-generated admin forms from Zod schemas with S3-compatible storage support.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add litecms
|
|
9
|
+
# or
|
|
10
|
+
npm install litecms
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Peer Dependencies
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add react react-dom next zod
|
|
17
|
+
# Optional: for image storage
|
|
18
|
+
bun add @aws-sdk/client-s3
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Quick Start
|
|
22
|
+
|
|
23
|
+
### 1. Define a Schema
|
|
24
|
+
|
|
25
|
+
```typescript
|
|
26
|
+
// cms/schemas/home.ts
|
|
27
|
+
import { z } from 'zod';
|
|
28
|
+
import { defineSchema } from 'litecms/schema';
|
|
29
|
+
|
|
30
|
+
export const HomePageDef = defineSchema({
|
|
31
|
+
schema: z.object({
|
|
32
|
+
heroTitle: z.string().min(1),
|
|
33
|
+
heroSubtitle: z.string().optional(),
|
|
34
|
+
heroImage: z.string().optional(),
|
|
35
|
+
ctaText: z.string().min(1),
|
|
36
|
+
ctaUrl: z.string().url(),
|
|
37
|
+
showInNav: z.boolean().default(false),
|
|
38
|
+
}),
|
|
39
|
+
fields: {
|
|
40
|
+
heroTitle: {
|
|
41
|
+
label: 'Hero Title',
|
|
42
|
+
placeholder: 'Welcome to...',
|
|
43
|
+
order: 1,
|
|
44
|
+
},
|
|
45
|
+
heroSubtitle: {
|
|
46
|
+
label: 'Subtitle',
|
|
47
|
+
type: 'textarea',
|
|
48
|
+
rows: 3,
|
|
49
|
+
order: 2,
|
|
50
|
+
},
|
|
51
|
+
heroImage: {
|
|
52
|
+
label: 'Hero Image',
|
|
53
|
+
type: 'image',
|
|
54
|
+
helpText: 'Upload or select a background image',
|
|
55
|
+
order: 3,
|
|
56
|
+
},
|
|
57
|
+
ctaText: { label: 'CTA Button Text', order: 4 },
|
|
58
|
+
ctaUrl: { label: 'CTA URL', type: 'url', order: 5 },
|
|
59
|
+
showInNav: {
|
|
60
|
+
label: 'Show in Navigation',
|
|
61
|
+
type: 'checkbox',
|
|
62
|
+
helpText: 'Display this page in the main nav',
|
|
63
|
+
group: 'Settings',
|
|
64
|
+
order: 10,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
defaults: {
|
|
68
|
+
heroTitle: '',
|
|
69
|
+
heroSubtitle: '',
|
|
70
|
+
heroImage: '',
|
|
71
|
+
ctaText: 'Get Started',
|
|
72
|
+
ctaUrl: '/',
|
|
73
|
+
showInNav: false,
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// Export the schema and types for use elsewhere
|
|
78
|
+
export const HomePageSchema = HomePageDef.schema;
|
|
79
|
+
export type HomePageData = z.infer<typeof HomePageDef.schema>;
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
### 2. Create a Server Action
|
|
83
|
+
|
|
84
|
+
```typescript
|
|
85
|
+
// cms/actions/home.ts
|
|
86
|
+
'use server';
|
|
87
|
+
|
|
88
|
+
import { revalidatePath } from 'next/cache';
|
|
89
|
+
import { createSaveAction } from 'litecms/server';
|
|
90
|
+
import { HomePageSchema, type HomePageData, HomePageDef } from '../schemas/home';
|
|
91
|
+
import { db } from '@/lib/db'; // Your database
|
|
92
|
+
|
|
93
|
+
export const saveHome = createSaveAction(HomePageSchema, {
|
|
94
|
+
save: async (data) => {
|
|
95
|
+
await db.upsert('pages', { key: 'home', data });
|
|
96
|
+
},
|
|
97
|
+
revalidatePath: '/',
|
|
98
|
+
onRevalidate: revalidatePath,
|
|
99
|
+
// Optional: add auth check
|
|
100
|
+
checkAuth: async () => {
|
|
101
|
+
const session = await getSession();
|
|
102
|
+
return !!session?.user;
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
export async function getHomePageData(): Promise<HomePageData> {
|
|
107
|
+
const data = await db.get('pages', 'home');
|
|
108
|
+
if (!data) return HomePageDef.defaults;
|
|
109
|
+
const result = HomePageSchema.safeParse(data);
|
|
110
|
+
return result.success ? result.data : HomePageDef.defaults;
|
|
111
|
+
}
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 3. Create CMS Config
|
|
115
|
+
|
|
116
|
+
```typescript
|
|
117
|
+
// cms/config.ts
|
|
118
|
+
import { createCmsConfig, definePage } from 'litecms/admin/config';
|
|
119
|
+
import { HomePageDef } from './schemas/home';
|
|
120
|
+
import { saveHome, getHomePageData } from './actions/home';
|
|
121
|
+
|
|
122
|
+
export const cmsConfig = createCmsConfig({
|
|
123
|
+
siteName: 'My Site',
|
|
124
|
+
adminTitle: 'Content',
|
|
125
|
+
basePath: '/admin',
|
|
126
|
+
publicSiteUrl: '/',
|
|
127
|
+
// Required for image uploads
|
|
128
|
+
storage: {
|
|
129
|
+
uploadEndpoint: '/api/storage/upload',
|
|
130
|
+
listEndpoint: '/api/storage/list',
|
|
131
|
+
},
|
|
132
|
+
pages: [
|
|
133
|
+
definePage({
|
|
134
|
+
slug: 'home',
|
|
135
|
+
title: 'Homepage',
|
|
136
|
+
description: 'Edit the main landing page content',
|
|
137
|
+
path: '/', // Public URL path (used for navigation building)
|
|
138
|
+
definition: HomePageDef,
|
|
139
|
+
action: saveHome,
|
|
140
|
+
getData: getHomePageData,
|
|
141
|
+
order: 1,
|
|
142
|
+
}),
|
|
143
|
+
definePage({
|
|
144
|
+
slug: 'settings',
|
|
145
|
+
title: 'General',
|
|
146
|
+
definition: SiteSettingsDef,
|
|
147
|
+
action: saveSiteSettings,
|
|
148
|
+
getData: getSiteSettings,
|
|
149
|
+
order: 99,
|
|
150
|
+
group: 'Settings', // Groups pages in the sidebar
|
|
151
|
+
}),
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
### 4. Create Admin Layout
|
|
157
|
+
|
|
158
|
+
The admin layout can be a client or server component. Here's a client component example with authentication:
|
|
159
|
+
|
|
160
|
+
```typescript
|
|
161
|
+
// app/admin/layout.tsx
|
|
162
|
+
'use client';
|
|
163
|
+
|
|
164
|
+
import { useRouter } from 'next/navigation';
|
|
165
|
+
import { useEffect } from 'react';
|
|
166
|
+
import { CmsAdminLayout } from 'litecms/admin';
|
|
167
|
+
import { extractLayoutProps } from 'litecms/admin/config';
|
|
168
|
+
import { cmsConfig } from '@/cms/config';
|
|
169
|
+
import { useSession, signOut } from '@/lib/auth-client';
|
|
170
|
+
|
|
171
|
+
export default function AdminLayout({
|
|
172
|
+
children
|
|
173
|
+
}: {
|
|
174
|
+
children: React.ReactNode
|
|
175
|
+
}) {
|
|
176
|
+
const router = useRouter();
|
|
177
|
+
const { data: session, isPending } = useSession();
|
|
178
|
+
|
|
179
|
+
useEffect(() => {
|
|
180
|
+
if (!isPending && !session) {
|
|
181
|
+
router.push('/login');
|
|
182
|
+
}
|
|
183
|
+
}, [isPending, session, router]);
|
|
184
|
+
|
|
185
|
+
const handleLogout = async () => {
|
|
186
|
+
await signOut();
|
|
187
|
+
router.push('/login');
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
if (isPending || !session) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<CmsAdminLayout
|
|
196
|
+
{...extractLayoutProps(cmsConfig)}
|
|
197
|
+
onLogout={handleLogout}
|
|
198
|
+
>
|
|
199
|
+
{children}
|
|
200
|
+
</CmsAdminLayout>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
### 5. Create Admin Page
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// app/admin/[slug]/page.tsx
|
|
209
|
+
import { CmsAdminPage } from 'litecms/admin';
|
|
210
|
+
import { extractPageProps } from 'litecms/admin/config';
|
|
211
|
+
import { cmsConfig } from '@/cms/config';
|
|
212
|
+
import { notFound } from 'next/navigation';
|
|
213
|
+
|
|
214
|
+
type PageProps = {
|
|
215
|
+
params: Promise<{ slug: string }>;
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
export default async function AdminPage({ params }: PageProps) {
|
|
219
|
+
const { slug } = await params;
|
|
220
|
+
const props = await extractPageProps(cmsConfig, slug);
|
|
221
|
+
|
|
222
|
+
if (!props) return notFound();
|
|
223
|
+
|
|
224
|
+
return <CmsAdminPage {...props} />;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function generateStaticParams() {
|
|
228
|
+
return cmsConfig.pages.map((page) => ({
|
|
229
|
+
slug: page.slug,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 6. Set Up Storage API Routes (Optional)
|
|
235
|
+
|
|
236
|
+
For image uploads, create API routes:
|
|
237
|
+
|
|
238
|
+
```typescript
|
|
239
|
+
// app/api/storage/upload/route.ts
|
|
240
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
241
|
+
import { storage } from '@/lib/storage';
|
|
242
|
+
import { auth } from '@/lib/auth';
|
|
243
|
+
import { headers } from 'next/headers';
|
|
244
|
+
|
|
245
|
+
export async function POST(request: NextRequest) {
|
|
246
|
+
// Check authentication
|
|
247
|
+
const session = await auth.api.getSession({
|
|
248
|
+
headers: await headers(),
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
if (!session) {
|
|
252
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const formData = await request.formData();
|
|
256
|
+
const file = formData.get('file') as File | null;
|
|
257
|
+
|
|
258
|
+
if (!file) {
|
|
259
|
+
return NextResponse.json({ error: 'No file provided' }, { status: 400 });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Validate file type
|
|
263
|
+
if (!file.type.startsWith('image/')) {
|
|
264
|
+
return NextResponse.json({ error: 'Only image files allowed' }, { status: 400 });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const result = await storage.uploadFile(file);
|
|
268
|
+
|
|
269
|
+
return NextResponse.json({
|
|
270
|
+
success: true,
|
|
271
|
+
url: result.url,
|
|
272
|
+
key: result.key,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
// app/api/storage/list/route.ts
|
|
279
|
+
import { NextResponse } from 'next/server';
|
|
280
|
+
import { storage } from '@/lib/storage';
|
|
281
|
+
import { auth } from '@/lib/auth';
|
|
282
|
+
import { headers } from 'next/headers';
|
|
283
|
+
|
|
284
|
+
export async function GET() {
|
|
285
|
+
const session = await auth.api.getSession({
|
|
286
|
+
headers: await headers(),
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
if (!session) {
|
|
290
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const files = await storage.listFiles();
|
|
294
|
+
|
|
295
|
+
return NextResponse.json({
|
|
296
|
+
success: true,
|
|
297
|
+
files,
|
|
298
|
+
});
|
|
299
|
+
}
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
// lib/storage.ts
|
|
304
|
+
import { createStorageClient } from 'litecms/storage';
|
|
305
|
+
|
|
306
|
+
export const storage = createStorageClient({
|
|
307
|
+
endpoint: process.env.STORAGE_URL!,
|
|
308
|
+
region: process.env.STORAGE_REGION || 'us-east-1',
|
|
309
|
+
accessKeyId: process.env.STORAGE_ACCESS_KEY!,
|
|
310
|
+
secretAccessKey: process.env.STORAGE_SECRET_KEY!,
|
|
311
|
+
bucket: process.env.STORAGE_BUCKET || 'mybucket',
|
|
312
|
+
forcePathStyle: true, // Required for Minio and S3-compatible services
|
|
313
|
+
publicUrlBase: '/api/storage/images',
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Export individual functions for convenience
|
|
317
|
+
export const { uploadFile, listFiles, deleteFile, getFile } = storage;
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
---
|
|
321
|
+
|
|
322
|
+
## API Reference
|
|
323
|
+
|
|
324
|
+
### Schema (`litecms/schema`)
|
|
325
|
+
|
|
326
|
+
#### `defineSchema(definition)`
|
|
327
|
+
|
|
328
|
+
Define a schema with field metadata for form generation.
|
|
329
|
+
|
|
330
|
+
```typescript
|
|
331
|
+
import { z } from 'zod';
|
|
332
|
+
import { defineSchema } from 'litecms/schema';
|
|
333
|
+
|
|
334
|
+
const MySchema = defineSchema({
|
|
335
|
+
schema: z.object({ ... }),
|
|
336
|
+
fields: { ... },
|
|
337
|
+
defaults: { ... },
|
|
338
|
+
});
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
**Types:**
|
|
342
|
+
|
|
343
|
+
```typescript
|
|
344
|
+
type SchemaDefinition<T extends z.ZodRawShape> = {
|
|
345
|
+
/** The Zod schema */
|
|
346
|
+
schema: z.ZodObject<T>;
|
|
347
|
+
/** Field metadata keyed by field name */
|
|
348
|
+
fields: { [K in keyof T]?: FieldMeta };
|
|
349
|
+
/** Default values */
|
|
350
|
+
defaults: z.infer<z.ZodObject<T>>;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
type FieldMeta = {
|
|
354
|
+
/** Display label for the field */
|
|
355
|
+
label: string;
|
|
356
|
+
/** Whether the field is editable in the admin panel (default: true) */
|
|
357
|
+
editable?: boolean;
|
|
358
|
+
/** Input type override */
|
|
359
|
+
type?: 'text' | 'textarea' | 'number' | 'email' | 'url' | 'select' | 'checkbox' | 'image';
|
|
360
|
+
/** Placeholder text */
|
|
361
|
+
placeholder?: string;
|
|
362
|
+
/** Help text shown below the input */
|
|
363
|
+
helpText?: string;
|
|
364
|
+
/** Options for select fields */
|
|
365
|
+
options?: Array<{ value: string; label: string }>;
|
|
366
|
+
/** Number of rows for textarea (default: 3) */
|
|
367
|
+
rows?: number;
|
|
368
|
+
/** Order in the form (lower = higher priority) */
|
|
369
|
+
order?: number;
|
|
370
|
+
/** Group name for organizing fields into collapsible sections */
|
|
371
|
+
group?: string;
|
|
372
|
+
/** Accepted file types for image fields (default: 'image/*') */
|
|
373
|
+
accept?: string;
|
|
374
|
+
};
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
#### `getEditableFields(definition)`
|
|
378
|
+
|
|
379
|
+
Extract editable field info from a schema definition. Used internally by form components.
|
|
380
|
+
|
|
381
|
+
```typescript
|
|
382
|
+
import { getEditableFields } from 'litecms/schema';
|
|
383
|
+
|
|
384
|
+
const fields = getEditableFields(HomePageDef);
|
|
385
|
+
// Returns: FieldInfo[] with name, meta, and required flag
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
### Server (`litecms/server`)
|
|
391
|
+
|
|
392
|
+
#### `createSaveAction(schema, options)`
|
|
393
|
+
|
|
394
|
+
Create a type-safe server action for form submission.
|
|
395
|
+
|
|
396
|
+
```typescript
|
|
397
|
+
import { createSaveAction } from 'litecms/server';
|
|
398
|
+
import { revalidatePath } from 'next/cache';
|
|
399
|
+
|
|
400
|
+
export const saveHome = createSaveAction(HomePageSchema, {
|
|
401
|
+
save: async (data) => {
|
|
402
|
+
await db.upsert('pages', { key: 'home', data });
|
|
403
|
+
},
|
|
404
|
+
revalidatePath: '/',
|
|
405
|
+
onRevalidate: revalidatePath,
|
|
406
|
+
checkAuth: async () => {
|
|
407
|
+
const session = await getSession();
|
|
408
|
+
return !!session;
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
**Types:**
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
type ActionOptions<TData> = {
|
|
417
|
+
/** Callback to save data */
|
|
418
|
+
save: (data: TData) => Promise<void>;
|
|
419
|
+
/** Path to revalidate after successful save */
|
|
420
|
+
revalidatePath?: string;
|
|
421
|
+
/** Callback to revalidate (pass revalidatePath from next/cache) */
|
|
422
|
+
onRevalidate?: (path: string) => void;
|
|
423
|
+
/** Optional auth check. Return true if authorized. */
|
|
424
|
+
checkAuth?: () => Promise<boolean>;
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
type ActionState<T = unknown> = {
|
|
428
|
+
success: boolean;
|
|
429
|
+
data?: T;
|
|
430
|
+
errors?: {
|
|
431
|
+
fieldErrors?: Record<string, string[]>;
|
|
432
|
+
formError?: string;
|
|
433
|
+
};
|
|
434
|
+
};
|
|
435
|
+
```
|
|
436
|
+
|
|
437
|
+
#### `parseFormDataToObject(formData)`
|
|
438
|
+
|
|
439
|
+
Parse FormData into a plain object with automatic type coercion. Handles:
|
|
440
|
+
|
|
441
|
+
- Empty strings → `undefined` (for optional fields)
|
|
442
|
+
- Number coercion for numeric values
|
|
443
|
+
- Boolean coercion for checkbox fields (`'true'`, `'on'`, `'false'`)
|
|
444
|
+
- Nested objects using dot notation (`address.city`)
|
|
445
|
+
- Arrays using bracket notation (`tags[0]`, `navLinks[0].label`)
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
import { parseFormDataToObject } from 'litecms/server';
|
|
449
|
+
|
|
450
|
+
const data = parseFormDataToObject(formData);
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
#### `parseFormData(schema, formData)`
|
|
454
|
+
|
|
455
|
+
Parse FormData and validate against a Zod schema. Returns parsed data or null.
|
|
456
|
+
|
|
457
|
+
```typescript
|
|
458
|
+
import { parseFormData } from 'litecms/server';
|
|
459
|
+
|
|
460
|
+
const data = parseFormData(MySchema, formData);
|
|
461
|
+
if (data) {
|
|
462
|
+
// data is typed as z.infer<typeof MySchema>
|
|
463
|
+
}
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
---
|
|
467
|
+
|
|
468
|
+
### Admin (`litecms/admin` and `litecms/admin/config`)
|
|
469
|
+
|
|
470
|
+
#### `createCmsConfig(config)`
|
|
471
|
+
|
|
472
|
+
Create the main CMS configuration.
|
|
473
|
+
|
|
474
|
+
```typescript
|
|
475
|
+
import { createCmsConfig, definePage } from 'litecms/admin/config';
|
|
476
|
+
|
|
477
|
+
export const cmsConfig = createCmsConfig({
|
|
478
|
+
siteName: 'My Site', // Shown in admin header
|
|
479
|
+
adminTitle: 'Content', // Admin panel title
|
|
480
|
+
basePath: '/admin', // Base path for admin routes
|
|
481
|
+
publicSiteUrl: '/', // Link to public site
|
|
482
|
+
storage: { // Required for image fields
|
|
483
|
+
uploadEndpoint: '/api/storage/upload',
|
|
484
|
+
listEndpoint: '/api/storage/list',
|
|
485
|
+
},
|
|
486
|
+
pages: [
|
|
487
|
+
definePage({ ... }),
|
|
488
|
+
],
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**Types:**
|
|
493
|
+
|
|
494
|
+
```typescript
|
|
495
|
+
type CmsConfig = {
|
|
496
|
+
siteName?: string;
|
|
497
|
+
adminTitle?: string;
|
|
498
|
+
basePath?: string;
|
|
499
|
+
publicSiteUrl?: string;
|
|
500
|
+
pages: CmsPageConfig[];
|
|
501
|
+
storage?: ImageUploadConfig;
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
type CmsPageConfig = {
|
|
505
|
+
/** URL slug for the admin page (e.g., 'home', 'about') */
|
|
506
|
+
slug: string;
|
|
507
|
+
/** Display name in navigation */
|
|
508
|
+
title: string;
|
|
509
|
+
/** Description shown below the title (helps guide users) */
|
|
510
|
+
description?: string;
|
|
511
|
+
/** Public URL path (e.g., '/', '/about'). Used for navigation building. */
|
|
512
|
+
path?: string;
|
|
513
|
+
/** Schema definition */
|
|
514
|
+
definition: SchemaDefinition<any>;
|
|
515
|
+
/** Server action to save data */
|
|
516
|
+
action: (prevState: ActionState, formData: FormData) => Promise<ActionState>;
|
|
517
|
+
/** Function to fetch current data */
|
|
518
|
+
getData: () => Promise<any>;
|
|
519
|
+
/** Icon name (optional) */
|
|
520
|
+
icon?: string;
|
|
521
|
+
/** Order in navigation (lower = higher) */
|
|
522
|
+
order?: number;
|
|
523
|
+
/** Group in navigation sidebar */
|
|
524
|
+
group?: string;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
type ImageUploadConfig = {
|
|
528
|
+
uploadEndpoint: string;
|
|
529
|
+
listEndpoint?: string;
|
|
530
|
+
};
|
|
531
|
+
```
|
|
532
|
+
|
|
533
|
+
#### `definePage(config)`
|
|
534
|
+
|
|
535
|
+
Type-safe helper to define a page config with proper inference.
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
definePage({
|
|
539
|
+
slug: 'home',
|
|
540
|
+
title: 'Homepage',
|
|
541
|
+
description: 'Edit homepage content',
|
|
542
|
+
path: '/',
|
|
543
|
+
definition: HomePageDef,
|
|
544
|
+
action: saveHome,
|
|
545
|
+
getData: getHomePageData,
|
|
546
|
+
order: 1,
|
|
547
|
+
group: 'Pages',
|
|
548
|
+
})
|
|
549
|
+
```
|
|
550
|
+
|
|
551
|
+
#### `extractLayoutProps(config)` and `extractPageProps(config, slug)`
|
|
552
|
+
|
|
553
|
+
Extract serializable props from CMS config for use in components.
|
|
554
|
+
|
|
555
|
+
```typescript
|
|
556
|
+
import { extractLayoutProps, extractPageProps } from 'litecms/admin/config';
|
|
557
|
+
|
|
558
|
+
// In layout
|
|
559
|
+
<CmsAdminLayout {...extractLayoutProps(cmsConfig)} />
|
|
560
|
+
|
|
561
|
+
// In page
|
|
562
|
+
const props = await extractPageProps(cmsConfig, slug);
|
|
563
|
+
<CmsAdminPage {...props} />
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
#### `CmsAdminLayout`
|
|
567
|
+
|
|
568
|
+
Admin layout component with sidebar navigation.
|
|
569
|
+
|
|
570
|
+
```tsx
|
|
571
|
+
import { CmsAdminLayout } from 'litecms/admin';
|
|
572
|
+
|
|
573
|
+
<CmsAdminLayout
|
|
574
|
+
adminTitle="Content"
|
|
575
|
+
siteName="My Site"
|
|
576
|
+
basePath="/admin"
|
|
577
|
+
publicSiteUrl="/"
|
|
578
|
+
pages={[{ slug: 'home', title: 'Home', group: 'Pages' }]}
|
|
579
|
+
currentSlug="home" // Optional: override auto-detection
|
|
580
|
+
onLogout={() => signOut()} // Optional: logout handler
|
|
581
|
+
>
|
|
582
|
+
{children}
|
|
583
|
+
</CmsAdminLayout>
|
|
584
|
+
```
|
|
585
|
+
|
|
586
|
+
#### `CmsAdminPage`
|
|
587
|
+
|
|
588
|
+
Admin page component that renders the form.
|
|
589
|
+
|
|
590
|
+
```tsx
|
|
591
|
+
import { CmsAdminPage } from 'litecms/admin';
|
|
592
|
+
|
|
593
|
+
<CmsAdminPage
|
|
594
|
+
title="Homepage"
|
|
595
|
+
description="Edit the main landing page"
|
|
596
|
+
fields={fields} // FieldInfo[] from extractPageProps
|
|
597
|
+
values={currentData}
|
|
598
|
+
action={saveAction}
|
|
599
|
+
storage={storageConfig} // For image fields
|
|
600
|
+
styles={customStyles} // Optional style overrides
|
|
601
|
+
submitText="Save"
|
|
602
|
+
successMessage="Changes saved!"
|
|
603
|
+
/>
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
---
|
|
607
|
+
|
|
608
|
+
### Components (`litecms/components`)
|
|
609
|
+
|
|
610
|
+
Components for building custom admin forms.
|
|
611
|
+
|
|
612
|
+
#### `CmsForm`
|
|
613
|
+
|
|
614
|
+
Form wrapper that integrates react-hook-form with server actions.
|
|
615
|
+
|
|
616
|
+
```tsx
|
|
617
|
+
import { CmsForm, CmsSubmitButton, CmsFormError, CmsFormSuccess } from 'litecms/components';
|
|
618
|
+
|
|
619
|
+
<CmsForm
|
|
620
|
+
schema={MySchema}
|
|
621
|
+
action={saveAction}
|
|
622
|
+
defaultValues={data}
|
|
623
|
+
onSuccess={(data) => console.log('Saved!', data)}
|
|
624
|
+
>
|
|
625
|
+
{/* Your field components */}
|
|
626
|
+
<CmsFormError />
|
|
627
|
+
<CmsFormSuccess>Saved successfully!</CmsFormSuccess>
|
|
628
|
+
<CmsSubmitButton pendingText="Saving...">Save</CmsSubmitButton>
|
|
629
|
+
</CmsForm>
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
#### `useCmsForm()`
|
|
633
|
+
|
|
634
|
+
Hook to access form state within CmsForm.
|
|
635
|
+
|
|
636
|
+
```tsx
|
|
637
|
+
import { useCmsForm } from 'litecms/components';
|
|
638
|
+
|
|
639
|
+
function MyComponent() {
|
|
640
|
+
const { isPending, formError, showSuccess } = useCmsForm();
|
|
641
|
+
// ...
|
|
642
|
+
}
|
|
643
|
+
```
|
|
644
|
+
|
|
645
|
+
#### `CmsField`
|
|
646
|
+
|
|
647
|
+
Renders text, textarea, select, number, email, or url inputs.
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
import { CmsField } from 'litecms/components';
|
|
651
|
+
|
|
652
|
+
<CmsField
|
|
653
|
+
name="title"
|
|
654
|
+
label="Page Title"
|
|
655
|
+
type="text" // 'text' | 'textarea' | 'select' | 'number' | 'email' | 'url'
|
|
656
|
+
placeholder="Enter title..."
|
|
657
|
+
helpText="This appears in the browser tab"
|
|
658
|
+
options={[{ value: 'a', label: 'Option A' }]} // For select type
|
|
659
|
+
rows={5} // For textarea type
|
|
660
|
+
required
|
|
661
|
+
disabled={false}
|
|
662
|
+
className="custom-wrapper"
|
|
663
|
+
labelClassName="custom-label"
|
|
664
|
+
inputClassName="custom-input"
|
|
665
|
+
errorClassName="custom-error"
|
|
666
|
+
helpClassName="custom-help"
|
|
667
|
+
/>
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
#### `CmsCheckbox`
|
|
671
|
+
|
|
672
|
+
Boolean checkbox input.
|
|
673
|
+
|
|
674
|
+
```tsx
|
|
675
|
+
import { CmsCheckbox } from 'litecms/components';
|
|
676
|
+
|
|
677
|
+
<CmsCheckbox
|
|
678
|
+
name="showInNav"
|
|
679
|
+
label="Show in Navigation"
|
|
680
|
+
helpText="Display this page in the nav bar"
|
|
681
|
+
/>
|
|
682
|
+
```
|
|
683
|
+
|
|
684
|
+
#### `CmsHiddenField`
|
|
685
|
+
|
|
686
|
+
Hidden input for non-editable values.
|
|
687
|
+
|
|
688
|
+
```tsx
|
|
689
|
+
import { CmsHiddenField } from 'litecms/components';
|
|
690
|
+
|
|
691
|
+
<CmsHiddenField name="internalId" value="abc123" />
|
|
692
|
+
```
|
|
693
|
+
|
|
694
|
+
#### `CmsImageField`
|
|
695
|
+
|
|
696
|
+
Image picker with upload support and gallery.
|
|
697
|
+
|
|
698
|
+
```tsx
|
|
699
|
+
import { CmsImageField } from 'litecms/components';
|
|
700
|
+
|
|
701
|
+
<CmsImageField
|
|
702
|
+
name="heroImage"
|
|
703
|
+
label="Hero Image"
|
|
704
|
+
helpText="Upload or select an image"
|
|
705
|
+
required
|
|
706
|
+
accept="image/png,image/jpeg" // Default: 'image/*'
|
|
707
|
+
storage={{
|
|
708
|
+
uploadEndpoint: '/api/storage/upload',
|
|
709
|
+
listEndpoint: '/api/storage/list',
|
|
710
|
+
}}
|
|
711
|
+
/>
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
#### `CmsAutoForm`
|
|
715
|
+
|
|
716
|
+
Auto-generates a complete form from a `SchemaDefinition`. Use this when you have direct access to the schema definition in client components.
|
|
717
|
+
|
|
718
|
+
```tsx
|
|
719
|
+
import { CmsAutoForm } from 'litecms/components';
|
|
720
|
+
|
|
721
|
+
<CmsAutoForm
|
|
722
|
+
definition={HomePageDef}
|
|
723
|
+
action={saveHome}
|
|
724
|
+
values={currentData}
|
|
725
|
+
submitText="Save Changes"
|
|
726
|
+
submitPendingText="Saving..."
|
|
727
|
+
successMessage="Changes saved!"
|
|
728
|
+
onSuccess={(data) => console.log('Success!', data)}
|
|
729
|
+
styles={{
|
|
730
|
+
wrapper: 'max-w-2xl',
|
|
731
|
+
submitButton: 'bg-blue-500 text-white px-4 py-2',
|
|
732
|
+
}}
|
|
733
|
+
/>
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
#### `CmsSimpleForm`
|
|
737
|
+
|
|
738
|
+
Form component that accepts pre-extracted field info. Use this in server components or when fields need to be serialized (like with `extractPageProps`).
|
|
739
|
+
|
|
740
|
+
```tsx
|
|
741
|
+
import { CmsSimpleForm } from 'litecms/components';
|
|
742
|
+
|
|
743
|
+
// Typically used via CmsAdminPage, but can be used directly:
|
|
744
|
+
<CmsSimpleForm
|
|
745
|
+
fields={fieldInfoArray} // FieldInfo[] (serializable)
|
|
746
|
+
action={saveAction}
|
|
747
|
+
values={currentData}
|
|
748
|
+
storage={storageConfig}
|
|
749
|
+
submitText="Save"
|
|
750
|
+
submitPendingText="Saving..."
|
|
751
|
+
successMessage="Changes saved"
|
|
752
|
+
/>
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
---
|
|
756
|
+
|
|
757
|
+
### Storage (`litecms/storage`)
|
|
758
|
+
|
|
759
|
+
#### `createStorageClient(config)`
|
|
760
|
+
|
|
761
|
+
Create an S3-compatible storage client.
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
import { createStorageClient } from 'litecms/storage';
|
|
765
|
+
|
|
766
|
+
export const storage = createStorageClient({
|
|
767
|
+
endpoint: process.env.S3_ENDPOINT!, // e.g., 'https://minio.example.com'
|
|
768
|
+
region: 'us-east-1', // Default: 'us-east-1'
|
|
769
|
+
accessKeyId: process.env.S3_ACCESS_KEY!,
|
|
770
|
+
secretAccessKey: process.env.S3_SECRET_KEY!,
|
|
771
|
+
bucket: 'my-bucket',
|
|
772
|
+
forcePathStyle: true, // Required for Minio (default: true)
|
|
773
|
+
publicUrlBase: '/api/storage/images', // URL prefix for serving files
|
|
774
|
+
});
|
|
775
|
+
```
|
|
776
|
+
|
|
777
|
+
**Returns:**
|
|
778
|
+
|
|
779
|
+
```typescript
|
|
780
|
+
type StorageClient = {
|
|
781
|
+
/** Upload a file to storage */
|
|
782
|
+
uploadFile: (file: File, path?: string) => Promise<{ url: string; key: string }>;
|
|
783
|
+
/** List files in storage */
|
|
784
|
+
listFiles: (prefix?: string) => Promise<FileInfo[]>;
|
|
785
|
+
/** Delete a file from storage */
|
|
786
|
+
deleteFile: (key: string) => Promise<void>;
|
|
787
|
+
/** Get file content as buffer */
|
|
788
|
+
getFile: (key: string) => Promise<{ buffer: ArrayBuffer; contentType: string | undefined }>;
|
|
789
|
+
/** The underlying S3 client (for advanced usage) */
|
|
790
|
+
s3Client: S3Client;
|
|
791
|
+
/** The bucket name */
|
|
792
|
+
bucket: string;
|
|
793
|
+
/** The public URL base */
|
|
794
|
+
publicUrlBase: string;
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
type FileInfo = {
|
|
798
|
+
key: string;
|
|
799
|
+
url: string;
|
|
800
|
+
lastModified?: Date;
|
|
801
|
+
size?: number;
|
|
802
|
+
};
|
|
803
|
+
```
|
|
804
|
+
|
|
805
|
+
**Usage:**
|
|
806
|
+
|
|
807
|
+
```typescript
|
|
808
|
+
// Upload
|
|
809
|
+
const { url, key } = await storage.uploadFile(file);
|
|
810
|
+
const { url, key } = await storage.uploadFile(file, 'custom/path/image.jpg');
|
|
811
|
+
|
|
812
|
+
// List
|
|
813
|
+
const files = await storage.listFiles(); // Default prefix: 'images/'
|
|
814
|
+
const files = await storage.listFiles('uploads/');
|
|
815
|
+
|
|
816
|
+
// Delete
|
|
817
|
+
await storage.deleteFile(key);
|
|
818
|
+
|
|
819
|
+
// Get file content
|
|
820
|
+
const { buffer, contentType } = await storage.getFile(key);
|
|
821
|
+
```
|
|
822
|
+
|
|
823
|
+
---
|
|
824
|
+
|
|
825
|
+
## Field Types
|
|
826
|
+
|
|
827
|
+
| Type | Description |
|
|
828
|
+
|------|-------------|
|
|
829
|
+
| `text` | Single-line text input (default) |
|
|
830
|
+
| `textarea` | Multi-line text input |
|
|
831
|
+
| `number` | Numeric input |
|
|
832
|
+
| `email` | Email input with validation |
|
|
833
|
+
| `url` | URL input with validation |
|
|
834
|
+
| `select` | Dropdown select (requires `options`) |
|
|
835
|
+
| `checkbox` | Boolean checkbox |
|
|
836
|
+
| `image` | Image picker with upload (requires storage config) |
|
|
837
|
+
|
|
838
|
+
## Field Grouping
|
|
839
|
+
|
|
840
|
+
Organize fields into collapsible groups in the admin form:
|
|
841
|
+
|
|
842
|
+
```typescript
|
|
843
|
+
fields: {
|
|
844
|
+
heroTitle: { label: 'Hero Title', group: 'Hero Section', order: 1 },
|
|
845
|
+
heroSubtitle: { label: 'Subtitle', group: 'Hero Section', order: 2 },
|
|
846
|
+
footerText: { label: 'Footer Text', group: 'Footer', order: 10 },
|
|
847
|
+
metaTitle: { label: 'SEO Title', group: 'SEO', order: 20 },
|
|
848
|
+
}
|
|
849
|
+
```
|
|
850
|
+
|
|
851
|
+
## Non-Editable Fields
|
|
852
|
+
|
|
853
|
+
Mark fields as `editable: false` to exclude them from the form. They will be preserved as hidden inputs:
|
|
854
|
+
|
|
855
|
+
```typescript
|
|
856
|
+
fields: {
|
|
857
|
+
internalNotes: {
|
|
858
|
+
label: 'Internal Notes',
|
|
859
|
+
editable: false, // Won't show in form, but value is preserved
|
|
860
|
+
},
|
|
861
|
+
features: {
|
|
862
|
+
label: 'Features',
|
|
863
|
+
editable: false, // Complex arrays often need custom editors
|
|
864
|
+
},
|
|
865
|
+
}
|
|
866
|
+
```
|
|
867
|
+
|
|
868
|
+
## Styling
|
|
869
|
+
|
|
870
|
+
Override default styles with className props:
|
|
871
|
+
|
|
872
|
+
```tsx
|
|
873
|
+
<CmsAutoForm
|
|
874
|
+
definition={def}
|
|
875
|
+
action={action}
|
|
876
|
+
styles={{
|
|
877
|
+
wrapper: 'max-w-2xl',
|
|
878
|
+
field: 'mb-4',
|
|
879
|
+
label: 'text-sm font-bold',
|
|
880
|
+
input: 'border-2 rounded-lg',
|
|
881
|
+
error: 'text-red-600 text-xs',
|
|
882
|
+
help: 'text-gray-500 text-xs',
|
|
883
|
+
group: 'p-4 border rounded',
|
|
884
|
+
groupTitle: 'text-lg font-semibold',
|
|
885
|
+
formError: 'bg-red-50 p-4 rounded',
|
|
886
|
+
formSuccess: 'bg-green-50 p-4 rounded',
|
|
887
|
+
submitButton: 'bg-blue-500 text-white px-4 py-2',
|
|
888
|
+
}}
|
|
889
|
+
/>
|
|
890
|
+
```
|
|
891
|
+
|
|
892
|
+
---
|
|
893
|
+
|
|
894
|
+
## Integration Guide: Better Auth + Drizzle
|
|
895
|
+
|
|
896
|
+
litecms works great with [Better Auth](https://better-auth.com) for authentication and [Drizzle ORM](https://orm.drizzle.team) for database access. Here's a complete setup guide.
|
|
897
|
+
|
|
898
|
+
### Prerequisites
|
|
899
|
+
|
|
900
|
+
```bash
|
|
901
|
+
bun add better-auth drizzle-orm postgres
|
|
902
|
+
bun add -D drizzle-kit
|
|
903
|
+
```
|
|
904
|
+
|
|
905
|
+
### 1. Database Setup with Drizzle
|
|
906
|
+
|
|
907
|
+
Create the database schema files:
|
|
908
|
+
|
|
909
|
+
```typescript
|
|
910
|
+
// app/db/auth-schema.ts
|
|
911
|
+
import { pgTable, text, timestamp, boolean } from "drizzle-orm/pg-core";
|
|
912
|
+
|
|
913
|
+
export const user = pgTable("user", {
|
|
914
|
+
id: text("id").primaryKey(),
|
|
915
|
+
name: text("name").notNull(),
|
|
916
|
+
email: text("email").notNull().unique(),
|
|
917
|
+
emailVerified: boolean("email_verified").notNull().default(false),
|
|
918
|
+
image: text("image"),
|
|
919
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
920
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
export const session = pgTable("session", {
|
|
924
|
+
id: text("id").primaryKey(),
|
|
925
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
926
|
+
token: text("token").notNull().unique(),
|
|
927
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
928
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
929
|
+
ipAddress: text("ip_address"),
|
|
930
|
+
userAgent: text("user_agent"),
|
|
931
|
+
userId: text("user_id")
|
|
932
|
+
.notNull()
|
|
933
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
export const account = pgTable("account", {
|
|
937
|
+
id: text("id").primaryKey(),
|
|
938
|
+
accountId: text("account_id").notNull(),
|
|
939
|
+
providerId: text("provider_id").notNull(),
|
|
940
|
+
userId: text("user_id")
|
|
941
|
+
.notNull()
|
|
942
|
+
.references(() => user.id, { onDelete: "cascade" }),
|
|
943
|
+
accessToken: text("access_token"),
|
|
944
|
+
refreshToken: text("refresh_token"),
|
|
945
|
+
idToken: text("id_token"),
|
|
946
|
+
accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true }),
|
|
947
|
+
refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true }),
|
|
948
|
+
scope: text("scope"),
|
|
949
|
+
password: text("password"),
|
|
950
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
951
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
952
|
+
});
|
|
953
|
+
|
|
954
|
+
export const verification = pgTable("verification", {
|
|
955
|
+
id: text("id").primaryKey(),
|
|
956
|
+
identifier: text("identifier").notNull(),
|
|
957
|
+
value: text("value").notNull(),
|
|
958
|
+
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
|
959
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
960
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
961
|
+
});
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
```typescript
|
|
965
|
+
// app/db/schema.ts
|
|
966
|
+
import { pgTable, text, timestamp, jsonb } from "drizzle-orm/pg-core";
|
|
967
|
+
|
|
968
|
+
// CMS documents table - stores all page content as JSON
|
|
969
|
+
export const cmsDocuments = pgTable("cms_documents", {
|
|
970
|
+
key: text("key").primaryKey(), // e.g., 'home', 'about', 'site-settings'
|
|
971
|
+
data: jsonb("data").notNull(), // The page data as JSON
|
|
972
|
+
content: text("content"), // Optional: rendered content for search
|
|
973
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
974
|
+
.notNull()
|
|
975
|
+
.defaultNow(),
|
|
976
|
+
updatedBy: text("updated_by"), // Optional: track who made changes
|
|
977
|
+
});
|
|
978
|
+
```
|
|
979
|
+
|
|
980
|
+
```typescript
|
|
981
|
+
// app/db/index.ts
|
|
982
|
+
import postgres from 'postgres';
|
|
983
|
+
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
984
|
+
import * as schema from './schema';
|
|
985
|
+
import * as authSchema from './auth-schema';
|
|
986
|
+
|
|
987
|
+
const url = process.env.DATABASE_URL ?? process.env.POSTGRES_URL;
|
|
988
|
+
|
|
989
|
+
if (!url) throw new Error('Missing DATABASE_URL env var');
|
|
990
|
+
|
|
991
|
+
// Prevent multiple connections in development
|
|
992
|
+
const globalForDb = globalThis as unknown as { _sql?: postgres.Sql };
|
|
993
|
+
|
|
994
|
+
const sql = globalForDb._sql ?? postgres(url, {
|
|
995
|
+
max: process.env.NODE_ENV === 'production' ? 10 : 1,
|
|
996
|
+
idle_timeout: 20,
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
if (process.env.NODE_ENV !== 'production') globalForDb._sql = sql;
|
|
1000
|
+
|
|
1001
|
+
export const db = drizzle(sql, { schema: { ...schema, ...authSchema } });
|
|
1002
|
+
|
|
1003
|
+
export * as authSchema from './auth-schema';
|
|
1004
|
+
```
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
// drizzle.config.ts
|
|
1008
|
+
import type { Config } from "drizzle-kit";
|
|
1009
|
+
|
|
1010
|
+
export default {
|
|
1011
|
+
schema: ["./app/db/schema.ts", "./app/db/auth-schema.ts"],
|
|
1012
|
+
out: "./drizzle",
|
|
1013
|
+
dialect: "postgresql",
|
|
1014
|
+
dbCredentials: {
|
|
1015
|
+
url: process.env.DATABASE_URL!,
|
|
1016
|
+
},
|
|
1017
|
+
} satisfies Config;
|
|
1018
|
+
```
|
|
1019
|
+
|
|
1020
|
+
Run migrations:
|
|
1021
|
+
|
|
1022
|
+
```bash
|
|
1023
|
+
bun drizzle-kit generate
|
|
1024
|
+
bun drizzle-kit migrate
|
|
1025
|
+
```
|
|
1026
|
+
|
|
1027
|
+
### 2. Better Auth Configuration
|
|
1028
|
+
|
|
1029
|
+
```typescript
|
|
1030
|
+
// app/lib/auth.ts
|
|
1031
|
+
import { betterAuth } from "better-auth";
|
|
1032
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
1033
|
+
import { nextCookies } from "better-auth/next-js";
|
|
1034
|
+
import { db, authSchema } from "@/app/db";
|
|
1035
|
+
|
|
1036
|
+
export const auth = betterAuth({
|
|
1037
|
+
database: drizzleAdapter(db, {
|
|
1038
|
+
provider: "pg",
|
|
1039
|
+
schema: authSchema,
|
|
1040
|
+
}),
|
|
1041
|
+
emailAndPassword: {
|
|
1042
|
+
enabled: true,
|
|
1043
|
+
},
|
|
1044
|
+
plugins: [nextCookies()],
|
|
1045
|
+
});
|
|
1046
|
+
```
|
|
1047
|
+
|
|
1048
|
+
```typescript
|
|
1049
|
+
// app/lib/auth-client.ts
|
|
1050
|
+
"use client";
|
|
1051
|
+
|
|
1052
|
+
import { createAuthClient } from "better-auth/react";
|
|
1053
|
+
|
|
1054
|
+
export const authClient = createAuthClient();
|
|
1055
|
+
|
|
1056
|
+
export const { signIn, signOut, signUp, useSession } = authClient;
|
|
1057
|
+
```
|
|
1058
|
+
|
|
1059
|
+
### 3. Auth API Route
|
|
1060
|
+
|
|
1061
|
+
```typescript
|
|
1062
|
+
// app/api/auth/[...all]/route.ts
|
|
1063
|
+
import { auth } from "@/app/lib/auth";
|
|
1064
|
+
import { toNextJsHandler } from "better-auth/next-js";
|
|
1065
|
+
|
|
1066
|
+
export const { GET, POST } = toNextJsHandler(auth.handler);
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
### 4. Server Actions with Drizzle
|
|
1070
|
+
|
|
1071
|
+
```typescript
|
|
1072
|
+
// app/admin/actions.ts
|
|
1073
|
+
'use server';
|
|
1074
|
+
|
|
1075
|
+
import { eq } from 'drizzle-orm';
|
|
1076
|
+
import { revalidatePath } from 'next/cache';
|
|
1077
|
+
import { createSaveAction } from 'litecms/server';
|
|
1078
|
+
import { db } from '../db';
|
|
1079
|
+
import { cmsDocuments } from '../db/schema';
|
|
1080
|
+
import { auth } from '../lib/auth';
|
|
1081
|
+
import { headers } from 'next/headers';
|
|
1082
|
+
import {
|
|
1083
|
+
HomePageSchema,
|
|
1084
|
+
type HomePageData,
|
|
1085
|
+
homePageDefaults,
|
|
1086
|
+
} from '../cms/schema';
|
|
1087
|
+
|
|
1088
|
+
// Generic helper to save any document
|
|
1089
|
+
async function saveDocument(key: string, data: unknown) {
|
|
1090
|
+
await db
|
|
1091
|
+
.insert(cmsDocuments)
|
|
1092
|
+
.values({
|
|
1093
|
+
key,
|
|
1094
|
+
data: data,
|
|
1095
|
+
updatedAt: new Date(),
|
|
1096
|
+
})
|
|
1097
|
+
.onConflictDoUpdate({
|
|
1098
|
+
target: cmsDocuments.key,
|
|
1099
|
+
set: {
|
|
1100
|
+
data: data,
|
|
1101
|
+
updatedAt: new Date(),
|
|
1102
|
+
},
|
|
1103
|
+
});
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
// Generic helper to get any document
|
|
1107
|
+
async function getDocument<T>(key: string): Promise<T | null> {
|
|
1108
|
+
const doc = await db.query.cmsDocuments.findFirst({
|
|
1109
|
+
where: eq(cmsDocuments.key, key),
|
|
1110
|
+
});
|
|
1111
|
+
return (doc?.data as T) ?? null;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// Auth check helper
|
|
1115
|
+
async function checkAuthenticated(): Promise<boolean> {
|
|
1116
|
+
const session = await auth.api.getSession({
|
|
1117
|
+
headers: await headers(),
|
|
1118
|
+
});
|
|
1119
|
+
return !!session;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
// Homepage actions
|
|
1123
|
+
export const saveHome = createSaveAction(HomePageSchema, {
|
|
1124
|
+
save: async (data) => saveDocument('home', data),
|
|
1125
|
+
revalidatePath: '/',
|
|
1126
|
+
onRevalidate: revalidatePath,
|
|
1127
|
+
checkAuth: checkAuthenticated,
|
|
1128
|
+
});
|
|
1129
|
+
|
|
1130
|
+
export async function getHomePageData(): Promise<HomePageData> {
|
|
1131
|
+
const data = await getDocument<HomePageData>('home');
|
|
1132
|
+
if (!data) return homePageDefaults;
|
|
1133
|
+
const result = HomePageSchema.safeParse(data);
|
|
1134
|
+
return result.success ? result.data : homePageDefaults;
|
|
1135
|
+
}
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
### 5. Protected Admin Layout
|
|
1139
|
+
|
|
1140
|
+
```typescript
|
|
1141
|
+
// app/admin/layout.tsx
|
|
1142
|
+
'use client';
|
|
1143
|
+
|
|
1144
|
+
import type { ReactNode } from 'react';
|
|
1145
|
+
import { useRouter } from 'next/navigation';
|
|
1146
|
+
import { useEffect } from 'react';
|
|
1147
|
+
import { CmsAdminLayout } from 'litecms/admin';
|
|
1148
|
+
import { extractLayoutProps } from 'litecms/admin/config';
|
|
1149
|
+
import { cmsConfig } from '../cms/config';
|
|
1150
|
+
import { useSession, signOut } from '../lib/auth-client';
|
|
1151
|
+
|
|
1152
|
+
export default function AdminLayout({ children }: { children: ReactNode }) {
|
|
1153
|
+
const router = useRouter();
|
|
1154
|
+
const { data: session, isPending } = useSession();
|
|
1155
|
+
|
|
1156
|
+
// Redirect to login if not authenticated
|
|
1157
|
+
useEffect(() => {
|
|
1158
|
+
if (!isPending && !session) {
|
|
1159
|
+
router.push('/login');
|
|
1160
|
+
}
|
|
1161
|
+
}, [isPending, session, router]);
|
|
1162
|
+
|
|
1163
|
+
const handleLogout = async () => {
|
|
1164
|
+
await signOut({
|
|
1165
|
+
fetchOptions: {
|
|
1166
|
+
onSuccess: () => {
|
|
1167
|
+
router.push('/login');
|
|
1168
|
+
router.refresh();
|
|
1169
|
+
},
|
|
1170
|
+
},
|
|
1171
|
+
});
|
|
1172
|
+
};
|
|
1173
|
+
|
|
1174
|
+
// Show nothing while checking auth
|
|
1175
|
+
if (isPending || !session) {
|
|
1176
|
+
return null;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
return (
|
|
1180
|
+
<CmsAdminLayout
|
|
1181
|
+
{...extractLayoutProps(cmsConfig)}
|
|
1182
|
+
onLogout={handleLogout}
|
|
1183
|
+
>
|
|
1184
|
+
{children}
|
|
1185
|
+
</CmsAdminLayout>
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
```
|
|
1189
|
+
|
|
1190
|
+
### 6. Login Page
|
|
1191
|
+
|
|
1192
|
+
```typescript
|
|
1193
|
+
// app/login/page.tsx
|
|
1194
|
+
"use client";
|
|
1195
|
+
|
|
1196
|
+
import { useState } from "react";
|
|
1197
|
+
import { useRouter } from "next/navigation";
|
|
1198
|
+
import Link from "next/link";
|
|
1199
|
+
import { signIn } from "@/app/lib/auth-client";
|
|
1200
|
+
|
|
1201
|
+
export default function LoginPage() {
|
|
1202
|
+
const router = useRouter();
|
|
1203
|
+
const [email, setEmail] = useState("");
|
|
1204
|
+
const [password, setPassword] = useState("");
|
|
1205
|
+
const [error, setError] = useState<string | null>(null);
|
|
1206
|
+
const [loading, setLoading] = useState(false);
|
|
1207
|
+
|
|
1208
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
1209
|
+
e.preventDefault();
|
|
1210
|
+
setError(null);
|
|
1211
|
+
setLoading(true);
|
|
1212
|
+
|
|
1213
|
+
try {
|
|
1214
|
+
const result = await signIn.email({
|
|
1215
|
+
email,
|
|
1216
|
+
password,
|
|
1217
|
+
callbackURL: "/admin",
|
|
1218
|
+
});
|
|
1219
|
+
|
|
1220
|
+
if (result.error) {
|
|
1221
|
+
setError(result.error.message || "Invalid credentials");
|
|
1222
|
+
} else {
|
|
1223
|
+
router.push("/admin");
|
|
1224
|
+
router.refresh();
|
|
1225
|
+
}
|
|
1226
|
+
} catch {
|
|
1227
|
+
setError("An error occurred. Please try again.");
|
|
1228
|
+
} finally {
|
|
1229
|
+
setLoading(false);
|
|
1230
|
+
}
|
|
1231
|
+
};
|
|
1232
|
+
|
|
1233
|
+
return (
|
|
1234
|
+
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
|
1235
|
+
<div className="max-w-md w-full space-y-8">
|
|
1236
|
+
<div className="text-center">
|
|
1237
|
+
<h1 className="text-3xl font-bold text-gray-900">Admin Login</h1>
|
|
1238
|
+
<p className="mt-2 text-gray-600">
|
|
1239
|
+
Sign in to access the content manager
|
|
1240
|
+
</p>
|
|
1241
|
+
</div>
|
|
1242
|
+
|
|
1243
|
+
<form onSubmit={handleSubmit} className="mt-8 space-y-6">
|
|
1244
|
+
<div className="space-y-4">
|
|
1245
|
+
<div>
|
|
1246
|
+
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
|
|
1247
|
+
Email address
|
|
1248
|
+
</label>
|
|
1249
|
+
<input
|
|
1250
|
+
id="email"
|
|
1251
|
+
type="email"
|
|
1252
|
+
required
|
|
1253
|
+
value={email}
|
|
1254
|
+
onChange={(e) => setEmail(e.target.value)}
|
|
1255
|
+
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg"
|
|
1256
|
+
placeholder="admin@example.com"
|
|
1257
|
+
/>
|
|
1258
|
+
</div>
|
|
1259
|
+
|
|
1260
|
+
<div>
|
|
1261
|
+
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
|
|
1262
|
+
Password
|
|
1263
|
+
</label>
|
|
1264
|
+
<input
|
|
1265
|
+
id="password"
|
|
1266
|
+
type="password"
|
|
1267
|
+
required
|
|
1268
|
+
value={password}
|
|
1269
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
1270
|
+
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg"
|
|
1271
|
+
/>
|
|
1272
|
+
</div>
|
|
1273
|
+
</div>
|
|
1274
|
+
|
|
1275
|
+
{error && (
|
|
1276
|
+
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-lg">
|
|
1277
|
+
{error}
|
|
1278
|
+
</div>
|
|
1279
|
+
)}
|
|
1280
|
+
|
|
1281
|
+
<button
|
|
1282
|
+
type="submit"
|
|
1283
|
+
disabled={loading}
|
|
1284
|
+
className="w-full py-3 px-4 rounded-lg text-white bg-blue-600 hover:bg-blue-700 disabled:opacity-50"
|
|
1285
|
+
>
|
|
1286
|
+
{loading ? "Signing in..." : "Sign in"}
|
|
1287
|
+
</button>
|
|
1288
|
+
|
|
1289
|
+
<p className="text-center text-sm text-gray-600">
|
|
1290
|
+
Need an account?{" "}
|
|
1291
|
+
<Link href="/signup" className="text-blue-600 hover:text-blue-500">
|
|
1292
|
+
Sign up
|
|
1293
|
+
</Link>
|
|
1294
|
+
</p>
|
|
1295
|
+
</form>
|
|
1296
|
+
</div>
|
|
1297
|
+
</div>
|
|
1298
|
+
);
|
|
1299
|
+
}
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
### 7. Protected API Routes
|
|
1303
|
+
|
|
1304
|
+
When using storage or other API routes, check authentication:
|
|
1305
|
+
|
|
1306
|
+
```typescript
|
|
1307
|
+
// app/api/storage/upload/route.ts
|
|
1308
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
1309
|
+
import { storage } from '@/lib/storage';
|
|
1310
|
+
import { auth } from '@/app/lib/auth';
|
|
1311
|
+
import { headers } from 'next/headers';
|
|
1312
|
+
|
|
1313
|
+
export async function POST(request: NextRequest) {
|
|
1314
|
+
// Check authentication using Better Auth
|
|
1315
|
+
const session = await auth.api.getSession({
|
|
1316
|
+
headers: await headers(),
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
if (!session) {
|
|
1320
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
// ... rest of upload logic
|
|
1324
|
+
}
|
|
1325
|
+
```
|
|
1326
|
+
|
|
1327
|
+
### Environment Variables
|
|
1328
|
+
|
|
1329
|
+
```bash
|
|
1330
|
+
# .env.local
|
|
1331
|
+
|
|
1332
|
+
# Database (PostgreSQL)
|
|
1333
|
+
DATABASE_URL="postgresql://user:password@localhost:5432/mydb"
|
|
1334
|
+
|
|
1335
|
+
# Better Auth
|
|
1336
|
+
BETTER_AUTH_SECRET="your-secret-key-min-32-chars"
|
|
1337
|
+
BETTER_AUTH_URL="http://localhost:3000"
|
|
1338
|
+
|
|
1339
|
+
# Storage (optional, for image uploads)
|
|
1340
|
+
STORAGE_URL="http://localhost:9000"
|
|
1341
|
+
STORAGE_ACCESS_KEY="minioadmin"
|
|
1342
|
+
STORAGE_SECRET_KEY="minioadmin"
|
|
1343
|
+
STORAGE_BUCKET="mybucket"
|
|
1344
|
+
```
|
|
1345
|
+
|
|
1346
|
+
### File Structure
|
|
1347
|
+
|
|
1348
|
+
```
|
|
1349
|
+
app/
|
|
1350
|
+
├── admin/
|
|
1351
|
+
│ ├── [slug]/
|
|
1352
|
+
│ │ └── page.tsx # Dynamic admin pages
|
|
1353
|
+
│ ├── actions.ts # Server actions with Drizzle
|
|
1354
|
+
│ ├── layout.tsx # Protected layout with Better Auth
|
|
1355
|
+
│ └── page.tsx # Admin index/redirect
|
|
1356
|
+
├── api/
|
|
1357
|
+
│ ├── auth/
|
|
1358
|
+
│ │ └── [...all]/
|
|
1359
|
+
│ │ └── route.ts # Better Auth handler
|
|
1360
|
+
│ └── storage/
|
|
1361
|
+
│ ├── upload/
|
|
1362
|
+
│ │ └── route.ts # Protected upload
|
|
1363
|
+
│ └── list/
|
|
1364
|
+
│ └── route.ts # Protected list
|
|
1365
|
+
├── cms/
|
|
1366
|
+
│ ├── config.ts # CMS configuration
|
|
1367
|
+
│ └── schema.ts # Zod schemas
|
|
1368
|
+
├── db/
|
|
1369
|
+
│ ├── auth-schema.ts # Better Auth tables
|
|
1370
|
+
│ ├── index.ts # Drizzle client
|
|
1371
|
+
│ └── schema.ts # CMS tables
|
|
1372
|
+
├── lib/
|
|
1373
|
+
│ ├── auth.ts # Better Auth server
|
|
1374
|
+
│ ├── auth-client.ts # Better Auth client
|
|
1375
|
+
│ └── storage.ts # Storage client
|
|
1376
|
+
├── login/
|
|
1377
|
+
│ └── page.tsx # Login page
|
|
1378
|
+
└── signup/
|
|
1379
|
+
└── page.tsx # Signup page
|
|
1380
|
+
drizzle/
|
|
1381
|
+
└── ... # Generated migrations
|
|
1382
|
+
drizzle.config.ts
|
|
1383
|
+
```
|
|
1384
|
+
|
|
1385
|
+
## License
|
|
1386
|
+
|
|
1387
|
+
MIT © Timo Weiss
|