Business admins can configure site-wide Schema.org and optional per-item SEO title/description; mass SMS continues on failures with an end report. Co-authored-by: Cursor <cursoragent@cursor.com>
363 lines
12 KiB
TypeScript
363 lines
12 KiB
TypeScript
import { useEffect, useState } from 'react'
|
|
import { Link, useParams, useNavigate } from 'react-router-dom'
|
|
import { SearchableSelect } from '../components/SearchableSelect'
|
|
import { ImageCropper } from '../components/ImageCropper'
|
|
import { TagInput } from '../components/TagInput'
|
|
import { RichTextEditor } from '../components/RichTextEditor'
|
|
import { SeoMetaFields } from '../components/SeoMetaFields'
|
|
import { Breadcrumbs } from '../components/Breadcrumbs'
|
|
import { useToast } from '../context/ToastContext'
|
|
import { useT } from '../i18n/useT'
|
|
import { ApiError } from '../lib/api'
|
|
import {
|
|
listVideoCategories,
|
|
mapVideoCategoryToUi,
|
|
} from '../services/videoCategoryService'
|
|
import {
|
|
resolveDataUrlToMediaId,
|
|
resolveHtmlEmbeddedDataUrls,
|
|
} from '../services/mediaService'
|
|
import {
|
|
createVideo,
|
|
getVideo,
|
|
mapVideoApiToFormState,
|
|
updateVideo,
|
|
} from '../services/videoService'
|
|
import type { Category } from '../types/category'
|
|
import type { VideoProvider } from '../types/video'
|
|
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
|
|
import { flattenCategories } from '../utils/categories'
|
|
import { seoMetaPayload } from '../utils/seoMeta'
|
|
import pageStyles from '../components/PageContent.module.css'
|
|
import styles from './AddNewProductPage.module.css'
|
|
|
|
export function AddNewVideoPage() {
|
|
const { id } = useParams()
|
|
const navigate = useNavigate()
|
|
const { showToast } = useToast()
|
|
const t = useT()
|
|
const isEdit = Boolean(id)
|
|
|
|
const [categories, setCategories] = useState<Category[]>([])
|
|
const [categoryId, setCategoryId] = useState('')
|
|
const [titleFa, setTitleFa] = useState('')
|
|
const [titleEn, setTitleEn] = useState('')
|
|
const [abstract, setAbstract] = useState('')
|
|
const [descriptionHtml, setDescriptionHtml] = useState('')
|
|
const [videoProvider, setVideoProvider] = useState<VideoProvider>('youtube')
|
|
const [embedCode, setEmbedCode] = useState('')
|
|
const [coverImage, setCoverImage] = useState<string | null>(null)
|
|
const [tags, setTags] = useState<string[]>([])
|
|
const [seoMetaTitle, setSeoMetaTitle] = useState('')
|
|
const [seoMetaDescription, setSeoMetaDescription] = useState('')
|
|
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
|
|
const [loaded, setLoaded] = useState(!isEdit)
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [error, setError] = useState('')
|
|
|
|
const categoryOptions = flattenCategories(categories)
|
|
|
|
useEffect(() => {
|
|
const controller = new AbortController()
|
|
|
|
async function loadCategories() {
|
|
try {
|
|
const items = await listVideoCategories(controller.signal)
|
|
setCategories(items.map(mapVideoCategoryToUi))
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('video.categories.errorLoad'))
|
|
}
|
|
}
|
|
}
|
|
|
|
void loadCategories()
|
|
return () => controller.abort()
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isEdit || !id) return
|
|
|
|
const controller = new AbortController()
|
|
|
|
async function loadVideo() {
|
|
setError('')
|
|
try {
|
|
const video = await getVideo(id!, controller.signal)
|
|
const form = mapVideoApiToFormState(video)
|
|
setCategoryId(form.categoryId)
|
|
setTitleFa(form.titleFa)
|
|
setTitleEn(form.titleEn)
|
|
setAbstract(form.abstract)
|
|
setDescriptionHtml(form.descriptionHtml)
|
|
setVideoProvider(form.videoProvider)
|
|
setEmbedCode(form.embedCode)
|
|
setCoverImage(form.coverImage)
|
|
setTags(form.tags)
|
|
setSeoMetaTitle(form.seoMetaTitle)
|
|
setSeoMetaDescription(form.seoMetaDescription)
|
|
setFeaturedMediaId(form.featuredMediaId)
|
|
setLoaded(true)
|
|
} catch (err) {
|
|
if (err instanceof DOMException && err.name === 'AbortError') return
|
|
navigate('/videos/list', { replace: true })
|
|
}
|
|
}
|
|
|
|
void loadVideo()
|
|
return () => controller.abort()
|
|
}, [isEdit, id, navigate])
|
|
|
|
if (!loaded) return null
|
|
|
|
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
|
e.preventDefault()
|
|
setIsSubmitting(true)
|
|
setError('')
|
|
|
|
try {
|
|
const nextFeaturedMediaId = await resolveDataUrlToMediaId(
|
|
coverImage,
|
|
'video-cover-image.jpg',
|
|
featuredMediaId,
|
|
)
|
|
const resolvedDescriptionHtml = await resolveHtmlEmbeddedDataUrls(descriptionHtml)
|
|
|
|
const payload = {
|
|
titleFa: titleFa.trim(),
|
|
titleEn: titleEn.trim() || undefined,
|
|
abstract: abstract.trim(),
|
|
descriptionHtml: resolvedDescriptionHtml,
|
|
videoProvider,
|
|
embedCode: embedCode.trim(),
|
|
categoryId: categoryId || undefined,
|
|
featuredMediaId: nextFeaturedMediaId ?? undefined,
|
|
tags,
|
|
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
|
|
status: 'published' as const,
|
|
}
|
|
|
|
if (isEdit && id) {
|
|
await updateVideo(id, {
|
|
...payload,
|
|
categoryId: categoryId || null,
|
|
featuredMediaId: nextFeaturedMediaId,
|
|
})
|
|
showToast(t('video.form.toast.updated'), 'success')
|
|
} else {
|
|
await createVideo(payload)
|
|
showToast(t('video.form.toast.created'), 'success')
|
|
}
|
|
|
|
navigate('/videos/list')
|
|
} catch (err) {
|
|
if (err instanceof ApiError) {
|
|
setError(err.message)
|
|
} else if (err instanceof Error) {
|
|
setError(err.message)
|
|
} else {
|
|
setError(t('video.form.errorSave'))
|
|
}
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<main className={pageStyles.content}>
|
|
<Breadcrumbs
|
|
items={
|
|
isEdit
|
|
? [
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Videos', href: '/videos' },
|
|
{ label: 'My Videos', href: '/videos/list' },
|
|
{ label: 'Edit Video' },
|
|
]
|
|
: [
|
|
{ label: 'Dashboard', href: '/' },
|
|
{ label: 'Videos', href: '/videos' },
|
|
{ label: 'Add New Video' },
|
|
]
|
|
}
|
|
/>
|
|
<div className={pageStyles.pageHeader}>
|
|
<div>
|
|
<h2 className={pageStyles.pageTitle}>
|
|
{isEdit ? t('title.editVideo') : t('title.addVideo')}
|
|
</h2>
|
|
<p className={pageStyles.pageSubtitle}>
|
|
{isEdit ? t('video.form.edit.subtitle') : t('video.form.add.subtitle')}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className={styles.error} role="alert">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<form className={styles.form} onSubmit={handleSubmit}>
|
|
<div className={styles.formGrid}>
|
|
<div className={`${styles.field} ${styles.col3} ${styles.rowSpan4}`}>
|
|
<label>{t('video.form.coverImage')}</label>
|
|
<ImageCropper
|
|
value={coverImage}
|
|
onChange={setCoverImage}
|
|
aspect={3 / 2}
|
|
uploadLabel={t('video.form.uploadCoverImage')}
|
|
hint={t('video.form.cropHint')}
|
|
changeLabel={t('video.form.changeCoverImage')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.formColumn}>
|
|
<div className={styles.field}>
|
|
<label>{t('video.form.category')}</label>
|
|
<SearchableSelect
|
|
options={categoryOptions}
|
|
value={categoryId}
|
|
onChange={setCategoryId}
|
|
placeholder={t('video.form.categoryPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.titleRow}>
|
|
<div className={styles.field}>
|
|
<label htmlFor="video-title-fa">{t('video.form.titleFa')}</label>
|
|
<input
|
|
id="video-title-fa"
|
|
name="titleFa"
|
|
type="text"
|
|
dir="rtl"
|
|
lang="fa"
|
|
className="faText"
|
|
placeholder={t('video.form.titleFaPlaceholder')}
|
|
value={titleFa}
|
|
onChange={(e) => setTitleFa(e.target.value)}
|
|
required
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="video-title-en">{t('video.form.titleEn')}</label>
|
|
<input
|
|
id="video-title-en"
|
|
name="titleEn"
|
|
type="text"
|
|
dir="ltr"
|
|
placeholder={t('video.form.titleEnPlaceholder')}
|
|
value={titleEn}
|
|
onChange={(e) => setTitleEn(e.target.value)}
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.field}>
|
|
<label htmlFor="video-abstract">{t('video.form.abstract')}</label>
|
|
<textarea
|
|
id="video-abstract"
|
|
name="abstract"
|
|
rows={4}
|
|
placeholder={t('video.form.abstractPlaceholder')}
|
|
value={abstract}
|
|
onChange={(e) => setAbstract(e.target.value)}
|
|
required
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.videoEmbedRow}>
|
|
<div className={styles.providerPanel}>
|
|
<span className={styles.providerLabel}>{t('video.form.provider')}</span>
|
|
<div
|
|
className={styles.providerRadioGroup}
|
|
role="radiogroup"
|
|
aria-label={t('video.form.provider')}
|
|
>
|
|
{VIDEO_PROVIDER_OPTIONS.map((option) => (
|
|
<label key={option.value} className={styles.providerRadioOption}>
|
|
<input
|
|
type="radio"
|
|
name="videoProvider"
|
|
value={option.value}
|
|
checked={videoProvider === option.value}
|
|
onChange={() => setVideoProvider(option.value)}
|
|
disabled={isSubmitting}
|
|
/>
|
|
<span>{option.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className={styles.embedPanel}>
|
|
<label htmlFor="video-embed">{t('video.form.embedCode')}</label>
|
|
<textarea
|
|
id="video-embed"
|
|
name="embedCode"
|
|
rows={5}
|
|
dir="ltr"
|
|
placeholder={t('video.form.embedCodePlaceholder')}
|
|
value={embedCode}
|
|
onChange={(e) => setEmbedCode(e.target.value)}
|
|
required
|
|
disabled={isSubmitting}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.col12}`}>
|
|
<label>{t('video.form.description')}</label>
|
|
<RichTextEditor
|
|
value={descriptionHtml}
|
|
onChange={setDescriptionHtml}
|
|
placeholder={t('video.form.descriptionPlaceholder')}
|
|
allowImages
|
|
editorMinHeight={320}
|
|
/>
|
|
</div>
|
|
|
|
<div className={`${styles.field} ${styles.col12}`}>
|
|
<label>{t('video.form.tags')}</label>
|
|
<TagInput tags={tags} onChange={setTags} />
|
|
</div>
|
|
|
|
<SeoMetaFields
|
|
seoMetaTitle={seoMetaTitle}
|
|
seoMetaDescription={seoMetaDescription}
|
|
onTitleChange={setSeoMetaTitle}
|
|
onDescriptionChange={setSeoMetaDescription}
|
|
disabled={isSubmitting}
|
|
hint={t('seo.meta.hint')}
|
|
titleLabel={t('seo.meta.title')}
|
|
descriptionLabel={t('seo.meta.description')}
|
|
titlePlaceholder={t('seo.meta.titlePlaceholder')}
|
|
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
|
|
/>
|
|
</div>
|
|
|
|
<div className={styles.actions}>
|
|
<Link to="/videos/list" className={styles.cancelBtn}>
|
|
{t('video.form.cancel')}
|
|
</Link>
|
|
<button type="submit" className={styles.submitBtn} disabled={isSubmitting}>
|
|
{isSubmitting
|
|
? t('video.form.saving')
|
|
: isEdit
|
|
? t('video.form.update')
|
|
: t('video.form.save')}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</main>
|
|
)
|
|
}
|