@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.
- package/README.md +68 -47
- package/dist/cli.js +1002 -753
- package/package.json +6 -2
- package/templates/{next-base/src/lib/axios-instance.ts → features/api/src/lib/api/axios.ts} +7 -2
- package/templates/{next-base/src/lib/api-response.ts → features/api/src/lib/api/response.ts} +1 -5
- package/templates/{next-base → features/auth}/src/app/(auth)/change-password/layout.tsx +1 -1
- package/templates/{next-base → features/auth}/src/app/(auth)/sign-in/layout.tsx +1 -1
- package/templates/{next-base → features/auth}/src/app/api/v1/auth/change-password/route.ts +3 -3
- package/templates/{next-base → features/auth}/src/app/api/v1/auth/login/route.ts +3 -3
- package/templates/{next-base → features/auth}/src/app/api/v1/auth/logout/route.ts +2 -2
- package/templates/{next-base → features/auth}/src/app/api/v1/auth/me/route.ts +2 -2
- package/templates/{next-base → features/auth}/src/app/api/v1/auth/refresh/route.ts +2 -2
- package/templates/{next-base → features/auth}/src/app/api/v1/users/[id]/route.ts +3 -3
- package/templates/{next-base → features/auth}/src/app/api/v1/users/route.ts +3 -3
- package/templates/{next-base → features/auth}/src/features/auth/components/account-panel.tsx +1 -1
- package/templates/{next-base → features/auth}/src/features/auth/components/change-password-form.tsx +1 -1
- package/templates/{next-base → features/auth}/src/features/auth/components/sign-in-form.tsx +2 -2
- package/templates/{next-base → features/auth}/src/features/users/services.ts +1 -1
- package/templates/{next-base → features/auth}/src/instrumentation.ts +1 -1
- package/templates/{next-base/src/lib → features/auth/src/lib/auth}/bootstrap.ts +2 -3
- package/templates/features/auth/src/lib/auth/index.ts +1 -0
- package/templates/{next-base/src/lib → features/auth/src/lib/auth}/rbac.ts +2 -5
- package/templates/{next-base/src/lib/auth.ts → features/auth/src/lib/auth/server.ts} +2 -1
- package/templates/{next-base → features/auth}/src/lib/constants.ts +3 -0
- package/templates/features/chat/src/app/api/v1/chat/route.ts +1 -1
- package/templates/features/chat/src/features/chat/api/use-chat-history.ts +1 -1
- package/templates/features/chat/src/features/chat/api/use-send-message.ts +1 -1
- package/templates/{next-base → features/dashboard}/src/app/(dashboard)/layout.tsx +1 -1
- package/templates/features/dashboard/src/app/page.tsx +5 -0
- package/templates/{next-base → features/dashboard}/src/components/layout/private/nav-user.tsx +1 -1
- package/templates/{next-base → features/database}/prisma/schema.prisma +0 -12
- package/templates/{next-base → features/database}/prisma.config.ts +2 -2
- package/templates/features/database/src/lib/prisma.ts +23 -0
- package/templates/{next-base → features/example}/src/app/api/v1/example/route.ts +2 -2
- package/templates/{next-base → features/example}/src/example/api/use-example.ts +1 -1
- package/templates/{next-base → features/example}/src/example/api/use-mutations.ts +1 -1
- package/templates/{next-base → features/example}/src/example/services.ts +1 -1
- package/templates/features/i18n/next.config.ts +17 -0
- package/templates/features/i18n/src/app/layout.tsx +42 -0
- package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/.env +0 -14
- package/templates/next-base/.env.development +0 -14
- package/templates/next-base/.env.example +0 -14
- package/templates/next-base/PROJECT_STRUCTURE.md +43 -54
- package/templates/next-base/SETUP.md +12 -60
- package/templates/next-base/bun.lock +59 -397
- package/templates/next-base/next-env.d.ts +1 -1
- package/templates/next-base/next.config.ts +1 -4
- package/templates/next-base/nextcli.json +3 -3
- package/templates/next-base/package.json +6 -21
- package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
- package/templates/next-base/src/app/globals.css +57 -0
- package/templates/next-base/src/app/layout.tsx +6 -14
- package/templates/next-base/src/app/page.tsx +25 -2
- package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
- package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
- package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
- package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
- package/templates/next-base/src/components/rich-text/index.ts +11 -0
- package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
- package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
- package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
- package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
- package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
- package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
- package/templates/next-base/src/components/rich-text/types.ts +60 -0
- package/templates/next-base/src/hooks/index.ts +1 -1
- package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
- package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
- package/templates/next-base/src/lib/rich-text/index.ts +12 -0
- package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
- package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
- package/templates/next-base/src/types/index.ts +0 -2
- package/templates/next-base/tsconfig.tsbuildinfo +1 -0
- package/templates/next-base/prisma/migrations/20260612000000_init/migration.sql +0 -104
- package/templates/next-base/prisma/migrations/migration_lock.toml +0 -3
- package/templates/next-base/src/app/(auth)/.gitkeep +0 -1
- /package/templates/{next-base → features/api}/src/components/providers/query-provider.tsx +0 -0
- /package/templates/{next-base/src/lib → features/api/src/lib/api}/token-store.ts +0 -0
- /package/templates/{next-base → features/auth}/messages/vi/auth.json +0 -0
- /package/templates/{next-base/prisma/migrations → features/auth/src/app/(auth)}/.gitkeep +0 -0
- /package/templates/{next-base → features/auth}/src/app/(auth)/change-password/page.tsx +0 -0
- /package/templates/{next-base → features/auth}/src/app/(auth)/layout.tsx +0 -0
- /package/templates/{next-base → features/auth}/src/app/(auth)/sign-in/page.tsx +0 -0
- /package/templates/{next-base → features/auth}/src/app/api/auth/[...all]/route.ts +0 -0
- /package/templates/{next-base → features/auth}/src/features/auth/validations.ts +0 -0
- /package/templates/{next-base → features/auth}/src/features/users/validations.ts +0 -0
- /package/templates/{next-base/src/lib/auth-client.ts → features/auth/src/lib/auth/client.ts} +0 -0
- /package/templates/{next-base/src/lib/auth-cookies.ts → features/auth/src/lib/auth/cookies.ts} +0 -0
- /package/templates/{next-base → features/dashboard}/src/app/(dashboard)/account/page.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/app/(dashboard)/dashboard/page.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/layout/private/app-sidebar.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/layout/private/dashboard-layout.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/layout/private/nav-sidebar.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-column-header.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-filter-list.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-pagination.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-skeleton.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-toolbar.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table-view-options.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/data-table/data-table.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/components/ui/sidebar.tsx +0 -0
- /package/templates/{next-base → features/dashboard}/src/data/sidebar-modules.ts +0 -0
- /package/templates/{next-base → features/dashboard}/src/hooks/table/use-data-table.ts +0 -0
- /package/templates/{next-base → features/dashboard}/src/hooks/use-mobile.ts +0 -0
- /package/templates/{next-base → features/dashboard}/src/types/data-table.ts +0 -0
- /package/templates/{next-base/src/lib → features/database/src/lib/db}/prisma.ts +0 -0
- /package/templates/{next-base → features/example}/messages/vi/example.json +0 -0
- /package/templates/{next-base → features/example}/src/app/(dashboard)/example/page.tsx +0 -0
- /package/templates/{next-base → features/example}/src/example/components/example-table.tsx +0 -0
- /package/templates/{next-base → features/example}/src/example/validations.ts +0 -0
- /package/templates/{next-base → features/i18n}/messages/vi/common.json +0 -0
- /package/templates/{next-base → features/i18n}/src/components/layout/private/locale-switcher.tsx +0 -0
- /package/templates/{next-base → features/i18n}/src/i18n/config.ts +0 -0
- /package/templates/{next-base → features/i18n}/src/i18n/namespaces.ts +0 -0
- /package/templates/{next-base → features/i18n}/src/i18n/request.ts +0 -0
- /package/templates/{next-base → features/supabase}/src/lib/supabase/client.ts +0 -0
- /package/templates/{next-base → features/supabase}/src/lib/supabase/storage-config.ts +0 -0
- /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/
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
export default withNextIntl(nextConfig);
|
|
14
|
+
export default nextConfig;
|
|
@@ -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
|
-
"
|
|
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
|
}
|
|
@@ -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
|
|
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=
|
|
25
|
+
<html lang="vi" suppressHydrationWarning>
|
|
32
26
|
<body className={beVietnamPro.className}>
|
|
33
|
-
<
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|