- 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.
516 lines
17 KiB
TypeScript
516 lines
17 KiB
TypeScript
'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
|