Add workshop generate-link type, Email DNS for domains, and editor polish.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
6be867ea7a
commit
3cbadb4001
@@ -91,7 +91,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
padding-bottom: 28px;
|
padding-bottom: 22px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
}
|
}
|
||||||
@@ -112,15 +112,10 @@
|
|||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartWrapScroll {
|
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
.chartGrid {
|
.chartGrid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
bottom: 28px;
|
bottom: 22px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -149,8 +144,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth {
|
.chartMonth {
|
||||||
gap: 6px;
|
gap: 4px;
|
||||||
overflow-x: auto;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
@@ -167,8 +162,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth .group {
|
.chartMonth .group {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 36px;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bars {
|
.bars {
|
||||||
@@ -177,14 +172,13 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 180px;
|
height: 180px;
|
||||||
gap: 1px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.barWrap {
|
.barWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 1 0;
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -257,6 +251,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -265,11 +262,22 @@
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartMonth .label {
|
.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;
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.barPrimary {
|
.barPrimary {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { BusinessMessageKey } from '../i18n/messages'
|
|||||||
import styles from './DailyActivityChart.module.css'
|
import styles from './DailyActivityChart.module.css'
|
||||||
|
|
||||||
const CHART_HEIGHT = 180
|
const CHART_HEIGHT = 180
|
||||||
|
const LABEL_BAND_DAY = 22
|
||||||
|
const LABEL_BAND_MONTH = 80
|
||||||
|
|
||||||
interface DailyActivityChartProps {
|
interface DailyActivityChartProps {
|
||||||
titleKey: BusinessMessageKey
|
titleKey: BusinessMessageKey
|
||||||
@@ -143,6 +145,7 @@ export function DailyActivityChart({
|
|||||||
|
|
||||||
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
|
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
|
||||||
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
|
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
|
||||||
|
const labelBand = granularity === 'month' ? LABEL_BAND_MONTH : LABEL_BAND_DAY
|
||||||
|
|
||||||
const subtitle = useMemo(() => {
|
const subtitle = useMemo(() => {
|
||||||
if (granularity === 'month') {
|
if (granularity === 'month') {
|
||||||
@@ -191,7 +194,7 @@ export function DailyActivityChart({
|
|||||||
<div
|
<div
|
||||||
className={styles.yAxis}
|
className={styles.yAxis}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ height: CHART_HEIGHT + 28 }}
|
style={{ height: CHART_HEIGHT + labelBand, paddingBottom: labelBand }}
|
||||||
>
|
>
|
||||||
{[...yAxisTicks].reverse().map((tick) => (
|
{[...yAxisTicks].reverse().map((tick) => (
|
||||||
<span key={tick} className={styles.yTick}>
|
<span key={tick} className={styles.yTick}>
|
||||||
@@ -200,17 +203,10 @@ export function DailyActivityChart({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div className={styles.chartWrap}>
|
||||||
className={[
|
|
||||||
styles.chartWrap,
|
|
||||||
granularity === 'month' ? styles.chartWrapScroll : '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={styles.chartGrid}
|
className={styles.chartGrid}
|
||||||
style={{ height: CHART_HEIGHT }}
|
style={{ height: CHART_HEIGHT, bottom: labelBand }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{yAxisTicks.map((tick) => (
|
{yAxisTicks.map((tick) => (
|
||||||
@@ -229,7 +225,7 @@ export function DailyActivityChart({
|
|||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')}
|
.join(' ')}
|
||||||
style={{ height: CHART_HEIGHT + 28 }}
|
style={{ height: CHART_HEIGHT + labelBand }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t(titleKey)}
|
aria-label={t(titleKey)}
|
||||||
>
|
>
|
||||||
@@ -277,9 +273,9 @@ export function DailyActivityChart({
|
|||||||
<span
|
<span
|
||||||
className={styles.label}
|
className={styles.label}
|
||||||
lang={locale === 'fa' ? 'fa' : 'en'}
|
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||||
dir="ltr"
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
>
|
>
|
||||||
{item.label}
|
<span className={styles.labelText}>{item.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { listBlogCategories } from '../services/blogCategoryService'
|
|||||||
import { listPortfolioCategories } from '../services/portfolioCategoryService'
|
import { listPortfolioCategories } from '../services/portfolioCategoryService'
|
||||||
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
|
||||||
import { listVideoCategories } from '../services/videoCategoryService'
|
import { listVideoCategories } from '../services/videoCategoryService'
|
||||||
|
import { listWorkshopCategories } from '../services/workshopCategoryService'
|
||||||
import type { ProductCategory } from '../types/category'
|
import type { ProductCategory } from '../types/category'
|
||||||
import {
|
import {
|
||||||
WEBSITE_LINK_KINDS,
|
WEBSITE_LINK_KINDS,
|
||||||
@@ -31,6 +32,7 @@ const KIND_LABEL: Record<WebsiteLinkKind, BusinessMessageKey> = {
|
|||||||
blog: 'website.linkGenerator.types.blog',
|
blog: 'website.linkGenerator.types.blog',
|
||||||
portfolio: 'website.linkGenerator.types.portfolio',
|
portfolio: 'website.linkGenerator.types.portfolio',
|
||||||
video: 'website.linkGenerator.types.video',
|
video: 'website.linkGenerator.types.video',
|
||||||
|
workshop: 'website.linkGenerator.types.workshop',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANIMATION_MS = 220
|
const ANIMATION_MS = 220
|
||||||
@@ -54,6 +56,8 @@ async function loadCategories(kind: WebsiteLinkKind, signal: AbortSignal) {
|
|||||||
return listPortfolioCategories(signal)
|
return listPortfolioCategories(signal)
|
||||||
case 'video':
|
case 'video':
|
||||||
return listVideoCategories(signal)
|
return listVideoCategories(signal)
|
||||||
|
case 'workshop':
|
||||||
|
return listWorkshopCategories(signal)
|
||||||
case 'product':
|
case 'product':
|
||||||
case 'storeItem':
|
case 'storeItem':
|
||||||
return listProductCategories(signal)
|
return listProductCategories(signal)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 2px dashed rgba(148, 163, 184, 0.4);
|
border: 2px dashed var(--primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border: 2px dashed rgba(148, 163, 184, 0.4);
|
border: 2px dashed var(--primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -41,6 +41,14 @@ export function InstructionCard({
|
|||||||
const hasAbstract = Boolean(video.abstract?.trim())
|
const hasAbstract = Boolean(video.abstract?.trim())
|
||||||
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
const titleDir = isFa ? 'rtl' : titleLocale.dir
|
||||||
const abstractDir = isFa ? 'rtl' : abstractLocale.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 (
|
return (
|
||||||
<article className={cardStyles.card}>
|
<article className={cardStyles.card}>
|
||||||
@@ -58,9 +66,11 @@ export function InstructionCard({
|
|||||||
<Play size={28} strokeWidth={1.5} />
|
<Play size={28} strokeWidth={1.5} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
|
{providerLabel ? (
|
||||||
{video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'}
|
<span className={`${cardStyles.statusBadge} ${cardStyles.verifiedBadge}`}>
|
||||||
</span>
|
{providerLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
|
<div className={cardStyles.body} dir={isFa ? 'rtl' : 'ltr'}>
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
width: 40px;
|
width: 40px;
|
||||||
padding-bottom: 32px;
|
padding-bottom: 80px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
}
|
}
|
||||||
@@ -107,14 +107,13 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow: visible;
|
||||||
overflow-y: visible;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chartGrid {
|
.chartGrid {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset-inline: 0;
|
inset-inline: 0;
|
||||||
bottom: 32px;
|
bottom: 80px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
@@ -132,16 +131,16 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 14px;
|
gap: 4px;
|
||||||
min-width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
direction: ltr;
|
direction: ltr;
|
||||||
unicode-bidi: isolate;
|
unicode-bidi: isolate;
|
||||||
}
|
}
|
||||||
|
|
||||||
.group {
|
.group {
|
||||||
flex: 1;
|
flex: 1 1 0;
|
||||||
min-width: 36px;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -151,13 +150,16 @@
|
|||||||
.bars {
|
.bars {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.barWrap {
|
.barWrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
@@ -228,13 +230,30 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.label {
|
.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);
|
color: var(--text-muted);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
text-align: center;
|
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) {
|
@media (max-width: 768px) {
|
||||||
.header {
|
.header {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { useLocale } from '@meshkee/dashboard-ui'
|
import { useLocale } from '@meshkee/dashboard-ui'
|
||||||
import { ApiError } from '../lib/api'
|
import { ApiError } from '../lib/api'
|
||||||
import { listAllProducts } from '../services/productService'
|
import { listAllProducts } from '../services/productService'
|
||||||
@@ -8,7 +8,8 @@ import { useT } from '../i18n/useT'
|
|||||||
import styles from './ProductActivityChart.module.css'
|
import styles from './ProductActivityChart.module.css'
|
||||||
|
|
||||||
const CHART_HEIGHT = 200
|
const CHART_HEIGHT = 200
|
||||||
const BAR_GAP = 6
|
const LABEL_BAND = 80
|
||||||
|
const BAR_GAP = 4
|
||||||
|
|
||||||
export function ProductActivityChart() {
|
export function ProductActivityChart() {
|
||||||
const t = useT()
|
const t = useT()
|
||||||
@@ -16,7 +17,6 @@ export function ProductActivityChart() {
|
|||||||
const [data, setData] = useState<ProductMonthActivity[]>([])
|
const [data, setData] = useState<ProductMonthActivity[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
const chartWrapRef = useRef<HTMLDivElement>(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -51,13 +51,6 @@ export function ProductActivityChart() {
|
|||||||
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
|
const yAxisTicks = useMemo(() => buildYAxisTicks(maxValue), [maxValue])
|
||||||
const chartScaleMax = yAxisTicks[yAxisTicks.length - 1] ?? 1
|
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(
|
const totals = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
added: data.reduce((sum, item) => sum + item.added, 0),
|
added: data.reduce((sum, item) => sum + item.added, 0),
|
||||||
@@ -96,7 +89,7 @@ export function ProductActivityChart() {
|
|||||||
<div
|
<div
|
||||||
className={styles.yAxis}
|
className={styles.yAxis}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
style={{ height: CHART_HEIGHT + 32 }}
|
style={{ height: CHART_HEIGHT + LABEL_BAND, paddingBottom: LABEL_BAND }}
|
||||||
>
|
>
|
||||||
{[...yAxisTicks].reverse().map((tick) => (
|
{[...yAxisTicks].reverse().map((tick) => (
|
||||||
<span key={tick} className={styles.yTick}>
|
<span key={tick} className={styles.yTick}>
|
||||||
@@ -105,10 +98,10 @@ export function ProductActivityChart() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.chartWrap} ref={chartWrapRef}>
|
<div className={styles.chartWrap}>
|
||||||
<div
|
<div
|
||||||
className={styles.chartGrid}
|
className={styles.chartGrid}
|
||||||
style={{ height: CHART_HEIGHT }}
|
style={{ height: CHART_HEIGHT, bottom: LABEL_BAND }}
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
{yAxisTicks.map((tick) => (
|
{yAxisTicks.map((tick) => (
|
||||||
@@ -122,7 +115,7 @@ export function ProductActivityChart() {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
className={styles.chart}
|
className={styles.chart}
|
||||||
style={{ height: CHART_HEIGHT + 32 }}
|
style={{ height: CHART_HEIGHT + LABEL_BAND }}
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={t('products.activity.chartAria')}
|
aria-label={t('products.activity.chartAria')}
|
||||||
>
|
>
|
||||||
@@ -158,8 +151,12 @@ export function ProductActivityChart() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={styles.label} dir="ltr">
|
<span
|
||||||
{item.label}
|
className={styles.label}
|
||||||
|
lang={locale === 'fa' ? 'fa' : 'en'}
|
||||||
|
dir={locale === 'fa' ? 'rtl' : 'ltr'}
|
||||||
|
>
|
||||||
|
<span className={styles.labelText}>{item.label}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1701,6 +1701,7 @@ const en = {
|
|||||||
'website.linkGenerator.types.blog': 'Blog',
|
'website.linkGenerator.types.blog': 'Blog',
|
||||||
'website.linkGenerator.types.portfolio': 'Portfolio',
|
'website.linkGenerator.types.portfolio': 'Portfolio',
|
||||||
'website.linkGenerator.types.video': 'Video',
|
'website.linkGenerator.types.video': 'Video',
|
||||||
|
'website.linkGenerator.types.workshop': 'Workshop',
|
||||||
|
|
||||||
'website.staticImages.subtitle':
|
'website.staticImages.subtitle':
|
||||||
'Websites load these by key from GET /tenants/{domain}/website/static-images',
|
'Websites load these by key from GET /tenants/{domain}/website/static-images',
|
||||||
@@ -4031,6 +4032,7 @@ const fa: Record<MessageKey, string> = {
|
|||||||
'website.linkGenerator.types.blog': 'بلاگ',
|
'website.linkGenerator.types.blog': 'بلاگ',
|
||||||
'website.linkGenerator.types.portfolio': 'نمونه کار',
|
'website.linkGenerator.types.portfolio': 'نمونه کار',
|
||||||
'website.linkGenerator.types.video': 'ویدیو',
|
'website.linkGenerator.types.video': 'ویدیو',
|
||||||
|
'website.linkGenerator.types.workshop': 'کارگاه',
|
||||||
|
|
||||||
'website.staticImages.subtitle':
|
'website.staticImages.subtitle':
|
||||||
'وبسایتها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images میخوانند',
|
'وبسایتها این تصاویر را با کلید از GET /tenants/{domain}/website/static-images میخوانند',
|
||||||
|
|||||||
@@ -290,7 +290,7 @@
|
|||||||
padding: 3px;
|
padding: 3px;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: 1px solid #0a1628;
|
border: 1px solid var(--primary);
|
||||||
border-radius: 50px;
|
border-radius: 50px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,8 +83,14 @@ export function InstructionDetailsPage() {
|
|||||||
const contentLocale = textLocaleAttrs(
|
const contentLocale = textLocaleAttrs(
|
||||||
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
|
video.descriptionHtml?.replace(/<[^>]+>/g, ' ') ?? '',
|
||||||
)
|
)
|
||||||
const providerLabel =
|
const hasVideo = Boolean(video.embedCode?.trim())
|
||||||
video.videoProvider === 'youtube' ? 'YouTube' : 'Aparat'
|
const providerLabel = !hasVideo
|
||||||
|
? null
|
||||||
|
: video.videoProvider === 'youtube'
|
||||||
|
? 'YouTube'
|
||||||
|
: video.videoProvider === 'aparat'
|
||||||
|
? 'Aparat'
|
||||||
|
: null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={pageStyles.content}>
|
<main className={pageStyles.content}>
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ export function InstructionListPage() {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<div className={pageStyles.grid}>
|
<div className={pageStyles.gridCols4}>
|
||||||
{videos.map((video) => (
|
{videos.map((video) => (
|
||||||
<InstructionCard
|
<InstructionCard
|
||||||
key={video.id}
|
key={video.id}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { getBusinessDomain } from '../lib/config'
|
import { getBusinessDomain } from '../lib/config'
|
||||||
|
|
||||||
export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video'
|
export type WebsiteLinkKind = 'product' | 'storeItem' | 'blog' | 'portfolio' | 'video' | 'workshop'
|
||||||
|
|
||||||
export type WebsiteLinkFilter = 'category' | 'tag'
|
export type WebsiteLinkFilter = 'category' | 'tag'
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export const WEBSITE_LINK_KINDS: WebsiteLinkKind[] = [
|
|||||||
'blog',
|
'blog',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
'video',
|
'video',
|
||||||
|
'workshop',
|
||||||
]
|
]
|
||||||
|
|
||||||
export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
|
export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
|
||||||
@@ -18,6 +19,7 @@ export const WEBSITE_LINK_KIND_PATH: Record<WebsiteLinkKind, string> = {
|
|||||||
blog: '/blogs',
|
blog: '/blogs',
|
||||||
portfolio: '/portfolios',
|
portfolio: '/portfolios',
|
||||||
video: '/videos',
|
video: '/videos',
|
||||||
|
workshop: '/workshops',
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
|
export function getWebsiteOrigin(domain = getBusinessDomain()): string {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
border: 2px dashed rgba(148, 163, 184, 0.4);
|
border: 2px dashed var(--primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -62,7 +62,7 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
border: 2px dashed rgba(148, 163, 184, 0.4);
|
border: 2px dashed var(--primary);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
background: color-mix(in srgb, var(--surface) 70%, transparent);
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ExternalLink,
|
ExternalLink,
|
||||||
FileText,
|
FileText,
|
||||||
Globe,
|
Globe,
|
||||||
|
Mail,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserRound,
|
UserRound,
|
||||||
@@ -20,6 +21,7 @@ type PopoverPosition = {
|
|||||||
interface DomainLinksOverflowMenuProps {
|
interface DomainLinksOverflowMenuProps {
|
||||||
host: string
|
host: string
|
||||||
onAiPrompts: () => void
|
onAiPrompts: () => void
|
||||||
|
onEmailDns: () => void
|
||||||
onRemove: () => void
|
onRemove: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,7 @@ function openHost(host: string) {
|
|||||||
export function DomainLinksOverflowMenu({
|
export function DomainLinksOverflowMenu({
|
||||||
host,
|
host,
|
||||||
onAiPrompts,
|
onAiPrompts,
|
||||||
|
onEmailDns,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: DomainLinksOverflowMenuProps) {
|
}: DomainLinksOverflowMenuProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
@@ -61,7 +64,7 @@ export function DomainLinksOverflowMenu({
|
|||||||
|
|
||||||
const size = popoverRef.current
|
const size = popoverRef.current
|
||||||
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
? { width: popoverRef.current.offsetWidth, height: popoverRef.current.offsetHeight }
|
||||||
: { width: 220, height: 280 }
|
: { width: 220, height: 320 }
|
||||||
|
|
||||||
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
setPopoverPos(fitFixedPopover(rect, size, 'end'))
|
||||||
}
|
}
|
||||||
@@ -190,6 +193,18 @@ export function DomainLinksOverflowMenu({
|
|||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
<div className={styles.menuDivider} role="separator" />
|
<div className={styles.menuDivider} role="separator" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.item}
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false)
|
||||||
|
onEmailDns()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Mail size={16} />
|
||||||
|
<span>Email DNS</span>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.item}
|
className={styles.item}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
.providerRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.providerLabel {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 110px;
|
||||||
|
padding: var(--field-padding-y) var(--field-padding-x);
|
||||||
|
font-size: var(--field-font-size);
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: rgba(255, 255, 255, 0.65);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:focus {
|
||||||
|
border-color: var(--primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.textarea:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Modal } from './Modal'
|
||||||
|
import { DnsProviderSwitch } from './DnsProviderSwitch'
|
||||||
|
import { ApiError } from '../lib/api'
|
||||||
|
import { applyMailDns } from '../services/domainService'
|
||||||
|
import type { DomainListItem, ParkedDnsProvider } from '../types/domain'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import tableStyles from '../pages/BusinessesPage.module.css'
|
||||||
|
import parkedStyles from './ParkedDomainsModal.module.css'
|
||||||
|
import styles from './EmailDnsModal.module.css'
|
||||||
|
|
||||||
|
interface EmailDnsModalProps {
|
||||||
|
domain: DomainListItem | null
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailDnsModal({ domain, onClose }: EmailDnsModalProps) {
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [dnsProvider, setDnsProvider] = useState<ParkedDnsProvider>('arvan')
|
||||||
|
const [ed25519Key, setEd25519Key] = useState('')
|
||||||
|
const [rsaKey, setRsaKey] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!domain) return
|
||||||
|
setDnsProvider('arvan')
|
||||||
|
setEd25519Key('')
|
||||||
|
setRsaKey('')
|
||||||
|
setError('')
|
||||||
|
setSubmitting(false)
|
||||||
|
}, [domain])
|
||||||
|
|
||||||
|
if (!domain) return null
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
setSubmitting(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const result = await applyMailDns(domain.id, {
|
||||||
|
dnsProvider,
|
||||||
|
ed25519Key,
|
||||||
|
rsaKey,
|
||||||
|
})
|
||||||
|
showToast(result.message, 'success')
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const message =
|
||||||
|
err instanceof ApiError ? err.message : 'Unable to update mail DNS.'
|
||||||
|
setError(message)
|
||||||
|
showToast(message, 'error')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
wide
|
||||||
|
title={`Email DNS · ${domain.host}`}
|
||||||
|
onClose={() => {
|
||||||
|
if (!submitting) onClose()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className={parkedStyles.hint}>
|
||||||
|
Paste the two DKIM lines from Stalwart (Directory → Domains → ⋮ → View DNS
|
||||||
|
zone file). We upsert mail records on{' '}
|
||||||
|
{dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} (DNS-only / not
|
||||||
|
proxied): <strong>mail</strong> A → 185.214.101.41, MX → mail.meshkee.com,
|
||||||
|
SPF, both DKIM keys, autoconfig / autodiscover. Existing DMARC is left as-is.
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<p className={tableStyles.alertError} role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{submitting ? (
|
||||||
|
<p className={tableStyles.helperText} role="status">
|
||||||
|
Updating {dnsProvider === 'cloudflare' ? 'Cloudflare' : 'Arvan'} mail DNS…
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.providerRow}>
|
||||||
|
<span className={styles.providerLabel}>DNS host</span>
|
||||||
|
<DnsProviderSwitch
|
||||||
|
value={dnsProvider}
|
||||||
|
onChange={setDnsProvider}
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="mail-dkim-ed25519">Ed25519 domain key</label>
|
||||||
|
<textarea
|
||||||
|
id="mail-dkim-ed25519"
|
||||||
|
className={styles.textarea}
|
||||||
|
value={ed25519Key}
|
||||||
|
onChange={(e) => setEd25519Key(e.target.value)}
|
||||||
|
placeholder="v1-ed25519-…. _domainkey IN TXT "v=DKIM1; k=ed25519; …""
|
||||||
|
disabled={submitting}
|
||||||
|
spellCheck={false}
|
||||||
|
dir="ltr"
|
||||||
|
rows={5}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ height: 12 }} />
|
||||||
|
<div className={tableStyles.field}>
|
||||||
|
<label htmlFor="mail-dkim-rsa">RSA domain key</label>
|
||||||
|
<textarea
|
||||||
|
id="mail-dkim-rsa"
|
||||||
|
className={styles.textarea}
|
||||||
|
value={rsaKey}
|
||||||
|
onChange={(e) => setRsaKey(e.target.value)}
|
||||||
|
placeholder="v1-rsa-…. _domainkey IN TXT "v=DKIM1; k=rsa; …""
|
||||||
|
disabled={submitting}
|
||||||
|
spellCheck={false}
|
||||||
|
dir="ltr"
|
||||||
|
rows={7}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ height: 12 }} />
|
||||||
|
<div className={tableStyles.actionsRow}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnGhost}`}
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${tableStyles.btn} ${tableStyles.btnPrimary}`}
|
||||||
|
onClick={() => void submit()}
|
||||||
|
disabled={submitting || !ed25519Key.trim() || !rsaKey.trim()}
|
||||||
|
>
|
||||||
|
Update DNS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { ConfirmDeleteModal } from '../components/ConfirmDeleteModal'
|
|||||||
import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal'
|
import { DomainAiPromptsModal } from '../components/DomainAiPromptsModal'
|
||||||
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
import { DomainLinksOverflowMenu } from '../components/DomainLinksOverflowMenu'
|
||||||
import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
|
import { DnsProviderSwitch } from '../components/DnsProviderSwitch'
|
||||||
|
import { EmailDnsModal } from '../components/EmailDnsModal'
|
||||||
import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
|
import { ParkedDomainsModal } from '../components/ParkedDomainsModal'
|
||||||
import { Modal } from '../components/Modal'
|
import { Modal } from '../components/Modal'
|
||||||
import { ToggleSwitch } from '../components/ToggleSwitch'
|
import { ToggleSwitch } from '../components/ToggleSwitch'
|
||||||
@@ -113,6 +114,7 @@ export function WebsitesPage() {
|
|||||||
|
|
||||||
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
const [removeTarget, setRemoveTarget] = useState<DomainListItem | null>(null)
|
||||||
const [promptsDomain, setPromptsDomain] = useState<DomainListItem | null>(null)
|
const [promptsDomain, setPromptsDomain] = useState<DomainListItem | null>(null)
|
||||||
|
const [mailDomain, setMailDomain] = useState<DomainListItem | null>(null)
|
||||||
const [parkedDomain, setParkedDomain] = useState<DomainListItem | null>(null)
|
const [parkedDomain, setParkedDomain] = useState<DomainListItem | null>(null)
|
||||||
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
const [togglingActiveId, setTogglingActiveId] = useState<number | null>(null)
|
||||||
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
const [deployingId, setDeployingId] = useState<DomainListItem['id'] | null>(null)
|
||||||
@@ -778,6 +780,7 @@ export function WebsitesPage() {
|
|||||||
<DomainLinksOverflowMenu
|
<DomainLinksOverflowMenu
|
||||||
host={domain.host}
|
host={domain.host}
|
||||||
onAiPrompts={() => setPromptsDomain(domain)}
|
onAiPrompts={() => setPromptsDomain(domain)}
|
||||||
|
onEmailDns={() => setMailDomain(domain)}
|
||||||
onRemove={() => setRemoveTarget(domain)}
|
onRemove={() => setRemoveTarget(domain)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -908,6 +911,8 @@ export function WebsitesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<EmailDnsModal domain={mailDomain} onClose={() => setMailDomain(null)} />
|
||||||
|
|
||||||
<ParkedDomainsModal
|
<ParkedDomainsModal
|
||||||
domain={parkedDomain}
|
domain={parkedDomain}
|
||||||
onClose={() => setParkedDomain(null)}
|
onClose={() => setParkedDomain(null)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
IssueWebsiteSslResponse,
|
IssueWebsiteSslResponse,
|
||||||
SyncSslResponse,
|
SyncSslResponse,
|
||||||
UpdateParkedHostsResponse,
|
UpdateParkedHostsResponse,
|
||||||
|
ApplyMailDnsResponse,
|
||||||
} from '../types/domain'
|
} from '../types/domain'
|
||||||
|
|
||||||
export interface ListDomainsParams {
|
export interface ListDomainsParams {
|
||||||
@@ -106,3 +107,18 @@ export async function issueDomainSsl(domainId: number | string) {
|
|||||||
auth: true,
|
auth: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function applyMailDns(
|
||||||
|
domainId: number | string,
|
||||||
|
payload: {
|
||||||
|
dnsProvider: 'arvan' | 'cloudflare'
|
||||||
|
ed25519Key: string
|
||||||
|
rsaKey: string
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiRequest<ApplyMailDnsResponse>(`/domains/${domainId}/mail-dns`, {
|
||||||
|
method: 'POST',
|
||||||
|
auth: true,
|
||||||
|
body: payload,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -94,3 +94,13 @@ export interface UpdateParkedHostsResponse {
|
|||||||
error?: string
|
error?: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApplyMailDnsResponse {
|
||||||
|
host: string
|
||||||
|
dnsProvider: ParkedDnsProvider
|
||||||
|
created: string[]
|
||||||
|
updated: string[]
|
||||||
|
skipped: string[]
|
||||||
|
removed: string[]
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ Add to `/etc/hosts` (one line per tenant):
|
|||||||
| `/businesses/:businessId/invoices` | Business invoices list |
|
| `/businesses/:businessId/invoices` | Business invoices list |
|
||||||
| `/businesses/:businessId/invoices/new` | Issue invoice (full page) |
|
| `/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) |
|
| `/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). |
|
| `/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` | Platform settings (invoice templates + item templates) |
|
||||||
| `/settings/invoice-templates/new` | Create invoice template |
|
| `/settings/invoice-templates/new` | Create invoice template |
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
border-color: #0a1628;
|
border-color: var(--primary);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
font-family: var(--font-ui);
|
font-family: var(--font-ui);
|
||||||
@@ -89,7 +89,7 @@ button.addBox {
|
|||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-width: 2px;
|
border-width: 2px;
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
border-color: #0a1628;
|
border-color: var(--primary);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ a.addBox:hover,
|
|||||||
button.addBox:hover {
|
button.addBox:hover {
|
||||||
background-color: rgba(var(--primary-rgb) / 0.08);
|
background-color: rgba(var(--primary-rgb) / 0.08);
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
border-color: #0a1628;
|
border-color: var(--primary);
|
||||||
color: var(--primary);
|
color: var(--primary);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18);
|
box-shadow: 0 8px 20px rgba(var(--primary-rgb) / 0.18);
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toolbar button.toolbarBtnActive {
|
||||||
|
background: rgba(var(--primary-rgb) / 0.1);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
.dirBtn {
|
.dirBtn {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
|
import { useRef, useEffect, useState, useCallback, useContext } from 'react'
|
||||||
import {
|
import {
|
||||||
|
AlignCenter,
|
||||||
|
AlignJustify,
|
||||||
|
AlignLeft,
|
||||||
|
AlignRight,
|
||||||
Bold,
|
Bold,
|
||||||
IndentDecrease,
|
IndentDecrease,
|
||||||
IndentIncrease,
|
IndentIncrease,
|
||||||
@@ -17,6 +21,15 @@ import styles from './RichTextEditor.module.css'
|
|||||||
const INDENT_STEP_PX = 24
|
const INDENT_STEP_PX = 24
|
||||||
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
|
const INDENT_BLOCK_TAGS = new Set(['P', 'DIV', 'LI', 'BLOCKQUOTE', 'H1', 'H2', 'H3', 'H4'])
|
||||||
const DEFAULT_TEXT_COLOR = '#0f172a'
|
const DEFAULT_TEXT_COLOR = '#0f172a'
|
||||||
|
|
||||||
|
type TextAlign = 'left' | 'center' | 'right' | 'justify'
|
||||||
|
|
||||||
|
const ALIGN_COMMAND: Record<TextAlign, string> = {
|
||||||
|
left: 'justifyLeft',
|
||||||
|
center: 'justifyCenter',
|
||||||
|
right: 'justifyRight',
|
||||||
|
justify: 'justifyFull',
|
||||||
|
}
|
||||||
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
const SELECTED_IMAGE_CLASS = 'richTextImageSelected'
|
||||||
const HEX_COLOR_PATTERN = /^#([0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/
|
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 [textColor, setTextColor] = useState(DEFAULT_TEXT_COLOR)
|
||||||
const [colorOpen, setColorOpen] = useState(false)
|
const [colorOpen, setColorOpen] = useState(false)
|
||||||
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
const [hexInput, setHexInput] = useState(DEFAULT_TEXT_COLOR)
|
||||||
|
const [currentAlign, setCurrentAlign] = useState<TextAlign | null>(null)
|
||||||
|
|
||||||
const syncChange = useCallback(() => {
|
const syncChange = useCallback(() => {
|
||||||
if (editorRef.current) {
|
if (editorRef.current) {
|
||||||
@@ -204,6 +218,48 @@ export function RichTextEditor({
|
|||||||
return null
|
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) {
|
function changeIndent(direction: 1 | -1) {
|
||||||
const editor = editorRef.current
|
const editor = editorRef.current
|
||||||
if (!editor) return
|
if (!editor) return
|
||||||
@@ -245,6 +301,23 @@ export function RichTextEditor({
|
|||||||
syncChange()
|
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) {
|
function applyTextColor(color: string) {
|
||||||
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
|
const normalized = normalizeHexColor(color) ?? DEFAULT_TEXT_COLOR
|
||||||
setTextColor(normalized)
|
setTextColor(normalized)
|
||||||
@@ -435,6 +508,51 @@ export function RichTextEditor({
|
|||||||
LTR
|
LTR
|
||||||
</button>
|
</button>
|
||||||
<span className={styles.divider} />
|
<span className={styles.divider} />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={currentAlign === 'left' ? styles.toolbarBtnActive : undefined}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setBlockAlign('left')}
|
||||||
|
title="Align left"
|
||||||
|
aria-label="Align left"
|
||||||
|
aria-pressed={currentAlign === 'left'}
|
||||||
|
>
|
||||||
|
<AlignLeft size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={currentAlign === 'center' ? styles.toolbarBtnActive : undefined}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setBlockAlign('center')}
|
||||||
|
title="Align center"
|
||||||
|
aria-label="Align center"
|
||||||
|
aria-pressed={currentAlign === 'center'}
|
||||||
|
>
|
||||||
|
<AlignCenter size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={currentAlign === 'right' ? styles.toolbarBtnActive : undefined}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setBlockAlign('right')}
|
||||||
|
title="Align right"
|
||||||
|
aria-label="Align right"
|
||||||
|
aria-pressed={currentAlign === 'right'}
|
||||||
|
>
|
||||||
|
<AlignRight size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={currentAlign === 'justify' ? styles.toolbarBtnActive : undefined}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setBlockAlign('justify')}
|
||||||
|
title="Justify"
|
||||||
|
aria-label="Justify"
|
||||||
|
aria-pressed={currentAlign === 'justify'}
|
||||||
|
>
|
||||||
|
<AlignJustify size={16} />
|
||||||
|
</button>
|
||||||
|
<span className={styles.divider} />
|
||||||
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
|
<button type="button" onClick={() => exec('insertUnorderedList')} title="Bullet list" aria-label="Bullet list">
|
||||||
<List size={16} />
|
<List size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -498,6 +616,8 @@ export function RichTextEditor({
|
|||||||
data-placeholder={placeholder}
|
data-placeholder={placeholder}
|
||||||
onInput={syncChange}
|
onInput={syncChange}
|
||||||
onKeyDown={onEditorKeyDown}
|
onKeyDown={onEditorKeyDown}
|
||||||
|
onKeyUp={refreshAlignFromSelection}
|
||||||
|
onMouseUp={refreshAlignFromSelection}
|
||||||
suppressContentEditableWarning
|
suppressContentEditableWarning
|
||||||
/>
|
/>
|
||||||
{allowImages && selectedImageEl && handlePos && (
|
{allowImages && selectedImageEl && handlePos && (
|
||||||
|
|||||||
Reference in New Issue
Block a user