diff --git a/apps/business/src/components/DailyActivityChart.module.css b/apps/business/src/components/DailyActivityChart.module.css
index cc39e62..c2f1b09 100644
--- a/apps/business/src/components/DailyActivityChart.module.css
+++ b/apps/business/src/components/DailyActivityChart.module.css
@@ -91,7 +91,7 @@
justify-content: space-between;
flex-shrink: 0;
width: 40px;
- padding-bottom: 28px;
+ padding-bottom: 22px;
box-sizing: border-box;
direction: ltr;
}
@@ -112,15 +112,10 @@
overflow: visible;
}
-.chartWrapScroll {
- overflow-x: auto;
- overflow-y: visible;
-}
-
.chartGrid {
position: absolute;
inset-inline: 0;
- bottom: 28px;
+ bottom: 22px;
pointer-events: none;
z-index: 0;
}
@@ -149,8 +144,8 @@
}
.chartMonth {
- gap: 6px;
- overflow-x: auto;
+ gap: 4px;
+ overflow: visible;
}
.group {
@@ -167,8 +162,8 @@
}
.chartMonth .group {
- flex: 1;
- min-width: 36px;
+ flex: 1 1 0;
+ min-width: 0;
}
.bars {
@@ -177,14 +172,13 @@
justify-content: center;
width: 100%;
height: 180px;
- gap: 1px;
+ gap: 2px;
}
.barWrap {
position: relative;
display: flex;
- flex: 1 1 0;
- min-width: 0;
+ flex: 0 0 auto;
align-items: flex-end;
justify-content: center;
height: 100%;
@@ -257,6 +251,9 @@
}
.label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
font-size: 9px;
color: var(--text-muted);
font-weight: 500;
@@ -265,11 +262,22 @@
text-align: center;
width: 100%;
overflow: hidden;
- text-overflow: ellipsis;
}
.chartMonth .label {
+ height: 68px;
+}
+
+.chartMonth .labelText {
+ writing-mode: vertical-rl;
+ transform: rotate(180deg);
+ white-space: nowrap;
+ max-height: 68px;
+ overflow: hidden;
+ text-overflow: ellipsis;
font-size: 10px;
+ line-height: 1.2;
+ letter-spacing: 0.04em;
}
.barPrimary {
diff --git a/apps/business/src/components/DailyActivityChart.tsx b/apps/business/src/components/DailyActivityChart.tsx
index 96906cd..dc87b15 100644
--- a/apps/business/src/components/DailyActivityChart.tsx
+++ b/apps/business/src/components/DailyActivityChart.tsx
@@ -12,6 +12,8 @@ import type { BusinessMessageKey } from '../i18n/messages'
import styles from './DailyActivityChart.module.css'
const CHART_HEIGHT = 180
+const LABEL_BAND_DAY = 22
+const LABEL_BAND_MONTH = 80
interface DailyActivityChartProps {
titleKey: BusinessMessageKey
@@ -143,6 +145,7 @@ export function DailyActivityChart({
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
+ const labelBand = granularity === 'month' ? LABEL_BAND_MONTH : LABEL_BAND_DAY
const subtitle = useMemo(() => {
if (granularity === 'month') {
@@ -191,7 +194,7 @@ export function DailyActivityChart({
{[...yAxisTicks].reverse().map((tick) => (
@@ -200,17 +203,10 @@ export function DailyActivityChart({
))}
-
+
{yAxisTicks.map((tick) => (
@@ -229,7 +225,7 @@ export function DailyActivityChart({
]
.filter(Boolean)
.join(' ')}
- style={{ height: CHART_HEIGHT + 28 }}
+ style={{ height: CHART_HEIGHT + labelBand }}
role="img"
aria-label={t(titleKey)}
>
@@ -277,9 +273,9 @@ export function DailyActivityChart({
- {item.label}
+ {item.label}
)
diff --git a/apps/business/src/components/GenerateWebsiteLinkModal.tsx b/apps/business/src/components/GenerateWebsiteLinkModal.tsx
index 9a50f38..e37ac50 100644
--- a/apps/business/src/components/GenerateWebsiteLinkModal.tsx
+++ b/apps/business/src/components/GenerateWebsiteLinkModal.tsx
@@ -9,6 +9,7 @@ import { listBlogCategories } from '../services/blogCategoryService'
import { listPortfolioCategories } from '../services/portfolioCategoryService'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listVideoCategories } from '../services/videoCategoryService'
+import { listWorkshopCategories } from '../services/workshopCategoryService'
import type { ProductCategory } from '../types/category'
import {
WEBSITE_LINK_KINDS,
@@ -31,6 +32,7 @@ const KIND_LABEL: Record
= {
blog: 'website.linkGenerator.types.blog',
portfolio: 'website.linkGenerator.types.portfolio',
video: 'website.linkGenerator.types.video',
+ workshop: 'website.linkGenerator.types.workshop',
}
const ANIMATION_MS = 220
@@ -54,6 +56,8 @@ async function loadCategories(kind: WebsiteLinkKind, signal: AbortSignal) {
return listPortfolioCategories(signal)
case 'video':
return listVideoCategories(signal)
+ case 'workshop':
+ return listWorkshopCategories(signal)
case 'product':
case 'storeItem':
return listProductCategories(signal)
diff --git a/apps/business/src/components/ImageCropper.module.css b/apps/business/src/components/ImageCropper.module.css
index f14cc13..6293e22 100644
--- a/apps/business/src/components/ImageCropper.module.css
+++ b/apps/business/src/components/ImageCropper.module.css
@@ -13,7 +13,7 @@
gap: 8px;
box-sizing: border-box;
min-height: 0;
- border: 2px dashed rgba(148, 163, 184, 0.4);
+ border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-secondary);
diff --git a/apps/business/src/components/ImageUploader.module.css b/apps/business/src/components/ImageUploader.module.css
index 11bf5ca..1f6b832 100644
--- a/apps/business/src/components/ImageUploader.module.css
+++ b/apps/business/src/components/ImageUploader.module.css
@@ -56,7 +56,7 @@
align-items: center;
justify-content: center;
gap: 6px;
- border: 2px dashed rgba(148, 163, 184, 0.4);
+ border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text-secondary);
diff --git a/apps/business/src/components/InstructionCard.tsx b/apps/business/src/components/InstructionCard.tsx
index 3b6ace8..43cde8b 100644
--- a/apps/business/src/components/InstructionCard.tsx
+++ b/apps/business/src/components/InstructionCard.tsx
@@ -41,6 +41,14 @@ export function InstructionCard({
const hasAbstract = Boolean(video.abstract?.trim())
const titleDir = isFa ? 'rtl' : titleLocale.dir
const abstractDir = isFa ? 'rtl' : abstractLocale.dir
+ const hasVideo = Boolean(video.embedCode?.trim())
+ const providerLabel = !hasVideo
+ ? null
+ : video.videoProvider === 'youtube'
+ ? 'YouTube'
+ : video.videoProvider === 'aparat'
+ ? 'Aparat'
+ : null
return (
@@ -58,9 +66,11 @@ export function InstructionCard({
)}
-
- {video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
-
+ {providerLabel ? (
+
+ {providerLabel}
+
+ ) : null}
diff --git a/apps/business/src/components/ProductActivityChart.module.css b/apps/business/src/components/ProductActivityChart.module.css
index b8ba766..6e3cc22 100644
--- a/apps/business/src/components/ProductActivityChart.module.css
+++ b/apps/business/src/components/ProductActivityChart.module.css
@@ -89,7 +89,7 @@
justify-content: space-between;
flex-shrink: 0;
width: 40px;
- padding-bottom: 32px;
+ padding-bottom: 80px;
box-sizing: border-box;
direction: ltr;
}
@@ -107,14 +107,13 @@
position: relative;
flex: 1;
min-width: 0;
- overflow-x: auto;
- overflow-y: visible;
+ overflow: visible;
}
.chartGrid {
position: absolute;
inset-inline: 0;
- bottom: 32px;
+ bottom: 80px;
pointer-events: none;
z-index: 0;
}
@@ -132,16 +131,16 @@
display: flex;
flex-direction: row;
align-items: flex-end;
- gap: 14px;
- min-width: 100%;
+ gap: 4px;
+ width: 100%;
padding-bottom: 4px;
direction: ltr;
unicode-bidi: isolate;
}
.group {
- flex: 1;
- min-width: 36px;
+ flex: 1 1 0;
+ min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
@@ -151,13 +150,16 @@
.bars {
display: flex;
align-items: flex-end;
+ justify-content: center;
height: 200px;
}
.barWrap {
position: relative;
display: flex;
+ flex: 0 0 auto;
align-items: flex-end;
+ justify-content: center;
height: 100%;
z-index: 1;
}
@@ -228,13 +230,30 @@
}
.label {
- font-size: 11px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 68px;
+ width: 100%;
+ overflow: hidden;
+ font-size: 10px;
color: var(--text-muted);
font-weight: 500;
font-family: var(--font-ui);
text-align: center;
}
+.labelText {
+ writing-mode: vertical-rl;
+ transform: rotate(180deg);
+ white-space: nowrap;
+ max-height: 68px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ line-height: 1.2;
+ letter-spacing: 0.04em;
+}
+
@media (max-width: 768px) {
.header {
flex-direction: column;
diff --git a/apps/business/src/components/ProductActivityChart.tsx b/apps/business/src/components/ProductActivityChart.tsx
index c2ef2f9..4a03fd0 100644
--- a/apps/business/src/components/ProductActivityChart.tsx
+++ b/apps/business/src/components/ProductActivityChart.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState } from 'react'
+import { useEffect, useMemo, useState } from 'react'
import { useLocale } from '@meshkee/dashboard-ui'
import { ApiError } from '../lib/api'
import { listAllProducts } from '../services/productService'
@@ -8,7 +8,8 @@ import { useT } from '../i18n/useT'
import styles from './ProductActivityChart.module.css'
const CHART_HEIGHT = 200
-const BAR_GAP = 6
+const LABEL_BAND = 80
+const BAR_GAP = 4
export function ProductActivityChart() {
const t = useT()
@@ -16,7 +17,6 @@ export function ProductActivityChart() {
const [data, setData] = useState
([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
- const chartWrapRef = useRef(null)
useEffect(() => {
const controller = new AbortController()
@@ -51,13 +51,6 @@ export function ProductActivityChart() {
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
- useEffect(() => {
- if (isLoading || error) return
- const wrap = chartWrapRef.current
- if (!wrap) return
- wrap.scrollLeft = wrap.scrollWidth
- }, [isLoading, error, data])
-
const totals = useMemo(
() => ({
added: data.reduce((sum, item) => sum + item.added, 0),
@@ -96,7 +89,7 @@ export function ProductActivityChart() {
{[...yAxisTicks].reverse().map((tick) => (
@@ -105,10 +98,10 @@ export function ProductActivityChart() {
))}
-
+
{yAxisTicks.map((tick) => (
@@ -122,7 +115,7 @@ export function ProductActivityChart() {
@@ -158,8 +151,12 @@ export function ProductActivityChart() {
-
- {item.label}
+
+ {item.label}
)
diff --git a/apps/business/src/i18n/messages.ts b/apps/business/src/i18n/messages.ts
index 8c0b87d..ee2393a 100644
--- a/apps/business/src/i18n/messages.ts
+++ b/apps/business/src/i18n/messages.ts
@@ -1701,6 +1701,7 @@ const en = {
'website.linkGenerator.types.blog': 'Blog',
'website.linkGenerator.types.portfolio': 'Portfolio',
'website.linkGenerator.types.video': 'Video',
+ 'website.linkGenerator.types.workshop': 'Workshop',
'website.staticImages.subtitle':
'Websites load these by key from GET /tenants/{domain}/website/static-images',
@@ -4031,6 +4032,7 @@ const fa: Record = {
'website.linkGenerator.types.blog': 'بلاگ',
'website.linkGenerator.types.portfolio': 'نمونه کار',
'website.linkGenerator.types.video': 'ویدیو',
+ 'website.linkGenerator.types.workshop': 'کارگاه',
'website.staticImages.subtitle':
'وبسایتها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images میخوانند',
diff --git a/apps/business/src/pages/AddNewProductPage.module.css b/apps/business/src/pages/AddNewProductPage.module.css
index 33d8e45..0204a95 100644
--- a/apps/business/src/pages/AddNewProductPage.module.css
+++ b/apps/business/src/pages/AddNewProductPage.module.css
@@ -290,7 +290,7 @@
padding: 3px;
gap: 2px;
background: transparent;
- border: 1px solid #0a1628;
+ border: 1px solid var(--primary);
border-radius: 50px;
}
diff --git a/apps/business/src/pages/InstructionDetailsPage.tsx b/apps/business/src/pages/InstructionDetailsPage.tsx
index a1c869d..22c9c4f 100644
--- a/apps/business/src/pages/InstructionDetailsPage.tsx
+++ b/apps/business/src/pages/InstructionDetailsPage.tsx
@@ -83,8 +83,14 @@ export function InstructionDetailsPage() {
const contentLocale = textLocaleAttrs(
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
)
- const providerLabel =
- video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
+ const hasVideo = Boolean(video.embedCode?.trim())
+ const providerLabel = !hasVideo
+ ? null
+ : video.videoProvider === 'youtube'
+ ? 'YouTube'
+ : video.videoProvider === 'aparat'
+ ? 'Aparat'
+ : null
return (
diff --git a/apps/business/src/pages/InstructionListPage.tsx b/apps/business/src/pages/InstructionListPage.tsx
index 1ddd143..f111c54 100644
--- a/apps/business/src/pages/InstructionListPage.tsx
+++ b/apps/business/src/pages/InstructionListPage.tsx
@@ -167,7 +167,7 @@ export function InstructionListPage() {
/>
) : (
<>
-
+
{videos.map((video) => (
= {
@@ -18,6 +19,7 @@ export const WEBSITE_LINK_KIND_PATH: Record = {
blog: '/blogs',
portfolio: '/portfolios',
video: '/videos',
+ workshop: '/workshops',
}
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
diff --git a/apps/customer/src/components/ImageCropper.module.css b/apps/customer/src/components/ImageCropper.module.css
index 9115e98..10a5d0a 100644
--- a/apps/customer/src/components/ImageCropper.module.css
+++ b/apps/customer/src/components/ImageCropper.module.css
@@ -13,7 +13,7 @@
gap: 8px;
width: 100%;
min-height: 0;
- border: 2px dashed rgba(148, 163, 184, 0.4);
+ border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
diff --git a/apps/customer/src/components/ImageUploader.module.css b/apps/customer/src/components/ImageUploader.module.css
index ed15e5e..75fdd85 100644
--- a/apps/customer/src/components/ImageUploader.module.css
+++ b/apps/customer/src/components/ImageUploader.module.css
@@ -62,7 +62,7 @@
align-items: center;
justify-content: center;
gap: 6px;
- border: 2px dashed rgba(148, 163, 184, 0.4);
+ border: 2px dashed var(--primary);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 70%, transparent);
color: var(--text-secondary);
diff --git a/apps/super-admin/src/components/DomainLinksOverflowMenu.tsx b/apps/super-admin/src/components/DomainLinksOverflowMenu.tsx
index 342f92e..e802d40 100644
--- a/apps/super-admin/src/components/DomainLinksOverflowMenu.tsx
+++ b/apps/super-admin/src/components/DomainLinksOverflowMenu.tsx
@@ -5,6 +5,7 @@ import {
ExternalLink,
FileText,
Globe,
+ Mail,
MoreHorizontal,
Trash2,
UserRound,
@@ -20,6 +21,7 @@ type PopoverPosition = {
interface DomainLinksOverflowMenuProps {
host: string
onAiPrompts: () => void
+ onEmailDns: () => void
onRemove: () => void
}
@@ -42,6 +44,7 @@ function openHost(host: string) {
export function DomainLinksOverflowMenu({
host,
onAiPrompts,
+ onEmailDns,
onRemove,
}: DomainLinksOverflowMenuProps) {
const [open, setOpen] = useState(false)
@@ -61,7 +64,7 @@ export function DomainLinksOverflowMenu({
const size = popoverRef.current
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
- : { width: 220, height: 280 }
+ : { width: 220, height: 320 }
setPopoverPos(fitFixedPopover(rect, size, 'end'))
}
@@ -190,6 +193,18 @@ export function DomainLinksOverflowMenu({
))}
+
@@ -908,6 +911,8 @@ export function WebsitesPage() {
+ setMailDomain(null)} />
+
setParkedDomain(null)}
diff --git a/apps/super-admin/src/services/domainService.ts b/apps/super-admin/src/services/domainService.ts
index 96c09b4..71062ee 100644
--- a/apps/super-admin/src/services/domainService.ts
+++ b/apps/super-admin/src/services/domainService.ts
@@ -6,6 +6,7 @@ import type {
IssueWebsiteSslResponse,
SyncSslResponse,
UpdateParkedHostsResponse,
+ ApplyMailDnsResponse,
} from '../types/domain'
export interface ListDomainsParams {
@@ -106,3 +107,18 @@ export async function issueDomainSsl(domainId: number | string) {
auth: true,
})
}
+
+export async function applyMailDns(
+ domainId: number | string,
+ payload: {
+ dnsProvider: 'arvan' | 'cloudflare'
+ ed25519Key: string
+ rsaKey: string
+ },
+) {
+ return apiRequest(`/domains/${domainId}/mail-dns`, {
+ method: 'POST',
+ auth: true,
+ body: payload,
+ })
+}
diff --git a/apps/super-admin/src/types/domain.ts b/apps/super-admin/src/types/domain.ts
index 004ead3..946d7cf 100644
--- a/apps/super-admin/src/types/domain.ts
+++ b/apps/super-admin/src/types/domain.ts
@@ -94,3 +94,13 @@ export interface UpdateParkedHostsResponse {
error?: string
}>
}
+
+export interface ApplyMailDnsResponse {
+ host: string
+ dnsProvider: ParkedDnsProvider
+ created: string[]
+ updated: string[]
+ skipped: string[]
+ removed: string[]
+ message: string
+}
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index a0246a8..2c04db8 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -142,7 +142,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/businesses/:businessId/invoices` | Business invoices list |
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
| `/users` | Users (business filter: Customer vs Manager → Admin/Editor/Viewer; Admin assignable by super-admin only; owners locked) |
-| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update DNS (Arvan or Cloudflare, same tenant records as Businesses). Overflow ⋯ includes website/dashboard links, **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. |
+| `/websites` | Websites / domains (Deploy when `deploy_slug` set). Edit domain can optionally update DNS (Arvan or Cloudflare, same tenant records as Businesses). Overflow ⋯ includes website/dashboard links, **Email DNS** (Stalwart DKIM + MX/SPF on Arvan or Cloudflare), **AI prompts**, and remove. **Parked domains** button: alias hosts 301 to the main domain. Per alias, choose **Arvan** or **Cloudflare** for DNS (`@` + `www` only, not proxied); SSL is always Let's Encrypt on the websites VM. |
| `/templates` | Global AI prompt templates. Assign from Businesses or Websites ⋯ → **AI prompts** (add from library; public pack link). |
| `/settings` | Platform settings (invoice templates + item templates) |
| `/settings/invoice-templates/new` | Create invoice template |
diff --git a/packages/dashboard-ui/src/components/EmptyState.module.css b/packages/dashboard-ui/src/components/EmptyState.module.css
index 96a85fe..bbc37ca 100644
--- a/packages/dashboard-ui/src/components/EmptyState.module.css
+++ b/packages/dashboard-ui/src/components/EmptyState.module.css
@@ -69,7 +69,7 @@
background-color: transparent;
border-width: 2px;
border-style: dashed;
- border-color: #0a1628;
+ border-color: var(--primary);
border-radius: 12px;
color: var(--primary);
font-family: var(--font-ui);
@@ -89,7 +89,7 @@ button.addBox {
background-color: transparent;
border-width: 2px;
border-style: dashed;
- border-color: #0a1628;
+ border-color: var(--primary);
color: var(--primary);
}
@@ -98,7 +98,7 @@ a.addBox:hover,
button.addBox:hover {
background-color: rgba(var(--primary-rgb) / 0.08);
border-style: dashed;
- border-color: #0a1628;
+ border-color: var(--primary);
color: var(--primary);
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18);
diff --git a/packages/dashboard-ui/src/components/RichTextEditor.module.css b/packages/dashboard-ui/src/components/RichTextEditor.module.css
index d1b085c..f565757 100644
--- a/packages/dashboard-ui/src/components/RichTextEditor.module.css
+++ b/packages/dashboard-ui/src/components/RichTextEditor.module.css
@@ -48,6 +48,11 @@
cursor: not-allowed;
}
+.toolbar button.toolbarBtnActive {
+ background: rgba(var(--primary-rgb) / 0.1);
+ color: var(--primary);
+}
+
.dirBtn {
width: auto;
min-width: 34px;
diff --git a/packages/dashboard-ui/src/components/RichTextEditor.tsx b/packages/dashboard-ui/src/components/RichTextEditor.tsx
index d864316..fec83bb 100644
--- a/packages/dashboard-ui/src/components/RichTextEditor.tsx
+++ b/packages/dashboard-ui/src/components/RichTextEditor.tsx
@@ -1,5 +1,9 @@
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
import {
+ AlignCenter,
+ AlignJustify,
+ AlignLeft,
+ AlignRight,
Bold,
IndentDecrease,
IndentIncrease,
@@ -17,6 +21,15 @@ 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'
+
+type TextAlign = 'left' | 'center' | 'right' | 'justify'
+
+const ALIGN_COMMAND: Record = {
+ left: 'justifyLeft',
+ center: 'justifyCenter',
+ right: 'justifyRight',
+ justify: 'justifyFull',
+}
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
@@ -77,6 +90,7 @@ export function RichTextEditor({
const [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
const [colorOpen, setColorOpen] = useState(false)
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
+ const [currentAlign, setCurrentAlign] = useState(null)
const syncChange = useCallback(() => {
if (editorRef.current) {
@@ -204,6 +218,48 @@ export function RichTextEditor({
return null
}
+ function readBlockAlign(editor: HTMLElement): TextAlign | null {
+ const block = findIndentBlock(editor)
+ if (!block) return null
+
+ const inline = block.style.textAlign.trim().toLowerCase()
+ if (inline === 'left' || inline === 'center' || inline === 'right' || inline === 'justify') {
+ return inline
+ }
+
+ const computed = getComputedStyle(block).textAlign.toLowerCase()
+ if (computed === 'center') return 'center'
+ if (computed === 'justify') return 'justify'
+ if (computed === 'right') return 'right'
+ if (computed === 'left') return 'left'
+
+ const blockDir = (block.getAttribute('dir') || editor.getAttribute('dir') || 'ltr').toLowerCase()
+ if (computed === 'start') return blockDir === 'rtl' ? 'right' : 'left'
+ if (computed === 'end') return blockDir === 'rtl' ? 'left' : 'right'
+ return null
+ }
+
+ function refreshAlignFromSelection() {
+ const editor = editorRef.current
+ if (!editor) return
+ const selection = window.getSelection()
+ if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
+ setCurrentAlign(readBlockAlign(editor))
+ }
+
+ useEffect(() => {
+ function onSelectionChange() {
+ const editor = editorRef.current
+ if (!editor) return
+ const selection = window.getSelection()
+ if (!selection || !selection.anchorNode || !editor.contains(selection.anchorNode)) return
+ setCurrentAlign(readBlockAlign(editor))
+ }
+
+ document.addEventListener('selectionchange', onSelectionChange)
+ return () => document.removeEventListener('selectionchange', onSelectionChange)
+ }, [])
+
function changeIndent(direction: 1 | -1) {
const editor = editorRef.current
if (!editor) return
@@ -245,6 +301,23 @@ export function RichTextEditor({
syncChange()
}
+ function setBlockAlign(align: TextAlign) {
+ const editor = editorRef.current
+ if (!editor) return
+
+ editor.focus()
+ document.execCommand(ALIGN_COMMAND[align], false)
+
+ const block = findIndentBlock(editor)
+ if (block) {
+ block.style.textAlign = align
+ block.removeAttribute('align')
+ }
+
+ setCurrentAlign(align)
+ syncChange()
+ }
+
function applyTextColor(color: string) {
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
setTextColor(normalized)
@@ -435,6 +508,51 @@ export function RichTextEditor({
LTR
+ e.preventDefault()}
+ onClick={() => setBlockAlign('left')}
+ title="Align left"
+ aria-label="Align left"
+ aria-pressed={currentAlign === 'left'}
+ >
+
+
+ e.preventDefault()}
+ onClick={() => setBlockAlign('center')}
+ title="Align center"
+ aria-label="Align center"
+ aria-pressed={currentAlign === 'center'}
+ >
+
+
+ e.preventDefault()}
+ onClick={() => setBlockAlign('right')}
+ title="Align right"
+ aria-label="Align right"
+ aria-pressed={currentAlign === 'right'}
+ >
+
+
+ e.preventDefault()}
+ onClick={() => setBlockAlign('justify')}
+ title="Justify"
+ aria-label="Justify"
+ aria-pressed={currentAlign === 'justify'}
+ >
+
+
+
exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
@@ -498,6 +616,8 @@ export function RichTextEditor({
data-placeholder={placeholder}
onInput={syncChange}
onKeyDown={onEditorKeyDown}
+ onKeyUp={refreshAlignFromSelection}
+ onMouseUp={refreshAlignFromSelection}
suppressContentEditableWarning
/>
{allowImages && selectedImageEl && handlePos && (