@thinhnguyencth1204/nextcli 0.6.1 → 0.8.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 (122) hide show
  1. package/README.md +68 -47
  2. package/dist/cli.js +1002 -753
  3. package/package.json +6 -2
  4. package/templates/{next-base/src/lib/axios-instance.ts → features/api/src/lib/api/axios.ts} +7 -2
  5. package/templates/{next-base/src/lib/api-response.ts → features/api/src/lib/api/response.ts} +1 -5
  6. package/templates/{next-base → features/auth}/src/app/(auth)/change-password/layout.tsx +1 -1
  7. package/templates/{next-base → features/auth}/src/app/(auth)/sign-in/layout.tsx +1 -1
  8. package/templates/{next-base → features/auth}/src/app/api/v1/auth/change-password/route.ts +3 -3
  9. package/templates/{next-base → features/auth}/src/app/api/v1/auth/login/route.ts +3 -3
  10. package/templates/{next-base → features/auth}/src/app/api/v1/auth/logout/route.ts +2 -2
  11. package/templates/{next-base → features/auth}/src/app/api/v1/auth/me/route.ts +2 -2
  12. package/templates/{next-base → features/auth}/src/app/api/v1/auth/refresh/route.ts +2 -2
  13. package/templates/{next-base → features/auth}/src/app/api/v1/users/[id]/route.ts +3 -3
  14. package/templates/{next-base → features/auth}/src/app/api/v1/users/route.ts +3 -3
  15. package/templates/{next-base → features/auth}/src/features/auth/components/account-panel.tsx +1 -1
  16. package/templates/{next-base → features/auth}/src/features/auth/components/change-password-form.tsx +1 -1
  17. package/templates/{next-base → features/auth}/src/features/auth/components/sign-in-form.tsx +2 -2
  18. package/templates/{next-base → features/auth}/src/features/users/services.ts +1 -1
  19. package/templates/{next-base → features/auth}/src/instrumentation.ts +1 -1
  20. package/templates/{next-base/src/lib → features/auth/src/lib/auth}/bootstrap.ts +2 -3
  21. package/templates/features/auth/src/lib/auth/index.ts +1 -0
  22. package/templates/{next-base/src/lib → features/auth/src/lib/auth}/rbac.ts +2 -5
  23. package/templates/{next-base/src/lib/auth.ts → features/auth/src/lib/auth/server.ts} +2 -1
  24. package/templates/{next-base → features/auth}/src/lib/constants.ts +3 -0
  25. package/templates/features/chat/src/app/api/v1/chat/route.ts +1 -1
  26. package/templates/features/chat/src/features/chat/api/use-chat-history.ts +1 -1
  27. package/templates/features/chat/src/features/chat/api/use-send-message.ts +1 -1
  28. package/templates/{next-base → features/dashboard}/src/app/(dashboard)/layout.tsx +1 -1
  29. package/templates/features/dashboard/src/app/page.tsx +5 -0
  30. package/templates/{next-base → features/dashboard}/src/components/layout/private/nav-user.tsx +1 -1
  31. package/templates/{next-base → features/database}/prisma/schema.prisma +0 -12
  32. package/templates/{next-base → features/database}/prisma.config.ts +2 -2
  33. package/templates/features/database/src/lib/prisma.ts +23 -0
  34. package/templates/{next-base → features/example}/src/app/api/v1/example/route.ts +2 -2
  35. package/templates/{next-base → features/example}/src/example/api/use-example.ts +1 -1
  36. package/templates/{next-base → features/example}/src/example/api/use-mutations.ts +1 -1
  37. package/templates/{next-base → features/example}/src/example/services.ts +1 -1
  38. package/templates/features/i18n/next.config.ts +17 -0
  39. package/templates/features/i18n/src/app/layout.tsx +42 -0
  40. package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
  41. package/templates/next-base/.env +0 -14
  42. package/templates/next-base/.env.development +0 -14
  43. package/templates/next-base/.env.example +0 -14
  44. package/templates/next-base/PROJECT_STRUCTURE.md +43 -54
  45. package/templates/next-base/SETUP.md +12 -60
  46. package/templates/next-base/bun.lock +59 -397
  47. package/templates/next-base/next-env.d.ts +1 -1
  48. package/templates/next-base/next.config.ts +1 -4
  49. package/templates/next-base/nextcli.json +3 -3
  50. package/templates/next-base/package.json +6 -21
  51. package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
  52. package/templates/next-base/src/app/globals.css +57 -0
  53. package/templates/next-base/src/app/layout.tsx +6 -14
  54. package/templates/next-base/src/app/page.tsx +25 -2
  55. package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
  56. package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
  57. package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
  58. package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
  59. package/templates/next-base/src/components/rich-text/index.ts +11 -0
  60. package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
  61. package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
  62. package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
  63. package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
  64. package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
  65. package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
  66. package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
  67. package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
  68. package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
  69. package/templates/next-base/src/components/rich-text/types.ts +60 -0
  70. package/templates/next-base/src/hooks/index.ts +1 -1
  71. package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
  72. package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
  73. package/templates/next-base/src/lib/rich-text/index.ts +12 -0
  74. package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
  75. package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
  76. package/templates/next-base/src/types/index.ts +0 -2
  77. package/templates/next-base/tsconfig.tsbuildinfo +1 -0
  78. package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +0 -104
  79. package/templates/next-base/prisma/migrations/migration_lock.toml +0 -3
  80. package/templates/next-base/src/app/(auth)/.gitkeep +0 -1
  81. /package/templates/{next-base → features/api}/src/components/providers/query-provider.tsx +0 -0
  82. /package/templates/{next-base/src/lib → features/api/src/lib/api}/token-store.ts +0 -0
  83. /package/templates/{next-base → features/auth}/messages/vi/auth.json +0 -0
  84. /package/templates/{next-base/prisma/migrations → features/auth/src/app/(auth)}/.gitkeep +0 -0
  85. /package/templates/{next-base → features/auth}/src/app/(auth)/change-password/page.tsx +0 -0
  86. /package/templates/{next-base → features/auth}/src/app/(auth)/layout.tsx +0 -0
  87. /package/templates/{next-base → features/auth}/src/app/(auth)/sign-in/page.tsx +0 -0
  88. /package/templates/{next-base → features/auth}/src/app/api/auth/[...all]/route.ts +0 -0
  89. /package/templates/{next-base → features/auth}/src/features/auth/validations.ts +0 -0
  90. /package/templates/{next-base → features/auth}/src/features/users/validations.ts +0 -0
  91. /package/templates/{next-base/src/lib/auth-client.ts → features/auth/src/lib/auth/client.ts} +0 -0
  92. /package/templates/{next-base/src/lib/auth-cookies.ts → features/auth/src/lib/auth/cookies.ts} +0 -0
  93. /package/templates/{next-base → features/dashboard}/src/app/(dashboard)/account/page.tsx +0 -0
  94. /package/templates/{next-base → features/dashboard}/src/app/(dashboard)/dashboard/page.tsx +0 -0
  95. /package/templates/{next-base → features/dashboard}/src/components/layout/private/app-sidebar.tsx +0 -0
  96. /package/templates/{next-base → features/dashboard}/src/components/layout/private/dashboard-layout.tsx +0 -0
  97. /package/templates/{next-base → features/dashboard}/src/components/layout/private/nav-sidebar.tsx +0 -0
  98. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-column-header.tsx +0 -0
  99. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-filter-list.tsx +0 -0
  100. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-pagination.tsx +0 -0
  101. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-skeleton.tsx +0 -0
  102. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-toolbar.tsx +0 -0
  103. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-view-options.tsx +0 -0
  104. /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table.tsx +0 -0
  105. /package/templates/{next-base → features/dashboard}/src/components/ui/sidebar.tsx +0 -0
  106. /package/templates/{next-base → features/dashboard}/src/data/sidebar-modules.ts +0 -0
  107. /package/templates/{next-base → features/dashboard}/src/hooks/table/use-data-table.ts +0 -0
  108. /package/templates/{next-base → features/dashboard}/src/hooks/use-mobile.ts +0 -0
  109. /package/templates/{next-base → features/dashboard}/src/types/data-table.ts +0 -0
  110. /package/templates/{next-base/src/lib → features/database/src/lib/db}/prisma.ts +0 -0
  111. /package/templates/{next-base → features/example}/messages/vi/example.json +0 -0
  112. /package/templates/{next-base → features/example}/src/app/(dashboard)/example/page.tsx +0 -0
  113. /package/templates/{next-base → features/example}/src/example/components/example-table.tsx +0 -0
  114. /package/templates/{next-base → features/example}/src/example/validations.ts +0 -0
  115. /package/templates/{next-base → features/i18n}/messages/vi/common.json +0 -0
  116. /package/templates/{next-base → features/i18n}/src/components/layout/private/locale-switcher.tsx +0 -0
  117. /package/templates/{next-base → features/i18n}/src/i18n/config.ts +0 -0
  118. /package/templates/{next-base → features/i18n}/src/i18n/namespaces.ts +0 -0
  119. /package/templates/{next-base → features/i18n}/src/i18n/request.ts +0 -0
  120. /package/templates/{next-base → features/supabase}/src/lib/supabase/client.ts +0 -0
  121. /package/templates/{next-base → features/supabase}/src/lib/supabase/storage-config.ts +0 -0
  122. /package/templates/{next-base → features/supabase}/src/lib/supabase/storage.ts +0 -0
@@ -1,6 +1,6 @@
1
1
  /// <reference types="next" />
2
2
  /// <reference types="next/image-types/global" />
3
- import "./.next/dev/types/routes.d.ts";
3
+ import "./.next/types/routes.d.ts";
4
4
 
5
5
  // NOTE: This file should not be edited
6
6
  // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
@@ -1,5 +1,4 @@
1
1
  import type { NextConfig } from "next";
2
- import createNextIntlPlugin from "next-intl/plugin";
3
2
  import path from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
4
 
@@ -12,6 +11,4 @@ const nextConfig: NextConfig = {
12
11
  },
13
12
  };
14
13
 
15
- const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
16
-
17
- export default withNextIntl(nextConfig);
14
+ export default nextConfig;
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "cli": "__NEXTCLI_VERSION__",
3
3
  "defaultLocale": "vi",
4
- "locales": ["vi"],
5
- "namespaces": ["common", "auth", "example"],
4
+ "locales": [],
5
+ "namespaces": [],
6
6
  "modules": [],
7
- "features": ["example"]
7
+ "features": []
8
8
  }
@@ -7,15 +7,9 @@
7
7
  "dev": "next dev",
8
8
  "build": "next build",
9
9
  "start": "next start",
10
- "lint": "eslint .",
11
- "postinstall": "prisma generate",
12
- "db:generate": "prisma generate",
13
- "db:migrate": "prisma migrate dev",
14
- "db:studio": "prisma studio"
10
+ "lint": "eslint ."
15
11
  },
16
12
  "dependencies": {
17
- "@prisma/client": "^7.8.0",
18
- "@prisma/adapter-pg": "^7.8.0",
19
13
  "@radix-ui/react-avatar": "^1.1.10",
20
14
  "@radix-ui/react-dialog": "^1.1.15",
21
15
  "@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -27,25 +21,18 @@
27
21
  "@radix-ui/react-slot": "^1.2.3",
28
22
  "@radix-ui/react-tabs": "^1.1.13",
29
23
  "@radix-ui/react-tooltip": "^1.2.8",
30
- "@supabase/supabase-js": "^2.44.2",
31
- "@tanstack/react-form": "^1.0.3",
32
- "@tanstack/react-query": "^5.56.2",
33
- "@tanstack/react-query-devtools": "^5.56.2",
34
- "@tanstack/react-table": "^8.20.5",
35
- "@dnd-kit/core": "^6.3.1",
36
- "axios": "^1.7.7",
37
- "better-auth": "^1.6.11",
38
24
  "class-variance-authority": "^0.7.0",
39
25
  "clsx": "^2.1.1",
40
- "date-fns": "^3.6.0",
26
+ "@lexical/history": "^0.45.0",
27
+ "@lexical/react": "^0.45.0",
28
+ "@lexical/rich-text": "^0.45.0",
29
+ "@lexical/utils": "^0.45.0",
30
+ "lexical": "^0.45.0",
41
31
  "lucide-react": "^0.525.0",
42
32
  "next": "^16.1.6",
43
- "next-intl": "^4.13.0",
44
33
  "next-themes": "^0.4.6",
45
- "nuqs": "^2.8.1",
46
34
  "react": "^19.0.0",
47
35
  "react-dom": "^19.0.0",
48
- "pg": "^8.16.3",
49
36
  "sonner": "^1.7.1",
50
37
  "tailwind-merge": "^2.5.3",
51
38
  "tw-animate-css": "^1.3.0",
@@ -53,13 +40,11 @@
53
40
  },
54
41
  "devDependencies": {
55
42
  "@tailwindcss/postcss": "^4.1.11",
56
- "dotenv": "^17.4.2",
57
43
  "@types/node": "^22.7.4",
58
44
  "@types/react": "^19.0.0",
59
45
  "@types/react-dom": "^19.0.0",
60
46
  "eslint": "^9.11.1",
61
47
  "eslint-config-next": "^16.1.6",
62
- "prisma": "^7.8.0",
63
48
  "tailwindcss": "^4.1.11",
64
49
  "typescript": "^5.6.2"
65
50
  }
@@ -0,0 +1,9 @@
1
+ "use client";
2
+
3
+ import { BlogRichTextDemo } from "@/components/rich-text/examples/blog-rich-text-demo";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export default function BlogDemoPage() {
8
+ return <BlogRichTextDemo />;
9
+ }
@@ -109,3 +109,60 @@
109
109
  @apply bg-background text-foreground antialiased;
110
110
  }
111
111
  }
112
+
113
+ /* Lexical rich text */
114
+ .lexical-content-editable {
115
+ position: relative;
116
+ }
117
+
118
+ .lexical-paragraph {
119
+ @apply mb-2;
120
+ }
121
+
122
+ .lexical-text-bold {
123
+ @apply font-bold;
124
+ }
125
+
126
+ .lexical-text-italic {
127
+ @apply italic;
128
+ }
129
+
130
+ .lexical-text-underline {
131
+ @apply underline;
132
+ }
133
+
134
+ .lexical-text-strikethrough {
135
+ @apply line-through;
136
+ }
137
+
138
+ .lexical-text-underline-strikethrough {
139
+ @apply underline line-through;
140
+ }
141
+
142
+ .lexical-text-code {
143
+ @apply bg-muted rounded px-1 font-mono text-[0.9em];
144
+ }
145
+
146
+ .lexical-heading-h1 {
147
+ @apply mb-3 text-3xl font-bold;
148
+ }
149
+
150
+ .lexical-heading-h2 {
151
+ @apply mb-2 text-2xl font-semibold;
152
+ }
153
+
154
+ .lexical-heading-h3 {
155
+ @apply mb-2 text-xl font-semibold;
156
+ }
157
+
158
+ .lexical-quote {
159
+ @apply border-muted-foreground mb-2 border-l-4 pl-4 italic;
160
+ }
161
+
162
+ .lexical-image-element {
163
+ @apply my-2 block;
164
+ }
165
+
166
+ .lexical-renderer .lexical-content-editable {
167
+ min-height: auto;
168
+ }
@@ -1,10 +1,7 @@
1
1
  import type { Metadata } from "next";
2
2
  import { Be_Vietnam_Pro } from "next/font/google";
3
- import { getLocale, getMessages } from "next-intl/server";
4
- import { NextIntlClientProvider } from "next-intl";
5
3
  import { Toaster } from "sonner";
6
4
  import type { ReactNode } from "react";
7
- import { QueryProvider } from "@/components/providers/query-provider";
8
5
  import { ThemeProvider } from "@/components/providers/theme-provider";
9
6
  import { branding } from "@/config/branding";
10
7
  import "@/app/globals.css";
@@ -19,23 +16,18 @@ export const metadata: Metadata = {
19
16
  description: branding.description,
20
17
  };
21
18
 
22
- export default async function RootLayout({
19
+ export default function RootLayout({
23
20
  children,
24
21
  }: Readonly<{
25
22
  children: ReactNode;
26
23
  }>) {
27
- const locale = await getLocale();
28
- const messages = await getMessages();
29
-
30
24
  return (
31
- <html lang={locale} suppressHydrationWarning>
25
+ <html lang="vi" suppressHydrationWarning>
32
26
  <body className={beVietnamPro.className}>
33
- <NextIntlClientProvider locale={locale} messages={messages}>
34
- <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
35
- <QueryProvider>{children}</QueryProvider>
36
- <Toaster richColors position="top-right" />
37
- </ThemeProvider>
38
- </NextIntlClientProvider>
27
+ <ThemeProvider attribute="class" defaultTheme="light" enableSystem>
28
+ {children}
29
+ <Toaster richColors position="top-right" />
30
+ </ThemeProvider>
39
31
  </body>
40
32
  </html>
41
33
  );
@@ -1,5 +1,28 @@
1
- import { redirect } from "next/navigation";
1
+ import Link from "next/link";
2
+ import { branding } from "@/config/branding";
3
+ import { Button } from "@/components/ui/button";
2
4
 
3
5
  export default function HomePage() {
4
- redirect("/dashboard");
6
+ return (
7
+ <main className="flex min-h-screen flex-col items-center justify-center gap-6 p-8 text-center">
8
+ <h1 className="text-3xl font-semibold tracking-tight">
9
+ {branding.projectName}
10
+ </h1>
11
+ <p className="text-muted-foreground max-w-md text-sm">
12
+ {branding.description}
13
+ </p>
14
+ <p className="text-muted-foreground max-w-lg text-sm">
15
+ This project was scaffolded with NexTCLI. Add optional modules with{" "}
16
+ <code className="bg-muted rounded px-1.5 py-0.5 text-xs">
17
+ nextcli add module
18
+ </code>{" "}
19
+ to enable database, auth, dashboard, and more.
20
+ </p>
21
+ <Button asChild variant="outline">
22
+ <Link href="https://github.com/thinhnguyencth1204/NexTCLI">
23
+ NexTCLI docs
24
+ </Link>
25
+ </Button>
26
+ </main>
27
+ );
5
28
  }
@@ -0,0 +1,50 @@
1
+ "use client";
2
+
3
+ import { Textarea } from "@/components/ui/textarea";
4
+ import { cn } from "@/utils/cn";
5
+ import type { TextareaEditorContent } from "../types";
6
+
7
+ export type TextareaFieldAdapterProps = {
8
+ value: TextareaEditorContent | null;
9
+ onChange: (value: TextareaEditorContent) => void;
10
+ placeholder?: string;
11
+ disabled?: boolean;
12
+ readOnly?: boolean;
13
+ error?: string;
14
+ className?: string;
15
+ id?: string;
16
+ name?: string;
17
+ };
18
+
19
+ export function TextareaFieldAdapter({
20
+ value,
21
+ onChange,
22
+ placeholder,
23
+ disabled,
24
+ readOnly,
25
+ error,
26
+ className,
27
+ id,
28
+ name,
29
+ }: TextareaFieldAdapterProps) {
30
+ return (
31
+ <div className={cn("space-y-1", className)}>
32
+ <Textarea
33
+ id={id}
34
+ name={name}
35
+ value={value ?? ""}
36
+ onChange={(event) => onChange(event.target.value)}
37
+ placeholder={placeholder}
38
+ disabled={disabled}
39
+ readOnly={readOnly}
40
+ aria-invalid={Boolean(error)}
41
+ className={cn(error && "border-destructive")}
42
+ />
43
+ {error ? (
44
+ <p className="text-destructive text-xs" role="alert">
45
+ {error}
46
+ </p>
47
+ ) : null}
48
+ </div>
49
+ );
50
+ }
@@ -0,0 +1,23 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState, type ReactNode } from "react";
4
+
5
+ type ClientOnlyProps = {
6
+ children: ReactNode;
7
+ fallback?: ReactNode;
8
+ };
9
+
10
+ /** Renders children only after mount — required for Lexical (no SSR). */
11
+ export function ClientOnly({ children, fallback = null }: ClientOnlyProps) {
12
+ const [mounted, setMounted] = useState(false);
13
+
14
+ useEffect(() => {
15
+ setMounted(true);
16
+ }, []);
17
+
18
+ if (!mounted) {
19
+ return fallback;
20
+ }
21
+
22
+ return children;
23
+ }
@@ -0,0 +1,62 @@
1
+ "use client";
2
+
3
+ import { cn } from "@/utils/cn";
4
+ import type { EditorFieldProps, LexicalEditorContent } from "./types";
5
+ import { isLexicalContent } from "./types";
6
+ import { TextareaFieldAdapter } from "./adapters/textarea-field";
7
+ import { LexicalRichTextEditor } from "./lexical/rich-text-editor";
8
+
9
+ export function EditorField({
10
+ variant = "lexical",
11
+ value,
12
+ onChange,
13
+ onImagesRemoved,
14
+ placeholder,
15
+ disabled,
16
+ readOnly,
17
+ error,
18
+ className,
19
+ id,
20
+ name,
21
+ }: EditorFieldProps) {
22
+ if (variant === "textarea") {
23
+ const stringValue = isLexicalContent(value) ? "" : (value ?? "");
24
+
25
+ return (
26
+ <TextareaFieldAdapter
27
+ id={id}
28
+ name={name}
29
+ value={stringValue}
30
+ onChange={onChange}
31
+ placeholder={placeholder}
32
+ disabled={disabled}
33
+ readOnly={readOnly}
34
+ error={error}
35
+ className={className}
36
+ />
37
+ );
38
+ }
39
+
40
+ const lexicalValue: LexicalEditorContent | null = isLexicalContent(value)
41
+ ? value
42
+ : null;
43
+
44
+ return (
45
+ <div className={cn("space-y-1", className)}>
46
+ <LexicalRichTextEditor
47
+ id={id}
48
+ value={lexicalValue}
49
+ onChange={onChange}
50
+ onImagesRemoved={onImagesRemoved}
51
+ placeholder={placeholder}
52
+ disabled={disabled}
53
+ readOnly={readOnly}
54
+ />
55
+ {error ? (
56
+ <p className="text-destructive text-xs" role="alert">
57
+ {error}
58
+ </p>
59
+ ) : null}
60
+ </div>
61
+ );
62
+ }
@@ -0,0 +1,218 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { Button } from "@/components/ui/button";
5
+ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
6
+ import { Input } from "@/components/ui/input";
7
+ import { Label } from "@/components/ui/label";
8
+ import {
9
+ Select,
10
+ SelectContent,
11
+ SelectItem,
12
+ SelectTrigger,
13
+ SelectValue,
14
+ } from "@/components/ui/select";
15
+ import {
16
+ EditorField,
17
+ RichTextRenderer,
18
+ createEmptyLexicalContent,
19
+ isLexicalContent,
20
+ type EditorContent,
21
+ type EditorVariant,
22
+ type LexicalEditorContent,
23
+ } from "@/components/rich-text";
24
+ import {
25
+ syncRemovedRichTextImages,
26
+ defaultRemoveManagedImage,
27
+ isSupabaseManagedImageUrl,
28
+ parseSupabaseStorageUrl,
29
+ type RemoveManagedImageFn,
30
+ } from "@/lib/rich-text";
31
+
32
+ type BlogPost = {
33
+ id: string;
34
+ title: string;
35
+ content: LexicalEditorContent;
36
+ };
37
+
38
+ /** Swap with `removeSupabaseManagedImage` when the supabase module is installed. */
39
+ const removeManagedImage: RemoveManagedImageFn = defaultRemoveManagedImage;
40
+
41
+ export function BlogRichTextDemo() {
42
+ const [variant, setVariant] = useState<EditorVariant>("lexical");
43
+ const [title, setTitle] = useState("");
44
+ const [draft, setDraft] = useState<EditorContent | null>(
45
+ createEmptyLexicalContent(),
46
+ );
47
+ const [posts, setPosts] = useState<BlogPost[]>([]);
48
+ const [editingId, setEditingId] = useState<string | null>(null);
49
+ const [previousContent, setPreviousContent] =
50
+ useState<LexicalEditorContent | null>(null);
51
+ const [editorKey, setEditorKey] = useState("new");
52
+
53
+ const handleImagesRemoved = useCallback((removedUrls: string[]) => {
54
+ void Promise.all(
55
+ removedUrls.map(async (url) => {
56
+ if (!isSupabaseManagedImageUrl(url)) {
57
+ return;
58
+ }
59
+
60
+ const ref = parseSupabaseStorageUrl(url);
61
+ if (!ref) {
62
+ return;
63
+ }
64
+
65
+ await removeManagedImage(url, ref);
66
+ }),
67
+ );
68
+ }, []);
69
+
70
+ const handleSave = useCallback(async () => {
71
+ if (!title.trim() || !isLexicalContent(draft)) {
72
+ return;
73
+ }
74
+
75
+ if (editingId && previousContent) {
76
+ await syncRemovedRichTextImages(previousContent, draft, {
77
+ removeManagedImage,
78
+ });
79
+ }
80
+
81
+ if (editingId) {
82
+ setPosts((current) =>
83
+ current.map((post) =>
84
+ post.id === editingId
85
+ ? { ...post, title: title.trim(), content: draft }
86
+ : post,
87
+ ),
88
+ );
89
+ } else {
90
+ setPosts((current) => [
91
+ {
92
+ id: crypto.randomUUID(),
93
+ title: title.trim(),
94
+ content: draft,
95
+ },
96
+ ...current,
97
+ ]);
98
+ }
99
+
100
+ setTitle("");
101
+ setDraft(createEmptyLexicalContent());
102
+ setPreviousContent(null);
103
+ setEditingId(null);
104
+ setEditorKey(`new-${Date.now()}`);
105
+ }, [draft, editingId, previousContent, title]);
106
+
107
+ const startEdit = useCallback((post: BlogPost) => {
108
+ setEditingId(post.id);
109
+ setTitle(post.title);
110
+ setDraft(post.content);
111
+ setPreviousContent(post.content);
112
+ setEditorKey(post.id);
113
+ }, []);
114
+
115
+ const resetForm = useCallback(() => {
116
+ setEditingId(null);
117
+ setTitle("");
118
+ setDraft(createEmptyLexicalContent());
119
+ setPreviousContent(null);
120
+ setEditorKey(`new-${Date.now()}`);
121
+ }, []);
122
+
123
+ return (
124
+ <div className="mx-auto flex w-full max-w-3xl flex-col gap-6 p-6">
125
+ <div>
126
+ <h1 className="text-2xl font-semibold tracking-tight">
127
+ Blog rich text demo
128
+ </h1>
129
+ <p className="text-muted-foreground mt-1 text-sm">
130
+ Toggle between native textarea and Lexical. Lexical content is stored
131
+ as JSON for API persistence. When the Supabase module is installed,
132
+ removed Supabase image URLs are deleted from storage.
133
+ </p>
134
+ </div>
135
+
136
+ <Card>
137
+ <CardHeader>
138
+ <CardTitle>{editingId ? "Edit post" : "Create post"}</CardTitle>
139
+ </CardHeader>
140
+ <CardContent className="space-y-4">
141
+ <div className="space-y-2">
142
+ <Label htmlFor="editor-variant">Editor type</Label>
143
+ <Select
144
+ value={variant}
145
+ onValueChange={(value) => setVariant(value as EditorVariant)}
146
+ >
147
+ <SelectTrigger id="editor-variant" className="w-48">
148
+ <SelectValue />
149
+ </SelectTrigger>
150
+ <SelectContent>
151
+ <SelectItem value="lexical">Lexical (rich text)</SelectItem>
152
+ <SelectItem value="textarea">Textarea (plain)</SelectItem>
153
+ </SelectContent>
154
+ </Select>
155
+ </div>
156
+
157
+ <div className="space-y-2">
158
+ <Label htmlFor="post-title">Title</Label>
159
+ <Input
160
+ id="post-title"
161
+ value={title}
162
+ onChange={(event) => setTitle(event.target.value)}
163
+ placeholder="Post title"
164
+ />
165
+ </div>
166
+
167
+ <div className="space-y-2">
168
+ <Label>Content</Label>
169
+ <EditorField
170
+ key={editorKey}
171
+ variant={variant}
172
+ value={draft}
173
+ onChange={setDraft}
174
+ onImagesRemoved={handleImagesRemoved}
175
+ placeholder="Write your post..."
176
+ />
177
+ </div>
178
+
179
+ <div className="flex gap-2">
180
+ <Button type="button" onClick={() => void handleSave()}>
181
+ {editingId ? "Update post" : "Save post"}
182
+ </Button>
183
+ {editingId ? (
184
+ <Button type="button" variant="outline" onClick={resetForm}>
185
+ Cancel
186
+ </Button>
187
+ ) : null}
188
+ </div>
189
+ </CardContent>
190
+ </Card>
191
+
192
+ <div className="space-y-4">
193
+ {posts.length === 0 ? (
194
+ <p className="text-muted-foreground text-sm">No posts yet.</p>
195
+ ) : (
196
+ posts.map((post) => (
197
+ <Card key={post.id}>
198
+ <CardHeader className="flex flex-row items-center justify-between gap-4">
199
+ <CardTitle className="text-lg">{post.title}</CardTitle>
200
+ <Button
201
+ type="button"
202
+ variant="outline"
203
+ size="sm"
204
+ onClick={() => startEdit(post)}
205
+ >
206
+ Edit
207
+ </Button>
208
+ </CardHeader>
209
+ <CardContent>
210
+ <RichTextRenderer content={post.content} />
211
+ </CardContent>
212
+ </Card>
213
+ ))
214
+ )}
215
+ </div>
216
+ </div>
217
+ );
218
+ }
@@ -0,0 +1,11 @@
1
+ export type { EditorFieldProps, EditorVariant, EditorContent } from "./types";
2
+ export {
3
+ createEmptyLexicalContent,
4
+ isLexicalContent,
5
+ type LexicalEditorContent,
6
+ type TextareaEditorContent,
7
+ } from "./types";
8
+ export { EditorField } from "./editor-field";
9
+ export { RichTextRenderer } from "./rich-text-renderer";
10
+ export { LexicalRichTextEditor } from "./lexical/rich-text-editor";
11
+ export { TextareaFieldAdapter } from "./adapters/textarea-field";
@@ -0,0 +1,37 @@
1
+ import { defineExtension, configExtension } from "lexical";
2
+ import { HistoryExtension } from "@lexical/history";
3
+ import { RichTextExtension } from "@lexical/rich-text";
4
+ import { ReactExtension } from "@lexical/react/ReactExtension";
5
+ import { ImageNode } from "./nodes/image-node";
6
+ import { richTextTheme } from "./theme";
7
+
8
+ /** Stable module-scoped extension graph (Lexical migration best practice). */
9
+ export const richTextEditorExtension = defineExtension({
10
+ name: "@nextcli/rich-text-editor",
11
+ namespace: "NextCLIRichText",
12
+ dependencies: [
13
+ RichTextExtension,
14
+ HistoryExtension,
15
+ configExtension(ReactExtension, { contentEditable: null }),
16
+ ],
17
+ nodes: [ImageNode],
18
+ theme: richTextTheme,
19
+ });
20
+
21
+ /** Read-only extension shares nodes/theme with the editor for identical rendering. */
22
+ export const richTextReadOnlyExtension = defineExtension({
23
+ name: "@nextcli/rich-text-readonly",
24
+ namespace: "NextCLIRichTextReadOnly",
25
+ dependencies: [
26
+ RichTextExtension,
27
+ configExtension(ReactExtension, { contentEditable: null }),
28
+ ],
29
+ nodes: [ImageNode],
30
+ theme: richTextTheme,
31
+ register(editor) {
32
+ editor.setEditable(false);
33
+ return () => {
34
+ editor.setEditable(true);
35
+ };
36
+ },
37
+ });