Skip to content

Commit dd7647a

Browse files
committed
feat: create reusable ui for update and create events
1 parent 4cd82d0 commit dd7647a

File tree

10 files changed

+755
-63
lines changed

10 files changed

+755
-63
lines changed

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"@radix-ui/react-select": "^2.1.1",
3131
"@radix-ui/react-separator": "^1.1.7",
3232
"@radix-ui/react-slot": "^1.2.3",
33+
"@radix-ui/react-switch": "^1.2.6",
3334
"@radix-ui/react-tabs": "^1.1.13",
3435
"@radix-ui/react-tooltip": "^1.2.7",
3536
"@tanstack/react-query": "^5.85.5",
@@ -48,11 +49,13 @@
4849
"clsx": "^2.1.1",
4950
"cookie": "^1.0.2",
5051
"date-fns": "^4.1.0",
52+
"dompurify": "^3.2.6",
5153
"embla-carousel-autoplay": "^8.2.0",
5254
"embla-carousel-react": "^8.2.0",
5355
"js-cookie": "^3.0.5",
5456
"jwt-decode": "^4.0.0",
5557
"lucide-react": "^0.536.0",
58+
"marked": "^16.2.1",
5659
"motion": "^12.7.4",
5760
"next": "15.3.2",
5861
"next-intl": "^4.1.0",

pnpm-lock.yaml

Lines changed: 61 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/components/common/TextEditor/TextEditor.tsx

Lines changed: 20 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,34 +7,23 @@ import Underline from "@tiptap/extension-underline";
77
import Typography from "@tiptap/extension-typography";
88
import Link from "@tiptap/extension-link";
99
import Placeholder from "@tiptap/extension-placeholder";
10-
import { useState, useCallback, useMemo } from "react";
11-
import TurndownService from "turndown";
10+
import { useState, useCallback, useMemo, useEffect } from "react";
11+
import { markdownToHtml, htmlToMarkdown } from "./utils";
1212

1313
import { Button } from "@/components/ui/Button";
1414
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/Card";
1515
import { Toolbar } from "@/components/common/TextEditor";
1616

1717
interface TextEditorProps {
1818
markdownOutput?: boolean;
19+
value?: string;
20+
onChange?: (value: string) => void;
1921
}
2022

21-
const TextEditor = ({ markdownOutput = false }: TextEditorProps) => {
23+
const TextEditor = ({ markdownOutput = false, value, onChange }: TextEditorProps) => {
2224
const [markdownContent, setMarkdownContent] = useState("");
2325

24-
const turndownService = useMemo(() => {
25-
const service = new TurndownService({
26-
headingStyle: "atx",
27-
codeBlockStyle: "fenced",
28-
});
29-
30-
// Custom rule for underline tags
31-
service.addRule("underline", {
32-
filter: "u",
33-
replacement: (content) => `<u>${content}</u>`,
34-
});
35-
36-
return service;
37-
}, []);
26+
const htmlContent = useMemo(() => markdownToHtml(value || ""), [value]);
3827

3928
const editor = useEditor({
4029
extensions: [
@@ -55,20 +44,24 @@ const TextEditor = ({ markdownOutput = false }: TextEditorProps) => {
5544
allowBase64: true,
5645
}),
5746
],
58-
content: "",
47+
content: htmlContent,
5948
immediatelyRender: false,
6049
onCreate: ({ editor }) => {
6150
const html = editor.getHTML();
62-
setMarkdownContent(turndownService.turndown(html));
51+
setMarkdownContent(htmlToMarkdown(html));
6352
},
6453
onUpdate: ({ editor }) => {
6554
const html = editor.getHTML();
66-
setMarkdownContent(turndownService.turndown(html));
55+
const markdown = htmlToMarkdown(html);
56+
setMarkdownContent(markdown);
57+
onChange?.(html);
58+
console.log("markdown: ", markdown);
59+
console.log("html: ", html);
6760
},
6861
editorProps: {
6962
attributes: {
7063
class:
71-
"prose dark:prose-invert max-w-none mx-auto focus:outline-none min-h-[300px] p-3 prose-blockquote:border-primary prose-blockquote:bg-muted/50 prose-blockquote:pl-4 prose-blockquote:py-1 prose-blockquote:before:content-none prose-blockquote:not-italic prose-code:bg-muted prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-pre:bg-muted prose-pre:border prose-pre:text-foreground",
64+
"prose dark:prose-invert max-w-none mx-auto focus:outline-none max-h-[300px] p-3 prose-blockquote:border-primary prose-blockquote:bg-muted/50 prose-blockquote:pl-4 prose-blockquote:py-1 prose-blockquote:before:content-none prose-blockquote:not-italic prose-code:bg-muted prose-code:rounded prose-code:before:content-none prose-code:after:content-none prose-pre:bg-muted prose-pre:border prose-pre:text-foreground prose-pre:p-3",
7265
},
7366
},
7467
});
@@ -132,6 +125,12 @@ const TextEditor = ({ markdownOutput = false }: TextEditorProps) => {
132125
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run();
133126
}, [editor]);
134127

128+
useEffect(() => {
129+
if (editor && htmlContent !== undefined && editor.getHTML() !== htmlContent) {
130+
editor.commands.setContent(htmlContent);
131+
}
132+
}, [editor, htmlContent]);
133+
135134
if (!editor) {
136135
return (
137136
<div className="flex h-64 items-center justify-center">

src/components/common/TextEditor/ToolbarButton.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface ToolbarButtonProps {
1111

1212
const ToolbarButton = ({ onClick, isActive, disabled, children, title, variant = "outline" }: ToolbarButtonProps) => (
1313
<Button
14+
type="button"
1415
onClick={onClick}
1516
disabled={disabled}
1617
title={title}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import { marked } from "marked";
2+
import TurndownService from "turndown";
3+
import DOMPurify from "dompurify";
4+
5+
// Configure marked for clean HTML output compatible with Tiptap
6+
const markedOptions = {
7+
breaks: true,
8+
gfm: true,
9+
headerIds: false,
10+
mangle: false,
11+
};
12+
13+
// Configure Turndown for consistent markdown output
14+
const turndownService = new TurndownService();
15+
16+
// Custom rule for underline tags
17+
turndownService.addRule("underline", {
18+
filter: "u",
19+
replacement: (content) => `<u>${content}</u>`,
20+
});
21+
22+
/**
23+
* Convert markdown to clean HTML compatible with Tiptap
24+
* @param markdown - The markdown string to convert
25+
* @returns Clean HTML string
26+
*/
27+
export const markdownToHtml = (markdown: string): string => {
28+
if (!markdown) return "";
29+
30+
// If it's already HTML, sanitize and return
31+
if (markdown.includes("<") && markdown.includes(">")) {
32+
return DOMPurify.sanitize(markdown as string);
33+
}
34+
35+
try {
36+
const html = marked.parse(markdown, markedOptions) as string;
37+
38+
// Sanitize HTML with DOMPurify
39+
const sanitizedHtml = DOMPurify.sanitize(html as string);
40+
41+
// Clean up HTML to be more compatible with Tiptap
42+
return sanitizedHtml;
43+
} catch (error) {
44+
console.error("Error parsing markdown:", error);
45+
return markdown;
46+
}
47+
};
48+
49+
/**
50+
* Convert HTML to clean markdown
51+
* @param html - The HTML string to convert
52+
* @returns Clean markdown string
53+
*/
54+
export const htmlToMarkdown = (html: string): string => {
55+
if (!html) return "";
56+
57+
try {
58+
// Sanitize HTML before converting to markdown
59+
// const sanitizedHtml = DOMPurify.sanitize(html as string);
60+
61+
return turndownService.turndown(html);
62+
} catch (error) {
63+
console.error("Error converting HTML to markdown:", error);
64+
return html;
65+
}
66+
};
67+
68+
/**
69+
* Initialize Tiptap content from markdown
70+
* @param markdown - The markdown content
71+
* @returns HTML content ready for Tiptap editor
72+
*/
73+
export const initTiptapContent = (markdown: string): string => {
74+
return markdownToHtml(markdown);
75+
};
76+
77+
/**
78+
* Process Tiptap content to markdown for storage
79+
* @param html - The HTML content from Tiptap editor
80+
* @returns Clean markdown for storage
81+
*/
82+
export const processTiptapContent = (html: string): string => {
83+
return htmlToMarkdown(html);
84+
};

src/components/ui/Switch/index.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
"use client";
2+
3+
import * as React from "react";
4+
import * as SwitchPrimitive from "@radix-ui/react-switch";
5+
6+
import { cn } from "@/lib/utils";
7+
8+
function Switch({ className, ...props }: React.ComponentProps<typeof SwitchPrimitive.Root>) {
9+
return (
10+
<SwitchPrimitive.Root
11+
data-slot="switch"
12+
className={cn(
13+
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
14+
className
15+
)}
16+
{...props}
17+
>
18+
<SwitchPrimitive.Thumb
19+
data-slot="switch-thumb"
20+
className={cn(
21+
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
22+
)}
23+
/>
24+
</SwitchPrimitive.Root>
25+
);
26+
}
27+
28+
export { Switch };
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as React from "react";
2+
3+
import { cn } from "@/lib/utils";
4+
5+
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6+
return (
7+
<textarea
8+
data-slot="textarea"
9+
className={cn(
10+
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11+
className
12+
)}
13+
{...props}
14+
/>
15+
);
16+
}
17+
18+
export { Textarea };

0 commit comments

Comments
 (0)