litecms 0.1.1 → 0.2.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 (40) hide show
  1. package/README.md +746 -7
  2. package/dist/admin/CmsAdminLanding.d.ts +89 -0
  3. package/dist/admin/CmsAdminLanding.d.ts.map +1 -0
  4. package/dist/admin/CmsAdminLayout.d.ts +26 -3
  5. package/dist/admin/CmsAdminLayout.d.ts.map +1 -1
  6. package/dist/admin/CmsAdminPage.d.ts.map +1 -1
  7. package/dist/admin/CmsBlogAdmin.d.ts +37 -0
  8. package/dist/admin/CmsBlogAdmin.d.ts.map +1 -0
  9. package/dist/admin/config.d.ts +32 -0
  10. package/dist/admin/config.d.ts.map +1 -1
  11. package/dist/admin/config.js +16 -0
  12. package/dist/admin/exports.d.ts +5 -2
  13. package/dist/admin/exports.d.ts.map +1 -1
  14. package/dist/admin/exports.js +1467 -30
  15. package/dist/admin/language.d.ts +53 -0
  16. package/dist/admin/language.d.ts.map +1 -0
  17. package/dist/components/CmsAutoForm.d.ts.map +1 -1
  18. package/dist/components/CmsForm.d.ts.map +1 -1
  19. package/dist/components/CmsImageField.d.ts +2 -1
  20. package/dist/components/CmsImageField.d.ts.map +1 -1
  21. package/dist/components/CmsImagePickerModal.d.ts +21 -0
  22. package/dist/components/CmsImagePickerModal.d.ts.map +1 -0
  23. package/dist/components/CmsSimpleForm.d.ts.map +1 -1
  24. package/dist/components/index.d.ts +1 -0
  25. package/dist/components/index.d.ts.map +1 -1
  26. package/dist/components/index.js +51 -190
  27. package/dist/index-c9btr14k.js +4422 -0
  28. package/dist/index-szreq4v9.js +12 -0
  29. package/dist/index-wmd953zf.js +11423 -0
  30. package/dist/index.js +6 -2
  31. package/dist/schema/index.js +2 -0
  32. package/dist/server/index.d.ts +301 -0
  33. package/dist/server/index.d.ts.map +1 -1
  34. package/dist/server/index.js +2585 -1
  35. package/dist/storage/index.js +2 -0
  36. package/package.json +13 -3
  37. package/dist/domain/index.d.ts +0 -1
  38. package/dist/domain/index.d.ts.map +0 -1
  39. package/dist/stores/index.d.ts +0 -1
  40. 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
- ### 6. Set Up Storage API Routes (Optional)
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 index/redirect
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