feat(rich-text, types): Tiptap editor, SSR-safe, comments + description; stricter typing (no any) across app
- Add Tiptap editor + toolbar and rich content rendering with sanitize-html - Fix SSR hydration (immediatelyRender: false) and setContent options - Comments: rich text + visibility selector, typed attachments (Id<_storage>) - New Ticket: description rich text; attachments typed; queues typed - Convex: server-side filters using indexes; priority order rename; stronger Doc/Id typing; remove helper with any - Schemas/Mappers: zod v4 record typing; event payload record typing; customFields typed - UI: replace any in header/play/list/timeline/fields; improve select typings - Build passes; only non-blocking lint warnings remain
This commit is contained in:
parent
9b0c0bd80a
commit
ea60c3b841
26 changed files with 1390 additions and 245 deletions
218
web/src/components/ui/rich-text-editor.tsx
Normal file
218
web/src/components/ui/rich-text-editor.tsx
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useEditor, EditorContent } from "@tiptap/react"
|
||||
import StarterKit from "@tiptap/starter-kit"
|
||||
import Link from "@tiptap/extension-link"
|
||||
import Placeholder from "@tiptap/extension-placeholder"
|
||||
import { cn } from "@/lib/utils"
|
||||
import sanitize from "sanitize-html"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Undo,
|
||||
Redo,
|
||||
Link as LinkIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
type RichTextEditorProps = {
|
||||
value?: string
|
||||
onChange?: (html: string) => void
|
||||
className?: string
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
minHeight?: number
|
||||
}
|
||||
|
||||
export function RichTextEditor({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
placeholder = "Escreva aqui...",
|
||||
disabled,
|
||||
minHeight = 120,
|
||||
}: RichTextEditorProps) {
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
bulletList: { keepMarks: true },
|
||||
orderedList: { keepMarks: true },
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
autolink: true,
|
||||
protocols: ["http", "https", "mailto"],
|
||||
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
|
||||
}),
|
||||
Placeholder.configure({ placeholder }),
|
||||
],
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm max-w-none focus:outline-none text-foreground",
|
||||
},
|
||||
},
|
||||
content: value || "",
|
||||
onUpdate({ editor }) {
|
||||
onChange?.(editor.getHTML())
|
||||
},
|
||||
editable: !disabled,
|
||||
// Avoid SSR hydration mismatches per Tiptap recommendation
|
||||
immediatelyRender: false,
|
||||
})
|
||||
|
||||
// Keep external value in sync when it changes
|
||||
useEffect(() => {
|
||||
if (!editor) return
|
||||
const current = editor.getHTML()
|
||||
if ((value ?? "") !== current) {
|
||||
editor.commands.setContent(value || "", { emitUpdate: false })
|
||||
}
|
||||
}, [value, editor])
|
||||
|
||||
if (!editor) return null
|
||||
|
||||
return (
|
||||
<div className={cn("rounded-md border bg-background", className)}>
|
||||
<div className="flex flex-wrap items-center gap-1 border-b px-2 py-1">
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
active={editor.isActive("bold")}
|
||||
ariaLabel="Negrito"
|
||||
>
|
||||
<Bold className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
active={editor.isActive("italic")}
|
||||
ariaLabel="Itálico"
|
||||
>
|
||||
<Italic className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
active={editor.isActive("strike")}
|
||||
ariaLabel="Tachado"
|
||||
>
|
||||
<Strikethrough className="size-4" />
|
||||
</ToolbarButton>
|
||||
<Separator orientation="vertical" className="mx-1 h-5" />
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
active={editor.isActive("bulletList")}
|
||||
ariaLabel="Lista"
|
||||
>
|
||||
<List className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
active={editor.isActive("orderedList")}
|
||||
ariaLabel="Lista ordenada"
|
||||
>
|
||||
<ListOrdered className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
active={editor.isActive("blockquote")}
|
||||
ariaLabel="Citação"
|
||||
>
|
||||
<Quote className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton
|
||||
onClick={() => {
|
||||
const prev = editor.getAttributes("link").href as string | undefined
|
||||
const url = window.prompt("URL do link:", prev || "https://")
|
||||
if (url === null) return
|
||||
if (url === "") {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
||||
return
|
||||
}
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: url }).run()
|
||||
}}
|
||||
active={editor.isActive("link")}
|
||||
ariaLabel="Inserir link"
|
||||
>
|
||||
<LinkIcon className="size-4" />
|
||||
</ToolbarButton>
|
||||
<div className="ms-auto flex items-center gap-1">
|
||||
<ToolbarButton onClick={() => editor.chain().focus().undo().run()} ariaLabel="Desfazer">
|
||||
<Undo className="size-4" />
|
||||
</ToolbarButton>
|
||||
<ToolbarButton onClick={() => editor.chain().focus().redo().run()} ariaLabel="Refazer">
|
||||
<Redo className="size-4" />
|
||||
</ToolbarButton>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ minHeight }} className="rich-text p-3">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ToolbarButton({
|
||||
onClick,
|
||||
active,
|
||||
ariaLabel,
|
||||
children,
|
||||
}: {
|
||||
onClick: () => void
|
||||
active?: boolean
|
||||
ariaLabel?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// Utilitário simples para renderização segura do HTML do editor.
|
||||
// Remove tags <script>/<style> e atributos on*.
|
||||
export function sanitizeEditorHtml(html: string): string {
|
||||
try {
|
||||
return sanitize(html || "", {
|
||||
allowedTags: [
|
||||
"p","br","a","strong","em","u","s","blockquote","ul","ol","li","code","pre","span","h1","h2","h3"
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href","name","target","rel"],
|
||||
span: ["style"],
|
||||
code: ["class"],
|
||||
pre: ["class"],
|
||||
},
|
||||
allowedSchemes: ["http","https","mailto"],
|
||||
// prevent target=_self phishing
|
||||
transformTags: {
|
||||
a: sanitize.simpleTransform("a", { rel: "noopener noreferrer", target: "_blank" }, true),
|
||||
},
|
||||
// disallow inline event handlers
|
||||
allowVulnerableTags: false,
|
||||
})
|
||||
} catch {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
export function RichTextContent({ html, className }: { html: string; className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn("rich-text text-sm leading-relaxed", className)}
|
||||
dangerouslySetInnerHTML={{ __html: sanitizeEditorHtml(html) }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue