@thinhnguyencth1204/nextcli 0.7.0 → 0.9.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 +37 -24
- package/dist/cli.js +168 -107
- package/package.json +5 -3
- package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/.env +16 -0
- package/templates/next-base/.env.development +16 -0
- package/templates/next-base/.env.example +16 -0
- package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
- package/templates/next-base/SETUP.md +62 -10
- package/templates/next-base/bun.lock +59 -414
- package/templates/next-base/messages/vi/auth.json +42 -0
- package/templates/next-base/messages/vi/common.json +34 -0
- package/templates/next-base/messages/vi/example.json +10 -0
- package/templates/next-base/next-env.d.ts +1 -1
- package/templates/next-base/next.config.ts +4 -1
- package/templates/next-base/nextcli.json +12 -4
- package/templates/next-base/package.json +25 -1
- package/templates/next-base/prisma/schema.prisma +84 -0
- package/templates/next-base/prisma.config.ts +16 -0
- package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
- package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
- package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
- package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
- package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
- package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
- package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
- package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
- package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
- package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
- package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
- package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
- package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
- package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
- package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
- package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
- package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
- package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
- package/templates/next-base/src/app/api/v1/users/route.ts +58 -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/app/layout.tsx +14 -6
- package/templates/next-base/src/app/page.tsx +2 -25
- package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
- package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
- package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
- package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
- package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
- package/templates/next-base/src/components/providers/query-provider.tsx +17 -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/components/ui/data-table/data-table-column-header.tsx +23 -0
- package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
- package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
- package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
- package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
- package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
- package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
- package/templates/next-base/src/data/sidebar-modules.ts +11 -0
- package/templates/next-base/src/example/api/use-example.ts +21 -0
- package/templates/next-base/src/example/api/use-mutations.ts +20 -0
- package/templates/next-base/src/example/components/example-table.tsx +51 -0
- package/templates/next-base/src/example/services.ts +9 -0
- package/templates/next-base/src/example/validations.ts +8 -0
- package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
- package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
- package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
- package/templates/next-base/src/features/auth/validations.ts +14 -0
- package/templates/next-base/src/features/users/services.ts +132 -0
- package/templates/next-base/src/features/users/validations.ts +21 -0
- package/templates/next-base/src/hooks/index.ts +1 -1
- package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
- package/templates/next-base/src/hooks/use-mobile.ts +25 -0
- package/templates/next-base/src/i18n/config.ts +7 -0
- package/templates/next-base/src/i18n/namespaces.ts +5 -0
- package/templates/next-base/src/i18n/request.ts +25 -0
- package/templates/next-base/src/instrumentation.ts +14 -0
- package/templates/next-base/src/lib/api/axios.ts +145 -0
- package/templates/next-base/src/lib/api/response.ts +45 -0
- package/templates/next-base/src/lib/api/token-store.ts +13 -0
- package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
- package/templates/next-base/src/lib/auth/client.ts +7 -0
- package/templates/next-base/src/lib/auth/cookies.ts +15 -0
- package/templates/next-base/src/lib/auth/index.ts +1 -0
- package/templates/next-base/src/lib/auth/rbac.ts +59 -0
- package/templates/next-base/src/lib/auth/server.ts +21 -0
- package/templates/next-base/src/lib/constants.ts +10 -0
- package/templates/next-base/src/lib/db/prisma.ts +23 -0
- package/templates/next-base/src/lib/prisma.ts +23 -0
- 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/lib/supabase/client.ts +6 -0
- package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
- package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
- package/templates/next-base/src/lib/supabase/storage.ts +164 -0
- package/templates/next-base/src/types/data-table.ts +4 -0
- package/templates/next-base/src/types/index.ts +0 -2
- package/templates/next-base/tsconfig.tsbuildinfo +1 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
}
|