refactor: simplify translations — shared utils, deduplicated code

- 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>
This commit is contained in:
fahed
2026-03-11 17:27:57 +03:00
parent 7ace32a070
commit ba3900bc33
8 changed files with 826 additions and 193 deletions
+187 -105
View File
@@ -1,35 +1,22 @@
import { useState, useEffect, useContext } from 'react'
import { Plus, Copy, Check, ExternalLink, Trash2, Save, FileEdit, Languages, ShieldCheck, Globe } from 'lucide-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'
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 = [] }) {
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 [editDescription, setEditDescription] = useState(translation.description || '')
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
const [editApproverIds, setEditApproverIds] = useState(
@@ -44,6 +31,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
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)
@@ -62,9 +56,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
loadTexts()
}, [translation.Id])
useEffect(() => {
if (externalPosts) setPosts(externalPosts)
}, [externalPosts])
useEffect(() => {
setEditTitle(translation.title || '')
setEditDescription(translation.description || '')
setEditSourceContent(translation.source_content || '')
setEditSourceLanguage(translation.source_language || 'EN')
setEditApproverIds(
@@ -92,7 +89,6 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
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,
@@ -142,11 +138,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
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,
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
content: editingContent,
})
toast.success(t('translations.updated'))
@@ -197,9 +189,35 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
}
}
// 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 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 },
@@ -222,12 +240,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
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"
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 ${STATUS_COLORS[translation.status] || 'bg-surface-tertiary text-text-secondary'}`}>
<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">
@@ -244,7 +263,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
footer={
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
@@ -265,7 +289,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
</button>
</div>
}
)}
>
{/* Details Tab */}
{activeTab === 'details' && (
@@ -275,7 +299,8 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<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"
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>
@@ -286,33 +311,71 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<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"
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>
<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"
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>
@@ -340,76 +403,92 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
</div>
{/* Add translation button */}
{/* 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>
{availableForAdd.length > 0 && (
{!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.addTranslation')}
{t('translations.addOption')}
</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>
</>
)}
{/* 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>
{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">
@@ -491,7 +570,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
</TabbedModal>
{/* Add Translation Modal */}
<Modal isOpen={showAddLang} onClose={() => setShowAddLang(false)} title={t('translations.addTranslation')} size="md">
<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>
@@ -501,7 +580,10 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
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>)}
{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>
@@ -522,7 +604,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
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')}
{savingLang ? t('common.loading') : t('translations.addOption')}
</button>
</div>
</div>