chore: reorganize project structure and ensure default queues
This commit is contained in:
parent
854887f499
commit
1cccb852a5
201 changed files with 417 additions and 838 deletions
322
src/components/ui/rich-text-editor.tsx
Normal file
322
src/components/ui/rich-text-editor.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
"use client"
|
||||
|
||||
import { forwardRef, useCallback, useEffect, useRef, useState } from "react"
|
||||
import type { ReactNode } 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 { Input } from "@/components/ui/input"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Undo,
|
||||
Redo,
|
||||
Link as LinkIcon,
|
||||
Check,
|
||||
Link2Off,
|
||||
} 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,
|
||||
})
|
||||
|
||||
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
|
||||
const [linkUrl, setLinkUrl] = useState("")
|
||||
const linkInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const closeLinkPopover = useCallback(() => {
|
||||
setLinkPopoverOpen(false)
|
||||
requestAnimationFrame(() => {
|
||||
editor?.commands.focus()
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
const openLinkPopover = useCallback(() => {
|
||||
if (!editor) return
|
||||
editor.chain().focus()
|
||||
const prev = (editor.getAttributes("link").href as string | undefined) ?? ""
|
||||
setLinkUrl(prev)
|
||||
setLinkPopoverOpen(true)
|
||||
requestAnimationFrame(() => {
|
||||
linkInputRef.current?.focus()
|
||||
if (prev) {
|
||||
linkInputRef.current?.select()
|
||||
}
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
const applyLink = useCallback(() => {
|
||||
if (!editor) return
|
||||
const trimmed = linkUrl.trim()
|
||||
if (!trimmed) {
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
||||
closeLinkPopover()
|
||||
return
|
||||
}
|
||||
const normalized = /^(https?:\/\/|mailto:)/i.test(trimmed) ? trimmed : `https://${trimmed}`
|
||||
editor.chain().focus().extendMarkRange("link").setLink({ href: normalized }).run()
|
||||
closeLinkPopover()
|
||||
}, [closeLinkPopover, editor, linkUrl])
|
||||
|
||||
const removeLink = useCallback(() => {
|
||||
if (!editor) return
|
||||
editor.chain().focus().extendMarkRange("link").unsetLink().run()
|
||||
closeLinkPopover()
|
||||
}, [closeLinkPopover, editor])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !linkPopoverOpen) return
|
||||
const handler = () => {
|
||||
const prev = (editor.getAttributes("link").href as string | undefined) ?? ""
|
||||
setLinkUrl(prev)
|
||||
}
|
||||
editor.on("selectionUpdate", handler)
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler)
|
||||
}
|
||||
}, [editor, linkPopoverOpen])
|
||||
|
||||
// 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>
|
||||
<Popover
|
||||
open={linkPopoverOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (open) {
|
||||
openLinkPopover()
|
||||
} else {
|
||||
closeLinkPopover()
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<ToolbarButton active={editor.isActive("link")} ariaLabel="Inserir link">
|
||||
<LinkIcon className="size-4" />
|
||||
</ToolbarButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 space-y-3" align="start">
|
||||
<div className="space-y-1">
|
||||
<label htmlFor="rich-text-editor-link" className="text-xs font-semibold text-neutral-600">
|
||||
URL do link
|
||||
</label>
|
||||
<Input
|
||||
id="rich-text-editor-link"
|
||||
ref={linkInputRef}
|
||||
placeholder="https://"
|
||||
value={linkUrl}
|
||||
onChange={(event) => setLinkUrl(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault()
|
||||
applyLink()
|
||||
} else if (event.key === "Escape") {
|
||||
event.preventDefault()
|
||||
closeLinkPopover()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="inline-flex items-center gap-2"
|
||||
onClick={removeLink}
|
||||
disabled={!editor.isActive("link")}
|
||||
>
|
||||
<Link2Off className="size-4" />
|
||||
Remover
|
||||
</Button>
|
||||
<Button type="button" size="sm" className="inline-flex items-center gap-2" onClick={applyLink}>
|
||||
<Check className="size-4" />
|
||||
Aplicar
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
type ToolbarButtonProps = {
|
||||
onClick?: () => void
|
||||
active?: boolean
|
||||
ariaLabel?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const ToolbarButton = forwardRef<HTMLButtonElement, ToolbarButtonProps>(
|
||||
({ onClick, active, ariaLabel, children }, ref) => {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={active ? "default" : "ghost"}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={onClick}
|
||||
aria-label={ariaLabel}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ToolbarButton.displayName = "ToolbarButton"
|
||||
|
||||
// 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