p]:m-0", className)}
+ dangerouslySetInnerHTML={{ __html: sanitizeRichTextHtml(html) }}
+ />
+ );
+}
diff --git a/components/rich-text-editor.tsx b/components/rich-text-editor.tsx
new file mode 100644
index 0000000..babce4a
--- /dev/null
+++ b/components/rich-text-editor.tsx
@@ -0,0 +1,301 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Editor } from "@tiptap/core";
+import Link from "@tiptap/extension-link";
+import Placeholder from "@tiptap/extension-placeholder";
+import { Color, TextStyle } from "@tiptap/extension-text-style";
+import Underline from "@tiptap/extension-underline";
+import { EditorContent, useEditor } from "@tiptap/react";
+import { BubbleMenu } from "@tiptap/react/menus";
+import StarterKit from "@tiptap/starter-kit";
+import {
+ BoldIcon,
+ ItalicIcon,
+ LinkIcon,
+ PaletteIcon,
+ RemoveFormattingIcon,
+ StrikethroughIcon,
+ UnderlineIcon,
+ UnlinkIcon,
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog";
+import {
+ Field,
+ FieldDescription,
+ FieldGroup,
+ FieldLabel,
+} from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import { Separator } from "@/components/ui/separator";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+import {
+ sanitizeRichTextHtml,
+ sanitizeRichTextUrl,
+} from "@/lib/rich-text";
+import { cn } from "@/lib/utils";
+
+export interface RichTextEditorProps {
+ value: string;
+ onChange: (value: string) => void;
+ placeholder?: string;
+ className?: string;
+ invalid?: boolean;
+}
+
+type Format = "bold" | "italic" | "underline" | "strike";
+
+function normalizedEditorHtml(editor: Editor): string {
+ const html = sanitizeRichTextHtml(editor.getHTML());
+ return html === "
" ? "" : html;
+}
+
+function FormatControls({
+ editor,
+ onLink,
+}: {
+ editor: Editor;
+ onLink: () => void;
+}) {
+ const formats: Format[] = ["bold", "italic", "underline", "strike"];
+ const active = formats.filter((format) => editor.isActive(format));
+ const color = editor.getAttributes("textStyle").color ?? "#FF3B30";
+
+ const toggleFormat = (format: Format) => {
+ const chain = editor.chain().focus();
+ if (format === "bold") chain.toggleBold().run();
+ if (format === "italic") chain.toggleItalic().run();
+ if (format === "underline") chain.toggleUnderline().run();
+ if (format === "strike") chain.toggleStrike().run();
+ };
+
+ return (
+
+
{
+ const changed = formats.find((format) => active.includes(format) !== next.includes(format));
+ if (changed) toggleFormat(changed);
+ }}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export function RichTextEditor({
+ value,
+ onChange,
+ placeholder = "เขียนข้อความ...",
+ className,
+ invalid = false,
+}: RichTextEditorProps) {
+ const [linkOpen, setLinkOpen] = useState(false);
+ const [linkText, setLinkText] = useState("");
+ const [linkUrl, setLinkUrl] = useState("");
+ const [linkInvalid, setLinkInvalid] = useState(false);
+ const editor = useEditor({
+ extensions: [
+ StarterKit.configure({
+ blockquote: false,
+ codeBlock: false,
+ heading: false,
+ horizontalRule: false,
+ link: false,
+ underline: false,
+ }),
+ Underline,
+ Link.configure({
+ openOnClick: false,
+ HTMLAttributes: {
+ class: "text-primary underline underline-offset-4",
+ target: "_blank",
+ rel: "noopener noreferrer",
+ },
+ isAllowedUri: (url) => Boolean(sanitizeRichTextUrl(url)),
+ }),
+ TextStyle,
+ Color,
+ Placeholder.configure({ placeholder }),
+ ],
+ content: value,
+ immediatelyRender: false,
+ onUpdate: ({ editor: currentEditor }) => onChange(normalizedEditorHtml(currentEditor)),
+ editorProps: {
+ attributes: {
+ class: "prose prose-sm prose-invert min-h-28 max-w-none px-3 py-2 leading-snug outline-none [&_.is-editor-empty:first-child]:before:pointer-events-none [&_.is-editor-empty:first-child]:before:float-left [&_.is-editor-empty:first-child]:before:h-0 [&_.is-editor-empty:first-child]:before:text-muted-foreground [&_.is-editor-empty:first-child]:before:content-[attr(data-placeholder)] [&>p]:m-0",
+ },
+ },
+ });
+
+ useEffect(() => {
+ if (!editor) return;
+ const next = sanitizeRichTextHtml(value);
+ if (normalizedEditorHtml(editor) !== next) {
+ editor.commands.setContent(next, { emitUpdate: false });
+ }
+ }, [editor, value]);
+
+ if (!editor) return null;
+
+ const openLinkDialog = () => {
+ if (editor.isActive("link")) {
+ editor.chain().focus().unsetLink().run();
+ return;
+ }
+ const { from, to } = editor.state.selection;
+ setLinkText(editor.state.doc.textBetween(from, to, " "));
+ setLinkUrl("");
+ setLinkInvalid(false);
+ setLinkOpen(true);
+ };
+
+ const saveLink = () => {
+ const safeUrl = sanitizeRichTextUrl(linkUrl);
+ if (!safeUrl) {
+ setLinkInvalid(true);
+ return;
+ }
+ const chain = editor.chain().focus();
+ if (linkText) {
+ chain.insertContent({
+ type: "text",
+ text: linkText,
+ marks: [{ type: "link", attrs: { href: safeUrl } }],
+ }).run();
+ } else {
+ chain.extendMarkRange("link").setLink({ href: safeUrl }).run();
+ }
+ setLinkOpen(false);
+ setLinkText("");
+ setLinkUrl("");
+ setLinkInvalid(false);
+ };
+
+ return (
+ <>
+
+
+
+ >
+ );
+}
diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts
index bb92909..a71c6fc 100644
--- a/lib/guides/mutations.ts
+++ b/lib/guides/mutations.ts
@@ -34,6 +34,7 @@ import {
weaponSectionSchema,
} from "@/lib/guides/schemas";
import { processCatalogSync } from "@/lib/catalog/sync";
+import { normalizeExtraRichText } from "@/lib/rich-text";
const DEFAULT_SECTIONS = [
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
@@ -208,7 +209,14 @@ export async function saveExtraSections(guideId: string, input: unknown) {
for (const [sortOrder, section] of data.sections.entries()) {
const [createdSection] = await tx.insert(guideSections).values({ guideId, kind: "extra", slug: `extra-${sortOrder + 1}`, title: section.title, enabled: true, sortOrder: 4 + sortOrder }).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
- if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({ ...row, percent: row.percent?.toString() ?? null, sectionId: createdSection.id, sortOrder: rowOrder })));
+ if (section.rows.length) await tx.insert(extraSectionRows).values(section.rows.map((row, rowOrder) => ({
+ ...row,
+ body: normalizeExtraRichText(row.body, row.highlights),
+ highlights: [],
+ percent: row.percent?.toString() ?? null,
+ sectionId: createdSection.id,
+ sortOrder: rowOrder,
+ })));
}
return updated;
});
diff --git a/lib/guides/schemas.test.ts b/lib/guides/schemas.test.ts
index 6880677..aa3dd27 100644
--- a/lib/guides/schemas.test.ts
+++ b/lib/guides/schemas.test.ts
@@ -158,6 +158,28 @@ describe("structured guide validation", () => {
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "red" }] })).success).toBe(false);
expect(extraSectionSchema.safeParse(input({ backgroundColor: null, highlights: [{ text: "MASSIVE", color: "#FF0000" }, { text: "MASSIVE", color: "#00FF00" }] })).success).toBe(false);
});
+
+ it("sanitizes Extra rich text and allows formatting markup", () => {
+ const result = extraSectionSchema.safeParse({
+ expectedVersion: 1,
+ sections: [{
+ title: "Extra",
+ rows: [{
+ mediaId: null,
+ heading: "Formatted card",
+ body: '
Safe
',
+ percent: null,
+ backgroundColor: null,
+ highlights: [],
+ }],
+ }],
+ });
+
+ expect(result.success).toBe(true);
+ if (result.success) {
+ expect(result.data.sections[0].rows[0].body).toBe("
Safe
");
+ }
+ });
});
describe("Team Calc", () => {
diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts
index e1f8147..b474939 100644
--- a/lib/guides/schemas.ts
+++ b/lib/guides/schemas.ts
@@ -2,6 +2,7 @@ import * as z from "zod";
import { targetStatOptions } from "@/lib/guides/stats";
import { parseYouTubeUrl } from "@/lib/guides/youtube";
+import { normalizeExtraRichText } from "@/lib/rich-text";
export const guideSectionKinds = ["weapon", "artifact", "constellations", "team"] as const;
export type GuideSectionKind = (typeof guideSectionKinds)[number];
@@ -136,7 +137,7 @@ const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/, "กรุณาเลื
const extraRowSchema = z.strictObject({
mediaId: z.string().uuid().nullable(),
heading: z.string().trim().min(1).max(200),
- body: z.string().trim().max(2_000),
+ body: z.string().trim().max(10_000),
percent: percent.nullable(),
backgroundColor: hexColor.nullable().default(null),
highlights: z.array(z.strictObject({
@@ -146,7 +147,11 @@ const extraRowSchema = z.strictObject({
(highlights) => new Set(highlights.map((highlight) => highlight.text)).size === highlights.length,
"คำหรือวลีที่ไฮไลต์ต้องไม่ซ้ำกัน",
).default([]),
-});
+}).transform((row) => ({
+ ...row,
+ body: normalizeExtraRichText(row.body, row.highlights),
+ highlights: [],
+}));
export const extraSectionSchema = z.strictObject({
expectedVersion: z.number().int().positive(),
diff --git a/lib/rich-text.test.ts b/lib/rich-text.test.ts
new file mode 100644
index 0000000..2be939d
--- /dev/null
+++ b/lib/rich-text.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from "vitest";
+
+import {
+ normalizeExtraRichText,
+ sanitizeRichTextHtml,
+ sanitizeRichTextUrl,
+} from "@/lib/rich-text";
+
+describe("rich text sanitization", () => {
+ it("keeps supported formatting, colors, and safe links", () => {
+ expect(sanitizeRichTextHtml(
+ '
Good news link
',
+ )).toBe(
+ '
Good news link
',
+ );
+ });
+
+ it("removes executable markup, unsafe links, and unsupported styles", () => {
+ expect(sanitizeRichTextHtml(
+ '
Safe textlink
',
+ )).toBe("
Safe textlink
");
+ expect(sanitizeRichTextUrl("data:text/html,bad")).toBe("");
+ });
+});
+
+describe("legacy Extra rich text conversion", () => {
+ it("escapes plain text, preserves newlines, and converts phrase colors", () => {
+ expect(normalizeExtraRichText("
\nMASSIVE damage", [
+ { text: "MASSIVE", color: "#ff0000" },
+ ])).toBe(
+ '<unsafe>
MASSIVE damage
',
+ );
+ });
+
+ it("leaves sanitized rich text in rich-text form", () => {
+ expect(normalizeExtraRichText('Ready
', [
+ { text: "Ready", color: "#ff0000" },
+ ])).toBe("Ready
");
+ });
+});
diff --git a/lib/rich-text.ts b/lib/rich-text.ts
new file mode 100644
index 0000000..ec7978b
--- /dev/null
+++ b/lib/rich-text.ts
@@ -0,0 +1,108 @@
+import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights";
+
+const ALLOWED_TAGS = new Set([
+ "a",
+ "blockquote",
+ "br",
+ "code",
+ "em",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "s",
+ "span",
+ "strong",
+ "u",
+ "ul",
+]);
+
+const BLOCKED_CONTENT = /<(script|style|iframe|object|embed|svg|math|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi;
+const HTML_COMMENT = //g;
+const TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g;
+const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g;
+const HEX_COLOR = /^#[0-9a-fA-F]{6}$/;
+
+export function sanitizeRichTextUrl(value: string): string {
+ const url = value.trim();
+ if (!url) return "";
+
+ try {
+ const parsed = new URL(url, "https://example.invalid");
+ return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? url : "";
+ } catch {
+ return "";
+ }
+}
+
+function escapeAttribute(value: string): string {
+ return value
+ .replaceAll("&", "&")
+ .replaceAll('"', """)
+ .replaceAll("<", "<")
+ .replaceAll(">", ">");
+}
+
+function escapeHtml(value: string): string {
+ return escapeAttribute(value).replaceAll("'", "'");
+}
+
+function attributeValue(rawAttributes: string, name: string): string {
+ for (const match of rawAttributes.matchAll(ATTRIBUTE)) {
+ if (match[1].toLowerCase() === name) {
+ return match[2].replace(/^['"]|['"]$/g, "");
+ }
+ }
+ return "";
+}
+
+export function sanitizeRichTextHtml(value: string): string {
+ return value
+ .replace(BLOCKED_CONTENT, "")
+ .replace(HTML_COMMENT, "")
+ .replace(TAG, (tag, rawName: string, rawAttributes: string) => {
+ const name = rawName.toLowerCase();
+ if (!ALLOWED_TAGS.has(name)) return "";
+ if (tag.startsWith("")) return name === "br" ? "" : `${name}>`;
+ if (name === "br") return "
";
+
+ if (name === "a") {
+ const href = sanitizeRichTextUrl(attributeValue(rawAttributes, "href"));
+ return href
+ ? ``
+ : "";
+ }
+
+ if (name === "span") {
+ const style = attributeValue(rawAttributes, "style");
+ const color = style.match(/(?:^|;)\s*color\s*:\s*(#[0-9a-fA-F]{6})\s*(?:;|$)/)?.[1];
+ return color && HEX_COLOR.test(color)
+ ? ``
+ : "";
+ }
+
+ return `<${name}>`;
+ });
+}
+
+export function isRichTextHtml(value: string): boolean {
+ return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value);
+}
+
+export function normalizeExtraRichText(
+ body: string,
+ highlights: readonly TextHighlight[] = [],
+): string {
+ if (isRichTextHtml(body)) return sanitizeRichTextHtml(body);
+
+ const content = highlightTextSegments(body, highlights)
+ .map((segment) => {
+ const text = escapeHtml(segment.text).replaceAll("\n", "
");
+ return segment.color && HEX_COLOR.test(segment.color)
+ ? `${text}`
+ : text;
+ })
+ .join("");
+
+ return content ? `${content}
` : "";
+}
diff --git a/package.json b/package.json
index 56a2173..dde8536 100644
--- a/package.json
+++ b/package.json
@@ -25,6 +25,15 @@
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
+ "@tailwindcss/typography": "0.5.20",
+ "@tiptap/core": "3.31.3",
+ "@tiptap/extension-link": "3.31.3",
+ "@tiptap/extension-placeholder": "3.31.3",
+ "@tiptap/extension-text-style": "3.31.3",
+ "@tiptap/extension-underline": "3.31.3",
+ "@tiptap/pm": "3.31.3",
+ "@tiptap/react": "3.31.3",
+ "@tiptap/starter-kit": "3.31.3",
"better-auth": "^1.7.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",