feat: add Translation Management with approval workflow
All checks were successful
Deploy / deploy (push) Successful in 12s
All checks were successful
Deploy / deploy (push) Successful in 12s
- New Translations + TranslationTexts NocoDB tables (auto-created on restart) - Full CRUD: list, create, update, delete, bulk-delete translations - Translation texts per language (add/edit/delete inline) - Review flow: submit-review generates public token link - Public review page: shows source + all translations, approve/reject/revision - Email notifications to approvers (registered users) - Sidebar nav under Marketing category - Bilingual i18n (80+ keys in en.json and ar.json) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -35,6 +35,8 @@ const PublicPostReview = lazy(() => import('./pages/PublicPostReview'))
|
||||
const Issues = lazy(() => import('./pages/Issues'))
|
||||
const PublicIssueSubmit = lazy(() => import('./pages/PublicIssueSubmit'))
|
||||
const PublicIssueTracker = lazy(() => import('./pages/PublicIssueTracker'))
|
||||
const Translations = lazy(() => import('./pages/Translations'))
|
||||
const PublicTranslationReview = lazy(() => import('./pages/PublicTranslationReview'))
|
||||
const ForgotPassword = lazy(() => import('./pages/ForgotPassword'))
|
||||
const ResetPassword = lazy(() => import('./pages/ResetPassword'))
|
||||
|
||||
@@ -295,6 +297,7 @@ function AppContent() {
|
||||
<Route path="/review-post/:token" element={<PublicPostReview />} />
|
||||
<Route path="/submit-issue" element={<PublicIssueSubmit />} />
|
||||
<Route path="/track/:token" element={<PublicIssueTracker />} />
|
||||
<Route path="/review-translation/:token" element={<PublicTranslationReview />} />
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{hasModule('marketing') && <>
|
||||
@@ -305,6 +308,7 @@ function AppContent() {
|
||||
<Route path="campaigns" element={<Campaigns />} />
|
||||
<Route path="campaigns/:id" element={<CampaignDetail />} />
|
||||
<Route path="brands" element={<Brands />} />
|
||||
<Route path="translations" element={<Translations />} />
|
||||
</>}
|
||||
{hasModule('finance') && (user?.role === 'superadmin' || user?.role === 'manager') && <>
|
||||
<Route path="finance" element={<Finance />} />
|
||||
|
||||
@@ -26,6 +26,7 @@ const moduleGroups = [
|
||||
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
||||
{ to: '/translations', icon: Languages, labelKey: 'nav.translations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
558
client/src/components/TranslationDetailPanel.jsx
Normal file
558
client/src/components/TranslationDetailPanel.jsx
Normal file
@@ -0,0 +1,558 @@
|
||||
import { useState, useEffect, useContext } from 'react'
|
||||
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from './Modal'
|
||||
import TabbedModal from './TabbedModal'
|
||||
import { useToast } from './ToastContainer'
|
||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: 'العربية' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Français' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [] }) {
|
||||
const { t } = useLanguage()
|
||||
const { brands } = useContext(AppContext)
|
||||
const toast = useToast()
|
||||
|
||||
const [editTitle, setEditTitle] = useState(translation.title || '')
|
||||
const [editDescription, setEditDescription] = useState(translation.description || '')
|
||||
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
|
||||
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
|
||||
const [editApproverIds, setEditApproverIds] = useState(
|
||||
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
const reviewUrl = translation.approval_token ? `${window.location.origin}/review-translation/${translation.approval_token}` : ''
|
||||
|
||||
const [activeTab, setActiveTab] = useState('details')
|
||||
const [texts, setTexts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [savingDraft, setSavingDraft] = useState(false)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
||||
|
||||
// Language add modal
|
||||
const [showAddLang, setShowAddLang] = useState(false)
|
||||
const [langForm, setLangForm] = useState({ language_code: '', content: '' })
|
||||
const [savingLang, setSavingLang] = useState(false)
|
||||
|
||||
// Delete confirm
|
||||
const [confirmDeleteTextId, setConfirmDeleteTextId] = useState(null)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
|
||||
// Inline editing for translation texts
|
||||
const [editingTextId, setEditingTextId] = useState(null)
|
||||
const [editingContent, setEditingContent] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadTexts()
|
||||
}, [translation.Id])
|
||||
|
||||
useEffect(() => {
|
||||
setEditTitle(translation.title || '')
|
||||
setEditDescription(translation.description || '')
|
||||
setEditSourceContent(translation.source_content || '')
|
||||
setEditSourceLanguage(translation.source_language || 'EN')
|
||||
setEditApproverIds(
|
||||
translation.approvers?.map(a => String(a.id)) || (translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
|
||||
)
|
||||
}, [translation.Id])
|
||||
|
||||
const loadTexts = async () => {
|
||||
try {
|
||||
const res = await api.get(`/translations/${translation.Id}/texts`)
|
||||
setTexts(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load texts:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveDraft = async () => {
|
||||
if (!editTitle.trim()) {
|
||||
toast.error(t('translations.titleRequired'))
|
||||
return
|
||||
}
|
||||
setSavingDraft(true)
|
||||
try {
|
||||
await api.patch(`/translations/${translation.Id}`, {
|
||||
title: editTitle,
|
||||
description: editDescription,
|
||||
source_content: editSourceContent,
|
||||
source_language: editSourceLanguage,
|
||||
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
|
||||
})
|
||||
toast.success(t('translations.draftSaved'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedSaveDraft'))
|
||||
} finally {
|
||||
setSavingDraft(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFieldUpdate = async (field, value) => {
|
||||
try {
|
||||
await api.patch(`/translations/${translation.Id}`, { [field]: value || null })
|
||||
toast.success(t('translations.updated'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTranslationText = async () => {
|
||||
if (!langForm.language_code || !langForm.content) {
|
||||
toast.error(t('translations.allFieldsRequired'))
|
||||
return
|
||||
}
|
||||
setSavingLang(true)
|
||||
try {
|
||||
const lang = AVAILABLE_LANGUAGES.find(l => l.code === langForm.language_code)
|
||||
await api.post(`/translations/${translation.Id}/texts`, {
|
||||
language_code: langForm.language_code,
|
||||
language_label: lang?.label || langForm.language_code,
|
||||
content: langForm.content,
|
||||
})
|
||||
toast.success(t('translations.translationAdded'))
|
||||
setShowAddLang(false)
|
||||
setLangForm({ language_code: '', content: '' })
|
||||
loadTexts()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedAddTranslation'))
|
||||
} finally {
|
||||
setSavingLang(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateText = async (textId) => {
|
||||
try {
|
||||
const text = texts.find(t => t.Id === textId)
|
||||
if (!text) return
|
||||
await api.post(`/translations/${translation.Id}/texts`, {
|
||||
language_code: text.language_code,
|
||||
language_label: text.language_label,
|
||||
content: editingContent,
|
||||
})
|
||||
toast.success(t('translations.updated'))
|
||||
setEditingTextId(null)
|
||||
loadTexts()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteText = async (textId) => {
|
||||
try {
|
||||
await api.delete(`/translations/${translation.Id}/texts/${textId}`)
|
||||
toast.success(t('translations.translationDeleted'))
|
||||
loadTexts()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedDeleteTranslation'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitReview = async () => {
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await api.post(`/translations/${translation.Id}/submit-review`)
|
||||
setFreshReviewUrl(res.reviewUrl || res.data?.reviewUrl || '')
|
||||
toast.success(t('translations.submittedForReview'))
|
||||
onUpdate()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedSubmitReview'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyReviewLink = () => {
|
||||
const url = freshReviewUrl || reviewUrl
|
||||
navigator.clipboard.writeText(url)
|
||||
setCopied(true)
|
||||
toast.success(t('translations.linkCopied'))
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await onDelete(translation.Id || translation.id || translation._id)
|
||||
} catch (err) {
|
||||
toast.error(t('translations.failedDelete'))
|
||||
}
|
||||
}
|
||||
|
||||
// Available languages for adding (exclude source + already added)
|
||||
const usedCodes = new Set([translation.source_language, ...texts.map(t => t.language_code)])
|
||||
const availableForAdd = AVAILABLE_LANGUAGES.filter(l => !usedCodes.has(l.code))
|
||||
|
||||
const tabs = [
|
||||
{ key: 'details', label: t('translations.details'), icon: FileEdit },
|
||||
{ key: 'translations', label: t('translations.translationTexts'), icon: Languages, badge: texts.length },
|
||||
{ key: 'review', label: t('translations.review'), icon: ShieldCheck },
|
||||
]
|
||||
|
||||
const currentReviewUrl = freshReviewUrl || reviewUrl
|
||||
|
||||
return (
|
||||
<>
|
||||
<TabbedModal
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
header={
|
||||
<>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<Languages className="w-5 h-5 text-brand-primary shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={editTitle}
|
||||
onChange={e => setEditTitle(e.target.value)}
|
||||
className="text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full"
|
||||
placeholder={t('translations.titlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{translation.status?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{AVAILABLE_LANGUAGES.find(l => l.code === editSourceLanguage)?.label || editSourceLanguage}
|
||||
</span>
|
||||
{translation.creator_name && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{t('review.createdBy')} <strong className="text-text-primary">{translation.creator_name}</strong>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
footer={
|
||||
<div className="flex items-center gap-2 w-full justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="p-2 rounded-lg text-red-500 hover:bg-red-50 transition-colors"
|
||||
title={t('translations.deleteTranslation')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveDraft}
|
||||
disabled={savingDraft}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
title={t('translations.saveDraftTooltip')}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Details Tab */}
|
||||
{activeTab === 'details' && (
|
||||
<div className="p-6 space-y-5">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
|
||||
<select
|
||||
value={editSourceLanguage}
|
||||
onChange={e => setEditSourceLanguage(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceContent')}</h4>
|
||||
<textarea
|
||||
value={editSourceContent}
|
||||
onChange={e => setEditSourceContent(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[150px] resize-y"
|
||||
placeholder={t('translations.sourceContentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.descriptionLabel')}</h4>
|
||||
<textarea
|
||||
value={editDescription}
|
||||
onChange={e => setEditDescription(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[80px] resize-y"
|
||||
placeholder={t('translations.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
|
||||
<select
|
||||
value={translation.brand_id || ''}
|
||||
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.approversLabel')}</h4>
|
||||
<ApproverMultiSelect
|
||||
selected={editApproverIds}
|
||||
onChange={setEditApproverIds}
|
||||
users={assignableUsers}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Translations Tab */}
|
||||
{activeTab === 'translations' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{/* Source content reference */}
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Globe className="w-4 h-4 text-blue-600" />
|
||||
<h4 className="text-sm font-semibold text-blue-900">
|
||||
{t('translations.sourceContent')} — {AVAILABLE_LANGUAGES.find(l => l.code === translation.source_language)?.label || translation.source_language}
|
||||
</h4>
|
||||
</div>
|
||||
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
|
||||
</div>
|
||||
|
||||
{/* Add translation button */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
|
||||
{availableForAdd.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowAddLang(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('translations.addTranslation')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Translation texts list */}
|
||||
{texts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{texts.map(text => (
|
||||
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-text-primary">
|
||||
{text.language_label || text.language_code}
|
||||
<span className="text-xs text-text-tertiary ml-1">({text.language_code})</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{editingTextId === text.Id ? (
|
||||
<>
|
||||
<button
|
||||
onClick={() => handleUpdateText(text.Id)}
|
||||
className="text-emerald-600 hover:text-emerald-700 p-1"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditingTextId(null)}
|
||||
className="text-text-tertiary hover:text-text-secondary p-1"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }}
|
||||
className="text-text-tertiary hover:text-text-secondary p-1"
|
||||
>
|
||||
<FileEdit className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setConfirmDeleteTextId(text.Id)}
|
||||
className="text-red-500 hover:text-red-600 p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editingTextId === text.Id ? (
|
||||
<textarea
|
||||
value={editingContent}
|
||||
onChange={e => setEditingContent(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[100px] resize-y"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap">{text.content}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
||||
<Languages className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
|
||||
<p className="text-sm text-text-secondary">{t('translations.noTranslationTexts')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Tab */}
|
||||
{activeTab === 'review' && (
|
||||
<div className="p-6 space-y-5">
|
||||
{['draft', 'revision_requested', 'rejected'].includes(translation.status) && (
|
||||
<button
|
||||
onClick={handleSubmitReview}
|
||||
disabled={submitting}
|
||||
className="w-full py-3 bg-brand-primary text-white rounded-lg font-medium hover:bg-brand-primary-light disabled:opacity-50 transition-colors shadow-sm"
|
||||
>
|
||||
{submitting ? t('translations.submitting') : t('translations.submitForReview')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentReviewUrl && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<div className="text-sm font-semibold text-blue-900 mb-2">{t('translations.reviewLinkTitle')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={currentReviewUrl}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 text-sm bg-white border border-blue-200 rounded-lg text-blue-800"
|
||||
/>
|
||||
<button
|
||||
onClick={copyReviewLink}
|
||||
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</button>
|
||||
<a
|
||||
href={currentReviewUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{translation.feedback && (
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||
<h4 className="text-sm font-semibold text-amber-900 mb-2">{t('translations.feedbackTitle')}</h4>
|
||||
<p className="text-sm text-amber-800 whitespace-pre-wrap">{translation.feedback}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{translation.status === 'approved' && translation.approved_by_name && (
|
||||
<div className="bg-emerald-50 border border-emerald-200 rounded-lg p-4">
|
||||
<div className="font-medium text-emerald-900">{t('translations.approvedByLabel')} {translation.approved_by_name}</div>
|
||||
{translation.approved_at && (
|
||||
<div className="text-sm text-emerald-700 mt-1">
|
||||
{new Date(translation.approved_at).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!['draft', 'revision_requested', 'rejected'].includes(translation.status) && !currentReviewUrl && !translation.feedback && !(translation.status === 'approved' && translation.approved_by_name) && (
|
||||
<p className="text-sm text-text-secondary text-center py-4">
|
||||
{translation.status === 'pending_review'
|
||||
? t('translations.pendingReviewInfo')
|
||||
: t('translations.noReviewInfo')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabbedModal>
|
||||
|
||||
{/* Add Translation Modal */}
|
||||
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addTranslation')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
|
||||
<select
|
||||
value={langForm.language_code}
|
||||
onChange={e => setLangForm(f => ({ ...f, language_code: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('translations.selectLanguage')}</option>
|
||||
{availableForAdd.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
|
||||
<textarea
|
||||
value={langForm.content}
|
||||
onChange={e => setLangForm(f => ({ ...f, content: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[150px] resize-y"
|
||||
placeholder={t('translations.enterTranslatedContent')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowAddLang(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddTranslationText}
|
||||
disabled={savingLang || !langForm.language_code || !langForm.content}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{savingLang ? t('common.loading') : t('translations.addTranslation')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete translation text confirm */}
|
||||
<Modal
|
||||
isOpen={!!confirmDeleteTextId}
|
||||
onClose={() => setConfirmDeleteTextId(null)}
|
||||
title={t('translations.deleteTranslationText')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => { handleDeleteText(confirmDeleteTextId); setConfirmDeleteTextId(null) }}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('translations.deleteTranslationTextDesc')}
|
||||
</Modal>
|
||||
|
||||
{/* Delete translation confirm */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('translations.deleteTranslation')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => { handleDelete(); setShowDeleteConfirm(false) }}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('translations.deleteTranslationDesc')}
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -938,5 +938,88 @@
|
||||
"posts.deleteLanguage": "حذف هذه اللغة؟",
|
||||
"posts.deleteLanguageConfirm": "سيتم حذف محتوى اللغة من هذا الإصدار.",
|
||||
"posts.media": "الوسائط",
|
||||
"posts.noMedia": "لم يتم رفع ملفات وسائط"
|
||||
"posts.noMedia": "لم يتم رفع ملفات وسائط",
|
||||
|
||||
"nav.translations": "الترجمات",
|
||||
"translations.title": "الترجمات",
|
||||
"translations.subtitle": "إدارة ترجمات المحتوى مع سير عمل الموافقة",
|
||||
"translations.newTranslation": "ترجمة جديدة",
|
||||
"translations.createTranslation": "إنشاء ترجمة",
|
||||
"translations.searchTranslations": "البحث في الترجمات...",
|
||||
"translations.titleLabel": "العنوان",
|
||||
"translations.titlePlaceholder": "مثال: ترجمة شعار الحملة",
|
||||
"translations.sourceLanguage": "لغة المصدر",
|
||||
"translations.sourceContent": "المحتوى الأصلي",
|
||||
"translations.sourceContentPlaceholder": "أدخل المحتوى الأصلي المراد ترجمته...",
|
||||
"translations.description": "الوصف",
|
||||
"translations.descriptionLabel": "الوصف",
|
||||
"translations.descriptionPlaceholder": "سياق أو ملاحظات حول هذه الترجمة...",
|
||||
"translations.brand": "العلامة التجارية",
|
||||
"translations.creator": "المنشئ",
|
||||
"translations.approvers": "المراجعون",
|
||||
"translations.approversLabel": "المراجعون",
|
||||
"translations.status": "الحالة",
|
||||
"translations.languagesLabel": "اللغات",
|
||||
"translations.languagesCount": "لغات",
|
||||
"translations.updated": "تم التحديث",
|
||||
"translations.grid": "شبكة",
|
||||
"translations.list": "قائمة",
|
||||
"translations.allBrands": "جميع العلامات",
|
||||
"translations.allStatuses": "جميع الحالات",
|
||||
"translations.allCreators": "جميع المنشئين",
|
||||
"translations.status.draft": "مسودة",
|
||||
"translations.status.pendingReview": "بانتظار المراجعة",
|
||||
"translations.status.approved": "موافق عليه",
|
||||
"translations.status.rejected": "مرفوض",
|
||||
"translations.status.revisionRequested": "طلب تعديل",
|
||||
"translations.sortRecentlyUpdated": "آخر تحديث",
|
||||
"translations.sortNewest": "الأحدث أولاً",
|
||||
"translations.sortOldest": "الأقدم أولاً",
|
||||
"translations.sortTitleAZ": "العنوان أ-ي",
|
||||
"translations.noTranslations": "لم يتم العثور على ترجمات",
|
||||
"translations.loadFailed": "فشل تحميل الترجمات",
|
||||
"translations.titleRequired": "العنوان مطلوب",
|
||||
"translations.sourceContentRequired": "المحتوى الأصلي مطلوب",
|
||||
"translations.created": "تم إنشاء الترجمة!",
|
||||
"translations.createFailed": "فشل إنشاء الترجمة",
|
||||
"translations.creating": "جارٍ الإنشاء...",
|
||||
"translations.deleted": "تم حذف الترجمة!",
|
||||
"translations.deleteFailed": "فشل حذف الترجمة",
|
||||
"translations.details": "التفاصيل",
|
||||
"translations.translationTexts": "الترجمات",
|
||||
"translations.review": "المراجعة",
|
||||
"translations.draftSaved": "تم حفظ المسودة!",
|
||||
"translations.failedSaveDraft": "فشل حفظ المسودة",
|
||||
"translations.saveDraft": "حفظ المسودة",
|
||||
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والوصف والمحتوى الأصلي",
|
||||
"translations.savingDraft": "جارٍ الحفظ...",
|
||||
"translations.updated": "تم التحديث!",
|
||||
"translations.failedUpdate": "فشل التحديث",
|
||||
"translations.addTranslation": "إضافة ترجمة",
|
||||
"translations.translationAdded": "تمت إضافة الترجمة!",
|
||||
"translations.failedAddTranslation": "فشل إضافة الترجمة",
|
||||
"translations.translationDeleted": "تم حذف الترجمة!",
|
||||
"translations.failedDeleteTranslation": "فشل حذف الترجمة",
|
||||
"translations.noTranslationTexts": "لا توجد ترجمات بعد. أضف واحدة لكل لغة مستهدفة.",
|
||||
"translations.allFieldsRequired": "اللغة والمحتوى مطلوبان",
|
||||
"translations.languageLabel": "اللغة",
|
||||
"translations.selectLanguage": "اختر لغة",
|
||||
"translations.translatedContent": "المحتوى المترجم",
|
||||
"translations.enterTranslatedContent": "أدخل المحتوى المترجم...",
|
||||
"translations.deleteTranslation": "حذف الترجمة",
|
||||
"translations.deleteTranslationDesc": "سيتم حذف هذه الترجمة وجميع نسخ اللغات نهائيًا.",
|
||||
"translations.deleteTranslationText": "حذف نص الترجمة",
|
||||
"translations.deleteTranslationTextDesc": "سيتم حذف ترجمة هذه اللغة.",
|
||||
"translations.bulkDeleteDesc": "حذف الترجمات المحددة؟",
|
||||
"translations.submitForReview": "تقديم للمراجعة",
|
||||
"translations.submitting": "جارٍ التقديم...",
|
||||
"translations.submittedForReview": "تم التقديم للمراجعة!",
|
||||
"translations.failedSubmitReview": "فشل التقديم للمراجعة",
|
||||
"translations.reviewLinkTitle": "رابط المراجعة",
|
||||
"translations.linkCopied": "تم نسخ الرابط!",
|
||||
"translations.feedbackTitle": "ملاحظات المراجع",
|
||||
"translations.approvedByLabel": "وافق عليه",
|
||||
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
|
||||
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||
"translations.failedDelete": "فشل الحذف"
|
||||
}
|
||||
@@ -938,5 +938,88 @@
|
||||
"posts.deleteLanguage": "Delete this language?",
|
||||
"posts.deleteLanguageConfirm": "This will remove the language content from this version.",
|
||||
"posts.media": "Media",
|
||||
"posts.noMedia": "No media files uploaded"
|
||||
"posts.noMedia": "No media files uploaded",
|
||||
|
||||
"nav.translations": "Translations",
|
||||
"translations.title": "Translations",
|
||||
"translations.subtitle": "Manage content translations with approval workflow",
|
||||
"translations.newTranslation": "New Translation",
|
||||
"translations.createTranslation": "Create Translation",
|
||||
"translations.searchTranslations": "Search translations...",
|
||||
"translations.titleLabel": "Title",
|
||||
"translations.titlePlaceholder": "e.g. Campaign tagline translation",
|
||||
"translations.sourceLanguage": "Source Language",
|
||||
"translations.sourceContent": "Source Content",
|
||||
"translations.sourceContentPlaceholder": "Enter the original content to translate...",
|
||||
"translations.description": "Description",
|
||||
"translations.descriptionLabel": "Description",
|
||||
"translations.descriptionPlaceholder": "Context or notes about this translation...",
|
||||
"translations.brand": "Brand",
|
||||
"translations.creator": "Creator",
|
||||
"translations.approvers": "Approvers",
|
||||
"translations.approversLabel": "Approvers",
|
||||
"translations.status": "Status",
|
||||
"translations.languagesLabel": "Languages",
|
||||
"translations.languagesCount": "languages",
|
||||
"translations.updated": "Updated",
|
||||
"translations.grid": "Grid",
|
||||
"translations.list": "List",
|
||||
"translations.allBrands": "All Brands",
|
||||
"translations.allStatuses": "All Statuses",
|
||||
"translations.allCreators": "All Creators",
|
||||
"translations.status.draft": "Draft",
|
||||
"translations.status.pendingReview": "Pending Review",
|
||||
"translations.status.approved": "Approved",
|
||||
"translations.status.rejected": "Rejected",
|
||||
"translations.status.revisionRequested": "Revision Requested",
|
||||
"translations.sortRecentlyUpdated": "Recently Updated",
|
||||
"translations.sortNewest": "Newest First",
|
||||
"translations.sortOldest": "Oldest First",
|
||||
"translations.sortTitleAZ": "Title A-Z",
|
||||
"translations.noTranslations": "No translations found",
|
||||
"translations.loadFailed": "Failed to load translations",
|
||||
"translations.titleRequired": "Title is required",
|
||||
"translations.sourceContentRequired": "Source content is required",
|
||||
"translations.created": "Translation created!",
|
||||
"translations.createFailed": "Failed to create translation",
|
||||
"translations.creating": "Creating...",
|
||||
"translations.deleted": "Translation deleted!",
|
||||
"translations.deleteFailed": "Failed to delete translation",
|
||||
"translations.details": "Details",
|
||||
"translations.translationTexts": "Translations",
|
||||
"translations.review": "Review",
|
||||
"translations.draftSaved": "Draft saved!",
|
||||
"translations.failedSaveDraft": "Failed to save draft",
|
||||
"translations.saveDraft": "Save Draft",
|
||||
"translations.saveDraftTooltip": "Save changes to title, description, and source content",
|
||||
"translations.savingDraft": "Saving...",
|
||||
"translations.updated": "Updated!",
|
||||
"translations.failedUpdate": "Failed to update",
|
||||
"translations.addTranslation": "Add Translation",
|
||||
"translations.translationAdded": "Translation added!",
|
||||
"translations.failedAddTranslation": "Failed to add translation",
|
||||
"translations.translationDeleted": "Translation deleted!",
|
||||
"translations.failedDeleteTranslation": "Failed to delete translation",
|
||||
"translations.noTranslationTexts": "No translations yet. Add one for each target language.",
|
||||
"translations.allFieldsRequired": "Language and content are required",
|
||||
"translations.languageLabel": "Language",
|
||||
"translations.selectLanguage": "Select a language",
|
||||
"translations.translatedContent": "Translated Content",
|
||||
"translations.enterTranslatedContent": "Enter the translated content...",
|
||||
"translations.deleteTranslation": "Delete Translation",
|
||||
"translations.deleteTranslationDesc": "This will permanently delete this translation and all its language versions.",
|
||||
"translations.deleteTranslationText": "Delete Translation Text",
|
||||
"translations.deleteTranslationTextDesc": "This will remove this language translation.",
|
||||
"translations.bulkDeleteDesc": "Delete selected translations?",
|
||||
"translations.submitForReview": "Submit for Review",
|
||||
"translations.submitting": "Submitting...",
|
||||
"translations.submittedForReview": "Submitted for review!",
|
||||
"translations.failedSubmitReview": "Failed to submit for review",
|
||||
"translations.reviewLinkTitle": "Review Link",
|
||||
"translations.linkCopied": "Link copied!",
|
||||
"translations.feedbackTitle": "Reviewer Feedback",
|
||||
"translations.approvedByLabel": "Approved by",
|
||||
"translations.pendingReviewInfo": "This translation is currently pending review.",
|
||||
"translations.noReviewInfo": "No review information available.",
|
||||
"translations.failedDelete": "Failed to delete"
|
||||
}
|
||||
292
client/src/pages/PublicTranslationReview.jsx
Normal file
292
client/src/pages/PublicTranslationReview.jsx
Normal file
@@ -0,0 +1,292 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
export default function PublicTranslationReview() {
|
||||
const { token } = useParams()
|
||||
const { t } = useLanguage()
|
||||
const toast = useToast()
|
||||
const [translation, setTranslation] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadTranslation()
|
||||
}, [token])
|
||||
|
||||
const loadTranslation = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-translation/${token}`)
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
setError(err.error || t('review.loadFailed'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
const data = await res.json()
|
||||
setTranslation(data)
|
||||
if (data.approvers?.length === 1 && data.approvers[0].name) {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('review.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAction = async (action) => {
|
||||
if (action === 'approve' && !reviewerName.trim()) {
|
||||
toast.error(t('review.nameRequired'))
|
||||
return
|
||||
}
|
||||
if (action === 'reject' && !feedback.trim()) {
|
||||
toast.error(t('review.feedbackRequired'))
|
||||
return
|
||||
}
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-translation/${token}/${action}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
approved_by_name: reviewerName || 'Anonymous',
|
||||
feedback: feedback || '',
|
||||
}),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const err = await res.json()
|
||||
throw new Error(err.error || 'Action failed')
|
||||
}
|
||||
if (action === 'approve') setSuccess(t('review.approveSuccess'))
|
||||
else if (action === 'reject') setSuccess(t('review.rejectSuccess'))
|
||||
else setSuccess(t('review.revisionSuccess'))
|
||||
setPendingAction(null)
|
||||
} catch (err) {
|
||||
toast.error(err.message)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
<div className="w-8 h-8 border-4 border-brand-primary border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
<div className="text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-1">{t('review.errorTitle')}</h2>
|
||||
<p className="text-text-secondary">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||
<div className="text-center">
|
||||
<CheckCircle className="w-12 h-12 text-emerald-500 mx-auto mb-3" />
|
||||
<h2 className="text-lg font-semibold text-text-primary mb-1">{success}</h2>
|
||||
<p className="text-text-secondary">{t('review.thankYou')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!translation) return null
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
||||
{/* Header */}
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-brand-primary/10 flex items-center justify-center shrink-0">
|
||||
<Languages className="w-6 h-6 text-brand-primary" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-2xl font-bold text-text-primary mb-1">{translation.title}</h2>
|
||||
{translation.description && (
|
||||
<p className="text-text-secondary mb-2">{translation.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
|
||||
{translation.brand_name && <span>{translation.brand_name}</span>}
|
||||
{translation.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{translation.creator_name}</strong></span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Content */}
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Globe className="w-5 h-5 text-blue-600" />
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{t('translations.sourceContent')}
|
||||
</h3>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{translation.source_language}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
|
||||
<p className="text-sm text-blue-900 whitespace-pre-wrap leading-relaxed">{translation.source_content}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Translations */}
|
||||
{translation.texts && translation.texts.length > 0 && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
||||
{t('translations.translationTexts')} ({translation.texts.length})
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{translation.texts.map((text, idx) => (
|
||||
<div key={text.Id || idx} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="text-sm font-semibold text-text-primary">
|
||||
{text.language_label || text.language_code}
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">({text.language_code})</span>
|
||||
</div>
|
||||
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Review Actions */}
|
||||
{translation.status === 'pending_review' && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||
|
||||
{/* Reviewer identity */}
|
||||
<div className="mb-4">
|
||||
{translation.approvers?.length === 1 ? (
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">{translation.approvers[0].name}</span>
|
||||
</div>
|
||||
) : translation.approvers?.length > 1 ? (
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.selectYourName')}</label>
|
||||
<select
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('review.selectApprover')}</option>
|
||||
{translation.approvers.map(a => (
|
||||
<option key={a.id} value={a.name}>{a.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-3">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.yourName')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={reviewerName}
|
||||
onChange={e => setReviewerName(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
placeholder={t('review.enterYourName')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Feedback */}
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.feedback')}</label>
|
||||
<textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[100px] resize-y"
|
||||
placeholder={t('review.feedbackPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => handleAction('approve')}
|
||||
disabled={submitting || !reviewerName.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<CheckCircle className="w-5 h-5" />
|
||||
{t('review.approve')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAction('revision')}
|
||||
disabled={submitting || !feedback.trim()}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-amber-500 text-white rounded-lg font-medium hover:bg-amber-600 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
{t('review.requestRevision')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPendingAction('reject')}
|
||||
disabled={submitting}
|
||||
className="flex-1 flex items-center justify-center gap-2 py-3 bg-red-600 text-white rounded-lg font-medium hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<XCircle className="w-5 h-5" />
|
||||
{t('review.reject')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Already reviewed */}
|
||||
{translation.status !== 'pending_review' && (
|
||||
<div className="bg-surface rounded-xl border border-border p-6">
|
||||
<div className="text-center py-4">
|
||||
<CheckCircle className="w-10 h-10 text-emerald-500 mx-auto mb-2" />
|
||||
<p className="text-text-primary font-medium">
|
||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
|
||||
</p>
|
||||
{translation.approved_by_name && (
|
||||
<p className="text-sm text-text-secondary mt-1">
|
||||
{t('review.reviewedBy')}: <span className="font-semibold">{translation.approved_by_name}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Reject confirmation modal */}
|
||||
<Modal
|
||||
isOpen={pendingAction === 'reject'}
|
||||
onClose={() => setPendingAction(null)}
|
||||
title={t('review.confirmReject')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={() => handleAction('reject')}
|
||||
confirmText={t('review.reject')}
|
||||
confirmDisabled={!feedback.trim() || !reviewerName.trim()}
|
||||
>
|
||||
<p className="text-sm text-text-secondary mb-3">{t('review.rejectConfirmDesc')}</p>
|
||||
{!feedback.trim() && (
|
||||
<p className="text-sm text-amber-600">{t('review.feedbackRequiredForReject')}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
503
client/src/pages/Translations.jsx
Normal file
503
client/src/pages/Translations.jsx
Normal file
@@ -0,0 +1,503 @@
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api } from '../utils/api'
|
||||
import Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
||||
import ApproverMultiSelect from '../components/ApproverMultiSelect'
|
||||
|
||||
const STATUS_COLORS = {
|
||||
draft: 'bg-surface-tertiary text-text-secondary',
|
||||
pending_review: 'bg-amber-100 text-amber-700',
|
||||
approved: 'bg-emerald-100 text-emerald-700',
|
||||
rejected: 'bg-red-100 text-red-700',
|
||||
revision_requested: 'bg-orange-100 text-orange-700',
|
||||
}
|
||||
|
||||
const AVAILABLE_LANGUAGES = [
|
||||
{ code: 'AR', label: 'العربية' },
|
||||
{ code: 'EN', label: 'English' },
|
||||
{ code: 'FR', label: 'Français' },
|
||||
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||
]
|
||||
|
||||
const SORT_OPTIONS = [
|
||||
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
|
||||
{ value: 'created_at', dir: 'desc', labelKey: 'translations.sortNewest' },
|
||||
{ value: 'created_at', dir: 'asc', labelKey: 'translations.sortOldest' },
|
||||
{ value: 'title', dir: 'asc', labelKey: 'translations.sortTitleAZ' },
|
||||
]
|
||||
|
||||
export default function Translations() {
|
||||
const { t } = useLanguage()
|
||||
const { brands, teamMembers } = useContext(AppContext)
|
||||
const { user, canDeleteResource } = useAuth()
|
||||
const toast = useToast()
|
||||
|
||||
const [translations, setTranslations] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [filters, setFilters] = useState({ brand: '', status: '', creator: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [selectedTranslation, setSelectedTranslation] = useState(null)
|
||||
const [newTranslation, setNewTranslation] = useState({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Bulk select
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
|
||||
// View + sort
|
||||
const [viewMode, setViewMode] = useState('list')
|
||||
const [sortOption, setSortOption] = useState(0)
|
||||
const [listSortBy, setListSortBy] = useState('updated_at')
|
||||
const [listSortDir, setListSortDir] = useState('desc')
|
||||
|
||||
const [assignableUsers, setAssignableUsers] = useState([])
|
||||
|
||||
useEffect(() => {
|
||||
loadTranslations()
|
||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadTranslations = async () => {
|
||||
try {
|
||||
const res = await api.get('/translations')
|
||||
setTranslations(Array.isArray(res) ? res : [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load translations:', err)
|
||||
toast.error(t('translations.loadFailed'))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newTranslation.title) {
|
||||
toast.error(t('translations.titleRequired'))
|
||||
return
|
||||
}
|
||||
if (!newTranslation.source_content) {
|
||||
toast.error(t('translations.sourceContentRequired'))
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const created = await api.post('/translations', {
|
||||
...newTranslation,
|
||||
approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
|
||||
})
|
||||
toast.success(t('translations.created'))
|
||||
setShowCreateModal(false)
|
||||
setNewTranslation({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
|
||||
loadTranslations()
|
||||
setSelectedTranslation(created)
|
||||
} catch (err) {
|
||||
console.error('Create failed:', err)
|
||||
toast.error(t('translations.createFailed'))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (id) => {
|
||||
try {
|
||||
await api.delete(`/translations/${id}`)
|
||||
toast.success(t('translations.deleted'))
|
||||
setSelectedTranslation(null)
|
||||
loadTranslations()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
try {
|
||||
await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
|
||||
toast.success(t('translations.deleted'))
|
||||
setSelectedIds(new Set())
|
||||
setShowBulkDeleteConfirm(false)
|
||||
loadTranslations()
|
||||
} catch (err) {
|
||||
toast.error(t('translations.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === sortedTranslations.length) setSelectedIds(new Set())
|
||||
else setSelectedIds(new Set(sortedTranslations.map(t => t.Id)))
|
||||
}
|
||||
|
||||
const filteredTranslations = useMemo(() => {
|
||||
return translations.filter(t => {
|
||||
if (filters.brand && String(t.brand_id) !== filters.brand) return false
|
||||
if (filters.status && t.status !== filters.status) return false
|
||||
if (filters.creator && String(t.created_by_user_id) !== filters.creator) return false
|
||||
if (searchTerm && !t.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
}, [translations, filters, searchTerm])
|
||||
|
||||
const sortedTranslations = useMemo(() => {
|
||||
const sBy = viewMode === 'grid' ? SORT_OPTIONS[sortOption].value : listSortBy
|
||||
const sDir = viewMode === 'grid' ? SORT_OPTIONS[sortOption].dir : listSortDir
|
||||
return [...filteredTranslations].sort((a, b) => {
|
||||
let cmp = 0
|
||||
if (sBy === 'updated_at') {
|
||||
cmp = (a.UpdatedAt || a.updated_at || '').localeCompare(b.UpdatedAt || b.updated_at || '')
|
||||
} else if (sBy === 'created_at') {
|
||||
cmp = (a.CreatedAt || a.created_at || '').localeCompare(b.CreatedAt || b.created_at || '')
|
||||
} else if (sBy === 'title') {
|
||||
cmp = (a.title || '').localeCompare(b.title || '')
|
||||
} else if (sBy === 'status') {
|
||||
cmp = (a.status || '').localeCompare(b.status || '')
|
||||
}
|
||||
return sDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
}, [filteredTranslations, viewMode, sortOption, listSortBy, listSortDir])
|
||||
|
||||
const toggleListSort = (col) => {
|
||||
if (listSortBy === col) setListSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||
else { setListSortBy(col); setListSortDir('asc') }
|
||||
}
|
||||
|
||||
const SortIcon = ({ col }) => {
|
||||
if (listSortBy !== col) return null
|
||||
return listSortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 inline ml-0.5" />
|
||||
: <ChevronDown className="w-3 h-3 inline ml-0.5" />
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '—'
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
const getLangLabel = (code) => AVAILABLE_LANGUAGES.find(l => l.code === code)?.label || code
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-text-primary">{t('translations.title')}</h1>
|
||||
<p className="text-sm text-text-secondary mt-1">{t('translations.subtitle')}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center bg-surface-tertiary rounded-lg p-0.5">
|
||||
{[
|
||||
{ mode: 'grid', icon: LayoutGrid, label: t('translations.grid') },
|
||||
{ mode: 'list', icon: List, label: t('translations.list') },
|
||||
].map(({ mode, icon: Icon, label }) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setViewMode(mode)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
viewMode === mode
|
||||
? 'bg-white text-text-primary shadow-sm'
|
||||
: 'text-text-tertiary hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
<span className="font-medium">{t('translations.newTranslation')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('translations.searchTranslations')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-surface transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">{t('translations.allBrands')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.status}
|
||||
onChange={e => setFilters(f => ({ ...f, status: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">{t('translations.allStatuses')}</option>
|
||||
<option value="draft">{t('translations.status.draft')}</option>
|
||||
<option value="pending_review">{t('translations.status.pendingReview')}</option>
|
||||
<option value="approved">{t('translations.status.approved')}</option>
|
||||
<option value="rejected">{t('translations.status.rejected')}</option>
|
||||
<option value="revision_requested">{t('translations.status.revisionRequested')}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.creator}
|
||||
onChange={e => setFilters(f => ({ ...f, creator: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
<option value="">{t('translations.allCreators')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
|
||||
{viewMode === 'grid' && (
|
||||
<select
|
||||
value={sortOption}
|
||||
onChange={e => setSortOption(Number(e.target.value))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 transition-colors"
|
||||
>
|
||||
{SORT_OPTIONS.map((opt, i) => <option key={i} value={i}>{t(opt.labelKey)}</option>)}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bulk select bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<BulkSelectBar
|
||||
count={selectedIds.size}
|
||||
onClear={() => setSelectedIds(new Set())}
|
||||
onDelete={() => setShowBulkDeleteConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{loading ? (
|
||||
viewMode === 'grid' ? <SkeletonCard count={6} /> : <SkeletonTable rows={5} cols={7} />
|
||||
) : viewMode === 'grid' ? (
|
||||
/* Grid View */
|
||||
sortedTranslations.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Languages className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedTranslations.map(tr => (
|
||||
<div
|
||||
key={tr.Id}
|
||||
onClick={() => setSelectedTranslation(tr)}
|
||||
className="bg-surface rounded-xl border border-border p-5 hover:shadow-md transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-text-primary line-clamp-1">{tr.title}</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{tr.status?.replace('_', ' ')}
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{getLangLabel(tr.source_language)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{tr.description && (
|
||||
<p className="text-sm text-text-secondary line-clamp-2 mb-3">{tr.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-text-tertiary flex-wrap">
|
||||
{tr.brand_name && <span>{tr.brand_name}</span>}
|
||||
{tr.creator_name && <span>by {tr.creator_name}</span>}
|
||||
<span>{tr.translation_count || 0} {t('translations.languagesCount')}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
/* List View */
|
||||
sortedTranslations.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<Languages className="w-12 h-12 text-text-tertiary mx-auto mb-3" />
|
||||
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="px-4 py-3 text-left w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === sortedTranslations.length && sortedTranslations.length > 0} onChange={toggleSelectAll} className="rounded border-border" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('title')}>
|
||||
{t('translations.titleLabel')} <SortIcon col="title" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">
|
||||
{t('translations.sourceLanguage')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('status')}>
|
||||
{t('translations.status')} <SortIcon col="status" />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.brand')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.creator')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase">{t('translations.languagesLabel')}</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-text-secondary uppercase cursor-pointer" onClick={() => toggleListSort('updated_at')}>
|
||||
{t('translations.updated')} <SortIcon col="updated_at" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedTranslations.map(tr => (
|
||||
<tr
|
||||
key={tr.Id}
|
||||
onClick={() => setSelectedTranslation(tr)}
|
||||
className="border-b border-border last:border-0 hover:bg-surface-secondary cursor-pointer transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(tr.Id)} onChange={() => toggleSelect(tr.Id)} className="rounded border-border" />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-text-primary">{tr.title}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||
{getLangLabel(tr.source_language)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||
{tr.status?.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{tr.brand_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{tr.creator_name || '—'}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-secondary">{tr.translation_count || 0}</td>
|
||||
<td className="px-4 py-3 text-sm text-text-tertiary">{formatDate(tr.UpdatedAt || tr.updated_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Detail Panel */}
|
||||
{selectedTranslation && (
|
||||
<TranslationDetailPanel
|
||||
translation={selectedTranslation}
|
||||
onClose={() => setSelectedTranslation(null)}
|
||||
onUpdate={loadTranslations}
|
||||
onDelete={handleDelete}
|
||||
assignableUsers={assignableUsers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Modal */}
|
||||
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('translations.createTranslation')} size="md">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.titleLabel')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newTranslation.title}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder={t('translations.titlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.sourceLanguage')} *</label>
|
||||
<select
|
||||
value={newTranslation.source_language}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, source_language: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.sourceContent')} *</label>
|
||||
<textarea
|
||||
value={newTranslation.source_content}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, source_content: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[120px] resize-y"
|
||||
placeholder={t('translations.sourceContentPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.brand')}</label>
|
||||
<select
|
||||
value={newTranslation.brand_id}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, brand_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">—</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.approvers')}</label>
|
||||
<ApproverMultiSelect
|
||||
selected={newTranslation.approver_ids}
|
||||
onChange={ids => setNewTranslation(f => ({ ...f, approver_ids: ids }))}
|
||||
users={assignableUsers}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.description')}</label>
|
||||
<textarea
|
||||
value={newTranslation.description}
|
||||
onChange={e => setNewTranslation(f => ({ ...f, description: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 min-h-[60px] resize-y"
|
||||
placeholder={t('translations.descriptionPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
<button onClick={() => setShowCreateModal(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg">
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving || !newTranslation.title || !newTranslation.source_content}
|
||||
className="px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{saving ? t('translations.creating') : t('common.create')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Bulk delete confirm */}
|
||||
<Modal
|
||||
isOpen={showBulkDeleteConfirm}
|
||||
onClose={() => setShowBulkDeleteConfirm(false)}
|
||||
title={t('translations.deleteTranslation')}
|
||||
isConfirm
|
||||
danger
|
||||
onConfirm={handleBulkDelete}
|
||||
confirmText={t('common.delete')}
|
||||
>
|
||||
{t('translations.bulkDeleteDesc', { count: selectedIds.size })}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -85,8 +85,10 @@ const t = {
|
||||
// Types
|
||||
post: { en: 'post', ar: 'منشور' },
|
||||
artefact: { en: 'artefact', ar: 'قطعة إبداعية' },
|
||||
Post: { en: 'Post', ar: 'المنشور' },
|
||||
Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' },
|
||||
Post: { en: 'Post', ar: 'المنشور' },
|
||||
Artefact: { en: 'Artefact', ar: 'القطعة الإبداعية' },
|
||||
translation: { en: 'translation', ar: 'ترجمة' },
|
||||
Translation: { en: 'Translation', ar: 'الترجمة' },
|
||||
|
||||
// Generic
|
||||
view: { en: 'View', ar: 'عرض' },
|
||||
@@ -192,14 +194,14 @@ function notifyApproved({ type, record, approverName }) {
|
||||
getUser(creatorId).then(user => {
|
||||
if (!user) return;
|
||||
const l = user.lang;
|
||||
const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l);
|
||||
const typeLabel = tr(type === 'post' ? 'Post' : type === 'translation' ? 'Translation' : 'Artefact', l);
|
||||
send({
|
||||
to: user.email, lang: l,
|
||||
subject: `${tr('approved', l)}: ${title}`,
|
||||
heading: tr('approvedHeading', l)(typeLabel),
|
||||
bodyHtml: `<p>${tr('approvedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>`,
|
||||
ctaText: `${tr('view', l)} ${typeLabel}`,
|
||||
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`,
|
||||
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -213,7 +215,7 @@ function notifyRejected({ type, record, approverName, feedback }) {
|
||||
getUser(creatorId).then(user => {
|
||||
if (!user) return;
|
||||
const l = user.lang;
|
||||
const typeLabel = tr(type === 'post' ? 'Post' : 'Artefact', l);
|
||||
const typeLabel = tr(type === 'post' ? 'Post' : type === 'translation' ? 'Translation' : 'Artefact', l);
|
||||
send({
|
||||
to: user.email, lang: l,
|
||||
subject: `${tr('needsChanges', l)}: ${title}`,
|
||||
@@ -222,16 +224,18 @@ function notifyRejected({ type, record, approverName, feedback }) {
|
||||
<p>${tr('rejectedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
|
||||
ctaText: `${tr('view', l)} ${typeLabel}`,
|
||||
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : 'artefacts'}`,
|
||||
ctaUrl: `${APP_URL}/${type === 'post' ? 'posts' : type === 'translation' ? 'translations' : 'artefacts'}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Revision requested (artefact) → notify creator
|
||||
function notifyRevisionRequested({ record, approverName, feedback }) {
|
||||
function notifyRevisionRequested({ type, record, approverName, feedback }) {
|
||||
const creatorId = record.created_by_user_id;
|
||||
if (!creatorId) return;
|
||||
const title = record.title || 'Untitled';
|
||||
const entityType = type === 'translation' ? 'Translation' : 'Artefact';
|
||||
const entityPath = type === 'translation' ? 'translations' : 'artefacts';
|
||||
|
||||
getUser(creatorId).then(user => {
|
||||
if (!user) return;
|
||||
@@ -243,8 +247,8 @@ function notifyRevisionRequested({ record, approverName, feedback }) {
|
||||
bodyHtml: `
|
||||
<p>${tr('revisionRequestedBody', l)(title, approverName || (l === 'ar' ? 'مراجع' : 'a reviewer'))}</p>
|
||||
${feedback ? `<blockquote ${BLOCKQUOTE}>${feedback}</blockquote>` : ''}`,
|
||||
ctaText: `${tr('view', l)} ${tr('Artefact', l)}`,
|
||||
ctaUrl: `${APP_URL}/artefacts`,
|
||||
ctaText: `${tr('view', l)} ${tr(entityType, l)}`,
|
||||
ctaUrl: `${APP_URL}/${entityPath}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
423
server/server.js
423
server/server.js
@@ -158,6 +158,8 @@ const FK_COLUMNS = {
|
||||
PostVersionTexts: ['version_id'],
|
||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||
Users: ['role_id'],
|
||||
Translations: ['brand_id', 'created_by_user_id'],
|
||||
TranslationTexts: ['translation_id'],
|
||||
};
|
||||
|
||||
// Maps link column names to FK field names for migration
|
||||
@@ -421,6 +423,27 @@ const REQUIRED_TABLES = {
|
||||
{ title: 'name', uidt: 'SingleLineText' },
|
||||
{ title: 'color', uidt: 'SingleLineText' },
|
||||
],
|
||||
Translations: [
|
||||
{ title: 'title', uidt: 'SingleLineText' },
|
||||
{ title: 'description', uidt: 'LongText' },
|
||||
{ title: 'source_language', uidt: 'SingleLineText' },
|
||||
{ title: 'source_content', uidt: 'LongText' },
|
||||
{ title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" },
|
||||
{ title: 'brand_id', uidt: 'Number' },
|
||||
{ title: 'approver_ids', uidt: 'SingleLineText' },
|
||||
{ title: 'approval_token', uidt: 'SingleLineText' },
|
||||
{ title: 'token_expires_at', uidt: 'DateTime' },
|
||||
{ title: 'approved_by_name', uidt: 'SingleLineText' },
|
||||
{ title: 'approved_at', uidt: 'DateTime' },
|
||||
{ title: 'feedback', uidt: 'LongText' },
|
||||
{ title: 'created_by_user_id', uidt: 'Number' },
|
||||
],
|
||||
TranslationTexts: [
|
||||
{ title: 'translation_id', uidt: 'Number' },
|
||||
{ title: 'language_code', uidt: 'SingleLineText' },
|
||||
{ title: 'language_label', uidt: 'SingleLineText' },
|
||||
{ title: 'content', uidt: 'LongText' },
|
||||
],
|
||||
};
|
||||
|
||||
async function ensureRequiredTables() {
|
||||
@@ -4299,6 +4322,406 @@ app.post('/api/public/review/:token/comment', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// ─── TRANSLATION MANAGEMENT API ──────────────────────────────────
|
||||
|
||||
// List translations
|
||||
app.get('/api/translations', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { brand, status } = req.query;
|
||||
const whereParts = [];
|
||||
if (brand) whereParts.push(`(brand_id,eq,${sanitizeWhereValue(brand)})`);
|
||||
if (status) whereParts.push(`(status,eq,${sanitizeWhereValue(status)})`);
|
||||
const where = whereParts.length > 0 ? whereParts.join('~and') : undefined;
|
||||
|
||||
let translations = await nocodb.list('Translations', { where, sort: '-UpdatedAt', limit: QUERY_LIMITS.medium });
|
||||
|
||||
// Visibility filtering
|
||||
const userId = req.session.userId;
|
||||
if (req.session.userRole === 'contributor') {
|
||||
translations = translations.filter(t => t.created_by_user_id === userId);
|
||||
}
|
||||
|
||||
// Enrich with names
|
||||
const brandIds = new Set(), userIds = new Set();
|
||||
for (const t of translations) {
|
||||
if (t.brand_id) brandIds.add(t.brand_id);
|
||||
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
|
||||
if (t.approver_ids) {
|
||||
for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||
userIds.add(Number(id));
|
||||
}
|
||||
}
|
||||
}
|
||||
const names = await batchResolveNames({
|
||||
brand: { table: 'Brands', ids: [...brandIds] },
|
||||
user: { table: 'Users', ids: [...userIds] },
|
||||
});
|
||||
|
||||
// Count translation texts per record
|
||||
const textCounts = {};
|
||||
try {
|
||||
const allTexts = await nocodb.list('TranslationTexts', { limit: QUERY_LIMITS.large });
|
||||
for (const tt of allTexts) {
|
||||
textCounts[tt.translation_id] = (textCounts[tt.translation_id] || 0) + 1;
|
||||
}
|
||||
} catch (e) { /* table may not exist yet */ }
|
||||
|
||||
res.json(translations.map(t => {
|
||||
const approverIdList = t.approver_ids ? t.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
return {
|
||||
...t,
|
||||
brand_name: names[`brand:${t.brand_id}`] || null,
|
||||
creator_name: names[`user:${t.created_by_user_id}`] || null,
|
||||
approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })),
|
||||
translation_count: textCounts[t.Id] || 0,
|
||||
};
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('GET /translations error:', err);
|
||||
res.status(500).json({ error: 'Failed to load translations' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create translation
|
||||
app.post('/api/translations', requireAuth, async (req, res) => {
|
||||
const { title, description, source_language, source_content, brand_id, approver_ids } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
if (!source_language) return res.status(400).json({ error: 'Source language is required' });
|
||||
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
|
||||
|
||||
try {
|
||||
const created = await nocodb.create('Translations', {
|
||||
title,
|
||||
description: description || null,
|
||||
source_language,
|
||||
source_content,
|
||||
status: 'draft',
|
||||
brand_id: brand_id ? Number(brand_id) : null,
|
||||
approver_ids: approver_ids || null,
|
||||
created_by_user_id: req.session.userId,
|
||||
});
|
||||
|
||||
const record = await nocodb.get('Translations', created.Id);
|
||||
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const approvers = [];
|
||||
for (const id of approverIdList) {
|
||||
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
|
||||
}
|
||||
res.status(201).json({
|
||||
...record,
|
||||
brand_name: await getRecordName('Brands', record.brand_id),
|
||||
creator_name: await getRecordName('Users', record.created_by_user_id),
|
||||
approvers,
|
||||
translation_count: 0,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Create translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to create translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Bulk delete translations (BEFORE /:id)
|
||||
app.post('/api/translations/bulk-delete', requireAuth, requireRole('superadmin', 'manager'), async (req, res) => {
|
||||
try {
|
||||
const { ids } = req.body;
|
||||
if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' });
|
||||
for (const id of ids) {
|
||||
const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(id)})`, limit: QUERY_LIMITS.large });
|
||||
if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id })));
|
||||
}
|
||||
await nocodb.bulkDelete('Translations', ids.map(id => ({ Id: id })));
|
||||
res.json({ deleted: ids.length });
|
||||
} catch (err) {
|
||||
console.error('Bulk delete translations error:', err);
|
||||
res.status(500).json({ error: 'Failed to bulk delete translations' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update translation
|
||||
app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const existing = await nocodb.get('Translations', req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Translation not found' });
|
||||
|
||||
if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only modify your own translations' });
|
||||
}
|
||||
|
||||
const data = {};
|
||||
for (const f of ['title', 'description', 'source_language', 'source_content', 'status', 'feedback']) {
|
||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||
}
|
||||
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
||||
if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || null;
|
||||
|
||||
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||
|
||||
await nocodb.update('Translations', req.params.id, data);
|
||||
|
||||
const record = await nocodb.get('Translations', req.params.id);
|
||||
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const approvers = [];
|
||||
for (const id of approverIdList) {
|
||||
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
|
||||
}
|
||||
res.json({
|
||||
...record,
|
||||
brand_name: await getRecordName('Brands', record.brand_id),
|
||||
creator_name: await getRecordName('Users', record.created_by_user_id),
|
||||
approvers,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Update translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to update translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete translation
|
||||
app.delete('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const existing = await nocodb.get('Translations', req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Translation not found' });
|
||||
|
||||
if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only delete your own translations' });
|
||||
}
|
||||
|
||||
// Cascade delete translation texts
|
||||
const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`, limit: QUERY_LIMITS.large });
|
||||
if (texts.length > 0) await nocodb.bulkDelete('TranslationTexts', texts.map(t => ({ Id: t.Id })));
|
||||
|
||||
await nocodb.delete('Translations', req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// List translation texts
|
||||
app.get('/api/translations/:id/texts', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const texts = await nocodb.list('TranslationTexts', {
|
||||
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
res.json(texts);
|
||||
} catch (err) {
|
||||
console.error('GET translation texts error:', err);
|
||||
res.status(500).json({ error: 'Failed to load translation texts' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add/update translation text
|
||||
app.post('/api/translations/:id/texts', requireAuth, async (req, res) => {
|
||||
const { language_code, language_label, content } = req.body;
|
||||
if (!language_code || !content) return res.status(400).json({ error: 'Language code and content are required' });
|
||||
|
||||
try {
|
||||
const translation = await nocodb.get('Translations', req.params.id);
|
||||
if (!translation) return res.status(404).json({ error: 'Translation not found' });
|
||||
|
||||
// Check if text for this language already exists (upsert)
|
||||
const existing = await nocodb.list('TranslationTexts', {
|
||||
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
let result;
|
||||
if (existing.length > 0) {
|
||||
await nocodb.update('TranslationTexts', existing[0].Id, { content, language_label: language_label || language_code });
|
||||
result = await nocodb.get('TranslationTexts', existing[0].Id);
|
||||
} else {
|
||||
result = await nocodb.create('TranslationTexts', {
|
||||
translation_id: Number(req.params.id),
|
||||
language_code,
|
||||
language_label: language_label || language_code,
|
||||
content,
|
||||
});
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
console.error('Add/update translation text error:', err);
|
||||
res.status(500).json({ error: 'Failed to save translation text' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete translation text
|
||||
app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
|
||||
try {
|
||||
await nocodb.delete('TranslationTexts', req.params.textId);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
console.error('Delete translation text error:', err);
|
||||
res.status(500).json({ error: 'Failed to delete translation text' });
|
||||
}
|
||||
});
|
||||
|
||||
// Submit translation for review
|
||||
app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const existing = await nocodb.get('Translations', req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Translation not found' });
|
||||
|
||||
if (req.session.userRole === 'contributor' && existing.created_by_user_id !== req.session.userId) {
|
||||
return res.status(403).json({ error: 'You can only submit your own translations' });
|
||||
}
|
||||
|
||||
const token = require('crypto').randomUUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
|
||||
|
||||
await nocodb.update('Translations', req.params.id, {
|
||||
status: 'pending_review',
|
||||
approval_token: token,
|
||||
token_expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`;
|
||||
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
||||
notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
|
||||
} catch (err) {
|
||||
console.error('Submit translation review error:', err);
|
||||
res.status(500).json({ error: 'Failed to submit for review' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Get translation for review
|
||||
app.get('/api/public/review-translation/:token', async (req, res) => {
|
||||
try {
|
||||
const translations = await nocodb.list('Translations', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (translations.length === 0) {
|
||||
return res.status(404).json({ error: 'Review link not found or expired' });
|
||||
}
|
||||
|
||||
const translation = translations[0];
|
||||
|
||||
if (translation.token_expires_at) {
|
||||
const expiresAt = new Date(translation.token_expires_at);
|
||||
if (expiresAt < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
}
|
||||
|
||||
// Get all translation texts
|
||||
const texts = await nocodb.list('TranslationTexts', {
|
||||
where: `(translation_id,eq,${translation.Id})`,
|
||||
limit: QUERY_LIMITS.large,
|
||||
});
|
||||
|
||||
// Resolve approver names
|
||||
const approvers = [];
|
||||
if (translation.approver_ids) {
|
||||
for (const id of translation.approver_ids.split(',').filter(Boolean)) {
|
||||
approvers.push({ id: Number(id), name: await getRecordName('Users', Number(id)) });
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
...translation,
|
||||
brand_name: await getRecordName('Brands', translation.brand_id),
|
||||
creator_name: await getRecordName('Users', translation.created_by_user_id),
|
||||
approvers,
|
||||
texts,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Public translation review fetch error:', err);
|
||||
res.status(500).json({ error: 'Failed to load translation for review' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Approve translation
|
||||
app.post('/api/public/review-translation/:token/approve', async (req, res) => {
|
||||
const { approved_by_name } = req.body;
|
||||
try {
|
||||
const translations = await nocodb.list('Translations', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
|
||||
const translation = translations[0];
|
||||
|
||||
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
|
||||
await nocodb.update('Translations', translation.Id, {
|
||||
status: 'approved',
|
||||
approved_by_name: approved_by_name || 'Anonymous',
|
||||
approved_at: new Date().toISOString(),
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Translation approved successfully' });
|
||||
notify.notifyApproved({ type: 'translation', record: translation, approverName: approved_by_name });
|
||||
} catch (err) {
|
||||
console.error('Approve translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to approve translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Reject translation
|
||||
app.post('/api/public/review-translation/:token/reject', async (req, res) => {
|
||||
const { approved_by_name, feedback } = req.body;
|
||||
try {
|
||||
const translations = await nocodb.list('Translations', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
|
||||
const translation = translations[0];
|
||||
|
||||
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
|
||||
await nocodb.update('Translations', translation.Id, {
|
||||
status: 'rejected',
|
||||
approved_by_name: approved_by_name || 'Anonymous',
|
||||
feedback: feedback || '',
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Translation rejected' });
|
||||
notify.notifyRejected({ type: 'translation', record: translation, approverName: approved_by_name, feedback });
|
||||
} catch (err) {
|
||||
console.error('Reject translation error:', err);
|
||||
res.status(500).json({ error: 'Failed to reject translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Request revision on translation
|
||||
app.post('/api/public/review-translation/:token/revision', async (req, res) => {
|
||||
const { feedback, approved_by_name } = req.body;
|
||||
try {
|
||||
const translations = await nocodb.list('Translations', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (translations.length === 0) return res.status(404).json({ error: 'Review link not found' });
|
||||
const translation = translations[0];
|
||||
|
||||
if (translation.token_expires_at && new Date(translation.token_expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
|
||||
await nocodb.update('Translations', translation.Id, {
|
||||
status: 'revision_requested',
|
||||
approved_by_name: approved_by_name || '',
|
||||
feedback: feedback || '',
|
||||
});
|
||||
|
||||
res.json({ success: true, message: 'Revision requested' });
|
||||
notify.notifyRevisionRequested({ record: translation, approverName: approved_by_name, feedback });
|
||||
} catch (err) {
|
||||
console.error('Translation revision request error:', err);
|
||||
res.status(500).json({ error: 'Failed to request revision' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── ISSUE TRACKER API ──────────────────────────────────────────
|
||||
|
||||
// Internal: List issues with filters
|
||||
|
||||
Reference in New Issue
Block a user