litecms 0.1.2 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +746 -7
- package/dist/admin/CmsAdminLanding.d.ts +89 -0
- package/dist/admin/CmsAdminLanding.d.ts.map +1 -0
- package/dist/admin/CmsAdminLayout.d.ts +26 -3
- package/dist/admin/CmsAdminLayout.d.ts.map +1 -1
- package/dist/admin/CmsAdminPage.d.ts.map +1 -1
- package/dist/admin/CmsBlogAdmin.d.ts +37 -0
- package/dist/admin/CmsBlogAdmin.d.ts.map +1 -0
- package/dist/admin/config.d.ts +32 -0
- package/dist/admin/config.d.ts.map +1 -1
- package/dist/admin/config.js +16 -0
- package/dist/admin/exports.d.ts +5 -2
- package/dist/admin/exports.d.ts.map +1 -1
- package/dist/admin/exports.js +1467 -30
- package/dist/admin/language.d.ts +53 -0
- package/dist/admin/language.d.ts.map +1 -0
- package/dist/components/CmsAutoForm.d.ts.map +1 -1
- package/dist/components/CmsForm.d.ts.map +1 -1
- package/dist/components/CmsImageField.d.ts +2 -1
- package/dist/components/CmsImageField.d.ts.map +1 -1
- package/dist/components/CmsImagePickerModal.d.ts +21 -0
- package/dist/components/CmsImagePickerModal.d.ts.map +1 -0
- package/dist/components/CmsSimpleForm.d.ts.map +1 -1
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +51 -190
- package/dist/index-c9btr14k.js +4422 -0
- package/dist/index-szreq4v9.js +12 -0
- package/dist/index-wmd953zf.js +11423 -0
- package/dist/index.js +6 -2
- package/dist/schema/index.js +2 -0
- package/dist/server/index.d.ts +301 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2585 -1
- package/dist/storage/index.js +2 -0
- package/package.json +14 -7
- package/dist/domain/index.d.ts +0 -1
- package/dist/domain/index.d.ts.map +0 -1
- package/dist/stores/index.d.ts +0 -1
- package/dist/stores/index.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -202,7 +202,20 @@ export default function AdminLayout({
|
|
|
202
202
|
}
|
|
203
203
|
```
|
|
204
204
|
|
|
205
|
-
### 5. Create Admin Page
|
|
205
|
+
### 5. Create Admin Index Page (Landing)
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// app/admin/page.tsx
|
|
209
|
+
import { CmsAdminLanding } from 'litecms/admin';
|
|
210
|
+
import { extractLandingProps } from 'litecms/admin/config';
|
|
211
|
+
import { cmsConfig } from '../cms/config';
|
|
212
|
+
|
|
213
|
+
export default function AdminIndexPage() {
|
|
214
|
+
return <CmsAdminLanding {...extractLandingProps(cmsConfig)} />;
|
|
215
|
+
}
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
### 6. Create Admin Page Editor
|
|
206
219
|
|
|
207
220
|
```typescript
|
|
208
221
|
// app/admin/[slug]/page.tsx
|
|
@@ -231,7 +244,7 @@ export function generateStaticParams() {
|
|
|
231
244
|
}
|
|
232
245
|
```
|
|
233
246
|
|
|
234
|
-
###
|
|
247
|
+
### 7. Set Up Storage API Routes (Optional)
|
|
235
248
|
|
|
236
249
|
For image uploads, create API routes:
|
|
237
250
|
|
|
@@ -463,6 +476,50 @@ if (data) {
|
|
|
463
476
|
}
|
|
464
477
|
```
|
|
465
478
|
|
|
479
|
+
#### `createLanguageRoutes(deps, NextResponse?)`
|
|
480
|
+
|
|
481
|
+
Create GET and POST handlers for the admin language preference API route.
|
|
482
|
+
|
|
483
|
+
```typescript
|
|
484
|
+
import { createLanguageRoutes } from 'litecms/server';
|
|
485
|
+
|
|
486
|
+
export const { GET, POST } = createLanguageRoutes({
|
|
487
|
+
getUserId: async () => {
|
|
488
|
+
// Return current user ID or null if not authenticated
|
|
489
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
490
|
+
return session?.user?.id ?? null;
|
|
491
|
+
},
|
|
492
|
+
getUserSettings: async (userId) => {
|
|
493
|
+
// Return user's settings or null
|
|
494
|
+
const user = await db.query.user.findFirst({ where: eq(users.id, userId) });
|
|
495
|
+
return user?.settings ?? null;
|
|
496
|
+
},
|
|
497
|
+
updateUserSettings: async (userId, settings) => {
|
|
498
|
+
// Save settings to database
|
|
499
|
+
await db.update(users).set({ settings }).where(eq(users.id, userId));
|
|
500
|
+
},
|
|
501
|
+
}, NextResponse); // Optional: pass NextResponse for proper typing
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
**Types:**
|
|
505
|
+
|
|
506
|
+
```typescript
|
|
507
|
+
type CmsLanguage = 'en' | 'de';
|
|
508
|
+
|
|
509
|
+
type UserLanguageSettings = {
|
|
510
|
+
language?: CmsLanguage;
|
|
511
|
+
};
|
|
512
|
+
|
|
513
|
+
type LanguageRoutesDeps = {
|
|
514
|
+
/** Get the current authenticated user's ID. Return null if not authenticated. */
|
|
515
|
+
getUserId: () => Promise<string | null>;
|
|
516
|
+
/** Get the user's current settings. Return null if user not found. */
|
|
517
|
+
getUserSettings: (userId: string) => Promise<UserLanguageSettings | null>;
|
|
518
|
+
/** Update the user's settings. */
|
|
519
|
+
updateUserSettings: (userId: string, settings: UserLanguageSettings) => Promise<void>;
|
|
520
|
+
};
|
|
521
|
+
```
|
|
522
|
+
|
|
466
523
|
---
|
|
467
524
|
|
|
468
525
|
### Admin (`litecms/admin` and `litecms/admin/config`)
|
|
@@ -553,12 +610,15 @@ definePage({
|
|
|
553
610
|
Extract serializable props from CMS config for use in components.
|
|
554
611
|
|
|
555
612
|
```typescript
|
|
556
|
-
import { extractLayoutProps, extractPageProps } from 'litecms/admin/config';
|
|
613
|
+
import { extractLayoutProps, extractPageProps, extractLandingProps } from 'litecms/admin/config';
|
|
557
614
|
|
|
558
615
|
// In layout
|
|
559
616
|
<CmsAdminLayout {...extractLayoutProps(cmsConfig)} />
|
|
560
617
|
|
|
561
|
-
// In page
|
|
618
|
+
// In landing page (/admin)
|
|
619
|
+
<CmsAdminLanding {...extractLandingProps(cmsConfig)} />
|
|
620
|
+
|
|
621
|
+
// In page editor (/admin/[slug])
|
|
562
622
|
const props = await extractPageProps(cmsConfig, slug);
|
|
563
623
|
<CmsAdminPage {...props} />
|
|
564
624
|
```
|
|
@@ -603,6 +663,671 @@ import { CmsAdminPage } from 'litecms/admin';
|
|
|
603
663
|
/>
|
|
604
664
|
```
|
|
605
665
|
|
|
666
|
+
#### `CmsAdminLanding`
|
|
667
|
+
|
|
668
|
+
Admin landing page / dashboard component that displays module cards.
|
|
669
|
+
|
|
670
|
+
```tsx
|
|
671
|
+
import { CmsAdminLanding } from 'litecms/admin';
|
|
672
|
+
import { extractLandingProps } from 'litecms/admin/config';
|
|
673
|
+
import { cmsConfig } from '@/cms/config';
|
|
674
|
+
|
|
675
|
+
export default function AdminIndexPage() {
|
|
676
|
+
return <CmsAdminLanding {...extractLandingProps(cmsConfig)} />;
|
|
677
|
+
}
|
|
678
|
+
```
|
|
679
|
+
|
|
680
|
+
By default, only the **Website Content** module is shown (links to the first configured page editor).
|
|
681
|
+
|
|
682
|
+
You can add additional modules using the `modules` prop:
|
|
683
|
+
|
|
684
|
+
```tsx
|
|
685
|
+
import { CmsAdminLanding, CmsModuleIcons } from 'litecms/admin';
|
|
686
|
+
import { extractLandingProps } from 'litecms/admin/config';
|
|
687
|
+
import { cmsConfig } from '@/cms/config';
|
|
688
|
+
|
|
689
|
+
export default function AdminIndexPage() {
|
|
690
|
+
return (
|
|
691
|
+
<CmsAdminLanding
|
|
692
|
+
{...extractLandingProps(cmsConfig)}
|
|
693
|
+
modules={[
|
|
694
|
+
{
|
|
695
|
+
id: 'blog',
|
|
696
|
+
title: 'Blog',
|
|
697
|
+
description: 'Write and manage blog posts',
|
|
698
|
+
icon: CmsModuleIcons.blog,
|
|
699
|
+
href: '/admin/blog',
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
id: 'email',
|
|
703
|
+
title: 'Email',
|
|
704
|
+
description: 'Manage email templates',
|
|
705
|
+
icon: CmsModuleIcons.email,
|
|
706
|
+
disabled: true,
|
|
707
|
+
badge: 'Coming soon',
|
|
708
|
+
},
|
|
709
|
+
]}
|
|
710
|
+
/>
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
**Types:**
|
|
716
|
+
|
|
717
|
+
```typescript
|
|
718
|
+
type CmsAdminModule = {
|
|
719
|
+
/** Unique module identifier */
|
|
720
|
+
id: string;
|
|
721
|
+
/** Module title */
|
|
722
|
+
title: string;
|
|
723
|
+
/** Module description */
|
|
724
|
+
description: string;
|
|
725
|
+
/** Icon element to display */
|
|
726
|
+
icon: React.ReactNode;
|
|
727
|
+
/** Link to the module's admin page */
|
|
728
|
+
href?: string;
|
|
729
|
+
/** Whether the module is disabled/coming soon */
|
|
730
|
+
disabled?: boolean;
|
|
731
|
+
/** Badge text (e.g., "Coming soon") */
|
|
732
|
+
badge?: string;
|
|
733
|
+
};
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
**Available Icons:**
|
|
737
|
+
|
|
738
|
+
```tsx
|
|
739
|
+
import { CmsModuleIcons } from 'litecms/admin';
|
|
740
|
+
|
|
741
|
+
// Pre-built icons for common modules:
|
|
742
|
+
CmsModuleIcons.pages // Document/pages icon
|
|
743
|
+
CmsModuleIcons.blog // Newspaper/blog icon
|
|
744
|
+
CmsModuleIcons.email // Envelope icon
|
|
745
|
+
CmsModuleIcons.analytics // Bar chart icon
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
---
|
|
749
|
+
|
|
750
|
+
### Admin Language (i18n)
|
|
751
|
+
|
|
752
|
+
litecms includes built-in support for switching the admin panel language between English and German. The language preference is stored per-user in your database and defaults to the browser language.
|
|
753
|
+
|
|
754
|
+
#### 1. Add Settings Column to User Table
|
|
755
|
+
|
|
756
|
+
Add a `settings` JSONB column to your user table:
|
|
757
|
+
|
|
758
|
+
```typescript
|
|
759
|
+
// app/db/auth-schema.ts
|
|
760
|
+
import { pgTable, text, timestamp, boolean, jsonb } from "drizzle-orm/pg-core";
|
|
761
|
+
|
|
762
|
+
export type UserSettings = {
|
|
763
|
+
language?: "en" | "de";
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
export const user = pgTable("user", {
|
|
767
|
+
id: text("id").primaryKey(),
|
|
768
|
+
name: text("name").notNull(),
|
|
769
|
+
email: text("email").notNull().unique(),
|
|
770
|
+
emailVerified: boolean("email_verified").notNull().default(false),
|
|
771
|
+
image: text("image"),
|
|
772
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
773
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
|
774
|
+
settings: jsonb("settings").$type<UserSettings>(),
|
|
775
|
+
});
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
Then generate and run the migration:
|
|
779
|
+
|
|
780
|
+
```bash
|
|
781
|
+
bun run db:generate
|
|
782
|
+
bun run db:migrate
|
|
783
|
+
```
|
|
784
|
+
|
|
785
|
+
#### 2. Create Language API Route
|
|
786
|
+
|
|
787
|
+
Create an API route that handles reading and saving the language preference:
|
|
788
|
+
|
|
789
|
+
```typescript
|
|
790
|
+
// app/api/admin/language/route.ts
|
|
791
|
+
import { NextResponse } from 'next/server';
|
|
792
|
+
import { headers } from 'next/headers';
|
|
793
|
+
import { eq } from 'drizzle-orm';
|
|
794
|
+
import { createLanguageRoutes } from 'litecms/server';
|
|
795
|
+
import { auth } from '@/app/lib/auth';
|
|
796
|
+
import { db } from '@/app/db';
|
|
797
|
+
import { user } from '@/app/db/auth-schema';
|
|
798
|
+
import type { UserSettings } from '@/app/db/auth-schema';
|
|
799
|
+
|
|
800
|
+
export const { GET, POST } = createLanguageRoutes({
|
|
801
|
+
getUserId: async () => {
|
|
802
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
803
|
+
return session?.user?.id ?? null;
|
|
804
|
+
},
|
|
805
|
+
getUserSettings: async (userId) => {
|
|
806
|
+
const userData = await db.query.user.findFirst({
|
|
807
|
+
where: eq(user.id, userId),
|
|
808
|
+
columns: { settings: true },
|
|
809
|
+
});
|
|
810
|
+
return (userData?.settings as UserSettings | null) ?? null;
|
|
811
|
+
},
|
|
812
|
+
updateUserSettings: async (userId, settings) => {
|
|
813
|
+
await db.update(user)
|
|
814
|
+
.set({ settings, updatedAt: new Date() })
|
|
815
|
+
.where(eq(user.id, userId));
|
|
816
|
+
},
|
|
817
|
+
}, NextResponse);
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
#### 3. That's It!
|
|
821
|
+
|
|
822
|
+
The `CmsAdminLayout` automatically includes the language selector in the sidebar footer. When users change the language:
|
|
823
|
+
|
|
824
|
+
- The UI updates immediately
|
|
825
|
+
- The preference is saved to the database
|
|
826
|
+
- On page reload, the saved preference is restored
|
|
827
|
+
- If no preference is saved, the browser language is used as default
|
|
828
|
+
|
|
829
|
+
#### Language Components and Hooks
|
|
830
|
+
|
|
831
|
+
You can also use the language system in your own components:
|
|
832
|
+
|
|
833
|
+
```tsx
|
|
834
|
+
import {
|
|
835
|
+
useCmsLanguage, // Hook to access language context (throws if no provider)
|
|
836
|
+
useCmsLanguageOptional, // Hook that returns null if no provider
|
|
837
|
+
CmsLanguageSelector, // The language dropdown component
|
|
838
|
+
detectBrowserLanguage, // Utility to detect browser language
|
|
839
|
+
type CmsLanguage, // Type: 'en' | 'de'
|
|
840
|
+
} from 'litecms/admin';
|
|
841
|
+
|
|
842
|
+
function MyComponent() {
|
|
843
|
+
const { language, setLanguage, t } = useCmsLanguage();
|
|
844
|
+
|
|
845
|
+
return (
|
|
846
|
+
<div>
|
|
847
|
+
<p>Current language: {language}</p>
|
|
848
|
+
<p>{t('save')}</p> {/* Translated string */}
|
|
849
|
+
<button onClick={() => setLanguage('de')}>Switch to German</button>
|
|
850
|
+
</div>
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
#### Available Translation Keys
|
|
856
|
+
|
|
857
|
+
| Key | English | German |
|
|
858
|
+
|-----|---------|--------|
|
|
859
|
+
| `viewSite` | View site | Seite ansehen |
|
|
860
|
+
| `signOut` | Sign out | Abmelden |
|
|
861
|
+
| `language` | Language | Sprache |
|
|
862
|
+
| `dashboard` | Dashboard | Dashboard |
|
|
863
|
+
| `pages` | Pages | Seiten |
|
|
864
|
+
| `cmsNotConfigured` | CMS not configured | CMS nicht konfiguriert |
|
|
865
|
+
| `modulePages` | Website Content | Website-Inhalte |
|
|
866
|
+
| `modulePagesDesc` | Edit pages, text, and images... | Seiten, Texte und Bilder... |
|
|
867
|
+
| `moduleEmail` | Email | E-Mail |
|
|
868
|
+
| `moduleAnalytics` | Analytics | Statistiken |
|
|
869
|
+
| `comingSoon` | Coming soon | Demnächst |
|
|
870
|
+
| `save` | Save | Speichern |
|
|
871
|
+
| `saving` | Saving... | Speichern... |
|
|
872
|
+
| `reset` | Reset | Zurücksetzen |
|
|
873
|
+
| `changesSaved` | Changes saved successfully. | Änderungen erfolgreich gespeichert. |
|
|
874
|
+
| `savedSuccessfully` | Saved successfully! | Erfolgreich gespeichert! |
|
|
875
|
+
| `saveChanges` | Save changes | Änderungen speichern |
|
|
876
|
+
| `selectImage` | Select Image | Bild auswählen |
|
|
877
|
+
| `replace` | Replace | Ersetzen |
|
|
878
|
+
| `remove` | Remove | Entfernen |
|
|
879
|
+
| `library` | Library | Bibliothek |
|
|
880
|
+
| `loading` | Loading... | Laden... |
|
|
881
|
+
| `noImagesYet` | No images yet | Noch keine Bilder |
|
|
882
|
+
| `cancel` | Cancel | Abbrechen |
|
|
883
|
+
| `select` | Select | Auswählen |
|
|
884
|
+
| `moduleBlog` | Blog | Blog |
|
|
885
|
+
| `moduleBlogDesc` | Write and manage blog posts | Blogbeiträge schreiben und verwalten |
|
|
886
|
+
| `blogPosts` | Blog Posts | Blogbeiträge |
|
|
887
|
+
| `blogNewPost` | New Post | Neuer Beitrag |
|
|
888
|
+
| `blogDraft` | Draft | Entwurf |
|
|
889
|
+
| `blogPublished` | Published | Veröffentlicht |
|
|
890
|
+
| `blogSaveDraft` | Save Draft | Entwurf speichern |
|
|
891
|
+
| `blogPublish` | Publish | Veröffentlichen |
|
|
892
|
+
| `blogUnpublish` | Unpublish | Zurückziehen |
|
|
893
|
+
| `blogDelete` | Delete | Löschen |
|
|
894
|
+
| `blogPreview` | Preview | Vorschau |
|
|
895
|
+
| `blogEditor` | Editor | Editor |
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
### Blog Module (Optional)
|
|
900
|
+
|
|
901
|
+
litecms includes an optional blog module for writing and managing blog posts with Markdown support. The blog is entirely opt-in — it only appears if you explicitly configure it.
|
|
902
|
+
|
|
903
|
+
#### Features
|
|
904
|
+
|
|
905
|
+
- **Split-view editor**: Markdown editor with live GFM (GitHub Flavored Markdown) preview
|
|
906
|
+
- **Draft/Published workflow**: Save drafts, publish when ready, unpublish if needed
|
|
907
|
+
- **Post metadata**: Title, slug, excerpt, cover image, tags, author
|
|
908
|
+
- **Translation support**: All UI strings available in English and German
|
|
909
|
+
|
|
910
|
+
#### 1. Add Blog Posts Database Table
|
|
911
|
+
|
|
912
|
+
Add the blog posts table to your Drizzle schema:
|
|
913
|
+
|
|
914
|
+
```typescript
|
|
915
|
+
// app/db/schema.ts
|
|
916
|
+
import { pgTable, text, timestamp, jsonb } from "drizzle-orm/pg-core";
|
|
917
|
+
|
|
918
|
+
export type BlogPostStatus = "draft" | "published";
|
|
919
|
+
|
|
920
|
+
export const blogPosts = pgTable("blog_posts", {
|
|
921
|
+
id: text("id").primaryKey(),
|
|
922
|
+
slug: text("slug").notNull().unique(),
|
|
923
|
+
title: text("title").notNull(),
|
|
924
|
+
excerpt: text("excerpt"),
|
|
925
|
+
coverImage: text("cover_image"),
|
|
926
|
+
content: text("content").notNull(), // Markdown content
|
|
927
|
+
tags: jsonb("tags").$type<string[]>().default([]),
|
|
928
|
+
authorName: text("author_name").notNull(),
|
|
929
|
+
status: text("status").$type<BlogPostStatus>().notNull().default("draft"),
|
|
930
|
+
publishedAt: timestamp("published_at", { withTimezone: true }),
|
|
931
|
+
createdAt: timestamp("created_at", { withTimezone: true })
|
|
932
|
+
.notNull()
|
|
933
|
+
.defaultNow(),
|
|
934
|
+
updatedAt: timestamp("updated_at", { withTimezone: true })
|
|
935
|
+
.notNull()
|
|
936
|
+
.defaultNow(),
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
export type BlogPost = typeof blogPosts.$inferSelect;
|
|
940
|
+
export type NewBlogPost = typeof blogPosts.$inferInsert;
|
|
941
|
+
```
|
|
942
|
+
|
|
943
|
+
Generate and run the migration:
|
|
944
|
+
|
|
945
|
+
```bash
|
|
946
|
+
bun drizzle-kit generate
|
|
947
|
+
bun drizzle-kit migrate
|
|
948
|
+
```
|
|
949
|
+
|
|
950
|
+
#### 2. Create Blog API Routes
|
|
951
|
+
|
|
952
|
+
Create the API routes for managing blog posts:
|
|
953
|
+
|
|
954
|
+
```typescript
|
|
955
|
+
// app/api/admin/blog/posts/route.ts
|
|
956
|
+
import { createBlogPostsRoutes } from 'litecms/server';
|
|
957
|
+
import { auth } from '@/app/lib/auth';
|
|
958
|
+
import { db } from '@/app/db';
|
|
959
|
+
import { blogPosts } from '@/app/db/schema';
|
|
960
|
+
import { headers } from 'next/headers';
|
|
961
|
+
import { desc, eq } from 'drizzle-orm';
|
|
962
|
+
|
|
963
|
+
export const { GET, POST } = createBlogPostsRoutes({
|
|
964
|
+
checkAuth: async () => {
|
|
965
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
966
|
+
return !!session?.user;
|
|
967
|
+
},
|
|
968
|
+
getPosts: async (filter) => {
|
|
969
|
+
const posts = await db.query.blogPosts.findMany({
|
|
970
|
+
where: filter?.status ? eq(blogPosts.status, filter.status) : undefined,
|
|
971
|
+
orderBy: [desc(blogPosts.createdAt)],
|
|
972
|
+
});
|
|
973
|
+
return posts.map(p => ({
|
|
974
|
+
...p,
|
|
975
|
+
excerpt: p.excerpt ?? undefined,
|
|
976
|
+
coverImage: p.coverImage ?? undefined,
|
|
977
|
+
tags: (p.tags as string[]) ?? [],
|
|
978
|
+
}));
|
|
979
|
+
},
|
|
980
|
+
createPost: async (post) => {
|
|
981
|
+
const [created] = await db.insert(blogPosts).values({
|
|
982
|
+
id: post.id,
|
|
983
|
+
slug: post.slug,
|
|
984
|
+
title: post.title,
|
|
985
|
+
excerpt: post.excerpt ?? null,
|
|
986
|
+
coverImage: post.coverImage ?? null,
|
|
987
|
+
content: post.content,
|
|
988
|
+
tags: post.tags ?? [],
|
|
989
|
+
authorName: post.authorName,
|
|
990
|
+
status: post.status ?? 'draft',
|
|
991
|
+
publishedAt: post.status === 'published' ? new Date() : null,
|
|
992
|
+
}).returning();
|
|
993
|
+
return {
|
|
994
|
+
...created,
|
|
995
|
+
excerpt: created.excerpt ?? undefined,
|
|
996
|
+
coverImage: created.coverImage ?? undefined,
|
|
997
|
+
tags: (created.tags as string[]) ?? [],
|
|
998
|
+
};
|
|
999
|
+
},
|
|
1000
|
+
slugExists: async (slug) => {
|
|
1001
|
+
const existing = await db.query.blogPosts.findFirst({
|
|
1002
|
+
where: eq(blogPosts.slug, slug),
|
|
1003
|
+
columns: { id: true },
|
|
1004
|
+
});
|
|
1005
|
+
return !!existing;
|
|
1006
|
+
},
|
|
1007
|
+
});
|
|
1008
|
+
```
|
|
1009
|
+
|
|
1010
|
+
```typescript
|
|
1011
|
+
// app/api/admin/blog/posts/[id]/route.ts
|
|
1012
|
+
import { createBlogPostRoutes } from 'litecms/server';
|
|
1013
|
+
import { auth } from '@/app/lib/auth';
|
|
1014
|
+
import { db } from '@/app/db';
|
|
1015
|
+
import { blogPosts } from '@/app/db/schema';
|
|
1016
|
+
import { headers } from 'next/headers';
|
|
1017
|
+
import { eq, and, ne } from 'drizzle-orm';
|
|
1018
|
+
|
|
1019
|
+
export const { GET, PATCH, DELETE } = createBlogPostRoutes({
|
|
1020
|
+
checkAuth: async () => {
|
|
1021
|
+
const session = await auth.api.getSession({ headers: await headers() });
|
|
1022
|
+
return !!session?.user;
|
|
1023
|
+
},
|
|
1024
|
+
getPost: async (id) => {
|
|
1025
|
+
const post = await db.query.blogPosts.findFirst({
|
|
1026
|
+
where: eq(blogPosts.id, id),
|
|
1027
|
+
});
|
|
1028
|
+
if (!post) return null;
|
|
1029
|
+
return {
|
|
1030
|
+
...post,
|
|
1031
|
+
excerpt: post.excerpt ?? undefined,
|
|
1032
|
+
coverImage: post.coverImage ?? undefined,
|
|
1033
|
+
tags: (post.tags as string[]) ?? [],
|
|
1034
|
+
};
|
|
1035
|
+
},
|
|
1036
|
+
updatePost: async (id, data) => {
|
|
1037
|
+
const current = await db.query.blogPosts.findFirst({
|
|
1038
|
+
where: eq(blogPosts.id, id),
|
|
1039
|
+
columns: { status: true, publishedAt: true },
|
|
1040
|
+
});
|
|
1041
|
+
|
|
1042
|
+
const updateData: Record<string, unknown> = {
|
|
1043
|
+
...data,
|
|
1044
|
+
updatedAt: new Date(),
|
|
1045
|
+
};
|
|
1046
|
+
|
|
1047
|
+
// Set publishedAt when publishing for the first time
|
|
1048
|
+
if (data.status === 'published' && current?.status !== 'published') {
|
|
1049
|
+
updateData.publishedAt = new Date();
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Clear publishedAt when unpublishing
|
|
1053
|
+
if (data.status === 'draft' && current?.status === 'published') {
|
|
1054
|
+
updateData.publishedAt = null;
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const [updated] = await db.update(blogPosts)
|
|
1058
|
+
.set(updateData)
|
|
1059
|
+
.where(eq(blogPosts.id, id))
|
|
1060
|
+
.returning();
|
|
1061
|
+
|
|
1062
|
+
return {
|
|
1063
|
+
...updated,
|
|
1064
|
+
excerpt: updated.excerpt ?? undefined,
|
|
1065
|
+
coverImage: updated.coverImage ?? undefined,
|
|
1066
|
+
tags: (updated.tags as string[]) ?? [],
|
|
1067
|
+
};
|
|
1068
|
+
},
|
|
1069
|
+
deletePost: async (id) => {
|
|
1070
|
+
await db.delete(blogPosts).where(eq(blogPosts.id, id));
|
|
1071
|
+
},
|
|
1072
|
+
slugExistsExcluding: async (slug, excludeId) => {
|
|
1073
|
+
const existing = await db.query.blogPosts.findFirst({
|
|
1074
|
+
where: and(eq(blogPosts.slug, slug), ne(blogPosts.id, excludeId)),
|
|
1075
|
+
columns: { id: true },
|
|
1076
|
+
});
|
|
1077
|
+
return !!existing;
|
|
1078
|
+
},
|
|
1079
|
+
});
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
#### 3. Create Blog Admin Page
|
|
1083
|
+
|
|
1084
|
+
```typescript
|
|
1085
|
+
// app/admin/blog/page.tsx
|
|
1086
|
+
import { CmsBlogAdmin } from 'litecms/admin';
|
|
1087
|
+
|
|
1088
|
+
export default function AdminBlogPage() {
|
|
1089
|
+
return (
|
|
1090
|
+
<CmsBlogAdmin
|
|
1091
|
+
postsEndpoint="/api/admin/blog/posts"
|
|
1092
|
+
defaultAuthorName="Admin"
|
|
1093
|
+
storage={{
|
|
1094
|
+
uploadEndpoint: '/api/storage/upload',
|
|
1095
|
+
}}
|
|
1096
|
+
/>
|
|
1097
|
+
);
|
|
1098
|
+
}
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
The `storage` prop enables image uploads via drag-and-drop or the toolbar button. Without it, users can still insert images by entering URLs manually.
|
|
1102
|
+
|
|
1103
|
+
#### 4. Add Blog Module to Admin Landing
|
|
1104
|
+
|
|
1105
|
+
```typescript
|
|
1106
|
+
// app/admin/page.tsx
|
|
1107
|
+
import { CmsAdminLanding, CmsModuleIcons } from 'litecms/admin';
|
|
1108
|
+
import { extractLandingProps } from 'litecms/admin/config';
|
|
1109
|
+
import { cmsConfig } from '../cms/config';
|
|
1110
|
+
|
|
1111
|
+
export default function AdminIndexPage() {
|
|
1112
|
+
return (
|
|
1113
|
+
<CmsAdminLanding
|
|
1114
|
+
{...extractLandingProps(cmsConfig)}
|
|
1115
|
+
modules={[
|
|
1116
|
+
{
|
|
1117
|
+
id: 'blog',
|
|
1118
|
+
title: 'Blog',
|
|
1119
|
+
description: 'Write and manage blog posts',
|
|
1120
|
+
icon: CmsModuleIcons.blog,
|
|
1121
|
+
href: '/admin/blog',
|
|
1122
|
+
},
|
|
1123
|
+
]}
|
|
1124
|
+
/>
|
|
1125
|
+
);
|
|
1126
|
+
}
|
|
1127
|
+
```
|
|
1128
|
+
|
|
1129
|
+
#### 5. Create Public Blog Pages (Optional)
|
|
1130
|
+
|
|
1131
|
+
Display published blog posts on your site. Use your site's layout (navigation/footer) so the blog matches the rest of the app:
|
|
1132
|
+
|
|
1133
|
+
```typescript
|
|
1134
|
+
// app/blog/page.tsx
|
|
1135
|
+
import { db } from '@/app/db';
|
|
1136
|
+
import { blogPosts } from '@/app/db/schema';
|
|
1137
|
+
import { desc, eq } from 'drizzle-orm';
|
|
1138
|
+
import Link from 'next/link';
|
|
1139
|
+
import { Navigation } from '@/app/components/Navigation';
|
|
1140
|
+
import { getNavigation, getSiteSettings } from '@/app/admin/actions';
|
|
1141
|
+
|
|
1142
|
+
export const revalidate = 60; // Revalidate every 60 seconds
|
|
1143
|
+
|
|
1144
|
+
export default async function BlogPage() {
|
|
1145
|
+
const [posts, siteSettings, navigation] = await Promise.all([
|
|
1146
|
+
db.query.blogPosts.findMany({
|
|
1147
|
+
where: eq(blogPosts.status, 'published'),
|
|
1148
|
+
orderBy: [desc(blogPosts.publishedAt)],
|
|
1149
|
+
}),
|
|
1150
|
+
getSiteSettings(),
|
|
1151
|
+
getNavigation(),
|
|
1152
|
+
]);
|
|
1153
|
+
|
|
1154
|
+
return (
|
|
1155
|
+
<div className="min-h-screen bg-white">
|
|
1156
|
+
<Navigation
|
|
1157
|
+
siteName={siteSettings.siteName}
|
|
1158
|
+
links={navigation.navLinks}
|
|
1159
|
+
linksHierarchical={navigation.navLinksHierarchical}
|
|
1160
|
+
/>
|
|
1161
|
+
|
|
1162
|
+
<main className="max-w-4xl mx-auto px-4 py-16">
|
|
1163
|
+
<h1 className="text-4xl font-bold mb-8">Blog</h1>
|
|
1164
|
+
{posts.map((post) => (
|
|
1165
|
+
<article key={post.id} className="mb-8 pb-8 border-b">
|
|
1166
|
+
<Link href={`/blog/${post.slug}`}>
|
|
1167
|
+
<h2 className="text-2xl font-semibold hover:text-gray-600">
|
|
1168
|
+
{post.title}
|
|
1169
|
+
</h2>
|
|
1170
|
+
</Link>
|
|
1171
|
+
<div className="text-sm text-gray-500 mt-2">
|
|
1172
|
+
{post.authorName} · {post.publishedAt?.toLocaleDateString()}
|
|
1173
|
+
</div>
|
|
1174
|
+
{post.excerpt && (
|
|
1175
|
+
<p className="mt-3 text-gray-600">{post.excerpt}</p>
|
|
1176
|
+
)}
|
|
1177
|
+
</article>
|
|
1178
|
+
))}
|
|
1179
|
+
</main>
|
|
1180
|
+
|
|
1181
|
+
<footer className="py-16 border-t">
|
|
1182
|
+
<div className="max-w-5xl mx-auto px-6">
|
|
1183
|
+
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
|
1184
|
+
<p className="text-xs tracking-widest text-gray-400">
|
|
1185
|
+
© {new Date().getFullYear()} {siteSettings.siteName}
|
|
1186
|
+
</p>
|
|
1187
|
+
<nav className="flex items-center gap-12">
|
|
1188
|
+
{navigation.footerLinks.map((link) => (
|
|
1189
|
+
<Link
|
|
1190
|
+
key={link.href}
|
|
1191
|
+
href={link.href}
|
|
1192
|
+
className="text-xs tracking-widest text-gray-400 hover:text-gray-900 transition-colors duration-300"
|
|
1193
|
+
>
|
|
1194
|
+
{link.label}
|
|
1195
|
+
</Link>
|
|
1196
|
+
))}
|
|
1197
|
+
</nav>
|
|
1198
|
+
</div>
|
|
1199
|
+
</div>
|
|
1200
|
+
</footer>
|
|
1201
|
+
</div>
|
|
1202
|
+
);
|
|
1203
|
+
}
|
|
1204
|
+
```
|
|
1205
|
+
|
|
1206
|
+
```typescript
|
|
1207
|
+
// app/blog/[slug]/page.tsx
|
|
1208
|
+
import { db } from '@/app/db';
|
|
1209
|
+
import { blogPosts } from '@/app/db/schema';
|
|
1210
|
+
import { eq, and } from 'drizzle-orm';
|
|
1211
|
+
import { notFound } from 'next/navigation';
|
|
1212
|
+
import ReactMarkdown from 'react-markdown';
|
|
1213
|
+
import remarkGfm from 'remark-gfm';
|
|
1214
|
+
import { Navigation } from '@/app/components/Navigation';
|
|
1215
|
+
import { getNavigation, getSiteSettings } from '@/app/admin/actions';
|
|
1216
|
+
|
|
1217
|
+
export const revalidate = 60;
|
|
1218
|
+
|
|
1219
|
+
type Props = {
|
|
1220
|
+
params: Promise<{ slug: string }>;
|
|
1221
|
+
};
|
|
1222
|
+
|
|
1223
|
+
export default async function BlogPostPage({ params }: Props) {
|
|
1224
|
+
const { slug } = await params;
|
|
1225
|
+
const [post, siteSettings, navigation] = await Promise.all([
|
|
1226
|
+
db.query.blogPosts.findFirst({
|
|
1227
|
+
where: and(eq(blogPosts.slug, slug), eq(blogPosts.status, 'published')),
|
|
1228
|
+
}),
|
|
1229
|
+
getSiteSettings(),
|
|
1230
|
+
getNavigation(),
|
|
1231
|
+
]);
|
|
1232
|
+
|
|
1233
|
+
if (!post) notFound();
|
|
1234
|
+
|
|
1235
|
+
return (
|
|
1236
|
+
<div className="min-h-screen bg-white">
|
|
1237
|
+
<Navigation
|
|
1238
|
+
siteName={siteSettings.siteName}
|
|
1239
|
+
links={navigation.navLinks}
|
|
1240
|
+
linksHierarchical={navigation.navLinksHierarchical}
|
|
1241
|
+
/>
|
|
1242
|
+
|
|
1243
|
+
<article className="max-w-3xl mx-auto px-4 py-16">
|
|
1244
|
+
<h1 className="text-4xl font-bold mb-4">{post.title}</h1>
|
|
1245
|
+
<div className="text-sm text-gray-500 mb-8">
|
|
1246
|
+
{post.authorName} · {post.publishedAt?.toLocaleDateString()}
|
|
1247
|
+
</div>
|
|
1248
|
+
<div className="prose prose-lg max-w-none">
|
|
1249
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
|
1250
|
+
{post.content}
|
|
1251
|
+
</ReactMarkdown>
|
|
1252
|
+
</div>
|
|
1253
|
+
</article>
|
|
1254
|
+
|
|
1255
|
+
<footer className="py-16 border-t">
|
|
1256
|
+
<div className="max-w-5xl mx-auto px-6">
|
|
1257
|
+
<div className="flex flex-col md:flex-row items-center justify-between gap-8">
|
|
1258
|
+
<p className="text-xs tracking-widest text-gray-400">
|
|
1259
|
+
© {new Date().getFullYear()} {siteSettings.siteName}
|
|
1260
|
+
</p>
|
|
1261
|
+
<nav className="flex items-center gap-12">
|
|
1262
|
+
{navigation.footerLinks.map((link) => (
|
|
1263
|
+
<Link
|
|
1264
|
+
key={link.href}
|
|
1265
|
+
href={link.href}
|
|
1266
|
+
className="text-xs tracking-widest text-gray-400 hover:text-gray-900 transition-colors duration-300"
|
|
1267
|
+
>
|
|
1268
|
+
{link.label}
|
|
1269
|
+
</Link>
|
|
1270
|
+
))}
|
|
1271
|
+
</nav>
|
|
1272
|
+
</div>
|
|
1273
|
+
</div>
|
|
1274
|
+
</footer>
|
|
1275
|
+
</div>
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
```
|
|
1279
|
+
|
|
1280
|
+
Install the markdown dependencies in your app:
|
|
1281
|
+
|
|
1282
|
+
```bash
|
|
1283
|
+
bun add react-markdown remark-gfm
|
|
1284
|
+
```
|
|
1285
|
+
|
|
1286
|
+
**Navigation note:** the blog is optional, so the main navigation only shows a Blog link if you add it in your app. If you use a CMS-driven nav builder, add `/blog` there, or add a simple rule (e.g., show Blog once a post exists).
|
|
1287
|
+
|
|
1288
|
+
#### API Reference
|
|
1289
|
+
|
|
1290
|
+
**`CmsBlogAdmin`** — Client component for the blog admin interface.
|
|
1291
|
+
|
|
1292
|
+
```tsx
|
|
1293
|
+
<CmsBlogAdmin
|
|
1294
|
+
postsEndpoint="/api/admin/blog/posts" // API endpoint for posts
|
|
1295
|
+
defaultAuthorName="Admin" // Default author for new posts
|
|
1296
|
+
storage={{ // Optional: for image uploads
|
|
1297
|
+
uploadEndpoint: '/api/storage/upload',
|
|
1298
|
+
}}
|
|
1299
|
+
/>
|
|
1300
|
+
```
|
|
1301
|
+
|
|
1302
|
+
**Features:**
|
|
1303
|
+
- **Markdown toolbar**: Bold, italic, headings, lists, quotes, code, links, and images
|
|
1304
|
+
- **Image insertion**: Click the image button to upload or enter a URL
|
|
1305
|
+
- **Drag-and-drop**: Drag images directly into the content area to upload and insert
|
|
1306
|
+
- **Live preview**: GFM (GitHub Flavored Markdown) preview with tables, strikethrough, and task lists
|
|
1307
|
+
|
|
1308
|
+
**`createBlogPostsRoutes(deps)`** — Create GET and POST handlers for the posts collection.
|
|
1309
|
+
|
|
1310
|
+
```typescript
|
|
1311
|
+
type BlogPostsRoutesDeps = {
|
|
1312
|
+
checkAuth: () => Promise<boolean>;
|
|
1313
|
+
getPosts: (filter?: { status?: 'draft' | 'published' }) => Promise<BlogPostData[]>;
|
|
1314
|
+
createPost: (post: CreateBlogPostInput) => Promise<BlogPostData>;
|
|
1315
|
+
slugExists: (slug: string) => Promise<boolean>;
|
|
1316
|
+
};
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
**`createBlogPostRoutes(deps)`** — Create GET, PATCH, and DELETE handlers for single posts.
|
|
1320
|
+
|
|
1321
|
+
```typescript
|
|
1322
|
+
type BlogPostRoutesDeps = {
|
|
1323
|
+
checkAuth: () => Promise<boolean>;
|
|
1324
|
+
getPost: (id: string) => Promise<BlogPostData | null>;
|
|
1325
|
+
updatePost: (id: string, data: UpdateBlogPostInput) => Promise<BlogPostData>;
|
|
1326
|
+
deletePost: (id: string) => Promise<void>;
|
|
1327
|
+
slugExistsExcluding: (slug: string, excludeId: string) => Promise<boolean>;
|
|
1328
|
+
};
|
|
1329
|
+
```
|
|
1330
|
+
|
|
606
1331
|
---
|
|
607
1332
|
|
|
608
1333
|
### Components (`litecms/components`)
|
|
@@ -1350,10 +2075,20 @@ app/
|
|
|
1350
2075
|
├── admin/
|
|
1351
2076
|
│ ├── [slug]/
|
|
1352
2077
|
│ │ └── page.tsx # Dynamic admin pages
|
|
2078
|
+
│ ├── blog/
|
|
2079
|
+
│ │ └── page.tsx # Blog admin (optional)
|
|
1353
2080
|
│ ├── actions.ts # Server actions with Drizzle
|
|
1354
2081
|
│ ├── layout.tsx # Protected layout with Better Auth
|
|
1355
|
-
│ └── page.tsx # Admin
|
|
2082
|
+
│ └── page.tsx # Admin landing/dashboard
|
|
1356
2083
|
├── api/
|
|
2084
|
+
│ ├── admin/
|
|
2085
|
+
│ │ ├── blog/
|
|
2086
|
+
│ │ │ └── posts/
|
|
2087
|
+
│ │ │ ├── [id]/
|
|
2088
|
+
│ │ │ │ └── route.ts # Single post API (optional)
|
|
2089
|
+
│ │ │ └── route.ts # Posts collection API (optional)
|
|
2090
|
+
│ │ └── language/
|
|
2091
|
+
│ │ └── route.ts # Language preference API
|
|
1357
2092
|
│ ├── auth/
|
|
1358
2093
|
│ │ └── [...all]/
|
|
1359
2094
|
│ │ └── route.ts # Better Auth handler
|
|
@@ -1362,13 +2097,17 @@ app/
|
|
|
1362
2097
|
│ │ └── route.ts # Protected upload
|
|
1363
2098
|
│ └── list/
|
|
1364
2099
|
│ └── route.ts # Protected list
|
|
2100
|
+
├── blog/
|
|
2101
|
+
│ ├── [slug]/
|
|
2102
|
+
│ │ └── page.tsx # Single post page (optional)
|
|
2103
|
+
│ └── page.tsx # Blog overview (optional)
|
|
1365
2104
|
├── cms/
|
|
1366
2105
|
│ ├── config.ts # CMS configuration
|
|
1367
2106
|
│ └── schema.ts # Zod schemas
|
|
1368
2107
|
├── db/
|
|
1369
|
-
│ ├── auth-schema.ts # Better Auth tables
|
|
2108
|
+
│ ├── auth-schema.ts # Better Auth tables (with settings column)
|
|
1370
2109
|
│ ├── index.ts # Drizzle client
|
|
1371
|
-
│ └── schema.ts # CMS tables
|
|
2110
|
+
│ └── schema.ts # CMS tables (+ blog_posts)
|
|
1372
2111
|
├── lib/
|
|
1373
2112
|
│ ├── auth.ts # Better Auth server
|
|
1374
2113
|
│ ├── auth-client.ts # Better Auth client
|