@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.
Files changed (114) hide show
  1. package/README.md +37 -24
  2. package/dist/cli.js +168 -107
  3. package/package.json +5 -3
  4. package/templates/features/supabase/src/lib/supabase/rich-text-image-sync.ts +28 -0
  5. package/templates/next-base/.env +16 -0
  6. package/templates/next-base/.env.development +16 -0
  7. package/templates/next-base/.env.example +16 -0
  8. package/templates/next-base/PROJECT_STRUCTURE.md +29 -18
  9. package/templates/next-base/SETUP.md +62 -10
  10. package/templates/next-base/bun.lock +59 -414
  11. package/templates/next-base/messages/vi/auth.json +42 -0
  12. package/templates/next-base/messages/vi/common.json +34 -0
  13. package/templates/next-base/messages/vi/example.json +10 -0
  14. package/templates/next-base/next-env.d.ts +1 -1
  15. package/templates/next-base/next.config.ts +4 -1
  16. package/templates/next-base/nextcli.json +12 -4
  17. package/templates/next-base/package.json +25 -1
  18. package/templates/next-base/prisma/schema.prisma +84 -0
  19. package/templates/next-base/prisma.config.ts +16 -0
  20. package/templates/next-base/src/app/(auth)/.gitkeep +1 -0
  21. package/templates/next-base/src/app/(auth)/change-password/layout.tsx +21 -0
  22. package/templates/next-base/src/app/(auth)/change-password/page.tsx +14 -0
  23. package/templates/next-base/src/app/(auth)/layout.tsx +9 -0
  24. package/templates/next-base/src/app/(auth)/sign-in/layout.tsx +17 -0
  25. package/templates/next-base/src/app/(auth)/sign-in/page.tsx +14 -0
  26. package/templates/next-base/src/app/(dashboard)/account/page.tsx +18 -0
  27. package/templates/next-base/src/app/(dashboard)/dashboard/page.tsx +17 -0
  28. package/templates/next-base/src/app/(dashboard)/example/page.tsx +13 -0
  29. package/templates/next-base/src/app/(dashboard)/layout.tsx +22 -0
  30. package/templates/next-base/src/app/api/auth/[...all]/route.ts +4 -0
  31. package/templates/next-base/src/app/api/v1/auth/change-password/route.ts +55 -0
  32. package/templates/next-base/src/app/api/v1/auth/login/route.ts +70 -0
  33. package/templates/next-base/src/app/api/v1/auth/logout/route.ts +28 -0
  34. package/templates/next-base/src/app/api/v1/auth/me/route.ts +24 -0
  35. package/templates/next-base/src/app/api/v1/auth/refresh/route.ts +32 -0
  36. package/templates/next-base/src/app/api/v1/example/route.ts +34 -0
  37. package/templates/next-base/src/app/api/v1/users/[id]/route.ts +104 -0
  38. package/templates/next-base/src/app/api/v1/users/route.ts +58 -0
  39. package/templates/next-base/src/app/blog-demo/page.tsx +9 -0
  40. package/templates/next-base/src/app/globals.css +57 -0
  41. package/templates/next-base/src/app/layout.tsx +14 -6
  42. package/templates/next-base/src/app/page.tsx +2 -25
  43. package/templates/next-base/src/components/layout/private/app-sidebar.tsx +44 -0
  44. package/templates/next-base/src/components/layout/private/dashboard-layout.tsx +54 -0
  45. package/templates/next-base/src/components/layout/private/locale-switcher.tsx +45 -0
  46. package/templates/next-base/src/components/layout/private/nav-sidebar.tsx +55 -0
  47. package/templates/next-base/src/components/layout/private/nav-user.tsx +99 -0
  48. package/templates/next-base/src/components/providers/query-provider.tsx +17 -0
  49. package/templates/next-base/src/components/rich-text/adapters/textarea-field.tsx +50 -0
  50. package/templates/next-base/src/components/rich-text/client-only.tsx +23 -0
  51. package/templates/next-base/src/components/rich-text/editor-field.tsx +62 -0
  52. package/templates/next-base/src/components/rich-text/examples/blog-rich-text-demo.tsx +218 -0
  53. package/templates/next-base/src/components/rich-text/index.ts +11 -0
  54. package/templates/next-base/src/components/rich-text/lexical/extension.ts +37 -0
  55. package/templates/next-base/src/components/rich-text/lexical/nodes/image-node.tsx +187 -0
  56. package/templates/next-base/src/components/rich-text/lexical/plugins/image-plugin.tsx +40 -0
  57. package/templates/next-base/src/components/rich-text/lexical/plugins/initial-state-plugin.tsx +26 -0
  58. package/templates/next-base/src/components/rich-text/lexical/plugins/on-change-plugin.tsx +26 -0
  59. package/templates/next-base/src/components/rich-text/lexical/plugins/toolbar-plugin.tsx +190 -0
  60. package/templates/next-base/src/components/rich-text/lexical/rich-text-editor.tsx +121 -0
  61. package/templates/next-base/src/components/rich-text/lexical/theme.ts +18 -0
  62. package/templates/next-base/src/components/rich-text/rich-text-renderer.tsx +72 -0
  63. package/templates/next-base/src/components/rich-text/types.ts +60 -0
  64. package/templates/next-base/src/components/ui/data-table/data-table-column-header.tsx +23 -0
  65. package/templates/next-base/src/components/ui/data-table/data-table-filter-list.tsx +3 -0
  66. package/templates/next-base/src/components/ui/data-table/data-table-pagination.tsx +35 -0
  67. package/templates/next-base/src/components/ui/data-table/data-table-skeleton.tsx +11 -0
  68. package/templates/next-base/src/components/ui/data-table/data-table-toolbar.tsx +14 -0
  69. package/templates/next-base/src/components/ui/data-table/data-table-view-options.tsx +3 -0
  70. package/templates/next-base/src/components/ui/data-table/data-table.tsx +72 -0
  71. package/templates/next-base/src/components/ui/sidebar.tsx +215 -0
  72. package/templates/next-base/src/data/sidebar-modules.ts +11 -0
  73. package/templates/next-base/src/example/api/use-example.ts +21 -0
  74. package/templates/next-base/src/example/api/use-mutations.ts +20 -0
  75. package/templates/next-base/src/example/components/example-table.tsx +51 -0
  76. package/templates/next-base/src/example/services.ts +9 -0
  77. package/templates/next-base/src/example/validations.ts +8 -0
  78. package/templates/next-base/src/features/auth/components/account-panel.tsx +80 -0
  79. package/templates/next-base/src/features/auth/components/change-password-form.tsx +82 -0
  80. package/templates/next-base/src/features/auth/components/sign-in-form.tsx +95 -0
  81. package/templates/next-base/src/features/auth/validations.ts +14 -0
  82. package/templates/next-base/src/features/users/services.ts +132 -0
  83. package/templates/next-base/src/features/users/validations.ts +21 -0
  84. package/templates/next-base/src/hooks/index.ts +1 -1
  85. package/templates/next-base/src/hooks/table/use-data-table.ts +33 -0
  86. package/templates/next-base/src/hooks/use-mobile.ts +25 -0
  87. package/templates/next-base/src/i18n/config.ts +7 -0
  88. package/templates/next-base/src/i18n/namespaces.ts +5 -0
  89. package/templates/next-base/src/i18n/request.ts +25 -0
  90. package/templates/next-base/src/instrumentation.ts +14 -0
  91. package/templates/next-base/src/lib/api/axios.ts +145 -0
  92. package/templates/next-base/src/lib/api/response.ts +45 -0
  93. package/templates/next-base/src/lib/api/token-store.ts +13 -0
  94. package/templates/next-base/src/lib/auth/bootstrap.ts +95 -0
  95. package/templates/next-base/src/lib/auth/client.ts +7 -0
  96. package/templates/next-base/src/lib/auth/cookies.ts +15 -0
  97. package/templates/next-base/src/lib/auth/index.ts +1 -0
  98. package/templates/next-base/src/lib/auth/rbac.ts +59 -0
  99. package/templates/next-base/src/lib/auth/server.ts +21 -0
  100. package/templates/next-base/src/lib/constants.ts +10 -0
  101. package/templates/next-base/src/lib/db/prisma.ts +23 -0
  102. package/templates/next-base/src/lib/prisma.ts +23 -0
  103. package/templates/next-base/src/lib/rich-text/default-image-removal.ts +10 -0
  104. package/templates/next-base/src/lib/rich-text/image-urls.ts +41 -0
  105. package/templates/next-base/src/lib/rich-text/index.ts +12 -0
  106. package/templates/next-base/src/lib/rich-text/supabase-url.ts +67 -0
  107. package/templates/next-base/src/lib/rich-text/sync-removed-images.ts +48 -0
  108. package/templates/next-base/src/lib/supabase/client.ts +6 -0
  109. package/templates/next-base/src/lib/supabase/rich-text-image-sync.ts +28 -0
  110. package/templates/next-base/src/lib/supabase/storage-config.ts +69 -0
  111. package/templates/next-base/src/lib/supabase/storage.ts +164 -0
  112. package/templates/next-base/src/types/data-table.ts +4 -0
  113. package/templates/next-base/src/types/index.ts +0 -2
  114. 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,3 @@
1
+ export function DataTableFilterList() {
2
+ return null;
3
+ }
@@ -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,3 @@
1
+ export function DataTableViewOptions() {
2
+ return null;
3
+ }
@@ -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
+ }