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.
Files changed (50) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1387 -0
  3. package/dist/admin/CmsAdminLayout.d.ts +27 -0
  4. package/dist/admin/CmsAdminLayout.d.ts.map +1 -0
  5. package/dist/admin/CmsAdminPage.d.ts +31 -0
  6. package/dist/admin/CmsAdminPage.d.ts.map +1 -0
  7. package/dist/admin/config.d.ts +83 -0
  8. package/dist/admin/config.d.ts.map +1 -0
  9. package/dist/admin/config.js +53 -0
  10. package/dist/admin/exports.d.ts +7 -0
  11. package/dist/admin/exports.d.ts.map +1 -0
  12. package/dist/admin/exports.js +452 -0
  13. package/dist/admin/index.d.ts +147 -0
  14. package/dist/admin/index.d.ts.map +1 -0
  15. package/dist/components/CmsAutoForm.d.ts +73 -0
  16. package/dist/components/CmsAutoForm.d.ts.map +1 -0
  17. package/dist/components/CmsField.d.ts +50 -0
  18. package/dist/components/CmsField.d.ts.map +1 -0
  19. package/dist/components/CmsForm.d.ts +74 -0
  20. package/dist/components/CmsForm.d.ts.map +1 -0
  21. package/dist/components/CmsImageField.d.ts +33 -0
  22. package/dist/components/CmsImageField.d.ts.map +1 -0
  23. package/dist/components/CmsNavSection.d.ts +7 -0
  24. package/dist/components/CmsNavSection.d.ts.map +1 -0
  25. package/dist/components/CmsSimpleForm.d.ts +54 -0
  26. package/dist/components/CmsSimpleForm.d.ts.map +1 -0
  27. package/dist/components/index.d.ts +43 -0
  28. package/dist/components/index.d.ts.map +1 -0
  29. package/dist/components/index.js +619 -0
  30. package/dist/domain/index.d.ts +1 -0
  31. package/dist/domain/index.d.ts.map +1 -0
  32. package/dist/index-8zcd33mx.js +39 -0
  33. package/dist/index-pmb5m3ek.js +4135 -0
  34. package/dist/index.d.ts +4 -0
  35. package/dist/index.d.ts.map +1 -0
  36. package/dist/index.js +32 -0
  37. package/dist/schema/index.d.ts +80 -0
  38. package/dist/schema/index.d.ts.map +1 -0
  39. package/dist/schema/index.js +46 -0
  40. package/dist/server/index.d.ts +79 -0
  41. package/dist/server/index.d.ts.map +1 -0
  42. package/dist/server/index.js +117 -0
  43. package/dist/shared/utils.d.ts +23 -0
  44. package/dist/shared/utils.d.ts.map +1 -0
  45. package/dist/storage/index.d.ts +86 -0
  46. package/dist/storage/index.d.ts.map +1 -0
  47. package/dist/storage/index.js +86 -0
  48. package/dist/stores/index.d.ts +1 -0
  49. package/dist/stores/index.d.ts.map +1 -0
  50. 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