You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
957 lines
46 KiB
957 lines
46 KiB
import React, { useState, useMemo } from 'react'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { Ic } from '@/icons'
|
|
import { Card, CardContent } from '@/components/ui/card'
|
|
import { Button } from '@/components/ui/button'
|
|
import { Input } from '@/components/ui/input'
|
|
import { Badge } from '@/components/ui/badge'
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
DialogDescription,
|
|
DialogBody,
|
|
DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import { toast } from '@/stores/toast'
|
|
import { toFa } from '@/lib/utils'
|
|
import { formatDualDate } from '@/lib/date-utils'
|
|
import { useTranslation } from '@/i18n'
|
|
import {
|
|
fetchAdminBlogs,
|
|
saveAdminArticle,
|
|
deleteAdminArticle,
|
|
toggleAdminArticleStatus,
|
|
type BlogArticleItem,
|
|
type SaveBlogArticlePayload,
|
|
} from '../services/blogs-api'
|
|
|
|
const LANGUAGE_CONFIG = [
|
|
{ code: 'fa', label: 'فارسی', flag: '🇮🇷', dir: 'rtl' },
|
|
{ code: 'ar', label: 'العربية', flag: '🇸🇦', dir: 'rtl' },
|
|
{ code: 'en', label: 'English', flag: '🇬🇧', dir: 'ltr' },
|
|
{ code: 'ru', label: 'Русский', flag: '🇷🇺', dir: 'ltr' },
|
|
{ code: 'id', label: 'Indonesia', flag: '🇮🇩', dir: 'ltr' },
|
|
] as const
|
|
|
|
type SupportedLang = (typeof LANGUAGE_CONFIG)[number]['code']
|
|
|
|
const FILTER_LANGUAGE_OPTIONS = [
|
|
{ code: 'ALL', label: 'همه زبانها', flag: '🌐' },
|
|
...LANGUAGE_CONFIG,
|
|
]
|
|
|
|
interface FormTranslationState {
|
|
title: string
|
|
summary: string
|
|
content: string
|
|
tag_names: string[]
|
|
}
|
|
|
|
export function BlogsServiceView() {
|
|
const { language } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const [selectedFilterLang, setSelectedFilterLang] = useState<string>('ALL')
|
|
const [statusFilter, setStatusFilter] = useState<string>('ALL')
|
|
const [searchQuery, setSearchQuery] = useState<string>('')
|
|
|
|
// Dialog state
|
|
const [isModalOpen, setIsModalOpen] = useState<boolean>(false)
|
|
const [editingArticleId, setEditingArticleId] = useState<number | null>(null)
|
|
const [formGroupKey, setFormGroupKey] = useState<string>('')
|
|
const [formBaseSlug, setFormBaseSlug] = useState<string>('')
|
|
const [formCover, setFormCover] = useState<string>('')
|
|
const [formStatus, setFormStatus] = useState<boolean>(true)
|
|
const [formIsTopList, setFormIsTopList] = useState<boolean>(false)
|
|
const [activeFormLang, setActiveFormLang] = useState<SupportedLang>('fa')
|
|
const [previewMode, setPreviewMode] = useState<boolean>(false)
|
|
|
|
// Translations dictionary for the 5 languages
|
|
const [formTranslations, setFormTranslations] = useState<Record<SupportedLang, FormTranslationState>>({
|
|
fa: { title: '', summary: '', content: '', tag_names: ['زیارت', 'عقیله'] },
|
|
ar: { title: '', summary: '', content: '', tag_names: ['زيارة', 'عقيلة'] },
|
|
en: { title: '', summary: '', content: '', tag_names: ['Pilgrimage', 'Aqila'] },
|
|
ru: { title: '', summary: '', content: '', tag_names: ['Паломничество', 'Акила'] },
|
|
id: { title: '', summary: '', content: '', tag_names: ['Ziarah', 'Aqila'] },
|
|
})
|
|
|
|
// Query articles list
|
|
const { data: articles = [], isLoading, refetch } = useQuery({
|
|
queryKey: ['admin-blogs', selectedFilterLang, statusFilter],
|
|
queryFn: () =>
|
|
fetchAdminBlogs({
|
|
language_code: selectedFilterLang !== 'ALL' ? selectedFilterLang : undefined,
|
|
status: statusFilter !== 'ALL' ? statusFilter : undefined,
|
|
}),
|
|
})
|
|
|
|
// Mutations
|
|
const saveMutation = useMutation({
|
|
mutationFn: (payload: SaveBlogArticlePayload) => saveAdminArticle(payload),
|
|
onSuccess: (_, variables) => {
|
|
qc.invalidateQueries({ queryKey: ['admin-blogs'] })
|
|
setIsModalOpen(false)
|
|
toast.success(variables.id ? 'مقاله و ترجمههای آن با موفقیت بهروزرسانی شد.' : 'مقاله جدید با موفقیت در دیتابیس ثبت گردید.')
|
|
},
|
|
onError: (err: any) => {
|
|
toast.error(err?.message || 'خطا در ثبت یا بهروزرسانی مقاله')
|
|
},
|
|
})
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (id: number) => deleteAdminArticle(id),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['admin-blogs'] })
|
|
toast.success('مقاله و کلیه ترجمههای آن حذف شدند.')
|
|
},
|
|
onError: (err: any) => {
|
|
toast.error(err?.message || 'خطا در حذف مقاله')
|
|
},
|
|
})
|
|
|
|
const toggleStatusMutation = useMutation({
|
|
mutationFn: ({ id, status }: { id: number; status: boolean }) =>
|
|
toggleAdminArticleStatus(id, status),
|
|
onSuccess: () => {
|
|
qc.invalidateQueries({ queryKey: ['admin-blogs'] })
|
|
toast.success('وضعیت انتشار مقاله در کل زبانها بهروزرسانی شد.')
|
|
},
|
|
onError: (err: any) => {
|
|
toast.error(err?.message || 'خطا در تغییر وضعیت مقاله')
|
|
},
|
|
})
|
|
|
|
// Filtered articles
|
|
const filteredArticles = useMemo(() => {
|
|
let list = articles
|
|
if (searchQuery.trim()) {
|
|
const q = searchQuery.toLowerCase().trim()
|
|
list = list.filter((a) => {
|
|
if (a.title?.toLowerCase().includes(q) || a.base_slug?.toLowerCase().includes(q)) {
|
|
return true
|
|
}
|
|
// Search across all translations
|
|
return Object.values(a.translations || {}).some(
|
|
(t) => t.title?.toLowerCase().includes(q) || t.summary?.toLowerCase().includes(q)
|
|
)
|
|
})
|
|
}
|
|
return list
|
|
}, [articles, searchQuery])
|
|
|
|
// Statistics
|
|
const totalCount = articles.length
|
|
const activeCount = articles.filter((a) => a.status).length
|
|
const draftCount = articles.filter((a) => !a.status).length
|
|
|
|
// Open Add Dialog
|
|
const handleOpenAdd = () => {
|
|
setEditingArticleId(null)
|
|
setFormGroupKey('')
|
|
setFormBaseSlug('')
|
|
setFormCover('')
|
|
setFormStatus(true)
|
|
setFormIsTopList(false)
|
|
setActiveFormLang('fa')
|
|
setPreviewMode(false)
|
|
setFormTranslations({
|
|
fa: { title: '', summary: '', content: '', tag_names: ['زیارت', 'عقیله'] },
|
|
ar: { title: '', summary: '', content: '', tag_names: ['زيارة', 'عقيلة'] },
|
|
en: { title: '', summary: '', content: '', tag_names: ['Pilgrimage', 'Aqila'] },
|
|
ru: { title: '', summary: '', content: '', tag_names: ['Паломничество', 'Акила'] },
|
|
id: { title: '', summary: '', content: '', tag_names: ['Ziarah', 'Aqila'] },
|
|
})
|
|
setIsModalOpen(true)
|
|
}
|
|
|
|
// Open Edit Dialog
|
|
const handleOpenEdit = (article: BlogArticleItem) => {
|
|
setEditingArticleId(article.id)
|
|
setFormGroupKey(article.group_key || article.base_slug)
|
|
setFormBaseSlug(article.base_slug || article.group_key)
|
|
setFormCover(article.cover || '')
|
|
setFormStatus(article.status)
|
|
setFormIsTopList(article.is_top_list)
|
|
setActiveFormLang(
|
|
(article.available_languages?.[0] as SupportedLang) || 'fa'
|
|
)
|
|
setPreviewMode(false)
|
|
|
|
// Populate translations
|
|
const newTrans: Record<SupportedLang, FormTranslationState> = {
|
|
fa: { title: '', summary: '', content: '', tag_names: [] },
|
|
ar: { title: '', summary: '', content: '', tag_names: [] },
|
|
en: { title: '', summary: '', content: '', tag_names: [] },
|
|
ru: { title: '', summary: '', content: '', tag_names: [] },
|
|
id: { title: '', summary: '', content: '', tag_names: [] },
|
|
}
|
|
|
|
LANGUAGE_CONFIG.forEach((cfg) => {
|
|
const existing = article.translations?.[cfg.code]
|
|
if (existing) {
|
|
newTrans[cfg.code] = {
|
|
title: existing.title || '',
|
|
summary: existing.summary || '',
|
|
content: existing.content || '',
|
|
tag_names:
|
|
existing.tag_names ||
|
|
existing.tags?.map((t) => t.name) ||
|
|
[],
|
|
}
|
|
}
|
|
})
|
|
|
|
setFormTranslations(newTrans)
|
|
setIsModalOpen(true)
|
|
}
|
|
|
|
// Save handler
|
|
const handleSaveArticle = () => {
|
|
// Validate that at least one language has a title
|
|
const hasAnyTranslation = Object.values(formTranslations).some(
|
|
(t) => t.title && t.title.trim().length > 0
|
|
)
|
|
|
|
if (!hasAnyTranslation) {
|
|
toast.error('لطفاً عنوان مقاله را حداقل برای یک زبان وارد نمایید.')
|
|
return
|
|
}
|
|
|
|
let finalSlug = formBaseSlug.trim()
|
|
if (!finalSlug) {
|
|
// Auto generate from first available title
|
|
const firstTitle = Object.values(formTranslations).find((t) => t.title.trim())?.title || 'article'
|
|
finalSlug = firstTitle
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\u0600-\u06FF\s-]/g, '')
|
|
.replace(/\s+/g, '-')
|
|
.slice(0, 50)
|
|
}
|
|
|
|
const payload: SaveBlogArticlePayload = {
|
|
id: editingArticleId || undefined,
|
|
group_key: formGroupKey || finalSlug,
|
|
base_slug: finalSlug,
|
|
cover: formCover.trim() || undefined,
|
|
status: formStatus,
|
|
is_top_list: formIsTopList,
|
|
translations: formTranslations,
|
|
}
|
|
|
|
saveMutation.mutate(payload)
|
|
}
|
|
|
|
const currentLangConfig = LANGUAGE_CONFIG.find((l) => l.code === activeFormLang) || LANGUAGE_CONFIG[0]
|
|
const currentTranslation = formTranslations[activeFormLang]
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ۱. سربرگ یکپارچه بخش وبلاگ و آمار کلان */}
|
|
<div className="relative overflow-hidden rounded-2xl border border-border-soft bg-card/40 p-5 sm:p-6 shadow-xs backdrop-blur-xs">
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
<Ic name="file" className="size-5" />
|
|
</div>
|
|
<h1 className="text-xl font-black text-foreground">
|
|
وبلاگ و مقالات دانشنامه
|
|
</h1>
|
|
<Badge
|
|
variant="outline"
|
|
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs"
|
|
>
|
|
Articles & Encyclopedia
|
|
</Badge>
|
|
</div>
|
|
<p className="text-xs text-grey-3">
|
|
بانک مقالات تخصصی، دانشنامه اماکن متبرکه، راهنمای جامع زیارت و سفر به عتبات عالیات با مدیریت یکپارچه ترجمه در ۵ زبان
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2.5 shrink-0 flex-wrap">
|
|
<Badge
|
|
variant="outline"
|
|
className="border-border-soft bg-card/70 text-grey-2 text-xs py-1.5 px-3 rounded-xl font-medium gap-1.5 shadow-2xs"
|
|
>
|
|
<span className="size-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
<span>محتوا و سئو چندزبانه</span>
|
|
</Badge>
|
|
|
|
<Button
|
|
onClick={handleOpenAdd}
|
|
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-bold h-9 px-4 rounded-xl shadow-xs cursor-pointer"
|
|
>
|
|
<Ic name="plus" className="size-4" />
|
|
افزودن مقاله جدید
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* کارتهای آماری */}
|
|
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
<Card className="border-border-soft bg-card/60 backdrop-blur">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-grey-3">کل مقالات دانشنامه</p>
|
|
<p className="text-xl font-black text-foreground mt-1 tabular-nums">{toFa(totalCount)}</p>
|
|
</div>
|
|
<span className="p-2 rounded-xl bg-primary/10 text-primary">
|
|
<Ic name="file" className="size-4" />
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/60 backdrop-blur">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-grey-3">مقالات منتشرشده (فعال)</p>
|
|
<p className="text-xl font-black text-emerald-400 mt-1 tabular-nums">{toFa(activeCount)}</p>
|
|
</div>
|
|
<span className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
|
|
<Ic name="check" className="size-4" />
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/60 backdrop-blur">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-grey-3">پیشنویس / غیرفعال</p>
|
|
<p className="text-xl font-black text-amber-400 mt-1 tabular-nums">{toFa(draftCount)}</p>
|
|
</div>
|
|
<span className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
|
|
<Ic name="clock" className="size-4" />
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card className="border-border-soft bg-card/60 backdrop-blur">
|
|
<CardContent className="p-4 flex items-center justify-between">
|
|
<div>
|
|
<p className="text-[11px] text-grey-3">پوشش زبانهای عقیله</p>
|
|
<p className="text-xl font-black text-sky-400 mt-1">۵ زبان رسمی</p>
|
|
</div>
|
|
<span className="p-2 rounded-xl bg-sky-500/10 text-sky-400">
|
|
<Ic name="globe" className="size-4" />
|
|
</span>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ۲. تبهای فیلتر زبان و نوار جستجو */}
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
{/* فیلتر زبان */}
|
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
|
|
{FILTER_LANGUAGE_OPTIONS.map((lang) => (
|
|
<button
|
|
key={lang.code}
|
|
onClick={() => setSelectedFilterLang(lang.code)}
|
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all shrink-0 cursor-pointer ${
|
|
selectedFilterLang === lang.code
|
|
? 'bg-primary text-primary-foreground shadow-sm'
|
|
: 'bg-card/60 text-grey-2 hover:bg-card/90 border border-border-soft'
|
|
}`}
|
|
>
|
|
<span>{lang.flag}</span>
|
|
<span>{lang.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* جستجو، وضعیت و دکمه بروزرسانی */}
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="relative w-full sm:w-64">
|
|
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
|
|
<Input
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
placeholder="جستجو در عنوان یا موضوع مقاله..."
|
|
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
value={statusFilter}
|
|
onChange={(e) => setStatusFilter(e.target.value)}
|
|
className="h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs text-foreground focus:outline-none focus:border-primary/50 cursor-pointer"
|
|
>
|
|
<option value="ALL">همه وضعیتها</option>
|
|
<option value="active">منتشرشده (فعال)</option>
|
|
<option value="inactive">پیشنویس / غیرفعال</option>
|
|
</select>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => refetch()}
|
|
className="h-9 px-3 gap-1.5 text-xs rounded-xl border-border-soft cursor-pointer"
|
|
>
|
|
<Ic name="refresh" className={`size-3.5 ${isLoading ? 'animate-spin' : ''}`} />
|
|
<span>بروزرسانی</span>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ۳. لیست و جدول مقالات یکپارچه */}
|
|
<div className="rounded-2xl border border-border-soft bg-card/40 backdrop-blur-xs overflow-hidden shadow-xs">
|
|
<div className="flex items-center justify-between border-b border-border-soft bg-card/60 px-5 py-3.5">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-bold text-foreground">فهرست مقالات دانشنامه عقیله</span>
|
|
<Badge variant="secondary" className="bg-primary/10 text-primary border-0 text-[11px] font-mono">
|
|
{toFa(filteredArticles.length)} مقاله مستقل
|
|
</Badge>
|
|
</div>
|
|
<span className="text-[11px] text-grey-3">مدیریت ترجمه ۵ زبانه در یک ص</span>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<div className="p-12 text-center text-xs text-grey-3 flex flex-col items-center justify-center gap-3">
|
|
<div className="size-8 border-2 border-primary/20 border-t-primary rounded-full animate-spin" />
|
|
<p>در حال بارگذاری بانک مقالات دانشنامه...</p>
|
|
</div>
|
|
) : filteredArticles.length === 0 ? (
|
|
<div className="p-12 text-center text-xs text-grey-3 flex flex-col items-center justify-center gap-3">
|
|
<div className="p-3 rounded-2xl bg-muted/40 text-grey-3">
|
|
<Ic name="file" className="size-8" />
|
|
</div>
|
|
<p className="font-bold text-foreground">هیچ مقالهای با این فیلترها یافت نشد.</p>
|
|
<p className="text-[11px]">با کلیک بر روی «افزودن مقاله جدید» دانشنامه را کامل کنید.</p>
|
|
<Button
|
|
onClick={handleOpenAdd}
|
|
size="sm"
|
|
className="mt-2 text-xs bg-primary text-primary-foreground rounded-xl font-bold cursor-pointer"
|
|
>
|
|
افزودن مقاله جدید
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-start text-xs border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-border-soft bg-muted/30 text-grey-2 text-[11px] font-bold">
|
|
<th className="py-3 px-4 text-start w-16">شناسه</th>
|
|
<th className="py-3 px-4 text-start">عنوان و تصویر شاخص</th>
|
|
<th className="py-3 px-4 text-center w-56">زبانهای ترجمهشده</th>
|
|
<th className="py-3 px-4 text-center w-32">وضعیت انتشار</th>
|
|
<th className="py-3 px-4 text-center w-32">تاریخ ایجاد</th>
|
|
<th className="py-3 px-4 text-end w-24">عملیات</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border-soft/60">
|
|
{filteredArticles.map((article) => {
|
|
const langs = article.available_languages || []
|
|
return (
|
|
<tr
|
|
key={article.id || article.group_key}
|
|
className="hover:bg-muted/20 transition-colors group"
|
|
>
|
|
{/* شناسه */}
|
|
<td className="py-3 px-4 font-mono text-grey-3 tabular-nums text-xs">
|
|
#{article.id}
|
|
</td>
|
|
|
|
{/* کاور + عنوان (+ نشان ویژه) + نامک سئو */}
|
|
<td className="py-3 px-4">
|
|
<div className="flex items-center gap-3">
|
|
{article.cover ? (
|
|
<img
|
|
src={article.cover}
|
|
alt={article.title}
|
|
className="size-11 rounded-xl object-cover border border-border-soft shrink-0 bg-muted/20 shadow-2xs"
|
|
onError={(e) => {
|
|
;(e.currentTarget as HTMLElement).style.display = 'none'
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="size-11 rounded-xl bg-primary/10 text-primary border border-border-soft flex items-center justify-center shrink-0">
|
|
<Ic name="file" className="size-5" />
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 space-y-1">
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<button
|
|
type="button"
|
|
onClick={() => handleOpenEdit(article)}
|
|
className="font-bold text-foreground hover:text-primary transition-colors text-start line-clamp-1 cursor-pointer text-sm"
|
|
title={article.title}
|
|
>
|
|
{article.title}
|
|
</button>
|
|
{article.is_top_list && (
|
|
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded-md bg-amber-500/10 border border-amber-500/25 text-[10px] text-amber-500 font-medium shrink-0">
|
|
<Ic name="star" className="size-2.5 fill-amber-500" />
|
|
<span>ویژه</span>
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="font-mono text-[11px] text-grey-3 truncate max-w-md" dir="ltr">
|
|
/blog/{article.base_slug || article.group_key}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</td>
|
|
|
|
{/* وضعیت زبانها و ترجمههای مقاله */}
|
|
<td className="py-3 px-4 text-center">
|
|
<div className="inline-flex items-center gap-1.5 p-1 rounded-xl bg-surface-base/80 border border-border-soft">
|
|
{LANGUAGE_CONFIG.map((lang) => {
|
|
const hasTranslation = langs.includes(lang.code)
|
|
return (
|
|
<button
|
|
key={lang.code}
|
|
type="button"
|
|
onClick={() => {
|
|
handleOpenEdit(article)
|
|
setActiveFormLang(lang.code)
|
|
}}
|
|
title={
|
|
hasTranslation
|
|
? `دارای ترجمه به ${lang.label} (برای ویرایش کلیک کنید)`
|
|
: `فاقد ترجمه به ${lang.label} (برای افزودن کلیک کنید)`
|
|
}
|
|
className={`px-2 py-1 rounded-lg text-[10px] font-bold font-mono transition-all flex items-center gap-1 cursor-pointer ${
|
|
hasTranslation
|
|
? 'bg-emerald-500/15 text-emerald-400 border border-emerald-500/30 hover:bg-emerald-500/25'
|
|
: 'bg-muted/30 text-grey-3 border border-dashed border-border-soft hover:border-grey-3 hover:text-foreground opacity-50'
|
|
}`}
|
|
>
|
|
<span>{lang.flag}</span>
|
|
<span className="uppercase">{lang.code}</span>
|
|
{hasTranslation ? (
|
|
<span className="size-1 rounded-full bg-emerald-400" />
|
|
) : (
|
|
<span className="text-[9px] leading-none">+</span>
|
|
)}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</td>
|
|
|
|
{/* وضعیت انتشار عمومی کل مقاله */}
|
|
<td className="py-3 px-4 text-center">
|
|
<button
|
|
onClick={() => toggleStatusMutation.mutate({ id: article.id, status: !article.status })}
|
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold transition-all cursor-pointer ${
|
|
article.status
|
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
|
|
: 'bg-muted text-grey-3 border border-border-soft hover:bg-muted/80'
|
|
}`}
|
|
>
|
|
<span className={`size-1.5 rounded-full ${article.status ? 'bg-emerald-400' : 'bg-grey-3'}`} />
|
|
<span>{article.status ? 'منتشرشده' : 'پیشنویس'}</span>
|
|
</button>
|
|
</td>
|
|
|
|
{/* تاریخ ثبت */}
|
|
<td className="py-3 px-4 text-center font-mono text-[11px] text-grey-3">
|
|
{article.created_at ? formatDualDate(article.created_at, language as any) : '—'}
|
|
</td>
|
|
|
|
{/* عملیات */}
|
|
<td className="py-3 px-4 text-end">
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleOpenEdit(article)}
|
|
className="size-8 p-0 text-grey-2 hover:text-foreground hover:bg-muted rounded-lg cursor-pointer"
|
|
title="ویرایش مقاله و ترجمهها"
|
|
>
|
|
<Ic name="pencil" className="size-3.5" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
if (confirm(`آیا از حذف مقاله «${article.title}» و کلیه ترجمههای ۵ زبانه آن اطمینان دارید؟`)) {
|
|
deleteMutation.mutate(article.id)
|
|
}
|
|
}}
|
|
className="size-8 p-0 text-grey-2 hover:text-red-400 hover:bg-red-500/10 rounded-lg cursor-pointer"
|
|
title="حذف کامل مقاله"
|
|
>
|
|
<Ic name="trash" className="size-3.5" />
|
|
</Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
)
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* ۴. دیالوگ یکپارچه مدیریت ۵ زبانه مقاله (همگی در یک ص هندل میشن) */}
|
|
<Dialog
|
|
open={isModalOpen}
|
|
onOpenChange={(open) => {
|
|
setIsModalOpen(open)
|
|
}}
|
|
>
|
|
<DialogContent className="sm:max-w-3xl md:max-w-4xl lg:max-w-5xl max-h-[92vh] overflow-hidden flex flex-col p-0 rounded-2xl">
|
|
{/* Header */}
|
|
<DialogHeader className="p-5 border-b border-border-soft bg-muted/20 shrink-0">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<div className="size-10 rounded-xl bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
|
<Ic name="file" className="size-5" />
|
|
</div>
|
|
<div>
|
|
<DialogTitle className="text-base font-bold text-foreground">
|
|
{editingArticleId ? 'ویرایش مقاله و مدیریت ترجمههای ۵ زبانه' : 'افزودن مقاله دانشنامه جدید (۵ زبانه)'}
|
|
</DialogTitle>
|
|
<DialogDescription className="text-xs text-muted-foreground mt-0.5">
|
|
یک مقاله ساخته میشود؛ اگر در یک زبان ترجمه داشته باشد در وبسایت آن زبان نمایش داده میشود، در غیر اینصورت نمایش داده نخواهد شد.
|
|
</DialogDescription>
|
|
</div>
|
|
</div>
|
|
|
|
{formBaseSlug && (
|
|
<Badge variant="outline" className="font-mono text-[11px] border-border-soft bg-surface-base px-2.5 py-1 text-grey-3 hidden sm:inline-flex" dir="ltr">
|
|
/blog/{formBaseSlug}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* Body */}
|
|
<DialogBody className="space-y-5 p-5 overflow-y-auto">
|
|
{/* ۱. بخش مشخصات مشترک بین همه زبانها (Common settings) */}
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 p-4 rounded-xl border border-border-soft bg-surface-base/60">
|
|
{/* نامک اسلاگ پایه */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-foreground flex items-center justify-between">
|
|
<span>نامک URL یکتای مقاله (Base Slug):</span>
|
|
<span className="text-[10px] text-grey-3 font-normal">مشترک در تمام زبانها</span>
|
|
</label>
|
|
<Input
|
|
value={formBaseSlug}
|
|
onChange={(e) => setFormBaseSlug(e.target.value)}
|
|
placeholder="najaf-holy-sites-guide"
|
|
dir="ltr"
|
|
className="bg-card border-border-soft rounded-xl text-xs font-mono h-9"
|
|
/>
|
|
</div>
|
|
|
|
{/* آدرس تصویر شاخص */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-foreground flex items-center justify-between">
|
|
<span>آدرس تصویر شاخص (Cover Image URL):</span>
|
|
<span className="text-[10px] text-grey-3 font-normal">مشترک در کارتها</span>
|
|
</label>
|
|
<div className="flex gap-2 items-center">
|
|
<Input
|
|
value={formCover}
|
|
onChange={(e) => setFormCover(e.target.value)}
|
|
placeholder="https://... یا /media/blog/cover/..."
|
|
dir="ltr"
|
|
className="bg-card border-border-soft rounded-xl text-xs font-mono h-9 flex-1"
|
|
/>
|
|
{formCover && (
|
|
<img
|
|
src={formCover}
|
|
alt="کاور"
|
|
className="size-9 rounded-lg object-cover border border-border-soft shrink-0 bg-muted/20"
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* وضعیت انتشار عمومی و مقاله ویژه */}
|
|
<div className="flex items-center justify-between p-3 rounded-xl border border-border-soft bg-card">
|
|
<div>
|
|
<p className="text-xs font-bold text-foreground">انتشار عمومی در وبسایت</p>
|
|
<p className="text-[10px] text-grey-3 mt-0.5">وضعیت فعال در کل زبانها</p>
|
|
</div>
|
|
<Switch checked={formStatus} onCheckedChange={setFormStatus} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-3 rounded-xl border border-border-soft bg-card">
|
|
<div>
|
|
<p className="text-xs font-bold text-foreground">نمایش در مقالات ویژه (Top List)</p>
|
|
<p className="text-[10px] text-grey-3 mt-0.5">برجسته کردن در بالای وبلاگ</p>
|
|
</div>
|
|
<Switch checked={formIsTopList} onCheckedChange={setFormIsTopList} />
|
|
</div>
|
|
</div>
|
|
|
|
{/* ۲. تبهای انتخاب زبان ترجمه (۵ زبانه) */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
|
|
<Ic name="globe" className="size-4 text-primary" />
|
|
<span>انتخاب زبان محتوا و ترجمه:</span>
|
|
</label>
|
|
<div className="flex items-center gap-2 text-[11px] text-grey-3">
|
|
<span className="size-2 rounded-full bg-emerald-500 inline-block" />
|
|
<span>دارای محتوا</span>
|
|
<span className="size-2 rounded-full bg-grey-3 inline-block ms-2" />
|
|
<span>فاقد ترجمه (عدم نمایش در وب این زبان)</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2 p-1.5 rounded-2xl bg-surface-base border border-border-soft">
|
|
{LANGUAGE_CONFIG.map((lang) => {
|
|
const isSelected = activeFormLang === lang.code
|
|
const hasData = Boolean(formTranslations[lang.code]?.title?.trim())
|
|
return (
|
|
<button
|
|
key={lang.code}
|
|
type="button"
|
|
onClick={() => setActiveFormLang(lang.code)}
|
|
className={`h-11 px-3 rounded-xl text-xs font-bold transition flex items-center justify-center gap-2 relative cursor-pointer ${
|
|
isSelected
|
|
? 'bg-primary text-primary-foreground shadow-xs'
|
|
: 'text-grey-2 hover:text-foreground hover:bg-card border border-transparent hover:border-border-soft'
|
|
}`}
|
|
>
|
|
<span className="text-base">{lang.flag}</span>
|
|
<span className="truncate">{lang.label}</span>
|
|
<span
|
|
className={`size-2 rounded-full shrink-0 ${
|
|
hasData
|
|
? isSelected
|
|
? 'bg-white'
|
|
: 'bg-emerald-500'
|
|
: 'bg-grey-3/40'
|
|
}`}
|
|
title={hasData ? 'دارای محتوا' : 'خالی'}
|
|
/>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* ۳. فرم ورود محتوا برای زبان فعال */}
|
|
<div className="p-5 rounded-2xl border border-border-soft bg-card space-y-4">
|
|
<div className="flex items-center justify-between border-b border-border-soft pb-3">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-base">{currentLangConfig.flag}</span>
|
|
<span className="text-sm font-bold text-foreground">
|
|
محتوای مقاله به زبان {currentLangConfig.label}
|
|
</span>
|
|
<Badge variant="outline" className="text-[10px] uppercase font-mono border-border-soft">
|
|
{currentLangConfig.dir.toUpperCase()}
|
|
</Badge>
|
|
</div>
|
|
|
|
{!currentTranslation.title.trim() && (
|
|
<span className="text-[11px] text-amber-500 font-medium">
|
|
(در صورت خالی ماندن، مقاله در نسخه {currentLangConfig.label} نمایش داده نمیشود)
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* عنوان مقاله در این زبان */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-foreground">
|
|
عنوان سئو در زبان {currentLangConfig.label} (H1 Title):
|
|
</label>
|
|
<Input
|
|
value={currentTranslation.title}
|
|
onChange={(e) =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: { ...currentTranslation, title: e.target.value },
|
|
})
|
|
}
|
|
dir={currentLangConfig.dir}
|
|
placeholder={`عنوان مقاله به زبان ${currentLangConfig.label}...`}
|
|
className="bg-surface-base border-border-soft rounded-xl text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* خلاصه متای سئو */}
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-bold text-foreground">
|
|
خلاصه متای سئو (Meta Description):
|
|
</label>
|
|
<span className="text-[10px] font-mono text-grey-3">
|
|
{toFa(currentTranslation.summary?.length || 0)} / ۱۶۰ کاراکتر
|
|
</span>
|
|
</div>
|
|
<Textarea
|
|
value={currentTranslation.summary}
|
|
onChange={(e) =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: { ...currentTranslation, summary: e.target.value },
|
|
})
|
|
}
|
|
dir={currentLangConfig.dir}
|
|
rows={2}
|
|
placeholder={`توضیحات کوتاه برای نمایش در کارتها و نتایج جستجو به زبان ${currentLangConfig.label}...`}
|
|
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:outline-none focus:border-primary/50"
|
|
/>
|
|
</div>
|
|
|
|
{/* برچسبها */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-bold text-foreground">
|
|
برچسبها در زبان {currentLangConfig.label} (با کاما جدا کنید):
|
|
</label>
|
|
<Input
|
|
value={currentTranslation.tag_names.join('، ')}
|
|
onChange={(e) =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: {
|
|
...currentTranslation,
|
|
tag_names: e.target.value.split(/[،,]+/).map((s) => s.trim()).filter(Boolean),
|
|
},
|
|
})
|
|
}
|
|
dir={currentLangConfig.dir}
|
|
placeholder="زیارت، نجف، کربلا، عقیله"
|
|
className="bg-surface-base border-border-soft rounded-xl text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* متن کامل مقاله و سئو با قابلیت سوئیچ پیشنمایش */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-xs font-bold text-foreground">
|
|
محتوای کامل مقاله (Semantic HTML):
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPreviewMode(!previewMode)}
|
|
className="h-7 text-[11px] gap-1 border-border-soft rounded-lg cursor-pointer"
|
|
>
|
|
<Ic name={previewMode ? 'file' : 'eye'} className="size-3" />
|
|
<span>{previewMode ? 'حالت ویرایش HTML' : 'پیشنمایش زنده'}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
{!previewMode ? (
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap gap-1 p-2 rounded-xl bg-muted/20 border border-border-soft text-[11px]">
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: {
|
|
...currentTranslation,
|
|
content: (currentTranslation.content || '') + '\n<h2>عنوان بخش جدید</h2>\n<p>متن توضیحات...</p>',
|
|
},
|
|
})
|
|
}
|
|
className="px-2.5 py-1 rounded-lg bg-card hover:bg-card/80 border border-border-soft text-grey-2 text-xs font-medium cursor-pointer transition-colors"
|
|
>
|
|
+ تیتر H2
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: {
|
|
...currentTranslation,
|
|
content: (currentTranslation.content || '') + '\n<h3>زیرتیتر</h3>\n<p>متن بخش...</p>',
|
|
},
|
|
})
|
|
}
|
|
className="px-2.5 py-1 rounded-lg bg-card hover:bg-card/80 border border-border-soft text-grey-2 text-xs font-medium cursor-pointer transition-colors"
|
|
>
|
|
+ زیرتیتر H3
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: {
|
|
...currentTranslation,
|
|
content: (currentTranslation.content || '') + '\n<ul>\n <li>مورد اول</li>\n <li>مورد دوم</li>\n</ul>',
|
|
},
|
|
})
|
|
}
|
|
className="px-2.5 py-1 rounded-lg bg-card hover:bg-card/80 border border-border-soft text-grey-2 text-xs font-medium cursor-pointer transition-colors"
|
|
>
|
|
+ لیست آیتمها
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: {
|
|
...currentTranslation,
|
|
content:
|
|
(currentTranslation.content || '') +
|
|
'\n<div class="callout-box bg-primary/10 border-r-4 border-primary p-4 rounded-xl my-4">\n <p>نکته زیارتی مهم برای زائران...</p>\n</div>',
|
|
},
|
|
})
|
|
}
|
|
className="px-2.5 py-1 rounded-lg bg-card hover:bg-card/80 border border-border-soft text-primary font-bold text-xs cursor-pointer transition-colors"
|
|
>
|
|
+ جعبه نکته کاربردی
|
|
</button>
|
|
</div>
|
|
|
|
<textarea
|
|
value={currentTranslation.content}
|
|
onChange={(e) =>
|
|
setFormTranslations({
|
|
...formTranslations,
|
|
[activeFormLang]: { ...currentTranslation, content: e.target.value },
|
|
})
|
|
}
|
|
rows={11}
|
|
dir={currentLangConfig.dir}
|
|
placeholder={`متن کامل مقاله به همراه تگهای استاندارد به زبان ${currentLangConfig.label}...`}
|
|
className="w-full rounded-xl border border-border-soft bg-surface-base p-4 text-xs font-mono text-foreground focus:outline-none focus:border-primary/50 leading-relaxed resize-y"
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div
|
|
dir={currentLangConfig.dir}
|
|
className="max-h-96 min-h-[160px] overflow-y-auto rounded-xl border border-border-soft bg-card/60 p-5 prose dark:prose-invert prose-sm text-xs leading-relaxed"
|
|
dangerouslySetInnerHTML={{
|
|
__html: currentTranslation.content || '<p class="text-grey-3">محتوایی برای این زبان وارد نشده است.</p>',
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</DialogBody>
|
|
|
|
{/* Footer */}
|
|
<DialogFooter className="p-4 border-t border-border-soft bg-muted/20 shrink-0">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setIsModalOpen(false)}
|
|
className="text-xs border-border-soft rounded-xl cursor-pointer"
|
|
>
|
|
انصراف
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveArticle}
|
|
disabled={saveMutation.isPending}
|
|
className="gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-bold px-5 rounded-xl cursor-pointer"
|
|
>
|
|
{saveMutation.isPending && <Ic name="refresh" className="size-3 animate-spin" />}
|
|
{editingArticleId ? 'ذخیره مقاله و ترجمهها' : 'ثبت مقاله ۵ زبانه در دیتابیس'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
)
|
|
}
|
|
|