admin/components/formElements/TextEditor.tsx
alisaza aff6eeace6 feat(api): add new endpoints for notifications and event FAQs
- Introduced a new PATCH endpoint `/api/v1/notifications/me/read-all` to mark all unread notifications as read for the authenticated user.
- Added a new GET endpoint `/api/v1/events/{eventId}/faqs/page` for retrieving paginated FAQs related to a specific event, with support for pagination, sorting, and filtering.
- Updated the existing GET endpoint `/api/v1/events/{eventId}/guest-list-links/invited-guests/page` to include pagination parameters.
2026-09-08 18:54:09 +03:30

516 lines
17 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import Underline from '@tiptap/extension-underline'
import Link from '@tiptap/extension-link'
import TextAlign from '@tiptap/extension-text-align'
import { Color } from '@tiptap/extension-color'
import { TextStyle } from '@tiptap/extension-text-style'
import Highlight from '@tiptap/extension-highlight'
import Image from '@tiptap/extension-image'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { buttonVariants } from '@heroui/react'
import { Dropdown, DropdownItem, DropdownMenu, DropdownTrigger } from '@/components/heroui/Dropdown'
import { addToast } from '@/lib/toast'
import { texts } from '@/texts'
import Button from '@/components/formElements/Button'
import Input from '@/components/formElements/Input'
import FileUpload from '@/components/media/FileUpload'
import ConsumerModal from '@/components/consumer/ConsumerModal'
import { fileAddress, coerceToString } from '@/helpers'
import { cn } from '@/lib/cn'
const CONSUMER_TOOLBAR_TRIGGER_CLASS =
'min-h-10 rounded-[10px] border border-consumer-border bg-consumer-surface px-3 text-sm font-medium text-consumer-text outline-none'
const ADMIN_TOOLBAR_TRIGGER_CLASS = cn(buttonVariants({ size: 'sm', variant: 'secondary' }), 'outline-none')
interface TextEditorProps {
label?: string
required?: boolean
value?: string
className?: string
variant?: 'default' | 'consumer'
onChange: (value: string) => void
}
const CONSUMER_TEXT_COLORS = [
{ label: 'رنگ پیش‌فرض', value: null, swatchClassName: 'bg-consumer-text' },
{ label: 'آبی', value: '#2563EB', swatchClassName: 'bg-blue-600' },
{ label: 'بنفش', value: '#7C3AED', swatchClassName: 'bg-violet-600' },
{ label: 'سبز', value: '#15803D', swatchClassName: 'bg-green-700' },
{ label: 'نارنجی', value: '#C2410C', swatchClassName: 'bg-orange-700' },
{ label: 'قرمز', value: '#B91C1C', swatchClassName: 'bg-red-700' },
] as const
const TextEditor = ({ label, required, value, onChange, className, variant = 'default' }: TextEditorProps) => {
const [imageId, setImageId] = useState('')
const [imageAlt, setImageAlt] = useState('')
const [isOpenUploadImageModal, setIsOpenUploadImageModal] = useState(false)
const isConsumer = variant === 'consumer'
// جلوگیری از destroy/rebind با هر رندر والد (مثل inline onChange در فرم‌ها)
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
}, [onChange])
const editor = useEditor({
extensions: [
StarterKit,
Underline,
TextStyle,
Color,
Highlight,
Image.configure({
allowBase64: true,
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
}),
Link.configure({
openOnClick: false,
autolink: true,
defaultProtocol: 'https',
protocols: ['http', 'https'],
isAllowedUri: (url, ctx) => {
try {
// construct URL
const parsedUrl = url.includes(':') ? new URL(url) : new URL(`${ctx.defaultProtocol}://${url}`)
// use default validation
if (!ctx.defaultValidate(parsedUrl.href)) {
return false
}
// disallowed protocols
const disallowedProtocols = ['ftp', 'file', 'mailto']
const protocol = parsedUrl.protocol.replace(':', '')
if (disallowedProtocols.includes(protocol)) {
return false
}
// only allow protocols specified in ctx.protocols
const allowedProtocols = ctx.protocols.map((p) => (typeof p === 'string' ? p : p.scheme))
if (!allowedProtocols.includes(protocol)) {
return false
}
// all checks have passed
return true
} catch {
return false
}
},
}),
],
content: value ?? '',
immediatelyRender: false,
onUpdate: ({ editor: current }) => {
onChangeRef.current(current.getHTML())
},
})
// فقط وقتی value از بیرون عوض شده (مثلاً reset فرم)، محتوا را همگام کن — نه موقع تایپ
useEffect(() => {
if (!editor) return
const next = value ?? ''
if (next === editor.getHTML()) return
// هنگام فوکوس، setContent کرسر را می‌پرد و تایپ را خراب می‌کند
if (editor.isFocused) return
editor.commands.setContent(next, { emitUpdate: false })
}, [value, editor])
const setHeading = (level: 1 | 2 | 3 | 4 | 5 | 6) => {
if (editor) {
editor.chain().focus().toggleHeading({ level }).run()
}
}
const setLink = useCallback(() => {
if (editor) {
const previousUrl = editor.getAttributes('link').href
const url = window.prompt('URL', previousUrl)
// cancelled
if (url === null) {
return
}
// empty
if (url === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run()
return
}
// update link
try {
editor.chain().focus().extendMarkRange('link').setLink({ href: url }).run()
} catch {
addToast({ title: texts.common.invalidUrl, color: 'danger' })
}
}
}, [editor])
if (!editor) {
return null
}
return (
<div className={cn(className, isConsumer && 'flex min-w-0 w-full flex-col gap-1')}>
{label && (
<label
className={isConsumer ? 'mb-1 block text-sm font-medium text-secondary-20' : 'mb-2 block'}
data-slot="label"
>
{label}
{required && <span className="text-fourth ms-1">*</span>}
</label>
)}
<div
className={
isConsumer
? 'overflow-hidden rounded-[10px] border border-consumer-border bg-consumer-surface shadow-none'
: 'border p-2 rounded-xl'
}
>
<div
className={
isConsumer
? 'toolbar flex flex-wrap items-center gap-2 border-b border-consumer-border bg-consumer-surface-muted p-2'
: 'toolbar flex flex-wrap gap-2'
}
>
{isConsumer ? (
<>
<Dropdown>
<DropdownTrigger
aria-label="عنوان"
className={CONSUMER_TOOLBAR_TRIGGER_CLASS}
>
عنوان
</DropdownTrigger>
<DropdownMenu aria-label="نوع عنوان">
<DropdownItem
key="paragraph"
onPress={() => editor.chain().focus().setParagraph().run()}
>
متن عادی
</DropdownItem>
<DropdownItem
key="heading-2"
onPress={() => {
setHeading(2)
}}
>
عنوان اصلی
</DropdownItem>
<DropdownItem
key="heading-3"
onPress={() => {
setHeading(3)
}}
>
عنوان فرعی
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger
aria-label="رنگ متن"
className={CONSUMER_TOOLBAR_TRIGGER_CLASS}
>
رنگ
</DropdownTrigger>
<DropdownMenu aria-label="رنگ متن">
{CONSUMER_TEXT_COLORS.map((color) => (
<DropdownItem
key={color.label}
startContent={<span className={cn('size-4 rounded-full border border-black/10', color.swatchClassName)} />}
onPress={() => {
const chain = editor.chain().focus()
if (color.value) chain.setColor(color.value).run()
else chain.unsetColor().run()
}}
>
{color.label}
</DropdownItem>
))}
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger
aria-label="فهرست"
className={CONSUMER_TOOLBAR_TRIGGER_CLASS}
>
فهرست
</DropdownTrigger>
<DropdownMenu aria-label="نوع فهرست">
<DropdownItem
key="ordered"
onPress={() => editor.chain().focus().toggleOrderedList().run()}
>
شمارهدار
</DropdownItem>
<DropdownItem
key="unordered"
onPress={() => editor.chain().focus().toggleBulletList().run()}
>
نشانهدار
</DropdownItem>
</DropdownMenu>
</Dropdown>
</>
) : (
<>
<Dropdown>
<DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>font</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="bold"
onPress={() => editor?.chain().focus().toggleBold().run()}
>
bold
</DropdownItem>
<DropdownItem
key="italic"
onPress={() => editor?.chain().focus().toggleItalic().run()}
>
italic
</DropdownItem>
<DropdownItem
key="underline"
onPress={() => editor?.chain().focus().toggleUnderline().run()}
>
underline
</DropdownItem>
<DropdownItem
key="strike"
onPress={() => editor?.chain().focus().toggleStrike().run()}
>
strike
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>heading</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="H1"
onPress={() => {
setHeading(1)
}}
>
H1
</DropdownItem>
<DropdownItem
key="H2"
onPress={() => {
setHeading(2)
}}
>
H2
</DropdownItem>
<DropdownItem
key="H3"
onPress={() => {
setHeading(3)
}}
>
H3
</DropdownItem>
<DropdownItem
key="H4"
onPress={() => {
setHeading(4)
}}
>
H4
</DropdownItem>
<DropdownItem
key="H5"
onPress={() => {
setHeading(5)
}}
>
H5
</DropdownItem>
<DropdownItem
key="H6"
onPress={() => {
setHeading(6)
}}
>
H6
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>align</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="left"
onPress={() => editor?.chain().focus().setTextAlign('left').run()}
>
left
</DropdownItem>
<DropdownItem
key="center"
onPress={() => editor?.chain().focus().setTextAlign('center').run()}
>
center
</DropdownItem>
<DropdownItem
key="right"
onPress={() => editor?.chain().focus().setTextAlign('right').run()}
>
right
</DropdownItem>
<DropdownItem
key="justify"
onPress={() => editor?.chain().focus().setTextAlign('justify').run()}
>
justify
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>link</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="link"
onPress={setLink}
>
link
</DropdownItem>
<DropdownItem
key="unlink"
onPress={() => editor?.chain().focus().unsetLink().run()}
>
unlink
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Dropdown>
<DropdownTrigger className={ADMIN_TOOLBAR_TRIGGER_CLASS}>list</DropdownTrigger>
<DropdownMenu aria-label="Static Actions">
<DropdownItem
key="ordered"
onPress={() => editor?.chain().focus().toggleOrderedList().run()}
>
ordered list
</DropdownItem>
<DropdownItem
key="unordered"
onPress={() => editor?.chain().focus().toggleBulletList().run()}
>
unordered list
</DropdownItem>
</DropdownMenu>
</Dropdown>
<Button
size="sm"
variant="bordered"
onClick={() => {
setIsOpenUploadImageModal(!isOpenUploadImageModal)
}}
>
image
</Button>
<Button
size="sm"
variant="bordered"
onClick={() => editor?.chain().focus().toggleHighlight().run()}
>
highlight
</Button>
<Button
size="sm"
variant="bordered"
>
<input
data-testid="setColor"
type="color"
value={editor?.getAttributes('textStyle').color}
onInput={(event) => {
const target = event.target as HTMLInputElement // Type Assertion
editor?.chain().focus().setColor(target.value).run()
}}
/>
</Button>
</>
)}
</div>
{!isConsumer ? <hr className="my-2" /> : null}
<EditorContent
className={cn(
'[&_.tiptap]:!outline-none no-reset',
isConsumer
? '[&_.tiptap]:min-h-32 [&_.tiptap]:px-3 [&_.tiptap]:py-3 [&_.tiptap]:text-sm [&_.tiptap]:font-medium [&_.tiptap]:text-consumer-text'
: '[&_.tiptap]:p-2'
)}
editor={editor}
/>
<ConsumerModal
hideCloseButton
hideHeader
acceptBtnDisabled={!imageId || !imageAlt.trim()}
acceptBtnText={texts.common.confirmPlain}
isOpen={isOpenUploadImageModal}
onAccept={() => {
editor
?.chain()
.focus()
.setImage({ src: fileAddress(imageId), alt: imageAlt.trim() })
.run()
setImageId('')
setImageAlt('')
setIsOpenUploadImageModal(false)
}}
onClose={() => {
setImageId('')
setImageAlt('')
setIsOpenUploadImageModal(false)
}}
onOpenChange={setIsOpenUploadImageModal}
>
<div className="flex flex-col gap-3">
<FileUpload
accept={['image']}
fileId={imageId}
fileUploaded={(fileId) => {
setImageId(fileId)
}}
/>
<Input
required
description={texts.common.imageAltDescription}
generalType="input"
label={texts.common.imageAltLabel}
name="imageAlt"
value={imageAlt}
onValueChange={(next) => {
setImageAlt(coerceToString(next))
}}
/>
</div>
</ConsumerModal>
</div>
</div>
)
}
export default TextEditor