@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,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
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Column } from "@tanstack/react-table";
|
|
4
|
+
import { Button } from "@/components/ui/button";
|
|
5
|
+
|
|
6
|
+
export function DataTableColumnHeader<TData, TValue>({
|
|
7
|
+
column,
|
|
8
|
+
title,
|
|
9
|
+
}: {
|
|
10
|
+
column: Column<TData, TValue>;
|
|
11
|
+
title: string;
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<Button
|
|
15
|
+
variant="ghost"
|
|
16
|
+
size="sm"
|
|
17
|
+
className="h-8 px-2"
|
|
18
|
+
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
|
|
19
|
+
>
|
|
20
|
+
{title}
|
|
21
|
+
</Button>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Table } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
|
|
7
|
+
export function DataTablePagination<TData>({ table }: { table: Table<TData> }) {
|
|
8
|
+
const t = useTranslations("common.table");
|
|
9
|
+
const pageCount = table.getPageCount();
|
|
10
|
+
const pageIndex = table.getState().pagination.pageIndex;
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex items-center justify-end gap-2">
|
|
14
|
+
<span className="text-xs text-muted-foreground">
|
|
15
|
+
{pageCount === 0 ? 0 : pageIndex + 1}/{Math.max(pageCount, 1)}
|
|
16
|
+
</span>
|
|
17
|
+
<Button
|
|
18
|
+
variant="outline"
|
|
19
|
+
size="sm"
|
|
20
|
+
onClick={() => table.previousPage()}
|
|
21
|
+
disabled={!table.getCanPreviousPage()}
|
|
22
|
+
>
|
|
23
|
+
{t("previous")}
|
|
24
|
+
</Button>
|
|
25
|
+
<Button
|
|
26
|
+
variant="outline"
|
|
27
|
+
size="sm"
|
|
28
|
+
onClick={() => table.nextPage()}
|
|
29
|
+
disabled={!table.getCanNextPage()}
|
|
30
|
+
>
|
|
31
|
+
{t("next")}
|
|
32
|
+
</Button>
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Skeleton } from "@/components/ui/skeleton";
|
|
2
|
+
|
|
3
|
+
export function DataTableSkeleton() {
|
|
4
|
+
return (
|
|
5
|
+
<div className="space-y-2">
|
|
6
|
+
<Skeleton className="h-8 w-full" />
|
|
7
|
+
<Skeleton className="h-8 w-full" />
|
|
8
|
+
<Skeleton className="h-8 w-full" />
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { Table } from "@tanstack/react-table";
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export function DataTableToolbar<TData>({
|
|
7
|
+
_table,
|
|
8
|
+
children,
|
|
9
|
+
}: {
|
|
10
|
+
_table: Table<TData>;
|
|
11
|
+
children?: ReactNode;
|
|
12
|
+
}) {
|
|
13
|
+
return <div className="flex items-center justify-between">{children}</div>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { flexRender, type Table as TanstackTable } from "@tanstack/react-table";
|
|
4
|
+
import { useTranslations } from "next-intl";
|
|
5
|
+
import { DataTablePagination } from "@/components/ui/data-table/data-table-pagination";
|
|
6
|
+
import {
|
|
7
|
+
Table,
|
|
8
|
+
TableBody,
|
|
9
|
+
TableCell,
|
|
10
|
+
TableHead,
|
|
11
|
+
TableHeader,
|
|
12
|
+
TableRow,
|
|
13
|
+
} from "@/components/ui/table";
|
|
14
|
+
|
|
15
|
+
type DataTableProps<TData> = {
|
|
16
|
+
table: TanstackTable<TData>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function DataTable<TData>({ table }: DataTableProps<TData>) {
|
|
20
|
+
const t = useTranslations("common.table");
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<div className="space-y-3">
|
|
24
|
+
<div className="overflow-hidden rounded-md border">
|
|
25
|
+
<Table>
|
|
26
|
+
<TableHeader>
|
|
27
|
+
{table.getHeaderGroups().map((headerGroup) => (
|
|
28
|
+
<TableRow key={headerGroup.id}>
|
|
29
|
+
{headerGroup.headers.map((header) => (
|
|
30
|
+
<TableHead key={header.id}>
|
|
31
|
+
{header.isPlaceholder
|
|
32
|
+
? null
|
|
33
|
+
: flexRender(
|
|
34
|
+
header.column.columnDef.header,
|
|
35
|
+
header.getContext(),
|
|
36
|
+
)}
|
|
37
|
+
</TableHead>
|
|
38
|
+
))}
|
|
39
|
+
</TableRow>
|
|
40
|
+
))}
|
|
41
|
+
</TableHeader>
|
|
42
|
+
<TableBody>
|
|
43
|
+
{table.getRowModel().rows.length > 0 ? (
|
|
44
|
+
table.getRowModel().rows.map((row) => (
|
|
45
|
+
<TableRow key={row.id}>
|
|
46
|
+
{row.getVisibleCells().map((cell) => (
|
|
47
|
+
<TableCell key={cell.id}>
|
|
48
|
+
{flexRender(
|
|
49
|
+
cell.column.columnDef.cell,
|
|
50
|
+
cell.getContext(),
|
|
51
|
+
)}
|
|
52
|
+
</TableCell>
|
|
53
|
+
))}
|
|
54
|
+
</TableRow>
|
|
55
|
+
))
|
|
56
|
+
) : (
|
|
57
|
+
<TableRow>
|
|
58
|
+
<TableCell
|
|
59
|
+
colSpan={table.getAllColumns().length}
|
|
60
|
+
className="h-24 text-center"
|
|
61
|
+
>
|
|
62
|
+
{t("noResults")}
|
|
63
|
+
</TableCell>
|
|
64
|
+
</TableRow>
|
|
65
|
+
)}
|
|
66
|
+
</TableBody>
|
|
67
|
+
</Table>
|
|
68
|
+
</div>
|
|
69
|
+
<DataTablePagination table={table} />
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|