Add business workshop management UI with Jalali scheduling and shared dashboard polish.

Includes workshop list/edit/details, registrations modal, category tree, shared EmptyState/RichTextEditor/toasts, and fixes for the Jalali date picker and thumbnail field layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-22 12:22:27 +03:30
co-authored by Cursor
parent 89269dff04
commit d5aca8bea7
76 changed files with 4719 additions and 974 deletions
@@ -0,0 +1,530 @@
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
import {
Bold,
IndentDecrease,
IndentIncrease,
Italic,
Underline,
List,
ListOrdered,
ImagePlus,
Palette,
} from 'lucide-react'
import type { DashboardLocale } from '@meshkee/dashboard-core'
import { LocaleContext } from '../context/LocaleContext'
import styles from './RichTextEditor.module.css'
const INDENT_STEP_PX = 24
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
const DEFAULT_TEXT_COLOR = '#0f172a'
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
function normalizeHexColor(value: string): string | null {
const trimmed = value.trim()
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
if (!HEX_COLOR_PATTERN.test(withHash)) return null
if (withHash.length === 4) {
const [, r, g, b] = withHash
return `#${r}${r}${g}${g}${b}${b}`.toLowerCase()
}
return withHash.toLowerCase()
}
function toHexInputValue(color: string): string {
return normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
}
export interface RichTextEditorProps {
value: string
onChange: (value: string) => void
placeholder?: string
allowImages?: boolean
/** Resolve uploaded image file to a public URL. Required when `allowImages` is true. */
onImageUpload?: (file: File) => Promise<string>
editorMinHeight?: number
locale?: DashboardLocale
dir?: 'ltr' | 'rtl'
}
export function RichTextEditor({
value,
onChange,
placeholder,
allowImages = false,
onImageUpload,
editorMinHeight = 140,
locale: localeProp,
dir: dirProp,
}: RichTextEditorProps) {
const localeContext = useContext(LocaleContext)
const locale = localeProp ?? localeContext?.locale ?? 'en'
const dir = dirProp ?? localeContext?.dir ?? 'ltr'
const editorShellRef = useRef<HTMLDivElement>(null)
const editorRef = useRef<HTMLDivElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const colorPickerRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{ startX: number; startWidth: number; img: HTMLImageElement } | null>(
null,
)
const [uploadingImage, setUploadingImage] = useState(false)
const [uploadError, setUploadError] = useState('')
const [selectedImageEl, setSelectedImageEl] = useState<HTMLImageElement | null>(null)
const [handlePos, setHandlePos] = useState<{ top: number; left: number } | null>(null)
const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
const [colorOpen, setColorOpen] = useState(false)
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
const syncChange = useCallback(() => {
if (editorRef.current) {
onChange(editorRef.current.innerHTML)
}
}, [onChange])
const updateHandlePosition = useCallback((img: HTMLImageElement | null) => {
if (!img || !editorShellRef.current) {
setHandlePos(null)
return
}
const shellRect = editorShellRef.current.getBoundingClientRect()
const imgRect = img.getBoundingClientRect()
setHandlePos({
top: imgRect.bottom - shellRect.top - 6,
left: imgRect.right - shellRect.left - 6,
})
}, [])
const clearImageSelection = useCallback(() => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((img) => {
img.classList.remove(SELECTED_IMAGE_CLASS)
})
setSelectedImageEl(null)
setHandlePos(null)
}, [])
const selectImage = useCallback(
(img: HTMLImageElement) => {
editorRef.current?.querySelectorAll(`img.${SELECTED_IMAGE_CLASS}`).forEach((node) => {
node.classList.remove(SELECTED_IMAGE_CLASS)
})
img.classList.add(SELECTED_IMAGE_CLASS)
setSelectedImageEl(img)
updateHandlePosition(img)
},
[updateHandlePosition],
)
useEffect(() => {
if (editorRef.current && editorRef.current.innerHTML !== value) {
editorRef.current.innerHTML = value
clearImageSelection()
}
}, [value, clearImageSelection])
useEffect(() => {
if (!allowImages) return
const editor = editorRef.current
if (!editor) return
function onEditorClick(e: MouseEvent) {
const target = e.target
if (!(target instanceof HTMLImageElement) || !editor!.contains(target)) return
e.preventDefault()
selectImage(target)
}
function onDocumentClick(e: MouseEvent) {
const target = e.target as Node
if (editor!.contains(target)) return
if (target instanceof Element && target.closest(`.${styles.resizeHandle}`)) return
clearImageSelection()
}
editor.addEventListener('click', onEditorClick)
document.addEventListener('click', onDocumentClick)
return () => {
editor.removeEventListener('click', onEditorClick)
document.removeEventListener('click', onDocumentClick)
}
}, [allowImages, selectImage, clearImageSelection])
useEffect(() => {
if (!selectedImageEl) return
function refreshHandle() {
if (selectedImageEl?.isConnected) {
updateHandlePosition(selectedImageEl)
}
}
window.addEventListener('resize', refreshHandle)
editorRef.current?.addEventListener('scroll', refreshHandle)
return () => {
window.removeEventListener('resize', refreshHandle)
editorRef.current?.removeEventListener('scroll', refreshHandle)
}
}, [selectedImageEl, updateHandlePosition])
useEffect(() => {
if (!colorOpen) return
function onDocumentClick(e: MouseEvent) {
const target = e.target as Node
if (colorPickerRef.current?.contains(target)) return
setColorOpen(false)
}
document.addEventListener('mousedown', onDocumentClick)
return () => document.removeEventListener('mousedown', onDocumentClick)
}, [colorOpen])
function exec(cmd: string, arg?: string) {
document.execCommand(cmd, false, arg)
editorRef.current?.focus()
syncChange()
}
function findIndentBlock(editor: HTMLElement): HTMLElement | null {
const selection = window.getSelection()
if (!selection || selection.rangeCount === 0) return null
let node: Node | null = selection.anchorNode
while (node && node !== editor) {
if (node instanceof HTMLElement && INDENT_BLOCK_TAGS.has(node.tagName)) {
return node
}
node = node.parentNode
}
return null
}
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (!block) {
document.execCommand(direction > 0 ? 'indent' : 'outdent')
syncChange()
return
}
const current =
Number.parseFloat(block.style.paddingInlineStart) ||
Number.parseFloat(getComputedStyle(block).paddingInlineStart) ||
0
const next = Math.max(0, Math.round(current + direction * INDENT_STEP_PX))
if (next === 0) {
block.style.paddingInlineStart = ''
} else {
block.style.paddingInlineStart = `${next}px`
}
syncChange()
}
function setBlockDirection(direction: 'rtl' | 'ltr') {
const editor = editorRef.current
if (!editor) return
editor.focus()
const block = findIndentBlock(editor)
if (block) {
block.setAttribute('dir', direction)
syncChange()
return
}
document.execCommand('dir', false, direction)
syncChange()
}
function applyTextColor(color: string) {
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
setTextColor(normalized)
setHexInput(normalized)
document.execCommand('styleWithCSS', false, 'true')
exec('foreColor', normalized)
}
function toggleColorPicker() {
setColorOpen((open) => {
const nextOpen = !open
if (nextOpen) {
setHexInput(toHexInputValue(textColor))
}
return nextOpen
})
}
function commitHexInput(rawValue?: string) {
const normalized = normalizeHexColor(rawValue ?? hexInput)
if (!normalized) {
setHexInput(toHexInputValue(textColor))
return
}
applyTextColor(normalized)
}
function onHexInputKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === 'Enter') {
e.preventDefault()
commitHexInput()
editorRef.current?.focus()
}
if (e.key === 'Escape') {
e.preventDefault()
setHexInput(toHexInputValue(textColor))
setColorOpen(false)
}
}
function onEditorKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
if (e.key !== 'Tab' || e.altKey || e.ctrlKey || e.metaKey) return
e.preventDefault()
changeIndent(e.shiftKey ? -1 : 1)
}
function styleInsertedImage(img: HTMLImageElement) {
img.style.width = '100%'
img.style.maxWidth = '100%'
img.style.height = 'auto'
img.draggable = false
}
async function handleImageSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = ''
if (!file || !editorRef.current || !onImageUpload) return
setUploadingImage(true)
setUploadError('')
try {
const url = await onImageUpload(file)
if (!url) throw new Error('Image upload failed.')
editorRef.current.focus()
document.execCommand('insertImage', false, url)
const imgs = editorRef.current.querySelectorAll('img')
const lastImg = imgs[imgs.length - 1]
if (lastImg instanceof HTMLImageElement) {
styleInsertedImage(lastImg)
selectImage(lastImg)
}
syncChange()
} catch (err) {
setUploadError(err instanceof Error ? err.message : 'Unable to upload image.')
} finally {
setUploadingImage(false)
}
}
function onResizeStart(e: React.MouseEvent) {
if (!selectedImageEl || !editorRef.current) return
e.preventDefault()
e.stopPropagation()
dragRef.current = {
startX: e.clientX,
startWidth: selectedImageEl.offsetWidth,
img: selectedImageEl,
}
function onMove(ev: MouseEvent) {
if (!dragRef.current || !editorRef.current) return
const delta = ev.clientX - dragRef.current.startX
const maxWidth = editorRef.current.clientWidth - 16
const nextWidth = Math.max(80, Math.min(maxWidth, dragRef.current.startWidth + delta))
dragRef.current.img.style.width = `${nextWidth}px`
dragRef.current.img.style.height = 'auto'
dragRef.current.img.style.maxWidth = '100%'
updateHandlePosition(dragRef.current.img)
}
function onUp() {
dragRef.current = null
syncChange()
document.removeEventListener('mousemove', onMove)
document.removeEventListener('mouseup', onUp)
}
document.addEventListener('mousemove', onMove)
document.addEventListener('mouseup', onUp)
}
return (
<div className={styles.wrapper} dir={dir} lang={locale}>
<div className={styles.toolbar} dir="ltr">
<button type="button" onClick={() => exec('bold')} title="Bold" aria-label="Bold">
<Bold size={16} />
</button>
<button type="button" onClick={() => exec('italic')} title="Italic" aria-label="Italic">
<Italic size={16} />
</button>
<button type="button" onClick={() => exec('underline')} title="Underline" aria-label="Underline">
<Underline size={16} />
</button>
<span className={styles.divider} />
<div ref={colorPickerRef} className={styles.colorPickerWrap}>
<button
type="button"
className={[styles.colorBtn, colorOpen ? styles.colorBtnActive : ''].filter(Boolean).join(' ')}
onClick={toggleColorPicker}
title="Text color"
aria-label="Text color"
aria-expanded={colorOpen}
aria-haspopup="dialog"
>
<Palette size={16} />
<span className={styles.colorSwatch} style={{ background: textColor }} aria-hidden="true" />
</button>
{colorOpen && (
<div className={styles.colorPopover} role="dialog" aria-label="Text color">
<input
type="color"
className={styles.colorVisualInput}
value={textColor}
onChange={(e) => applyTextColor(e.target.value)}
aria-label="Pick color"
/>
<label className={styles.hexField}>
<span className={styles.hexLabel}>HEX</span>
<input
type="text"
className={styles.hexInput}
value={hexInput}
onChange={(e) => setHexInput(e.target.value)}
onBlur={() => commitHexInput()}
onKeyDown={onHexInputKeyDown}
spellCheck={false}
autoComplete="off"
dir="ltr"
aria-label="Hex color"
/>
</label>
</div>
)}
</div>
<span className={styles.divider} />
<button
type="button"
className={styles.dirBtn}
onClick={() => setBlockDirection('rtl')}
title="Right to left"
aria-label="Right to left"
>
RTL
</button>
<button
type="button"
className={styles.dirBtn}
onClick={() => setBlockDirection('ltr')}
title="Left to right"
aria-label="Left to right"
>
LTR
</button>
<span className={styles.divider} />
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
<List size={16} />
</button>
<button type="button" onClick={() => exec('insertOrderedList')} title="Numbered list" aria-label="Numbered list">
<ListOrdered size={16} />
</button>
<span className={styles.divider} />
<button
type="button"
onClick={() => changeIndent(-1)}
title="Decrease indent"
aria-label="Decrease indent"
>
<IndentDecrease size={16} />
</button>
<button
type="button"
onClick={() => changeIndent(1)}
title="Increase indent"
aria-label="Increase indent"
>
<IndentIncrease size={16} />
</button>
{allowImages && onImageUpload && (
<>
<span className={styles.divider} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
title="Insert image"
aria-label="Insert image"
>
<ImagePlus size={16} />
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
hidden
onChange={handleImageSelect}
/>
</>
)}
</div>
{uploadError && (
<p className={styles.uploadError} role="alert">
{uploadError}
</p>
)}
<div ref={editorShellRef} className={styles.editorShell}>
<div
ref={editorRef}
className={styles.editor}
style={{ minHeight: editorMinHeight }}
contentEditable
role="textbox"
aria-multiline="true"
dir={dir}
lang={locale}
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
suppressContentEditableWarning
/>
{allowImages && selectedImageEl && handlePos && (
<button
type="button"
className={styles.resizeHandle}
style={{ top: handlePos.top, left: handlePos.left }}
onMouseDown={onResizeStart}
aria-label="Resize image"
/>
)}
</div>
</div>
)
}
/** Strip tags for single-line list previews. */
export function richTextToPlain(html: string | null | undefined): string {
if (!html) return ''
return html
.replace(/<br\s*\/?>/gi, ' ')
.replace(/&nbsp;/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/\s+/g, ' ')
.trim()
}
export function isEmptyRichText(html: string | null | undefined): boolean {
return richTextToPlain(html).length === 0
}