@thinhnguyencth1204/nextcli 0.7.0 → 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 +12 -2
- package/dist/cli.js +3 -3
- package/package.json +5 -3
- package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
- package/templates/next-base/bun.lock +59 -414
- package/templates/next-base/next-env.d.ts +1 -1
- package/templates/next-base/package.json +5 -0
- 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/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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { SerializedEditorState } from "lexical";
|
|
2
|
+
|
|
3
|
+
/** Lexical editor state persisted as JSON (API / database). */
|
|
4
|
+
export type LexicalEditorContent = SerializedEditorState;
|
|
5
|
+
|
|
6
|
+
/** Plain string content for the native textarea adapter. */
|
|
7
|
+
export type TextareaEditorContent = string;
|
|
8
|
+
|
|
9
|
+
export type EditorVariant = "textarea" | "lexical";
|
|
10
|
+
|
|
11
|
+
export type EditorContent = LexicalEditorContent | TextareaEditorContent;
|
|
12
|
+
|
|
13
|
+
export type EditorFieldProps = {
|
|
14
|
+
/** Switch editor implementation without changing form code. */
|
|
15
|
+
variant?: EditorVariant;
|
|
16
|
+
value: EditorContent | null;
|
|
17
|
+
onChange: (value: EditorContent) => void;
|
|
18
|
+
/** Called when image URLs disappear from Lexical content (for storage cleanup). */
|
|
19
|
+
onImagesRemoved?: (removedUrls: string[]) => void;
|
|
20
|
+
placeholder?: string;
|
|
21
|
+
disabled?: boolean;
|
|
22
|
+
readOnly?: boolean;
|
|
23
|
+
error?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
id?: string;
|
|
26
|
+
name?: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function isLexicalContent(
|
|
30
|
+
value: EditorContent | null,
|
|
31
|
+
): value is LexicalEditorContent {
|
|
32
|
+
return (
|
|
33
|
+
value !== null &&
|
|
34
|
+
typeof value === "object" &&
|
|
35
|
+
"root" in value &&
|
|
36
|
+
typeof value.root === "object"
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function createEmptyLexicalContent(): LexicalEditorContent {
|
|
41
|
+
return {
|
|
42
|
+
root: {
|
|
43
|
+
children: [
|
|
44
|
+
{
|
|
45
|
+
children: [],
|
|
46
|
+
direction: null,
|
|
47
|
+
format: "",
|
|
48
|
+
indent: 0,
|
|
49
|
+
type: "paragraph",
|
|
50
|
+
version: 1,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
direction: null,
|
|
54
|
+
format: "",
|
|
55
|
+
indent: 0,
|
|
56
|
+
type: "root",
|
|
57
|
+
version: 1,
|
|
58
|
+
},
|
|
59
|
+
} as unknown as LexicalEditorContent;
|
|
60
|
+
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export
|
|
1
|
+
/** Re-export hooks from optional modules here when needed. */
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RemoveManagedImageFn } from "@/lib/rich-text";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default no-op image remover for base-only projects.
|
|
5
|
+
* After `nextcli add module supabase`, use
|
|
6
|
+
* `removeSupabaseManagedImage` from `@/lib/supabase/rich-text-image-sync`.
|
|
7
|
+
*/
|
|
8
|
+
export const defaultRemoveManagedImage: RemoveManagedImageFn = async () => {
|
|
9
|
+
// Intentionally empty — wire Supabase removal when the module is installed.
|
|
10
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { SerializedEditorState } from "lexical";
|
|
2
|
+
|
|
3
|
+
type LexicalJsonNode = {
|
|
4
|
+
type?: string;
|
|
5
|
+
src?: string;
|
|
6
|
+
children?: LexicalJsonNode[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function walkNodes(node: LexicalJsonNode, urls: Set<string>): void {
|
|
10
|
+
if (node.type === "image" && typeof node.src === "string" && node.src) {
|
|
11
|
+
urls.add(node.src);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (Array.isArray(node.children)) {
|
|
15
|
+
for (const child of node.children) {
|
|
16
|
+
walkNodes(child, urls);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function extractImageUrlsFromState(
|
|
22
|
+
state: SerializedEditorState | null | undefined,
|
|
23
|
+
): string[] {
|
|
24
|
+
if (!state?.root) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const urls = new Set<string>();
|
|
29
|
+
walkNodes(state.root as LexicalJsonNode, urls);
|
|
30
|
+
return [...urls];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function diffRemovedImageUrls(
|
|
34
|
+
previous: SerializedEditorState | null | undefined,
|
|
35
|
+
next: SerializedEditorState | null | undefined,
|
|
36
|
+
): string[] {
|
|
37
|
+
const previousUrls = extractImageUrlsFromState(previous);
|
|
38
|
+
const nextUrls = new Set(extractImageUrlsFromState(next));
|
|
39
|
+
|
|
40
|
+
return previousUrls.filter((url) => !nextUrls.has(url));
|
|
41
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { diffRemovedImageUrls, extractImageUrlsFromState } from "./image-urls";
|
|
2
|
+
export {
|
|
3
|
+
isSupabaseManagedImageUrl,
|
|
4
|
+
parseSupabaseStorageUrl,
|
|
5
|
+
type SupabaseStorageRef,
|
|
6
|
+
} from "./supabase-url";
|
|
7
|
+
export {
|
|
8
|
+
syncRemovedRichTextImages,
|
|
9
|
+
type RemoveManagedImageFn,
|
|
10
|
+
type SyncRemovedImagesOptions,
|
|
11
|
+
} from "./sync-removed-images";
|
|
12
|
+
export { defaultRemoveManagedImage } from "./default-image-removal";
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
const DEFAULT_BUCKET =
|
|
2
|
+
process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET?.trim() || "public";
|
|
3
|
+
|
|
4
|
+
export type SupabaseStorageRef = {
|
|
5
|
+
bucket: string;
|
|
6
|
+
path: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function getConfiguredBucket(): string {
|
|
10
|
+
return (
|
|
11
|
+
process.env.NEXT_PUBLIC_SUPABASE_STORAGE_BUCKET?.trim() || DEFAULT_BUCKET
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Maps a Supabase public object URL back to `{ bucket, path }`.
|
|
17
|
+
* Returns null for external URLs or when Supabase env is not configured.
|
|
18
|
+
*/
|
|
19
|
+
export function parseSupabaseStorageUrl(
|
|
20
|
+
url: string,
|
|
21
|
+
): SupabaseStorageRef | null {
|
|
22
|
+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
|
|
23
|
+
if (!supabaseUrl) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
const parsed = new URL(url);
|
|
29
|
+
const base = new URL(supabaseUrl);
|
|
30
|
+
|
|
31
|
+
if (parsed.origin !== base.origin) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const publicPrefix = "/storage/v1/object/public/";
|
|
36
|
+
if (!parsed.pathname.startsWith(publicPrefix)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const remainder = parsed.pathname.slice(publicPrefix.length);
|
|
41
|
+
const slashIndex = remainder.indexOf("/");
|
|
42
|
+
if (slashIndex <= 0) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const bucket = remainder.slice(0, slashIndex);
|
|
47
|
+
const path = remainder.slice(slashIndex + 1);
|
|
48
|
+
|
|
49
|
+
if (!path) {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { bucket, path };
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isSupabaseManagedImageUrl(url: string): boolean {
|
|
60
|
+
const ref = parseSupabaseStorageUrl(url);
|
|
61
|
+
if (!ref) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const configuredBucket = getConfiguredBucket();
|
|
66
|
+
return ref.bucket === configuredBucket;
|
|
67
|
+
}
|