ba3900bc33
- Extract shared constants to client/src/utils/translations.js (AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage) - TranslationDetailPanel: deduplicate copy button JSX, hoist hasSelected - PublicTranslationReview: memoize textsByLanguage, use shared isTextSelected - Translations page: import from shared module - Server: translation schema updates, post_id linking - Add reassign-user utility script - Add new translation i18n keys to en.json and ar.json Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
641 lines
29 KiB
React
641 lines
29 KiB
React
import { useState, useEffect, useContext } from 'react'
|
|
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe, Lock } from 'lucide-react'
|
|
import { AppContext } from '../App'
|
|
import { useLanguage } from '../i18n/LanguageContext'
|
|
import { api } from '../utils/api'
|
|
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage } from '../utils/translations'
|
|
import Modal from './Modal'
|
|
import TabbedModal from './TabbedModal'
|
|
import { useToast } from './ToastContainer'
|
|
import ApproverMultiSelect from './ApproverMultiSelect'
|
|
|
|
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
|
|
const { t } = useLanguage()
|
|
const { brands } = useContext(AppContext)
|
|
const toast = useToast()
|
|
|
|
const isApproved = translation.status === 'approved'
|
|
|
|
const [editTitle, setEditTitle] = useState(translation.title || '')
|
|
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('')
|
|
const [copiedTextId, setCopiedTextId] = useState(null)
|
|
|
|
// Post selector
|
|
const [posts, setPosts] = useState(externalPosts || [])
|
|
const [showCreatePost, setShowCreatePost] = useState(false)
|
|
const [newPostTitle, setNewPostTitle] = useState('')
|
|
const [creatingPost, setCreatingPost] = useState(false)
|
|
|
|
// 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(() => {
|
|
if (externalPosts) setPosts(externalPosts)
|
|
}, [externalPosts])
|
|
|
|
useEffect(() => {
|
|
setEditTitle(translation.title || '')
|
|
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,
|
|
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 {
|
|
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
|
|
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'))
|
|
}
|
|
}
|
|
|
|
const handleCreatePost = async () => {
|
|
if (!newPostTitle.trim()) return
|
|
setCreatingPost(true)
|
|
try {
|
|
const created = await api.post('/posts', { title: newPostTitle, status: 'draft' })
|
|
const postId = created.Id || created.id || created._id
|
|
setPosts(prev => [created, ...prev])
|
|
await handleFieldUpdate('post_id', postId)
|
|
setShowCreatePost(false)
|
|
setNewPostTitle('')
|
|
} catch (err) {
|
|
toast.error(t('translations.postCreateFailed'))
|
|
} finally {
|
|
setCreatingPost(false)
|
|
}
|
|
}
|
|
|
|
const copyTextContent = (content, id) => {
|
|
navigator.clipboard.writeText(content)
|
|
setCopiedTextId(id)
|
|
toast.success(t('translations.copiedToClipboard'))
|
|
setTimeout(() => setCopiedTextId(null), 2000)
|
|
}
|
|
|
|
// Available languages (exclude source language only — multiple options per language allowed)
|
|
const targetLanguages = AVAILABLE_LANGUAGES.filter(l => l.code !== translation.source_language)
|
|
|
|
// Group texts by language
|
|
const textsByLanguage = groupTextsByLanguage(texts)
|
|
|
|
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)}
|
|
readOnly={isApproved}
|
|
className={`text-lg font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 w-full ${isApproved ? 'cursor-default' : ''}`}
|
|
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 ${TRANSLATION_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={isApproved ? (
|
|
<div className="flex items-center gap-2 w-full justify-center">
|
|
<Lock className="w-4 h-4 text-text-tertiary" />
|
|
<span className="text-sm text-text-tertiary">{t('translations.approvedReadOnly')}</span>
|
|
</div>
|
|
) : (
|
|
<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)}
|
|
disabled={isApproved}
|
|
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 disabled:opacity-60 disabled:cursor-default"
|
|
>
|
|
{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)}
|
|
readOnly={isApproved}
|
|
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 ${isApproved ? 'opacity-60 cursor-default' : ''}`}
|
|
placeholder={t('translations.sourceContentPlaceholder')}
|
|
/>
|
|
</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)}
|
|
disabled={isApproved}
|
|
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 disabled:opacity-60 disabled:cursor-default"
|
|
>
|
|
<option value="">—</option>
|
|
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
|
</select>
|
|
</div>
|
|
<div>
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.linkedPost')}</h4>
|
|
{isApproved ? (
|
|
<p className="px-3 py-2 text-sm text-text-secondary">{translation.post_name || '—'}</p>
|
|
) : showCreatePost ? (
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={newPostTitle}
|
|
onChange={e => setNewPostTitle(e.target.value)}
|
|
onKeyDown={e => e.key === 'Enter' && handleCreatePost()}
|
|
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
placeholder={t('translations.newPostTitle')}
|
|
autoFocus
|
|
/>
|
|
<button
|
|
onClick={handleCreatePost}
|
|
disabled={creatingPost || !newPostTitle.trim()}
|
|
className="px-2 py-2 bg-brand-primary text-white text-xs rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
|
|
>
|
|
{creatingPost ? '...' : t('common.create')}
|
|
</button>
|
|
<button onClick={() => setShowCreatePost(false)} className="text-xs text-text-secondary hover:text-text-primary">
|
|
{t('common.cancel')}
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center gap-1">
|
|
<select
|
|
value={translation.post_id || ''}
|
|
onChange={e => handleFieldUpdate('post_id', e.target.value)}
|
|
className="flex-1 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>
|
|
{posts.map(p => <option key={p.Id || p.id || p._id} value={p.Id || p.id || p._id}>{p.title}</option>)}
|
|
</select>
|
|
<button
|
|
onClick={() => setShowCreatePost(true)}
|
|
className="p-2 text-brand-primary hover:text-brand-primary/80"
|
|
title={t('translations.createPost')}
|
|
>
|
|
<Plus className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</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 option button */}
|
|
<div className="flex items-center justify-between">
|
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
|
|
{!isApproved && (
|
|
<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.addOption')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Grouped by language */}
|
|
{targetLanguages.some(l => textsByLanguage[l.code]?.length > 0) ? (
|
|
<div className="space-y-5">
|
|
{targetLanguages.map(lang => {
|
|
const options = textsByLanguage[lang.code] || []
|
|
if (options.length === 0) return null
|
|
return (
|
|
<div key={lang.code}>
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<span className="text-sm font-semibold text-text-primary">{lang.label}</span>
|
|
<span className="text-xs text-text-tertiary">({lang.code})</span>
|
|
<span className="text-xs px-1.5 py-0.5 rounded-full bg-surface-tertiary text-text-tertiary">
|
|
{options.length} {options.length === 1 ? t('translations.option') : t('translations.options')}
|
|
</span>
|
|
</div>
|
|
<div className="space-y-2">
|
|
{(() => { const hasSelected = options.some(isTextSelected); return options.map((text, idx) => {
|
|
const selected = isTextSelected(text)
|
|
const isDimmed = isApproved && hasSelected && !selected
|
|
return (
|
|
<div key={text.Id} className={`rounded-lg p-3 border ${selected ? 'bg-emerald-50 border-emerald-300' : isDimmed ? 'bg-surface-secondary border-border opacity-50' : 'bg-surface-secondary border-border'}`}>
|
|
<div className="flex items-center justify-between mb-1.5">
|
|
<span className="text-xs font-medium text-text-tertiary">
|
|
{t('translations.optionLabel')} {text.option_number || idx + 1}
|
|
{selected && <span className="ml-2 text-emerald-600 font-semibold">{t('translations.selected')}</span>}
|
|
</span>
|
|
<div className="flex items-center gap-1">
|
|
{editingTextId !== text.Id && (
|
|
<button
|
|
onClick={() => copyTextContent(text.content, text.Id)}
|
|
className="text-text-tertiary hover:text-text-primary p-1"
|
|
title={t('translations.copyContent')}
|
|
>
|
|
{copiedTextId === text.Id ? <Check className="w-3.5 h-3.5 text-emerald-600" /> : <Copy className="w-3.5 h-3.5" />}
|
|
</button>
|
|
)}
|
|
{isApproved ? null : editingTextId === text.Id ? (
|
|
<>
|
|
<button onClick={() => handleUpdateText(text.Id)} className="text-emerald-600 hover:text-emerald-700 p-1">
|
|
<Check className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button onClick={() => setEditingTextId(null)} className="text-text-tertiary hover:text-text-secondary p-1 text-xs">✕</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }} className="text-text-tertiary hover:text-text-secondary p-1">
|
|
<FileEdit className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button onClick={() => setConfirmDeleteTextId(text.Id)} className="text-red-500 hover:text-red-600 p-1">
|
|
<Trash2 className="w-3.5 h-3.5" />
|
|
</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-[80px] resize-y"
|
|
autoFocus
|
|
/>
|
|
) : (
|
|
<p className="text-sm text-text-secondary whitespace-pre-wrap">{text.content}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}) })()}
|
|
</div>
|
|
</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.addOption')} 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>
|
|
{targetLanguages.map(l => {
|
|
const count = textsByLanguage[l.code]?.length || 0
|
|
return <option key={l.code} value={l.code}>{l.label} ({l.code}){count > 0 ? ` — ${count} ${t('translations.existing')}` : ''}</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.addOption')}
|
|
</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>
|
|
</>
|
|
)
|
|
}
|