@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
@@ -0,0 +1,187 @@
1
+ "use client";
2
+
3
+ import type {
4
+ DOMConversionMap,
5
+ DOMConversionOutput,
6
+ DOMExportOutput,
7
+ EditorConfig,
8
+ LexicalNode,
9
+ NodeKey,
10
+ SerializedLexicalNode,
11
+ Spread,
12
+ } from "lexical";
13
+ import {
14
+ $applyNodeReplacement,
15
+ DecoratorNode,
16
+ createCommand,
17
+ type LexicalCommand,
18
+ } from "lexical";
19
+ import type { JSX } from "react";
20
+
21
+ export type SerializedImageNode = Spread<
22
+ {
23
+ altText: string;
24
+ height?: number;
25
+ maxWidth?: number;
26
+ src: string;
27
+ width?: number;
28
+ },
29
+ SerializedLexicalNode
30
+ >;
31
+
32
+ export type InsertImagePayload = {
33
+ altText?: string;
34
+ src: string;
35
+ };
36
+
37
+ export const INSERT_IMAGE_COMMAND: LexicalCommand<InsertImagePayload> =
38
+ createCommand("INSERT_IMAGE_COMMAND");
39
+
40
+ function convertImageElement(domNode: Node): DOMConversionOutput | null {
41
+ if (!(domNode instanceof HTMLImageElement)) {
42
+ return null;
43
+ }
44
+
45
+ const src = domNode.getAttribute("src");
46
+ if (!src) {
47
+ return null;
48
+ }
49
+
50
+ const altText = domNode.getAttribute("alt") ?? "";
51
+ const node = $createImageNode({ src, altText });
52
+ return { node };
53
+ }
54
+
55
+ export class ImageNode extends DecoratorNode<JSX.Element> {
56
+ __src: string;
57
+ __altText: string;
58
+ __width: number | "inherit";
59
+ __height: number | "inherit";
60
+ __maxWidth: number;
61
+
62
+ static getType(): string {
63
+ return "image";
64
+ }
65
+
66
+ static clone(node: ImageNode): ImageNode {
67
+ return new ImageNode(
68
+ node.__src,
69
+ node.__altText,
70
+ node.__maxWidth,
71
+ node.__width,
72
+ node.__height,
73
+ node.__key,
74
+ );
75
+ }
76
+
77
+ static importJSON(serializedNode: SerializedImageNode): ImageNode {
78
+ return $createImageNode({
79
+ altText: serializedNode.altText,
80
+ height: serializedNode.height,
81
+ maxWidth: serializedNode.maxWidth,
82
+ src: serializedNode.src,
83
+ width: serializedNode.width,
84
+ });
85
+ }
86
+
87
+ static importDOM(): DOMConversionMap | null {
88
+ return {
89
+ img: () => ({
90
+ conversion: convertImageElement,
91
+ priority: 0,
92
+ }),
93
+ };
94
+ }
95
+
96
+ constructor(
97
+ src: string,
98
+ altText: string,
99
+ maxWidth: number,
100
+ width?: number | "inherit",
101
+ height?: number | "inherit",
102
+ key?: NodeKey,
103
+ ) {
104
+ super(key);
105
+ this.__src = src;
106
+ this.__altText = altText;
107
+ this.__maxWidth = maxWidth;
108
+ this.__width = width ?? "inherit";
109
+ this.__height = height ?? "inherit";
110
+ }
111
+
112
+ exportJSON(): SerializedImageNode {
113
+ return {
114
+ altText: this.__altText,
115
+ height: this.__height === "inherit" ? undefined : this.__height,
116
+ maxWidth: this.__maxWidth,
117
+ src: this.__src,
118
+ type: "image",
119
+ version: 1,
120
+ width: this.__width === "inherit" ? undefined : this.__width,
121
+ };
122
+ }
123
+
124
+ exportDOM(): DOMExportOutput {
125
+ const element = document.createElement("img");
126
+ element.setAttribute("src", this.__src);
127
+ element.setAttribute("alt", this.__altText);
128
+ return { element };
129
+ }
130
+
131
+ createDOM(config: EditorConfig): HTMLElement {
132
+ const span = document.createElement("span");
133
+ const theme = config.theme.image;
134
+ if (typeof theme === "string") {
135
+ span.className = theme;
136
+ }
137
+ return span;
138
+ }
139
+
140
+ updateDOM(): false {
141
+ return false;
142
+ }
143
+
144
+ getSrc(): string {
145
+ return this.__src;
146
+ }
147
+
148
+ decorate(): JSX.Element {
149
+ return (
150
+ <img
151
+ src={this.__src}
152
+ alt={this.__altText}
153
+ className="lexical-image-element max-w-full rounded-md"
154
+ draggable={false}
155
+ style={{
156
+ maxWidth: this.__maxWidth,
157
+ width: this.__width === "inherit" ? undefined : this.__width,
158
+ height: this.__height === "inherit" ? undefined : this.__height,
159
+ }}
160
+ />
161
+ );
162
+ }
163
+ }
164
+
165
+ export function $createImageNode({
166
+ altText = "",
167
+ height,
168
+ maxWidth = 800,
169
+ src,
170
+ width,
171
+ }: {
172
+ altText?: string;
173
+ height?: number | "inherit";
174
+ maxWidth?: number;
175
+ src: string;
176
+ width?: number | "inherit";
177
+ }): ImageNode {
178
+ return $applyNodeReplacement(
179
+ new ImageNode(src, altText, maxWidth, width, height),
180
+ );
181
+ }
182
+
183
+ export function $isImageNode(
184
+ node: LexicalNode | null | undefined,
185
+ ): node is ImageNode {
186
+ return node instanceof ImageNode;
187
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
5
+ import {
6
+ $getSelection,
7
+ $isRangeSelection,
8
+ COMMAND_PRIORITY_EDITOR,
9
+ } from "lexical";
10
+ import {
11
+ $createImageNode,
12
+ INSERT_IMAGE_COMMAND,
13
+ type InsertImagePayload,
14
+ } from "../nodes/image-node";
15
+
16
+ export function ImagePlugin(): null {
17
+ const [editor] = useLexicalComposerContext();
18
+
19
+ useEffect(() => {
20
+ return editor.registerCommand<InsertImagePayload>(
21
+ INSERT_IMAGE_COMMAND,
22
+ (payload) => {
23
+ const selection = $getSelection();
24
+ if (!$isRangeSelection(selection)) {
25
+ return false;
26
+ }
27
+
28
+ const imageNode = $createImageNode({
29
+ altText: payload.altText ?? "",
30
+ src: payload.src,
31
+ });
32
+ selection.insertNodes([imageNode]);
33
+ return true;
34
+ },
35
+ COMMAND_PRIORITY_EDITOR,
36
+ );
37
+ }, [editor]);
38
+
39
+ return null;
40
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
5
+ import type { SerializedEditorState } from "lexical";
6
+
7
+ type InitialStatePluginProps = {
8
+ value: SerializedEditorState | null;
9
+ };
10
+
11
+ export function InitialStatePlugin({ value }: InitialStatePluginProps) {
12
+ const [editor] = useLexicalComposerContext();
13
+ const hydratedRef = useRef(false);
14
+
15
+ useEffect(() => {
16
+ if (hydratedRef.current || !value) {
17
+ return;
18
+ }
19
+
20
+ hydratedRef.current = true;
21
+ const parsed = editor.parseEditorState(value);
22
+ editor.setEditorState(parsed);
23
+ }, [editor, value]);
24
+
25
+ return null;
26
+ }
@@ -0,0 +1,26 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
5
+ import type { SerializedEditorState } from "lexical";
6
+
7
+ type OnChangePluginProps = {
8
+ onChange: (state: SerializedEditorState) => void;
9
+ disabled?: boolean;
10
+ };
11
+
12
+ export function OnChangePlugin({ onChange, disabled }: OnChangePluginProps) {
13
+ const [editor] = useLexicalComposerContext();
14
+
15
+ useEffect(() => {
16
+ if (disabled) {
17
+ return;
18
+ }
19
+
20
+ return editor.registerUpdateListener(({ editorState }) => {
21
+ onChange(editorState.toJSON());
22
+ });
23
+ }, [disabled, editor, onChange]);
24
+
25
+ return null;
26
+ }
@@ -0,0 +1,190 @@
1
+ "use client";
2
+
3
+ import { useCallback, useState } from "react";
4
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
5
+ import { $getSelection, $isRangeSelection, FORMAT_TEXT_COMMAND } from "lexical";
6
+ import { INSERT_IMAGE_COMMAND } from "../nodes/image-node";
7
+ import { Button } from "@/components/ui/button";
8
+ import { Input } from "@/components/ui/input";
9
+ import {
10
+ Popover,
11
+ PopoverContent,
12
+ PopoverTrigger,
13
+ } from "@/components/ui/popover";
14
+ import { Bold, ImageIcon, Italic, Smile, Underline } from "lucide-react";
15
+
16
+ const COMMON_EMOJIS = [
17
+ "😀",
18
+ "😂",
19
+ "😍",
20
+ "🥳",
21
+ "👍",
22
+ "🎉",
23
+ "🔥",
24
+ "✨",
25
+ "❤️",
26
+ "🙏",
27
+ "💡",
28
+ "📝",
29
+ ];
30
+
31
+ type ToolbarPluginProps = {
32
+ disabled?: boolean;
33
+ };
34
+
35
+ export function ToolbarPlugin({ disabled }: ToolbarPluginProps) {
36
+ const [editor] = useLexicalComposerContext();
37
+ const [imageUrl, setImageUrl] = useState("");
38
+ const [imageAlt, setImageAlt] = useState("");
39
+ const [imageOpen, setImageOpen] = useState(false);
40
+ const [emojiOpen, setEmojiOpen] = useState(false);
41
+
42
+ const insertEmoji = useCallback(
43
+ (emoji: string) => {
44
+ editor.update(() => {
45
+ const selection = $getSelection();
46
+ if ($isRangeSelection(selection)) {
47
+ selection.insertText(emoji);
48
+ }
49
+ });
50
+ setEmojiOpen(false);
51
+ },
52
+ [editor],
53
+ );
54
+
55
+ const insertImage = useCallback(() => {
56
+ const src = imageUrl.trim();
57
+ if (!src) {
58
+ return;
59
+ }
60
+
61
+ try {
62
+ new URL(src);
63
+ } catch {
64
+ return;
65
+ }
66
+
67
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
68
+ altText: imageAlt.trim() || "Image",
69
+ src,
70
+ });
71
+ setImageUrl("");
72
+ setImageAlt("");
73
+ setImageOpen(false);
74
+ }, [editor, imageAlt, imageUrl]);
75
+
76
+ return (
77
+ <div className="flex flex-wrap items-center gap-1 border-b border-input px-2 py-1.5">
78
+ <Button
79
+ type="button"
80
+ variant="ghost"
81
+ size="icon"
82
+ className="h-8 w-8"
83
+ disabled={disabled}
84
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
85
+ aria-label="Bold"
86
+ >
87
+ <Bold className="h-4 w-4" />
88
+ </Button>
89
+ <Button
90
+ type="button"
91
+ variant="ghost"
92
+ size="icon"
93
+ className="h-8 w-8"
94
+ disabled={disabled}
95
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")}
96
+ aria-label="Italic"
97
+ >
98
+ <Italic className="h-4 w-4" />
99
+ </Button>
100
+ <Button
101
+ type="button"
102
+ variant="ghost"
103
+ size="icon"
104
+ className="h-8 w-8"
105
+ disabled={disabled}
106
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "underline")}
107
+ aria-label="Underline"
108
+ >
109
+ <Underline className="h-4 w-4" />
110
+ </Button>
111
+
112
+ <Popover open={emojiOpen} onOpenChange={setEmojiOpen}>
113
+ <PopoverTrigger asChild>
114
+ <Button
115
+ type="button"
116
+ variant="ghost"
117
+ size="icon"
118
+ className="h-8 w-8"
119
+ disabled={disabled}
120
+ aria-label="Insert emoji"
121
+ >
122
+ <Smile className="h-4 w-4" />
123
+ </Button>
124
+ </PopoverTrigger>
125
+ <PopoverContent className="w-56 p-2" align="start">
126
+ <div className="grid grid-cols-6 gap-1">
127
+ {COMMON_EMOJIS.map((emoji) => (
128
+ <button
129
+ key={emoji}
130
+ type="button"
131
+ className="hover:bg-accent rounded p-1 text-lg"
132
+ onClick={() => insertEmoji(emoji)}
133
+ >
134
+ {emoji}
135
+ </button>
136
+ ))}
137
+ </div>
138
+ </PopoverContent>
139
+ </Popover>
140
+
141
+ <Popover open={imageOpen} onOpenChange={setImageOpen}>
142
+ <PopoverTrigger asChild>
143
+ <Button
144
+ type="button"
145
+ variant="ghost"
146
+ size="icon"
147
+ className="h-8 w-8"
148
+ disabled={disabled}
149
+ aria-label="Insert image URL"
150
+ >
151
+ <ImageIcon className="h-4 w-4" />
152
+ </Button>
153
+ </PopoverTrigger>
154
+ <PopoverContent className="w-80 space-y-3" align="start">
155
+ <div className="space-y-1">
156
+ <label className="text-xs font-medium" htmlFor="image-url">
157
+ Image URL
158
+ </label>
159
+ <Input
160
+ id="image-url"
161
+ placeholder="https://..."
162
+ value={imageUrl}
163
+ onChange={(event) => setImageUrl(event.target.value)}
164
+ />
165
+ </div>
166
+ <div className="space-y-1">
167
+ <label className="text-xs font-medium" htmlFor="image-alt">
168
+ Alt text
169
+ </label>
170
+ <Input
171
+ id="image-alt"
172
+ placeholder="Description"
173
+ value={imageAlt}
174
+ onChange={(event) => setImageAlt(event.target.value)}
175
+ />
176
+ </div>
177
+ <Button
178
+ type="button"
179
+ size="sm"
180
+ className="w-full"
181
+ onClick={insertImage}
182
+ disabled={!imageUrl.trim()}
183
+ >
184
+ Insert image
185
+ </Button>
186
+ </PopoverContent>
187
+ </Popover>
188
+ </div>
189
+ );
190
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import { useCallback, useMemo, useRef } from "react";
4
+ import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";
5
+ import { ContentEditable } from "@lexical/react/LexicalContentEditable";
6
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
7
+ import { cn } from "@/utils/cn";
8
+ import { richTextEditorExtension } from "./extension";
9
+ import { ImagePlugin } from "./plugins/image-plugin";
10
+ import { InitialStatePlugin } from "./plugins/initial-state-plugin";
11
+ import { OnChangePlugin } from "./plugins/on-change-plugin";
12
+ import { ToolbarPlugin } from "./plugins/toolbar-plugin";
13
+ import { createEmptyLexicalContent, type LexicalEditorContent } from "../types";
14
+ import { extractImageUrlsFromState } from "@/lib/rich-text/image-urls";
15
+ import type { SerializedEditorState } from "lexical";
16
+ import { ClientOnly } from "../client-only";
17
+
18
+ export type LexicalRichTextEditorProps = {
19
+ value: LexicalEditorContent | null;
20
+ onChange: (value: LexicalEditorContent) => void;
21
+ onImagesRemoved?: (removedUrls: string[]) => void;
22
+ placeholder?: string;
23
+ disabled?: boolean;
24
+ readOnly?: boolean;
25
+ className?: string;
26
+ id?: string;
27
+ };
28
+
29
+ function LexicalRichTextEditorInner({
30
+ value,
31
+ onChange,
32
+ onImagesRemoved,
33
+ placeholder = "Write something...",
34
+ disabled = false,
35
+ readOnly = false,
36
+ className,
37
+ id,
38
+ }: LexicalRichTextEditorProps) {
39
+ const previousImagesRef = useRef<string[]>(
40
+ extractImageUrlsFromState(value ?? createEmptyLexicalContent()),
41
+ );
42
+
43
+ const handleChange = useCallback(
44
+ (nextState: SerializedEditorState) => {
45
+ const nextImages = extractImageUrlsFromState(nextState);
46
+ const previousImages = previousImagesRef.current;
47
+ const removed = previousImages.filter((url) => !nextImages.includes(url));
48
+
49
+ if (removed.length > 0) {
50
+ onImagesRemoved?.(removed);
51
+ }
52
+
53
+ previousImagesRef.current = nextImages;
54
+ onChange(nextState);
55
+ },
56
+ [onChange, onImagesRemoved],
57
+ );
58
+
59
+ const initialValue = useMemo(
60
+ () => value ?? createEmptyLexicalContent(),
61
+ [value],
62
+ );
63
+
64
+ const isInteractive = !disabled && !readOnly;
65
+
66
+ return (
67
+ <div
68
+ id={id}
69
+ className={cn(
70
+ "bg-background overflow-hidden rounded-md border border-input",
71
+ (disabled || readOnly) && "opacity-60",
72
+ className,
73
+ )}
74
+ >
75
+ <LexicalExtensionComposer
76
+ extension={richTextEditorExtension}
77
+ contentEditable={null}
78
+ >
79
+ {isInteractive ? <ToolbarPlugin disabled={disabled} /> : null}
80
+ <div className="relative min-h-[160px]">
81
+ <LexicalErrorBoundary onError={(error) => console.error(error)}>
82
+ <ContentEditable
83
+ className={cn(
84
+ "lexical-content-editable min-h-[160px] px-3 py-2 text-sm outline-none",
85
+ !isInteractive && "pointer-events-none",
86
+ )}
87
+ aria-placeholder={placeholder}
88
+ placeholder={
89
+ <div className="text-muted-foreground pointer-events-none absolute top-2 left-3 text-sm">
90
+ {placeholder}
91
+ </div>
92
+ }
93
+ />
94
+ </LexicalErrorBoundary>
95
+ <InitialStatePlugin value={initialValue} />
96
+ <ImagePlugin />
97
+ <OnChangePlugin onChange={handleChange} disabled={!isInteractive} />
98
+ </div>
99
+ </LexicalExtensionComposer>
100
+ </div>
101
+ );
102
+ }
103
+
104
+ export function LexicalRichTextEditor(props: LexicalRichTextEditorProps) {
105
+ return (
106
+ <ClientOnly
107
+ fallback={
108
+ <div
109
+ className={cn(
110
+ "bg-muted/40 text-muted-foreground min-h-[160px] rounded-md border border-input px-3 py-2 text-sm",
111
+ props.className,
112
+ )}
113
+ >
114
+ Loading editor...
115
+ </div>
116
+ }
117
+ >
118
+ <LexicalRichTextEditorInner {...props} />
119
+ </ClientOnly>
120
+ );
121
+ }
@@ -0,0 +1,18 @@
1
+ export const richTextTheme = {
2
+ paragraph: "lexical-paragraph",
3
+ text: {
4
+ bold: "lexical-text-bold",
5
+ italic: "lexical-text-italic",
6
+ underline: "lexical-text-underline",
7
+ strikethrough: "lexical-text-strikethrough",
8
+ underlineStrikethrough: "lexical-text-underline-strikethrough",
9
+ code: "lexical-text-code",
10
+ },
11
+ heading: {
12
+ h1: "lexical-heading-h1",
13
+ h2: "lexical-heading-h2",
14
+ h3: "lexical-heading-h3",
15
+ },
16
+ quote: "lexical-quote",
17
+ image: "lexical-image",
18
+ };
@@ -0,0 +1,72 @@
1
+ "use client";
2
+
3
+ import { useEffect, type ReactNode } from "react";
4
+ import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";
5
+ import { ContentEditable } from "@lexical/react/LexicalContentEditable";
6
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
7
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
8
+ import { cn } from "@/utils/cn";
9
+ import { richTextReadOnlyExtension } from "./lexical/extension";
10
+ import type { LexicalEditorContent } from "./types";
11
+ import { ClientOnly } from "./client-only";
12
+
13
+ type HydrateStatePluginProps = {
14
+ content: LexicalEditorContent;
15
+ };
16
+
17
+ function HydrateStatePlugin({ content }: HydrateStatePluginProps) {
18
+ const [editor] = useLexicalComposerContext();
19
+
20
+ useEffect(() => {
21
+ const parsed = editor.parseEditorState(content);
22
+ editor.setEditorState(parsed);
23
+ }, [content, editor]);
24
+
25
+ return null;
26
+ }
27
+
28
+ export type RichTextRendererProps = {
29
+ content: LexicalEditorContent | null;
30
+ className?: string;
31
+ emptyFallback?: ReactNode;
32
+ };
33
+
34
+ function RichTextRendererInner({
35
+ content,
36
+ className,
37
+ }: {
38
+ content: LexicalEditorContent;
39
+ className?: string;
40
+ }) {
41
+ return (
42
+ <div className={cn("lexical-renderer", className)}>
43
+ <LexicalExtensionComposer
44
+ extension={richTextReadOnlyExtension}
45
+ contentEditable={null}
46
+ >
47
+ <LexicalErrorBoundary onError={(error) => console.error(error)}>
48
+ <ContentEditable className="lexical-content-editable pointer-events-none text-sm outline-none" />
49
+ </LexicalErrorBoundary>
50
+ <HydrateStatePlugin content={content} />
51
+ </LexicalExtensionComposer>
52
+ </div>
53
+ );
54
+ }
55
+
56
+ export function RichTextRenderer({
57
+ content,
58
+ className,
59
+ emptyFallback = (
60
+ <p className="text-muted-foreground text-sm">No content yet.</p>
61
+ ),
62
+ }: RichTextRendererProps) {
63
+ if (!content) {
64
+ return <>{emptyFallback}</>;
65
+ }
66
+
67
+ return (
68
+ <ClientOnly fallback={emptyFallback}>
69
+ <RichTextRendererInner content={content} className={className} />
70
+ </ClientOnly>
71
+ );
72
+ }