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:
co-authored by
Cursor
parent
89269dff04
commit
d5aca8bea7
@@ -38,6 +38,9 @@
|
||||
--blur-glass: 28px;
|
||||
--modal-overlay-bg: rgba(15, 23, 42, 0.4);
|
||||
--modal-overlay-blur: 16px;
|
||||
--toast-z-index: 11000;
|
||||
--toast-bg: rgba(255, 255, 255, 0.94);
|
||||
--toast-shadow: 0 12px 40px rgba(15, 23, 42, 0.14);
|
||||
--text-primary: #1e293b;
|
||||
--text-secondary: #64748b;
|
||||
--text-muted: #94a3b8;
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
.layoutPage {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 32px 0 48px;
|
||||
}
|
||||
|
||||
.rootDashed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
margin-inline: auto;
|
||||
padding: 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.rootDashedInline {
|
||||
max-width: 100%;
|
||||
padding: 24px 12px;
|
||||
}
|
||||
|
||||
.messageDashed {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.messageOnly {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
font-family: var(--font-ui);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
line-height: 1.85;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.messageEmphasis {
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.addBox {
|
||||
appearance: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
max-width: 880px;
|
||||
min-height: 180px;
|
||||
padding: 32px 28px;
|
||||
background: var(--glass-bg);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 2px dashed rgba(var(--primary-rgb) / 0.38);
|
||||
border-radius: var(--radius);
|
||||
color: var(--primary);
|
||||
font-family: var(--font-ui);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
border-color 0.2s ease,
|
||||
transform 0.2s ease,
|
||||
box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.addBox:hover {
|
||||
background: rgba(var(--primary-rgb) / 0.08);
|
||||
border-color: rgba(var(--primary-rgb) / 0.58);
|
||||
transform: translateY(-3px);
|
||||
box-shadow: var(--card-hover-shadow);
|
||||
}
|
||||
|
||||
.addBox:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.addIcon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
border-radius: 50%;
|
||||
background: rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.addLabel {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.cell {
|
||||
padding: 0 !important;
|
||||
background: transparent !important;
|
||||
border-bottom: none !important;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.messageDashed {
|
||||
white-space: normal;
|
||||
line-height: 1.85;
|
||||
}
|
||||
|
||||
.rootDashed,
|
||||
.messageDashed,
|
||||
.messageOnly,
|
||||
.addBox {
|
||||
max-width: min(100%, 640px);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import type { LucideIcon } from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import styles from './EmptyState.module.css'
|
||||
|
||||
export interface EmptyStateProps {
|
||||
/** Plain-text fallback for accessibility and tables. */
|
||||
message: string
|
||||
/** Rich message content; used instead of `message` when provided. */
|
||||
messageNode?: ReactNode
|
||||
actionLabel?: string
|
||||
actionTo?: string
|
||||
onAction?: () => void
|
||||
/** Unused visually; kept for call-site compatibility. */
|
||||
icon?: LucideIcon
|
||||
/** Page lists use top padding; tables/modals use inline. */
|
||||
layout?: 'page' | 'inline'
|
||||
}
|
||||
|
||||
export function EmptyStateEmphasis({ children }: { children: ReactNode }) {
|
||||
return <strong className={styles.messageEmphasis}>{children}</strong>
|
||||
}
|
||||
|
||||
export interface ListEmptyMessageProps {
|
||||
part1: string
|
||||
noneLabel: string
|
||||
part2: string
|
||||
actionLabel: string
|
||||
part3: string
|
||||
}
|
||||
|
||||
/** Standard list empty copy: part1 + bold none + part2 + bold action + part3. */
|
||||
export function ListEmptyMessage({
|
||||
part1,
|
||||
noneLabel,
|
||||
part2,
|
||||
actionLabel,
|
||||
part3,
|
||||
}: ListEmptyMessageProps) {
|
||||
return (
|
||||
<>
|
||||
{part1}
|
||||
<EmptyStateEmphasis>{noneLabel}</EmptyStateEmphasis>
|
||||
{part2}
|
||||
<EmptyStateEmphasis>{actionLabel}</EmptyStateEmphasis>
|
||||
{part3}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function buildListEmptyPlainMessage(parts: ListEmptyMessageProps): string {
|
||||
return `${parts.part1}${parts.noneLabel}${parts.part2}${parts.actionLabel}${parts.part3}`
|
||||
}
|
||||
|
||||
function EmptyStateContent({
|
||||
message,
|
||||
messageNode,
|
||||
actionLabel,
|
||||
actionTo,
|
||||
onAction,
|
||||
layout = 'page',
|
||||
}: EmptyStateProps) {
|
||||
const showAction = Boolean(actionLabel && (actionTo || onAction))
|
||||
const messageContent = messageNode ?? message
|
||||
const isInline = layout === 'inline'
|
||||
|
||||
if (!showAction) {
|
||||
return (
|
||||
<div
|
||||
className={[styles.rootDashed, isInline ? styles.rootDashedInline : ''].filter(Boolean).join(' ')}
|
||||
role="status"
|
||||
>
|
||||
<p className={styles.messageOnly}>{messageContent}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const addContent = (
|
||||
<>
|
||||
<span className={styles.addIcon} aria-hidden="true">
|
||||
<Plus size={28} strokeWidth={2} />
|
||||
</span>
|
||||
{actionLabel ? <span className={styles.addLabel}>{actionLabel}</span> : null}
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={[styles.rootDashed, isInline ? styles.rootDashedInline : ''].filter(Boolean).join(' ')}
|
||||
role="status"
|
||||
>
|
||||
<p className={styles.messageDashed}>{messageContent}</p>
|
||||
{actionTo ? (
|
||||
<Link to={actionTo} className={styles.addBox} onClick={onAction}>
|
||||
{addContent}
|
||||
</Link>
|
||||
) : (
|
||||
<button type="button" className={styles.addBox} onClick={onAction}>
|
||||
{addContent}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function EmptyState(props: EmptyStateProps) {
|
||||
const content = <EmptyStateContent {...props} />
|
||||
|
||||
if (props.layout === 'inline') {
|
||||
return content
|
||||
}
|
||||
|
||||
return <div className={styles.layoutPage}>{content}</div>
|
||||
}
|
||||
|
||||
export function EmptyTableRow({
|
||||
colSpan,
|
||||
layout: _layout,
|
||||
...props
|
||||
}: EmptyStateProps & { colSpan: number }) {
|
||||
return (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className={styles.cell}>
|
||||
<EmptyStateContent {...props} layout="inline" />
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
.wrapper {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
overflow: visible;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.wrapper:focus-within {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 6px;
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||
background: rgba(248, 250, 252, 0.8);
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.toolbar button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.toolbar button:hover:not(:disabled) {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.toolbar button:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dirBtn {
|
||||
width: auto;
|
||||
min-width: 34px;
|
||||
padding: 0 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 20px;
|
||||
background: rgba(148, 163, 184, 0.3);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.colorPickerWrap {
|
||||
position: relative;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.colorBtn {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 6px;
|
||||
color: var(--text-secondary);
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.colorBtn:hover,
|
||||
.colorBtnActive {
|
||||
background: rgba(var(--primary-rgb) / 0.1);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.colorSwatch {
|
||||
width: 14px;
|
||||
height: 3px;
|
||||
margin-top: 1px;
|
||||
border-radius: 2px;
|
||||
background: var(--text-primary);
|
||||
}
|
||||
|
||||
.colorPopover {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
min-width: 168px;
|
||||
padding: 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.colorVisualInput {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.hexField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.hexLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.hexInput {
|
||||
min-height: var(--field-height);
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
font-size: var(--field-font-size);
|
||||
font-family: var(--font-ui);
|
||||
color: var(--text-primary);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.hexInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||
}
|
||||
|
||||
.editorShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 140px;
|
||||
padding: var(--field-padding-y) var(--field-padding-x);
|
||||
padding-bottom: 40px;
|
||||
font-size: var(--field-font-size);
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
overflow-wrap: break-word;
|
||||
word-break: break-word;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.editor:empty::before {
|
||||
content: attr(data-placeholder);
|
||||
color: var(--text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.editor > * {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.editor ul,
|
||||
.editor ol {
|
||||
padding-inline-start: 1.5em;
|
||||
padding-inline-end: 0;
|
||||
margin: 0.5em 0;
|
||||
}
|
||||
|
||||
.editor p {
|
||||
margin: 0 0 0.5em;
|
||||
}
|
||||
|
||||
.editor p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.editor blockquote {
|
||||
margin: 0.5em 0;
|
||||
margin-inline-start: 1.5em;
|
||||
margin-inline-end: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.editor img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0.5em 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editor img.richTextImageSelected {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid white;
|
||||
border-radius: 3px;
|
||||
background: var(--primary);
|
||||
box-shadow: 0 1px 4px rgba(15, 23, 42, 0.25);
|
||||
cursor: nwse-resize;
|
||||
z-index: 2;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.uploadError {
|
||||
margin: 0;
|
||||
padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
@@ -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(/ /gi, ' ')
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
export function isEmptyRichText(html: string | null | undefined): boolean {
|
||||
return richTextToPlain(html).length === 0
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
z-index: 300;
|
||||
z-index: var(--toast-z-index, 11000);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
@@ -19,12 +19,12 @@
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
color: var(--text-primary);
|
||||
background: var(--elevated-surface);
|
||||
background: var(--toast-bg, rgba(255, 255, 255, 0.94));
|
||||
backdrop-filter: blur(var(--blur-glass));
|
||||
-webkit-backdrop-filter: blur(var(--blur-glass));
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius-sm);
|
||||
box-shadow: var(--glass-shadow);
|
||||
box-shadow: var(--toast-shadow, 0 12px 40px rgba(15, 23, 42, 0.14));
|
||||
}
|
||||
|
||||
.toastIn {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './Toast.module.css'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
@@ -74,17 +75,20 @@ export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{createPortal(
|
||||
<div className={styles.container} aria-live="polite" aria-atomic="true">
|
||||
{toast && (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`${styles.toast} ${styles[toast.variant]} ${closing ? styles.toastOut : styles.toastIn}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.message}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -10,8 +10,23 @@ export {
|
||||
type CityOption,
|
||||
} from './components/AddressListEditor'
|
||||
export { Breadcrumbs, type BreadcrumbItem } from './components/Breadcrumbs'
|
||||
export {
|
||||
EmptyState,
|
||||
EmptyStateEmphasis,
|
||||
EmptyTableRow,
|
||||
ListEmptyMessage,
|
||||
buildListEmptyPlainMessage,
|
||||
type EmptyStateProps,
|
||||
type ListEmptyMessageProps,
|
||||
} from './components/EmptyState'
|
||||
export { Pagination } from './components/Pagination'
|
||||
export type { PaginationProps } from './components/Pagination'
|
||||
export {
|
||||
RichTextEditor,
|
||||
isEmptyRichText,
|
||||
richTextToPlain,
|
||||
type RichTextEditorProps,
|
||||
} from './components/RichTextEditor'
|
||||
export { SectionCard } from './components/SectionCard'
|
||||
export { RouteLoader } from './components/RouteLoader'
|
||||
export { createDomainGuard, type DomainGuardConfig } from './components/createDomainGuard'
|
||||
|
||||
Reference in New Issue
Block a user