Compare commits
3 Commits
main
..
94f448344e
| Author | SHA1 | Date | |
|---|---|---|---|
| 94f448344e | |||
| ba3900bc33 | |||
| 7ace32a070 |
@@ -8,3 +8,4 @@ dist/
|
|||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
server/uploads/
|
server/uploads/
|
||||||
|
.superpowers/
|
||||||
|
|||||||
@@ -1,35 +1,22 @@
|
|||||||
import { useState, useEffect, useContext } from 'react'
|
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 { AppContext } from '../App'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api } from '../utils/api'
|
import { api } from '../utils/api'
|
||||||
|
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS, isTextSelected, groupTextsByLanguage } from '../utils/translations'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
import TabbedModal from './TabbedModal'
|
import TabbedModal from './TabbedModal'
|
||||||
import { useToast } from './ToastContainer'
|
import { useToast } from './ToastContainer'
|
||||||
import ApproverMultiSelect from './ApproverMultiSelect'
|
import ApproverMultiSelect from './ApproverMultiSelect'
|
||||||
|
|
||||||
const STATUS_COLORS = {
|
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
|
||||||
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 { t } = useLanguage()
|
||||||
const { brands } = useContext(AppContext)
|
const { brands } = useContext(AppContext)
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
|
const isApproved = translation.status === 'approved'
|
||||||
|
|
||||||
const [editTitle, setEditTitle] = useState(translation.title || '')
|
const [editTitle, setEditTitle] = useState(translation.title || '')
|
||||||
const [editDescription, setEditDescription] = useState(translation.description || '')
|
|
||||||
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
|
const [editSourceContent, setEditSourceContent] = useState(translation.source_content || '')
|
||||||
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
|
const [editSourceLanguage, setEditSourceLanguage] = useState(translation.source_language || 'EN')
|
||||||
const [editApproverIds, setEditApproverIds] = useState(
|
const [editApproverIds, setEditApproverIds] = useState(
|
||||||
@@ -44,6 +31,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
const [freshReviewUrl, setFreshReviewUrl] = useState('')
|
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
|
// Language add modal
|
||||||
const [showAddLang, setShowAddLang] = useState(false)
|
const [showAddLang, setShowAddLang] = useState(false)
|
||||||
@@ -62,9 +56,12 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
loadTexts()
|
loadTexts()
|
||||||
}, [translation.Id])
|
}, [translation.Id])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (externalPosts) setPosts(externalPosts)
|
||||||
|
}, [externalPosts])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setEditTitle(translation.title || '')
|
setEditTitle(translation.title || '')
|
||||||
setEditDescription(translation.description || '')
|
|
||||||
setEditSourceContent(translation.source_content || '')
|
setEditSourceContent(translation.source_content || '')
|
||||||
setEditSourceLanguage(translation.source_language || 'EN')
|
setEditSourceLanguage(translation.source_language || 'EN')
|
||||||
setEditApproverIds(
|
setEditApproverIds(
|
||||||
@@ -92,7 +89,6 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
try {
|
try {
|
||||||
await api.patch(`/translations/${translation.Id}`, {
|
await api.patch(`/translations/${translation.Id}`, {
|
||||||
title: editTitle,
|
title: editTitle,
|
||||||
description: editDescription,
|
|
||||||
source_content: editSourceContent,
|
source_content: editSourceContent,
|
||||||
source_language: editSourceLanguage,
|
source_language: editSourceLanguage,
|
||||||
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
|
approver_ids: editApproverIds.length > 0 ? editApproverIds.join(',') : null,
|
||||||
@@ -142,11 +138,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
|
|
||||||
const handleUpdateText = async (textId) => {
|
const handleUpdateText = async (textId) => {
|
||||||
try {
|
try {
|
||||||
const text = texts.find(t => t.Id === textId)
|
await api.patch(`/translations/${translation.Id}/texts/${textId}`, {
|
||||||
if (!text) return
|
|
||||||
await api.post(`/translations/${translation.Id}/texts`, {
|
|
||||||
language_code: text.language_code,
|
|
||||||
language_label: text.language_label,
|
|
||||||
content: editingContent,
|
content: editingContent,
|
||||||
})
|
})
|
||||||
toast.success(t('translations.updated'))
|
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 handleCreatePost = async () => {
|
||||||
const usedCodes = new Set([translation.source_language, ...texts.map(t => t.language_code)])
|
if (!newPostTitle.trim()) return
|
||||||
const availableForAdd = AVAILABLE_LANGUAGES.filter(l => !usedCodes.has(l.code))
|
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 = [
|
const tabs = [
|
||||||
{ key: 'details', label: t('translations.details'), icon: FileEdit },
|
{ key: 'details', label: t('translations.details'), icon: FileEdit },
|
||||||
@@ -222,12 +240,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
type="text"
|
type="text"
|
||||||
value={editTitle}
|
value={editTitle}
|
||||||
onChange={e => setEditTitle(e.target.value)}
|
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')}
|
placeholder={t('translations.titlePlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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('_', ' ')}
|
{translation.status?.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
<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}
|
tabs={tabs}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
onTabChange={setActiveTab}
|
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 w-full justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -265,7 +289,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
|
{savingDraft ? t('translations.savingDraft') : t('translations.saveDraft')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
>
|
>
|
||||||
{/* Details Tab */}
|
{/* Details Tab */}
|
||||||
{activeTab === 'details' && (
|
{activeTab === 'details' && (
|
||||||
@@ -275,7 +299,8 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
<select
|
<select
|
||||||
value={editSourceLanguage}
|
value={editSourceLanguage}
|
||||||
onChange={e => setEditSourceLanguage(e.target.value)}
|
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>)}
|
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
|
||||||
</select>
|
</select>
|
||||||
@@ -286,33 +311,71 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
<textarea
|
<textarea
|
||||||
value={editSourceContent}
|
value={editSourceContent}
|
||||||
onChange={e => setEditSourceContent(e.target.value)}
|
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')}
|
placeholder={t('translations.sourceContentPlaceholder')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
|
||||||
<select
|
<select
|
||||||
value={translation.brand_id || ''}
|
value={translation.brand_id || ''}
|
||||||
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
|
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>
|
<option value="">—</option>
|
||||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<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>
|
<p className="text-sm text-blue-800 whitespace-pre-wrap">{translation.source_content}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add translation button */}
|
{/* Add translation option button */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
|
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('translations.translationTexts')}</h4>
|
||||||
{availableForAdd.length > 0 && (
|
{!isApproved && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowAddLang(true)}
|
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"
|
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" />
|
<Plus className="w-3 h-3" />
|
||||||
{t('translations.addTranslation')}
|
{t('translations.addOption')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Translation texts list */}
|
{/* Grouped by language */}
|
||||||
{texts.length > 0 ? (
|
{targetLanguages.some(l => textsByLanguage[l.code]?.length > 0) ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-5">
|
||||||
{texts.map(text => (
|
{targetLanguages.map(lang => {
|
||||||
<div key={text.Id} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
const options = textsByLanguage[lang.code] || []
|
||||||
<div className="flex items-center justify-between mb-2">
|
if (options.length === 0) return null
|
||||||
<span className="text-sm font-medium text-text-primary">
|
return (
|
||||||
{text.language_label || text.language_code}
|
<div key={lang.code}>
|
||||||
<span className="text-xs text-text-tertiary ml-1">({text.language_code})</span>
|
<div className="flex items-center gap-2 mb-2">
|
||||||
</span>
|
<span className="text-sm font-semibold text-text-primary">{lang.label}</span>
|
||||||
<div className="flex items-center gap-1">
|
<span className="text-xs text-text-tertiary">({lang.code})</span>
|
||||||
{editingTextId === text.Id ? (
|
<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')}
|
||||||
<button
|
</span>
|
||||||
onClick={() => handleUpdateText(text.Id)}
|
</div>
|
||||||
className="text-emerald-600 hover:text-emerald-700 p-1"
|
<div className="space-y-2">
|
||||||
>
|
{(() => { const hasSelected = options.some(isTextSelected); return options.map((text, idx) => {
|
||||||
<Check className="w-4 h-4" />
|
const selected = isTextSelected(text)
|
||||||
</button>
|
const isDimmed = isApproved && hasSelected && !selected
|
||||||
<button
|
return (
|
||||||
onClick={() => setEditingTextId(null)}
|
<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'}`}>
|
||||||
className="text-text-tertiary hover:text-text-secondary p-1"
|
<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}
|
||||||
</button>
|
{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
|
<button
|
||||||
onClick={() => { setEditingTextId(text.Id); setEditingContent(text.content || '') }}
|
onClick={() => copyTextContent(text.content, text.Id)}
|
||||||
className="text-text-tertiary hover:text-text-secondary p-1"
|
className="text-text-tertiary hover:text-text-primary p-1"
|
||||||
>
|
title={t('translations.copyContent')}
|
||||||
<FileEdit className="w-4 h-4" />
|
>
|
||||||
</button>
|
{copiedTextId === text.Id ? <Check className="w-3.5 h-3.5 text-emerald-600" /> : <Copy className="w-3.5 h-3.5" />}
|
||||||
<button
|
</button>
|
||||||
onClick={() => setConfirmDeleteTextId(text.Id)}
|
)}
|
||||||
className="text-red-500 hover:text-red-600 p-1"
|
{isApproved ? null : editingTextId === text.Id ? (
|
||||||
>
|
<>
|
||||||
<Trash2 className="w-4 h-4" />
|
<button onClick={() => handleUpdateText(text.Id)} className="text-emerald-600 hover:text-emerald-700 p-1">
|
||||||
</button>
|
<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>
|
||||||
{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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
|
<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>
|
</TabbedModal>
|
||||||
|
|
||||||
{/* Add Translation Modal */}
|
{/* 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 className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
|
<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"
|
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>
|
<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>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -522,7 +604,7 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
|
|||||||
disabled={savingLang || !langForm.language_code || !langForm.content}
|
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"
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+47
-3
@@ -922,6 +922,30 @@
|
|||||||
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
"review.confirmRejectPostDesc": "هل أنت متأكد من رفض هذا المنشور؟ يرجى تقديم ملاحظات توضح السبب.",
|
||||||
"review.feedbackRequired": "الملاحظات (مطلوبة)",
|
"review.feedbackRequired": "الملاحظات (مطلوبة)",
|
||||||
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
|
"review.feedbackRequiredError": "يرجى تقديم ملاحظات عند الرفض",
|
||||||
|
"review.loadFailed": "فشل تحميل المراجعة",
|
||||||
|
"review.errorTitle": "خطأ",
|
||||||
|
"review.thankYou": "شكراً لمراجعتك!",
|
||||||
|
"review.approveSuccess": "تمت الموافقة على الترجمة بنجاح!",
|
||||||
|
"review.rejectSuccess": "تم رفض الترجمة.",
|
||||||
|
"review.revisionSuccess": "تم طلب التعديل بنجاح.",
|
||||||
|
"review.nameRequired": "يرجى إدخال اسمك",
|
||||||
|
"review.yourReview": "مراجعتك",
|
||||||
|
"review.selectYourName": "اختر اسمك",
|
||||||
|
"review.selectApprover": "اختر المراجع...",
|
||||||
|
"review.yourName": "اسمك",
|
||||||
|
"review.enterYourName": "أدخل اسمك...",
|
||||||
|
"review.feedback": "الملاحظات",
|
||||||
|
"review.feedbackPlaceholder": "شارك أفكارك أو ملاحظاتك...",
|
||||||
|
"review.approve": "موافقة",
|
||||||
|
"review.approved": "تمت الموافقة",
|
||||||
|
"review.rejected": "مرفوض",
|
||||||
|
"review.requestRevision": "طلب تعديل",
|
||||||
|
"review.reject": "رفض",
|
||||||
|
"review.statusLabel": "الحالة",
|
||||||
|
"review.reviewedBy": "تمت المراجعة بواسطة",
|
||||||
|
"review.confirmReject": "تأكيد الرفض",
|
||||||
|
"review.rejectConfirmDesc": "هل أنت متأكد من رفض هذه الترجمة؟ تأكد من تقديم الملاحظات.",
|
||||||
|
"review.feedbackRequiredForReject": "يرجى تقديم ملاحظات قبل الرفض.",
|
||||||
"posts.versions": "الإصدارات",
|
"posts.versions": "الإصدارات",
|
||||||
"posts.newVersion": "إصدار جديد",
|
"posts.newVersion": "إصدار جديد",
|
||||||
"posts.createNewVersion": "إنشاء إصدار جديد",
|
"posts.createNewVersion": "إنشاء إصدار جديد",
|
||||||
@@ -961,7 +985,6 @@
|
|||||||
"translations.status": "الحالة",
|
"translations.status": "الحالة",
|
||||||
"translations.languagesLabel": "اللغات",
|
"translations.languagesLabel": "اللغات",
|
||||||
"translations.languagesCount": "لغات",
|
"translations.languagesCount": "لغات",
|
||||||
"translations.updated": "تم التحديث",
|
|
||||||
"translations.grid": "شبكة",
|
"translations.grid": "شبكة",
|
||||||
"translations.list": "قائمة",
|
"translations.list": "قائمة",
|
||||||
"translations.allBrands": "جميع العلامات",
|
"translations.allBrands": "جميع العلامات",
|
||||||
@@ -991,7 +1014,7 @@
|
|||||||
"translations.draftSaved": "تم حفظ المسودة!",
|
"translations.draftSaved": "تم حفظ المسودة!",
|
||||||
"translations.failedSaveDraft": "فشل حفظ المسودة",
|
"translations.failedSaveDraft": "فشل حفظ المسودة",
|
||||||
"translations.saveDraft": "حفظ المسودة",
|
"translations.saveDraft": "حفظ المسودة",
|
||||||
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والوصف والمحتوى الأصلي",
|
"translations.saveDraftTooltip": "حفظ التغييرات على العنوان والمحتوى الأصلي",
|
||||||
"translations.savingDraft": "جارٍ الحفظ...",
|
"translations.savingDraft": "جارٍ الحفظ...",
|
||||||
"translations.updated": "تم التحديث!",
|
"translations.updated": "تم التحديث!",
|
||||||
"translations.failedUpdate": "فشل التحديث",
|
"translations.failedUpdate": "فشل التحديث",
|
||||||
@@ -1021,5 +1044,26 @@
|
|||||||
"translations.approvedByLabel": "وافق عليه",
|
"translations.approvedByLabel": "وافق عليه",
|
||||||
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
|
"translations.pendingReviewInfo": "هذه الترجمة بانتظار المراجعة حاليًا.",
|
||||||
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
"translations.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||||
"translations.failedDelete": "فشل الحذف"
|
"translations.failedDelete": "فشل الحذف",
|
||||||
|
"translations.addOption": "إضافة خيار",
|
||||||
|
"translations.option": "خيار",
|
||||||
|
"translations.options": "خيارات",
|
||||||
|
"translations.optionLabel": "الخيار",
|
||||||
|
"translations.selected": "محدد",
|
||||||
|
"translations.selectThis": "اختيار",
|
||||||
|
"translations.optionSelected": "تم اختيار الخيار!",
|
||||||
|
"translations.suggestAlternative": "اقتراح بديل",
|
||||||
|
"translations.suggestForLang": "اقترح ترجمة لـ",
|
||||||
|
"translations.enterSuggestion": "أدخل الترجمة المقترحة...",
|
||||||
|
"translations.submitSuggestion": "إرسال الاقتراح",
|
||||||
|
"translations.suggestionAdded": "تمت إضافة الاقتراح!",
|
||||||
|
"translations.existing": "موجود",
|
||||||
|
"translations.copyContent": "نسخ إلى الحافظة",
|
||||||
|
"translations.copiedToClipboard": "تم النسخ!",
|
||||||
|
"translations.approvedReadOnly": "هذه الترجمة معتمدة ولا يمكن تعديلها.",
|
||||||
|
"translations.linkedPost": "المنشور المرتبط",
|
||||||
|
"translations.createPost": "منشور جديد",
|
||||||
|
"translations.newPostTitle": "عنوان المنشور...",
|
||||||
|
"translations.postCreated": "تم إنشاء المنشور!",
|
||||||
|
"translations.postCreateFailed": "فشل إنشاء المنشور"
|
||||||
}
|
}
|
||||||
+48
-4
@@ -922,6 +922,30 @@
|
|||||||
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
|
"review.confirmRejectPostDesc": "Are you sure you want to reject this post? Please provide feedback explaining why.",
|
||||||
"review.feedbackRequired": "Feedback (required)",
|
"review.feedbackRequired": "Feedback (required)",
|
||||||
"review.feedbackRequiredError": "Please provide feedback when rejecting",
|
"review.feedbackRequiredError": "Please provide feedback when rejecting",
|
||||||
|
"review.loadFailed": "Failed to load review",
|
||||||
|
"review.errorTitle": "Error",
|
||||||
|
"review.thankYou": "Thank you for your review!",
|
||||||
|
"review.approveSuccess": "Translation approved successfully!",
|
||||||
|
"review.rejectSuccess": "Translation has been rejected.",
|
||||||
|
"review.revisionSuccess": "Revision requested successfully.",
|
||||||
|
"review.nameRequired": "Please provide your name",
|
||||||
|
"review.yourReview": "Your Review",
|
||||||
|
"review.selectYourName": "Select your name",
|
||||||
|
"review.selectApprover": "Select approver...",
|
||||||
|
"review.yourName": "Your Name",
|
||||||
|
"review.enterYourName": "Enter your name...",
|
||||||
|
"review.feedback": "Feedback",
|
||||||
|
"review.feedbackPlaceholder": "Share your thoughts or feedback...",
|
||||||
|
"review.approve": "Approve",
|
||||||
|
"review.approved": "Approved",
|
||||||
|
"review.rejected": "Rejected",
|
||||||
|
"review.requestRevision": "Request Revision",
|
||||||
|
"review.reject": "Reject",
|
||||||
|
"review.statusLabel": "Status",
|
||||||
|
"review.reviewedBy": "Reviewed by",
|
||||||
|
"review.confirmReject": "Confirm Rejection",
|
||||||
|
"review.rejectConfirmDesc": "Are you sure you want to reject this translation? Please make sure you have provided feedback.",
|
||||||
|
"review.feedbackRequiredForReject": "Please provide feedback before rejecting.",
|
||||||
"posts.versions": "Versions",
|
"posts.versions": "Versions",
|
||||||
"posts.newVersion": "New Version",
|
"posts.newVersion": "New Version",
|
||||||
"posts.createNewVersion": "Create New Version",
|
"posts.createNewVersion": "Create New Version",
|
||||||
@@ -961,7 +985,6 @@
|
|||||||
"translations.status": "Status",
|
"translations.status": "Status",
|
||||||
"translations.languagesLabel": "Languages",
|
"translations.languagesLabel": "Languages",
|
||||||
"translations.languagesCount": "languages",
|
"translations.languagesCount": "languages",
|
||||||
"translations.updated": "Updated",
|
|
||||||
"translations.grid": "Grid",
|
"translations.grid": "Grid",
|
||||||
"translations.list": "List",
|
"translations.list": "List",
|
||||||
"translations.allBrands": "All Brands",
|
"translations.allBrands": "All Brands",
|
||||||
@@ -991,9 +1014,9 @@
|
|||||||
"translations.draftSaved": "Draft saved!",
|
"translations.draftSaved": "Draft saved!",
|
||||||
"translations.failedSaveDraft": "Failed to save draft",
|
"translations.failedSaveDraft": "Failed to save draft",
|
||||||
"translations.saveDraft": "Save Draft",
|
"translations.saveDraft": "Save Draft",
|
||||||
"translations.saveDraftTooltip": "Save changes to title, description, and source content",
|
"translations.saveDraftTooltip": "Save changes to title and source content",
|
||||||
"translations.savingDraft": "Saving...",
|
"translations.savingDraft": "Saving...",
|
||||||
"translations.updated": "Updated!",
|
"translations.updated": "Updated",
|
||||||
"translations.failedUpdate": "Failed to update",
|
"translations.failedUpdate": "Failed to update",
|
||||||
"translations.addTranslation": "Add Translation",
|
"translations.addTranslation": "Add Translation",
|
||||||
"translations.translationAdded": "Translation added!",
|
"translations.translationAdded": "Translation added!",
|
||||||
@@ -1021,5 +1044,26 @@
|
|||||||
"translations.approvedByLabel": "Approved by",
|
"translations.approvedByLabel": "Approved by",
|
||||||
"translations.pendingReviewInfo": "This translation is currently pending review.",
|
"translations.pendingReviewInfo": "This translation is currently pending review.",
|
||||||
"translations.noReviewInfo": "No review information available.",
|
"translations.noReviewInfo": "No review information available.",
|
||||||
"translations.failedDelete": "Failed to delete"
|
"translations.failedDelete": "Failed to delete",
|
||||||
|
"translations.addOption": "Add Option",
|
||||||
|
"translations.option": "option",
|
||||||
|
"translations.options": "options",
|
||||||
|
"translations.optionLabel": "Option",
|
||||||
|
"translations.selected": "Selected",
|
||||||
|
"translations.selectThis": "Select",
|
||||||
|
"translations.optionSelected": "Option selected!",
|
||||||
|
"translations.suggestAlternative": "Suggest alternative",
|
||||||
|
"translations.suggestForLang": "Suggest a translation for",
|
||||||
|
"translations.enterSuggestion": "Enter your suggested translation...",
|
||||||
|
"translations.submitSuggestion": "Submit Suggestion",
|
||||||
|
"translations.suggestionAdded": "Suggestion added!",
|
||||||
|
"translations.existing": "existing",
|
||||||
|
"translations.copyContent": "Copy to clipboard",
|
||||||
|
"translations.copiedToClipboard": "Copied to clipboard!",
|
||||||
|
"translations.approvedReadOnly": "This translation is approved and cannot be modified.",
|
||||||
|
"translations.linkedPost": "Linked Post",
|
||||||
|
"translations.createPost": "New Post",
|
||||||
|
"translations.newPostTitle": "Post title...",
|
||||||
|
"translations.postCreated": "Post created!",
|
||||||
|
"translations.postCreateFailed": "Failed to create post"
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect, useMemo } from 'react'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams } from 'react-router-dom'
|
||||||
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User } from 'lucide-react'
|
import { CheckCircle, XCircle, AlertCircle, Languages, Globe, User, Check, PenLine, Copy, Lock } from 'lucide-react'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
import { AVAILABLE_LANGUAGES, isTextSelected, groupTextsByLanguage } from '../utils/translations'
|
||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
|
|
||||||
@@ -17,6 +18,10 @@ export default function PublicTranslationReview() {
|
|||||||
const [reviewerName, setReviewerName] = useState('')
|
const [reviewerName, setReviewerName] = useState('')
|
||||||
const [feedback, setFeedback] = useState('')
|
const [feedback, setFeedback] = useState('')
|
||||||
const [pendingAction, setPendingAction] = useState(null)
|
const [pendingAction, setPendingAction] = useState(null)
|
||||||
|
const [suggestingLang, setSuggestingLang] = useState(null)
|
||||||
|
const [suggestionContent, setSuggestionContent] = useState('')
|
||||||
|
const [selectingId, setSelectingId] = useState(null)
|
||||||
|
const [copiedId, setCopiedId] = useState(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTranslation()
|
loadTranslation()
|
||||||
@@ -44,12 +49,12 @@ export default function PublicTranslationReview() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleAction = async (action) => {
|
const handleAction = async (action) => {
|
||||||
if (action === 'approve' && !reviewerName.trim()) {
|
if ((action === 'approve' || action === 'reject') && !reviewerName.trim()) {
|
||||||
toast.error(t('review.nameRequired'))
|
toast.error(t('review.nameRequired'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (action === 'reject' && !feedback.trim()) {
|
if ((action === 'reject' || action === 'revision') && !feedback.trim()) {
|
||||||
toast.error(t('review.feedbackRequired'))
|
toast.error(t('review.feedbackRequiredError'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,6 +83,86 @@ export default function PublicTranslationReview() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSelect = async (textId) => {
|
||||||
|
setSelectingId(textId)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/public/review-translation/${token}/select`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text_id: textId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json()
|
||||||
|
throw new Error(err.error || 'Selection failed')
|
||||||
|
}
|
||||||
|
setTranslation(prev => ({
|
||||||
|
...prev,
|
||||||
|
texts: prev.texts.map(txt => ({
|
||||||
|
...txt,
|
||||||
|
is_selected: txt.language_code === prev.texts.find(t => (t.Id || t.id) === textId)?.language_code
|
||||||
|
? (txt.Id || txt.id) === textId
|
||||||
|
: txt.is_selected,
|
||||||
|
})),
|
||||||
|
}))
|
||||||
|
toast.success(t('translations.optionSelected'))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
} finally {
|
||||||
|
setSelectingId(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSuggest = async (langCode) => {
|
||||||
|
if (!suggestionContent.trim()) return
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const langDef = AVAILABLE_LANGUAGES.find(l => l.code === langCode)
|
||||||
|
const res = await fetch(`/api/public/review-translation/${token}/suggest`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
language_code: langCode,
|
||||||
|
language_label: langDef?.label || langCode,
|
||||||
|
content: suggestionContent,
|
||||||
|
suggested_by: reviewerName || 'Reviewer',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json()
|
||||||
|
throw new Error(err.error || 'Suggestion failed')
|
||||||
|
}
|
||||||
|
const newText = await res.json()
|
||||||
|
setTranslation(prev => ({
|
||||||
|
...prev,
|
||||||
|
texts: [...(prev.texts || []), newText],
|
||||||
|
}))
|
||||||
|
setSuggestingLang(null)
|
||||||
|
setSuggestionContent('')
|
||||||
|
toast.success(t('translations.suggestionAdded'))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err.message)
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyContent = (text, id) => {
|
||||||
|
navigator.clipboard.writeText(text)
|
||||||
|
setCopiedId(id)
|
||||||
|
toast.success(t('translations.copiedToClipboard'))
|
||||||
|
setTimeout(() => setCopiedId(null), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group texts by language (memoized)
|
||||||
|
const textsByLanguage = useMemo(
|
||||||
|
() => translation?.texts ? groupTextsByLanguage(translation.texts) : {},
|
||||||
|
[translation?.texts]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isPendingReview = translation?.status === 'pending_review'
|
||||||
|
const isApproved = translation?.status === 'approved'
|
||||||
|
const isRejected = translation?.status === 'rejected'
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
<div className="min-h-screen flex items-center justify-center bg-surface-secondary">
|
||||||
@@ -122,10 +207,20 @@ export default function PublicTranslationReview() {
|
|||||||
<Languages className="w-6 h-6 text-brand-primary" />
|
<Languages className="w-6 h-6 text-brand-primary" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h2 className="text-2xl font-bold text-text-primary mb-1">{translation.title}</h2>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{translation.description && (
|
<h2 className="text-2xl font-bold text-text-primary">{translation.title}</h2>
|
||||||
<p className="text-text-secondary mb-2">{translation.description}</p>
|
{isApproved && (
|
||||||
)}
|
<span className="text-xs px-2 py-0.5 rounded-full bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
|
||||||
|
<Lock className="w-3 h-3" />
|
||||||
|
{t('review.approved')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isRejected && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium">
|
||||||
|
{t('review.rejected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
|
<div className="flex items-center gap-2 text-sm text-text-tertiary flex-wrap">
|
||||||
{translation.brand_name && <span>{translation.brand_name}</span>}
|
{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>}
|
{translation.creator_name && <span className="font-medium text-text-secondary">{t('review.createdBy')} <strong>{translation.creator_name}</strong></span>}
|
||||||
@@ -150,30 +245,139 @@ export default function PublicTranslationReview() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Translations */}
|
{/* Translation Options by Language */}
|
||||||
{translation.texts && translation.texts.length > 0 && (
|
{Object.keys(textsByLanguage).length > 0 && (
|
||||||
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
<div className="bg-surface rounded-xl border border-border p-6 mb-6">
|
||||||
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
<h3 className="text-lg font-semibold text-text-primary mb-4">
|
||||||
{t('translations.translationTexts')} ({translation.texts.length})
|
{t('translations.translationTexts')}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-4">
|
<div className="space-y-6">
|
||||||
{translation.texts.map((text, idx) => (
|
{Object.entries(textsByLanguage).map(([langCode, options]) => {
|
||||||
<div key={text.Id || idx} className="bg-surface-secondary rounded-lg p-4 border border-border">
|
const langLabel = options[0]?.language_label || langCode
|
||||||
<div className="flex items-center gap-2 mb-2">
|
const hasSelected = options.some(isTextSelected)
|
||||||
<span className="text-sm font-semibold text-text-primary">
|
return (
|
||||||
{text.language_label || text.language_code}
|
<div key={langCode}>
|
||||||
</span>
|
<div className="flex items-center justify-between mb-3">
|
||||||
<span className="text-xs text-text-tertiary">({text.language_code})</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm font-semibold text-text-primary">{langLabel}</span>
|
||||||
|
<span className="text-xs text-text-tertiary">({langCode})</span>
|
||||||
|
<span className="text-xs text-text-tertiary">
|
||||||
|
— {options.length} {options.length === 1 ? t('translations.option') : t('translations.options')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{isPendingReview && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSuggestingLang(langCode)
|
||||||
|
setSuggestionContent('')
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary/80 font-medium"
|
||||||
|
>
|
||||||
|
<PenLine className="w-3.5 h-3.5" />
|
||||||
|
{t('translations.suggestAlternative')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{options.map((text) => {
|
||||||
|
const textId = text.Id || text.id
|
||||||
|
const isSelected = isTextSelected(text)
|
||||||
|
// When approved, show only the selected option prominently; others are dimmed
|
||||||
|
const isDimmed = isApproved && hasSelected && !isSelected
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={textId}
|
||||||
|
className={`rounded-lg p-4 border transition-all ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-emerald-50 border-emerald-300 ring-1 ring-emerald-200'
|
||||||
|
: isDimmed
|
||||||
|
? 'bg-surface-secondary border-border opacity-50'
|
||||||
|
: 'bg-surface-secondary border-border hover:border-brand-primary/30'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-xs font-medium text-text-tertiary">
|
||||||
|
{t('translations.optionLabel')} {text.option_number || 1}
|
||||||
|
</span>
|
||||||
|
{isSelected && (
|
||||||
|
<span className="text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium flex items-center gap-1">
|
||||||
|
<Check className="w-3 h-3" />
|
||||||
|
{t('translations.selected')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
|
{/* Copy button — always available, especially useful for approved */}
|
||||||
|
{(isApproved || isSelected) && (
|
||||||
|
<button
|
||||||
|
onClick={() => copyContent(text.content, textId)}
|
||||||
|
className="p-1.5 rounded-lg text-text-tertiary hover:text-text-primary hover:bg-surface-tertiary transition-colors"
|
||||||
|
title={t('translations.copyContent')}
|
||||||
|
>
|
||||||
|
{copiedId === textId ? <Check className="w-4 h-4 text-emerald-600" /> : <Copy className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Select button — only when pending review */}
|
||||||
|
{isPendingReview && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleSelect(textId)}
|
||||||
|
disabled={selectingId === textId || isSelected}
|
||||||
|
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
isSelected
|
||||||
|
? 'bg-emerald-100 text-emerald-700 cursor-default'
|
||||||
|
: 'bg-brand-primary/10 text-brand-primary hover:bg-brand-primary/20'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{selectingId === textId ? '...' : isSelected ? t('translations.selected') : t('translations.selectThis')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline suggestion form for this language */}
|
||||||
|
{suggestingLang === langCode && (
|
||||||
|
<div className="mt-3 bg-amber-50 border border-amber-200 rounded-lg p-4">
|
||||||
|
<p className="text-sm font-medium text-amber-800 mb-2">{t('translations.suggestForLang')} {langLabel}</p>
|
||||||
|
<textarea
|
||||||
|
value={suggestionContent}
|
||||||
|
onChange={e => setSuggestionContent(e.target.value)}
|
||||||
|
placeholder={t('translations.enterSuggestion')}
|
||||||
|
className="w-full px-3 py-2 text-sm border border-amber-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-amber-400/30 min-h-[80px] resize-y bg-white"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSuggest(langCode)}
|
||||||
|
disabled={submitting || !suggestionContent.trim()}
|
||||||
|
className="px-3 py-1.5 bg-amber-600 text-white text-xs font-medium rounded-lg hover:bg-amber-700 disabled:opacity-50 transition-colors"
|
||||||
|
>
|
||||||
|
{submitting ? '...' : t('translations.submitSuggestion')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSuggestingLang(null)}
|
||||||
|
className="px-3 py-1.5 text-xs text-text-secondary hover:text-text-primary transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">{text.content}</p>
|
)
|
||||||
</div>
|
})}
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Review Actions */}
|
{/* Review Actions — only pending_review */}
|
||||||
{translation.status === 'pending_review' && (
|
{isPendingReview && (
|
||||||
<div className="bg-surface rounded-xl border border-border p-6">
|
<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>
|
<h3 className="text-lg font-semibold text-text-primary mb-4">{t('review.yourReview')}</h3>
|
||||||
|
|
||||||
@@ -253,17 +457,63 @@ export default function PublicTranslationReview() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Already reviewed */}
|
{/* Approved state — read-only with copy buttons */}
|
||||||
{translation.status !== 'pending_review' && (
|
{isApproved && (
|
||||||
|
<div className="bg-surface rounded-xl border border-border p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<CheckCircle className="w-6 h-6 text-emerald-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-text-primary font-semibold">{t('review.approved')}</p>
|
||||||
|
{translation.approved_by_name && (
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{translation.feedback && (
|
||||||
|
<div className="bg-surface-secondary rounded-lg p-3 mt-3">
|
||||||
|
<p className="text-xs font-medium text-text-tertiary mb-1">{t('review.feedback')}</p>
|
||||||
|
<p className="text-sm text-text-secondary whitespace-pre-wrap">{translation.feedback}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rejected state */}
|
||||||
|
{isRejected && (
|
||||||
|
<div className="bg-surface rounded-xl border border-border p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<XCircle className="w-6 h-6 text-red-500" />
|
||||||
|
<div>
|
||||||
|
<p className="text-text-primary font-semibold">{t('review.rejected')}</p>
|
||||||
|
{translation.approved_by_name && (
|
||||||
|
<p className="text-sm text-text-secondary">
|
||||||
|
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{translation.feedback && (
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 mt-3">
|
||||||
|
<p className="text-xs font-medium text-red-700 mb-1">{t('review.feedback')}</p>
|
||||||
|
<p className="text-sm text-red-800 whitespace-pre-wrap">{translation.feedback}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Other statuses (revision_requested, draft) */}
|
||||||
|
{!isPendingReview && !isApproved && !isRejected && (
|
||||||
<div className="bg-surface rounded-xl border border-border p-6">
|
<div className="bg-surface rounded-xl border border-border p-6">
|
||||||
<div className="text-center py-4">
|
<div className="text-center py-4">
|
||||||
<CheckCircle className="w-10 h-10 text-emerald-500 mx-auto mb-2" />
|
<AlertCircle className="w-10 h-10 text-amber-500 mx-auto mb-2" />
|
||||||
<p className="text-text-primary font-medium">
|
<p className="text-text-primary font-medium">
|
||||||
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
|
{t('review.statusLabel')}: <span className="font-semibold capitalize">{translation.status.replace('_', ' ')}</span>
|
||||||
</p>
|
</p>
|
||||||
{translation.approved_by_name && (
|
{translation.approved_by_name && (
|
||||||
<p className="text-sm text-text-secondary mt-1">
|
<p className="text-sm text-text-secondary mt-1">
|
||||||
{t('review.reviewedBy')}: <span className="font-semibold">{translation.approved_by_name}</span>
|
{t('review.reviewedBy')} <strong>{translation.approved_by_name}</strong>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||||
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe } from 'lucide-react'
|
import { Plus, Search, LayoutGrid, List, ChevronUp, ChevronDown, Languages, Globe, FileEdit } from 'lucide-react'
|
||||||
import { AppContext } from '../App'
|
import { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
@@ -10,21 +10,8 @@ import { useToast } from '../components/ToastContainer'
|
|||||||
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
|
import { SkeletonCard, SkeletonTable } from '../components/SkeletonLoader'
|
||||||
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
||||||
import ApproverMultiSelect from '../components/ApproverMultiSelect'
|
import ApproverMultiSelect from '../components/ApproverMultiSelect'
|
||||||
|
import { AVAILABLE_LANGUAGES, TRANSLATION_STATUS_COLORS } from '../utils/translations'
|
||||||
|
|
||||||
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 = [
|
const SORT_OPTIONS = [
|
||||||
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
|
{ value: 'updated_at', dir: 'desc', labelKey: 'translations.sortRecentlyUpdated' },
|
||||||
@@ -45,8 +32,12 @@ export default function Translations() {
|
|||||||
const [searchTerm, setSearchTerm] = useState('')
|
const [searchTerm, setSearchTerm] = useState('')
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||||
const [selectedTranslation, setSelectedTranslation] = useState(null)
|
const [selectedTranslation, setSelectedTranslation] = useState(null)
|
||||||
const [newTranslation, setNewTranslation] = useState({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
|
const [newTranslation, setNewTranslation] = useState({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [posts, setPosts] = useState([])
|
||||||
|
const [showCreatePost, setShowCreatePost] = useState(false)
|
||||||
|
const [newPostTitle, setNewPostTitle] = useState('')
|
||||||
|
const [creatingPost, setCreatingPost] = useState(false)
|
||||||
|
|
||||||
// Bulk select
|
// Bulk select
|
||||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||||
@@ -63,6 +54,7 @@ export default function Translations() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadTranslations()
|
loadTranslations()
|
||||||
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
api.get('/users/assignable').then(res => setAssignableUsers(Array.isArray(res) ? res : [])).catch(() => {})
|
||||||
|
api.get('/posts').then(res => setPosts(Array.isArray(res) ? res : [])).catch(() => {})
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const loadTranslations = async () => {
|
const loadTranslations = async () => {
|
||||||
@@ -91,10 +83,11 @@ export default function Translations() {
|
|||||||
const created = await api.post('/translations', {
|
const created = await api.post('/translations', {
|
||||||
...newTranslation,
|
...newTranslation,
|
||||||
approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
|
approver_ids: newTranslation.approver_ids.length > 0 ? newTranslation.approver_ids.join(',') : null,
|
||||||
|
post_id: newTranslation.post_id || null,
|
||||||
})
|
})
|
||||||
toast.success(t('translations.created'))
|
toast.success(t('translations.created'))
|
||||||
setShowCreateModal(false)
|
setShowCreateModal(false)
|
||||||
setNewTranslation({ title: '', description: '', source_language: 'EN', source_content: '', brand_id: '', approver_ids: [] })
|
setNewTranslation({ title: '', source_language: 'EN', source_content: '', brand_id: '', post_id: '', approver_ids: [] })
|
||||||
loadTranslations()
|
loadTranslations()
|
||||||
setSelectedTranslation(created)
|
setSelectedTranslation(created)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -116,6 +109,24 @@ export default function Translations() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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])
|
||||||
|
setNewTranslation(f => ({ ...f, post_id: String(postId) }))
|
||||||
|
setShowCreatePost(false)
|
||||||
|
setNewPostTitle('')
|
||||||
|
toast.success(t('translations.postCreated'))
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(t('translations.postCreateFailed'))
|
||||||
|
} finally {
|
||||||
|
setCreatingPost(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
|
await api.post('/translations/bulk-delete', { ids: [...selectedIds] })
|
||||||
@@ -314,7 +325,7 @@ export default function Translations() {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="font-semibold text-text-primary line-clamp-1">{tr.title}</h3>
|
<h3 className="font-semibold text-text-primary line-clamp-1">{tr.title}</h3>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<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'}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||||
{tr.status?.replace('_', ' ')}
|
{tr.status?.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 font-medium">
|
||||||
@@ -323,11 +334,9 @@ export default function Translations() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div className="flex items-center gap-2 text-xs text-text-tertiary flex-wrap">
|
||||||
{tr.brand_name && <span>{tr.brand_name}</span>}
|
{tr.brand_name && <span>{tr.brand_name}</span>}
|
||||||
|
{tr.post_name && <span className="flex items-center gap-1"><FileEdit className="w-3 h-3" />{tr.post_name}</span>}
|
||||||
{tr.creator_name && <span>by {tr.creator_name}</span>}
|
{tr.creator_name && <span>by {tr.creator_name}</span>}
|
||||||
<span>{tr.translation_count || 0} {t('translations.languagesCount')}</span>
|
<span>{tr.translation_count || 0} {t('translations.languagesCount')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,7 +393,7 @@ export default function Translations() {
|
|||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<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'}`}>
|
<span className={`text-xs px-2 py-0.5 rounded-full ${TRANSLATION_STATUS_COLORS[tr.status] || 'bg-surface-tertiary text-text-secondary'}`}>
|
||||||
{tr.status?.replace('_', ' ')}
|
{tr.status?.replace('_', ' ')}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
@@ -408,6 +417,7 @@ export default function Translations() {
|
|||||||
onUpdate={loadTranslations}
|
onUpdate={loadTranslations}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
assignableUsers={assignableUsers}
|
assignableUsers={assignableUsers}
|
||||||
|
posts={posts}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -454,6 +464,53 @@ export default function Translations() {
|
|||||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.linkedPost')}</label>
|
||||||
|
{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-3 py-2 bg-brand-primary text-white text-sm rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{creatingPost ? '...' : t('common.create')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreatePost(false)}
|
||||||
|
className="px-2 py-2 text-sm text-text-secondary hover:text-text-primary"
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<select
|
||||||
|
value={newTranslation.post_id}
|
||||||
|
onChange={e => setNewTranslation(f => ({ ...f, 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="flex items-center gap-1 px-3 py-2 text-sm text-brand-primary hover:text-brand-primary/80 font-medium whitespace-nowrap"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" />
|
||||||
|
{t('translations.createPost')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.approvers')}</label>
|
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.approvers')}</label>
|
||||||
<ApproverMultiSelect
|
<ApproverMultiSelect
|
||||||
@@ -462,15 +519,6 @@ export default function Translations() {
|
|||||||
users={assignableUsers}
|
users={assignableUsers}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<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">
|
<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')}
|
{t('common.cancel')}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
export const AVAILABLE_LANGUAGES = [
|
||||||
|
{ code: 'AR', label: 'العربية' },
|
||||||
|
{ code: 'EN', label: 'English' },
|
||||||
|
{ code: 'FR', label: 'Français' },
|
||||||
|
{ code: 'ID', label: 'Bahasa Indonesia' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TRANSLATION_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',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTextSelected(text) {
|
||||||
|
return text.is_selected === true || text.is_selected === 1
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTextsByLanguage(texts) {
|
||||||
|
const grouped = {}
|
||||||
|
for (const text of texts) {
|
||||||
|
if (!grouped[text.language_code]) grouped[text.language_code] = []
|
||||||
|
grouped[text.language_code].push(text)
|
||||||
|
}
|
||||||
|
for (const code in grouped) {
|
||||||
|
grouped[code].sort((a, b) => (a.option_number || 1) - (b.option_number || 1))
|
||||||
|
}
|
||||||
|
return grouped
|
||||||
|
}
|
||||||
@@ -39,11 +39,11 @@ Settings ← absorbs Brands, Assets as tabs
|
|||||||
|-----------------|-------------|
|
|-----------------|-------------|
|
||||||
| Posts (page) | Content → Posts tab |
|
| Posts (page) | Content → Posts tab |
|
||||||
| Calendar (page) | Content → Posts tab → Calendar view toggle |
|
| Calendar (page) | Content → Posts tab → Calendar view toggle |
|
||||||
| Translations (page) | Content → Copy tab + Translations tab |
|
| Translations (page) | Content → Copy tab (originals, `is_original=true`) + Translations tab (translated versions, `is_original=false`) |
|
||||||
| Artefacts (page) | Content → Design tab |
|
| Artefacts (page) | Content → Design tab |
|
||||||
| Assets (page) | Settings → Assets tab |
|
| Assets (page) | Settings → Assets tab |
|
||||||
| Brands (page) | Settings → Brands tab |
|
| Brands (page) | Settings → Brands tab |
|
||||||
| Tasks (page) | Projects → Tasks tab inside project detail |
|
| Tasks (page) | Projects → Tasks tab inside project detail. Unlinked tasks accessible via a global "My Tasks" widget on Dashboard + a "All Tasks" view inside the Projects page (not just per-project). The standalone `/tasks` route redirects to `/projects?tab=tasks`. |
|
||||||
| Budgets (page) | Finance → Budgets tab |
|
| Budgets (page) | Finance → Budgets tab |
|
||||||
|
|
||||||
## 2. Content Page — Unified Pipeline
|
## 2. Content Page — Unified Pipeline
|
||||||
@@ -68,12 +68,49 @@ The content production pipeline is: **Copy → Translate → Design → Post →
|
|||||||
- Campaign grouping: cards from the same campaign share a visual group (shared top border or header)
|
- Campaign grouping: cards from the same campaign share a visual group (shared top border or header)
|
||||||
- "New Content" button creates a content item starting at Copy stage
|
- "New Content" button creates a content item starting at Copy stage
|
||||||
|
|
||||||
### Content Item — The Linking Model
|
### Content Item — Data Model
|
||||||
A **Content Item** is the thread connecting all stages:
|
|
||||||
- Has a title (the post concept/idea)
|
A **Content Item** is a new NocoDB table (`ContentItems`) that threads all pipeline stages together.
|
||||||
- Optionally linked to a Campaign
|
|
||||||
- Has linked records: copy entries, translations, design artefacts, post
|
**ContentItems table schema:**
|
||||||
- Each linked record has its own approval status
|
| Column | NocoDB Type | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `Id` | AutoNumber (PK) | NocoDB default |
|
||||||
|
| `title` | SingleLineText | Post concept / idea name |
|
||||||
|
| `stage` | SingleLineText | Current pipeline stage: `copy`, `translate`, `design`, `post`, `published` |
|
||||||
|
| `campaign_id` | Number (FK) | Optional link to Campaigns table |
|
||||||
|
| `brand_id` | Number (FK) | Brand association |
|
||||||
|
| `assignee_id` | Number (FK) | Current stage assignee |
|
||||||
|
| `created_by` | SingleLineText | Creator user ID |
|
||||||
|
|
||||||
|
**Stage is stored explicitly**, not derived. Advancing stage is a manual action (drag on kanban or "Advance" button) that also triggers the approval flow for the new stage. This keeps queries simple and avoids complex derivation logic.
|
||||||
|
|
||||||
|
**FK linkage — existing tables get a `content_item_id` column:**
|
||||||
|
- `Translations` table → `content_item_id` (Number, FK) — for both original copy and translations
|
||||||
|
- `Artefacts` table → `content_item_id` (Number, FK) — for designs/videos
|
||||||
|
- `Posts` table → `content_item_id` (Number, FK) — for the assembled post
|
||||||
|
|
||||||
|
These use the existing `FK_COLUMNS` pattern (Number columns, added via `ensureFKColumns()`).
|
||||||
|
|
||||||
|
**Copy vs Translation distinction:** Both live in the `Translations` table. A copy entry has `is_original: true` (new Boolean column). The Copy tab filters to `is_original = true`, the Translations tab filters to `is_original = false`. No new table needed.
|
||||||
|
|
||||||
|
**Server FK_COLUMNS additions:**
|
||||||
|
```js
|
||||||
|
FK_COLUMNS = {
|
||||||
|
...existing,
|
||||||
|
Translations: [...existing, 'content_item_id'],
|
||||||
|
Artefacts: [...existing, 'content_item_id'],
|
||||||
|
Posts: [...existing, 'content_item_id'],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Server TEXT_COLUMNS additions:**
|
||||||
|
```js
|
||||||
|
TEXT_COLUMNS = {
|
||||||
|
...existing,
|
||||||
|
Translations: [...existing, { name: 'is_original', uidt: 'Checkbox' }],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Detail Panel
|
### Detail Panel
|
||||||
- Pipeline breadcrumb at top: `Copy ✅ → Translate ✅ → Design ⏳ → Post ○`
|
- Pipeline breadcrumb at top: `Copy ✅ → Translate ✅ → Design ⏳ → Post ○`
|
||||||
@@ -95,19 +132,47 @@ Not every content item needs a campaign or full pipeline. Users can:
|
|||||||
- Create copy without linking to a campaign
|
- Create copy without linking to a campaign
|
||||||
- The pipeline is the recommended flow, not enforced
|
- The pipeline is the recommended flow, not enforced
|
||||||
|
|
||||||
|
### Routing Scheme
|
||||||
|
| Route | Content |
|
||||||
|
|-------|---------|
|
||||||
|
| `/content` | Default → Pipeline tab |
|
||||||
|
| `/content/pipeline` | Pipeline kanban |
|
||||||
|
| `/content/copy` | Copy tab (original texts) |
|
||||||
|
| `/content/translations` | Translations tab |
|
||||||
|
| `/content/design` | Design tab (artefacts) |
|
||||||
|
| `/content/posts` | Posts tab (kanban/list/calendar) |
|
||||||
|
|
||||||
|
**Old route redirects:** `/posts` → `/content/posts`, `/translations` → `/content/translations`, `/artefacts` → `/content/design`, `/calendar` → `/content/posts?view=calendar`. Redirects ensure existing bookmarks and shared links continue to work.
|
||||||
|
|
||||||
|
**Public review routes remain unchanged:** `/review/:token`, `/review-post/:token`, `/review-translation/:token` are not affected by this reorganization.
|
||||||
|
|
||||||
## 3. Campaign Brief Enhancement
|
## 3. Campaign Brief Enhancement
|
||||||
|
|
||||||
### Current State
|
### Current State
|
||||||
Campaigns exist but are mostly containers for posts. The campaign detail has a calendar timeline view.
|
Campaigns exist but are mostly containers for posts. The campaign detail has a calendar timeline view.
|
||||||
|
|
||||||
### Enhanced Campaign Brief
|
### Enhanced Campaign Brief
|
||||||
Campaign detail page becomes a proper strategic document:
|
Campaign detail page becomes a proper strategic document.
|
||||||
|
|
||||||
- **Brief section**: goals (awareness/engagement/conversions), target audience, key messages
|
**New columns on Campaigns table:**
|
||||||
- **Metrics targets**: reach, impressions, engagement rate, conversion targets
|
| Column | NocoDB Type | Description |
|
||||||
|
|--------|-----------|-------------|
|
||||||
|
| `goals` | SingleLineText | Comma-separated from: `awareness`, `engagement`, `conversions`, `brand_building`, `lead_generation` |
|
||||||
|
| `target_audience` | LongText | Free text description of target audience |
|
||||||
|
| `key_messages` | LongText | Free text key messages / talking points |
|
||||||
|
| `reach_target` | Number | Target reach count |
|
||||||
|
| `engagement_target` | Number | Target engagement rate (stored as percentage × 100) |
|
||||||
|
| `conversion_target` | Number | Target conversion count |
|
||||||
|
| `approval_status` | SingleLineText | `draft`, `pending_approval`, `approved`, `rejected` |
|
||||||
|
| `approved_by` | SingleLineText | User ID who approved |
|
||||||
|
| `approved_at` | SingleLineText | ISO timestamp of approval |
|
||||||
|
|
||||||
|
**Campaign detail sections:**
|
||||||
|
- **Brief section**: goals (multi-select chips), target audience, key messages
|
||||||
|
- **Metrics targets**: reach, engagement rate, conversions (numeric inputs)
|
||||||
- **Timeline**: keep existing calendar timeline (no changes)
|
- **Timeline**: keep existing calendar timeline (no changes)
|
||||||
- **Budget**: link to finance/budget allocation
|
- **Budget**: link to finance/budget allocation
|
||||||
- **Approval gate**: campaign must be approved before content work begins
|
- **Approval gate**: campaign must be approved (`approval_status = approved`) before "Create Content" button appears
|
||||||
- **Content items**: cards showing linked content items with pipeline progress indicators
|
- **Content items**: cards showing linked content items with pipeline progress indicators
|
||||||
|
|
||||||
### Campaign → Content Flow
|
### Campaign → Content Flow
|
||||||
@@ -141,6 +206,15 @@ Dashboard feels messy and doesn't answer "what needs my attention?"
|
|||||||
### Principle
|
### Principle
|
||||||
Dashboard answers: **"What needs my attention right now?"** — not just display stats.
|
Dashboard answers: **"What needs my attention right now?"** — not just display stats.
|
||||||
|
|
||||||
|
### "My Tasks" Widget — Data Sources
|
||||||
|
The "My Tasks" widget aggregates items assigned to the current user from:
|
||||||
|
- **Content items** where `assignee_id = currentUser` and stage needs action
|
||||||
|
- **Approval requests** pending the user's review (from any stage)
|
||||||
|
- **Tasks** assigned to the user (from Projects)
|
||||||
|
- **Issues** assigned to the user
|
||||||
|
|
||||||
|
Sorted by: overdue first, then by due date ascending, then by creation date.
|
||||||
|
|
||||||
## 5. Consistency Standards
|
## 5. Consistency Standards
|
||||||
|
|
||||||
### Page Header Pattern
|
### Page Header Pattern
|
||||||
@@ -166,7 +240,7 @@ Every page uses the same header layout:
|
|||||||
- Pipeline breadcrumb at top (for content items)
|
- Pipeline breadcrumb at top (for content items)
|
||||||
- Tab order: Details → Activity → Approval (consistent)
|
- Tab order: Details → Activity → Approval (consistent)
|
||||||
- Save: always top-right in header
|
- Save: always top-right in header
|
||||||
- Delete: always inside ⋯ menu (never standalone button)
|
- Delete: moved from standalone header button to ⋯ overflow menu (intentional change from current Artefacts pattern — reduces accidental deletes, consistent placement)
|
||||||
|
|
||||||
### Cards (Kanban/Grid)
|
### Cards (Kanban/Grid)
|
||||||
- Single `KanbanCard` component used everywhere (already exists — enforce it)
|
- Single `KanbanCard` component used everywhere (already exists — enforce it)
|
||||||
@@ -189,11 +263,11 @@ Every page uses the same header layout:
|
|||||||
| Element | Animation |
|
| Element | Animation |
|
||||||
|---------|-----------|
|
|---------|-----------|
|
||||||
| Route changes | Fade + slide-up (150ms ease-out) |
|
| Route changes | Fade + slide-up (150ms ease-out) |
|
||||||
| Detail panel open | Spring slide-in from right (slight overshoot) |
|
| Detail panel open | CSS `cubic-bezier(0.34, 1.56, 0.64, 1)` slide-in from right (simulates spring overshoot without a library) |
|
||||||
| Kanban drag | Card lifts with shadow + slight rotation (2deg), drop zone pulses |
|
| Kanban drag | Card lifts with shadow + slight rotation (2deg), drop zone pulses |
|
||||||
| Status badge change | Color crossfade (not instant swap) |
|
| Status badge change | Color crossfade (not instant swap) |
|
||||||
| View toggle (list↔grid) | Crossfade between views |
|
| View toggle (list↔grid) | Crossfade between views |
|
||||||
| Toasts | Spring slide-in from top-right, stack with spacing |
|
| Toasts | CSS `cubic-bezier(0.34, 1.56, 0.64, 1)` slide-in from top-right, stack with spacing |
|
||||||
| Tab active indicator | Slides to follow selection |
|
| Tab active indicator | Slides to follow selection |
|
||||||
|
|
||||||
### Hover & Interaction States
|
### Hover & Interaction States
|
||||||
@@ -218,7 +292,7 @@ Every page uses the same header layout:
|
|||||||
- Tighter heading letter-spacing across the board
|
- Tighter heading letter-spacing across the board
|
||||||
|
|
||||||
### Empty States — Premium
|
### Empty States — Premium
|
||||||
- Illustrated line-art SVG icons (matching app aesthetic) instead of plain Lucide icons
|
- Illustrated line-art SVG icons from [Iconoir](https://iconoir.com/) library (matching app's line-art aesthetic) instead of plain Lucide icons. Fallback: compose simple illustrations from multiple Lucide icons if Iconoir doesn't have a match.
|
||||||
- Subtle gradient background behind icon circle
|
- Subtle gradient background behind icon circle
|
||||||
- Friendly, helpful copy ("No content yet — start by writing some copy")
|
- Friendly, helpful copy ("No content yet — start by writing some copy")
|
||||||
|
|
||||||
@@ -228,7 +302,42 @@ Every page uses the same header layout:
|
|||||||
- Activity feed: staggered fade-in (50ms between items)
|
- Activity feed: staggered fade-in (50ms between items)
|
||||||
- Metric card hover: gentle pulse on accent border
|
- Metric card hover: gentle pulse on accent border
|
||||||
|
|
||||||
## 7. Files Impacted
|
## 7. Animation Approach
|
||||||
|
|
||||||
|
All animations use **CSS only** — no motion library dependency (no framer-motion, no react-spring). Spring-like effects approximated with `cubic-bezier(0.34, 1.56, 0.64, 1)`. All animations respect `prefers-reduced-motion: reduce` — when enabled, transitions are instant (duration: 0ms) and all decorative animations are disabled.
|
||||||
|
|
||||||
|
## 8. Migration & Compatibility
|
||||||
|
|
||||||
|
### Route Redirects
|
||||||
|
Old routes redirect to new locations via React Router `<Navigate>`:
|
||||||
|
- `/posts` → `/content/posts`
|
||||||
|
- `/calendar` → `/content/posts?view=calendar`
|
||||||
|
- `/translations` → `/content/translations`
|
||||||
|
- `/artefacts` → `/content/design`
|
||||||
|
- `/assets` → `/settings?tab=assets`
|
||||||
|
- `/brands` → `/settings?tab=brands`
|
||||||
|
- `/tasks` → `/projects?tab=tasks`
|
||||||
|
- `/budgets` → `/finance?tab=budgets`
|
||||||
|
|
||||||
|
### Data Migration
|
||||||
|
- New `ContentItems` table created via `ensureRequiredTables()` on server restart
|
||||||
|
- New columns (`content_item_id`, `is_original`) added via `FK_COLUMNS` / `TEXT_COLUMNS` — auto-created on restart
|
||||||
|
- Campaign brief columns added via `TEXT_COLUMNS` — auto-created on restart
|
||||||
|
- **No data migration needed for existing records** — existing posts/translations/artefacts continue to work without a content_item_id (they're standalone)
|
||||||
|
|
||||||
|
### Rollout Strategy
|
||||||
|
Phased implementation — each phase is independently deployable:
|
||||||
|
1. **Phase 1**: Nav reorganization + consistency standards (no data model changes)
|
||||||
|
2. **Phase 2**: Content page with tabs (restructure existing pages as tab sub-views)
|
||||||
|
3. **Phase 3**: Content Item model + pipeline (new table, FK linkages, pipeline kanban)
|
||||||
|
4. **Phase 4**: Campaign brief enhancement
|
||||||
|
5. **Phase 5**: Dashboard redesign
|
||||||
|
6. **Phase 6**: Premium polish (animations, glass effects, typography)
|
||||||
|
|
||||||
|
### Public Routes
|
||||||
|
Unchanged: `/review/:token`, `/review-post/:token`, `/review-translation/:token` continue to work as-is.
|
||||||
|
|
||||||
|
## 9. Files Impacted
|
||||||
|
|
||||||
### Navigation
|
### Navigation
|
||||||
- `client/src/components/Sidebar.jsx` — rewrite nav structure
|
- `client/src/components/Sidebar.jsx` — rewrite nav structure
|
||||||
@@ -264,7 +373,9 @@ Every page uses the same header layout:
|
|||||||
|
|
||||||
### Server
|
### Server
|
||||||
- `server/server.js` — content item model, campaign brief fields, pipeline stage tracking
|
- `server/server.js` — content item model, campaign brief fields, pipeline stage tracking
|
||||||
- New columns in NocoDB: content_item_id linkages, campaign brief fields (goals, metrics, audience, approval_status)
|
- New `ContentItems` table in `REQUIRED_TABLES`
|
||||||
|
- New columns via `FK_COLUMNS`: `content_item_id` on Translations, Artefacts, Posts
|
||||||
|
- New columns via `TEXT_COLUMNS`: `is_original` on Translations, campaign brief fields on Campaigns (goals, target_audience, key_messages, reach_target, engagement_target, conversion_target, approval_status, approved_by, approved_at)
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
- `client/src/i18n/en.json` — new keys for Content page, pipeline stages, dashboard widgets, campaign brief
|
- `client/src/i18n/en.json` — new keys for Content page, pipeline stages, dashboard widgets, campaign brief
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const n = require('./nocodb');
|
||||||
|
const OLD_ID = 6;
|
||||||
|
const NEW_ID = 1;
|
||||||
|
|
||||||
|
const TABLES_AND_FIELDS = [
|
||||||
|
{ table: 'Posts', fields: ['assigned_to_id', 'created_by_user_id'] },
|
||||||
|
{ table: 'Tasks', fields: ['assigned_to_id', 'created_by_user_id'] },
|
||||||
|
{ table: 'Projects', fields: ['owner_id', 'created_by_user_id'] },
|
||||||
|
{ table: 'Campaigns', fields: ['created_by_user_id'] },
|
||||||
|
{ table: 'Assets', fields: ['uploader_id'] },
|
||||||
|
{ table: 'Comments', fields: ['user_id'] },
|
||||||
|
{ table: 'CampaignAssignments', fields: ['member_id', 'assigner_id'] },
|
||||||
|
{ table: 'Artefacts', fields: ['created_by_user_id'] },
|
||||||
|
{ table: 'ArtefactVersions', fields: ['created_by_user_id'] },
|
||||||
|
{ table: 'Issues', fields: ['assigned_to_id'] },
|
||||||
|
{ table: 'TeamMembers', fields: ['user_id'] },
|
||||||
|
{ table: 'BudgetEntries', fields: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
for (const { table, fields } of TABLES_AND_FIELDS) {
|
||||||
|
for (const field of fields) {
|
||||||
|
try {
|
||||||
|
const records = await n.list(table, { where: `(${field},eq,${OLD_ID})`, limit: 200 });
|
||||||
|
if (records.length === 0) continue;
|
||||||
|
console.log(`${table}.${field}: ${records.length} records to update`);
|
||||||
|
for (const r of records) {
|
||||||
|
await n.update(table, r.Id, { [field]: NEW_ID });
|
||||||
|
}
|
||||||
|
console.log(` -> done`);
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`${table}.${field}: skipped (${err.message})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log('Reassignment complete.');
|
||||||
|
})();
|
||||||
+119
-43
@@ -158,7 +158,7 @@ const FK_COLUMNS = {
|
|||||||
PostVersionTexts: ['version_id'],
|
PostVersionTexts: ['version_id'],
|
||||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||||
Users: ['role_id'],
|
Users: ['role_id'],
|
||||||
Translations: ['brand_id', 'created_by_user_id'],
|
Translations: ['brand_id', 'post_id', 'created_by_user_id'],
|
||||||
TranslationTexts: ['translation_id'],
|
TranslationTexts: ['translation_id'],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -425,11 +425,11 @@ const REQUIRED_TABLES = {
|
|||||||
],
|
],
|
||||||
Translations: [
|
Translations: [
|
||||||
{ title: 'title', uidt: 'SingleLineText' },
|
{ title: 'title', uidt: 'SingleLineText' },
|
||||||
{ title: 'description', uidt: 'LongText' },
|
|
||||||
{ title: 'source_language', uidt: 'SingleLineText' },
|
{ title: 'source_language', uidt: 'SingleLineText' },
|
||||||
{ title: 'source_content', uidt: 'LongText' },
|
{ title: 'source_content', uidt: 'LongText' },
|
||||||
{ title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" },
|
{ title: 'status', uidt: 'SingleSelect', dtxp: "'draft','pending_review','approved','rejected','revision_requested'" },
|
||||||
{ title: 'brand_id', uidt: 'Number' },
|
{ title: 'brand_id', uidt: 'Number' },
|
||||||
|
{ title: 'post_id', uidt: 'Number' },
|
||||||
{ title: 'approver_ids', uidt: 'SingleLineText' },
|
{ title: 'approver_ids', uidt: 'SingleLineText' },
|
||||||
{ title: 'approval_token', uidt: 'SingleLineText' },
|
{ title: 'approval_token', uidt: 'SingleLineText' },
|
||||||
{ title: 'token_expires_at', uidt: 'DateTime' },
|
{ title: 'token_expires_at', uidt: 'DateTime' },
|
||||||
@@ -443,6 +443,8 @@ const REQUIRED_TABLES = {
|
|||||||
{ title: 'language_code', uidt: 'SingleLineText' },
|
{ title: 'language_code', uidt: 'SingleLineText' },
|
||||||
{ title: 'language_label', uidt: 'SingleLineText' },
|
{ title: 'language_label', uidt: 'SingleLineText' },
|
||||||
{ title: 'content', uidt: 'LongText' },
|
{ title: 'content', uidt: 'LongText' },
|
||||||
|
{ title: 'option_number', uidt: 'Number' },
|
||||||
|
{ title: 'is_selected', uidt: 'Checkbox' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -640,28 +642,6 @@ app.get('/api/health', async (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── EMAIL TEST ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
app.post('/api/admin/test-email', requireAuth, async (req, res) => {
|
|
||||||
if (req.session.userRole !== 'superadmin') return res.status(403).json({ error: 'Superadmin only' });
|
|
||||||
const to = req.body.to || req.session.userEmail;
|
|
||||||
if (!to) return res.status(400).json({ error: 'No recipient — pass { "to": "email@example.com" }' });
|
|
||||||
const { sendMail, getSmtpConfig } = require('./mail');
|
|
||||||
const config = getSmtpConfig();
|
|
||||||
if (!config) return res.status(503).json({ error: 'SMTP not configured', env: { server: !!process.env.CLOUDRON_MAIL_SMTP_SERVER || !!process.env.MAIL_SMTP_SERVER } });
|
|
||||||
try {
|
|
||||||
const info = await sendMail({
|
|
||||||
to,
|
|
||||||
subject: 'Rawaj — Test Email',
|
|
||||||
html: '<p>If you received this, email delivery is working correctly.</p>',
|
|
||||||
text: 'If you received this, email delivery is working correctly.',
|
|
||||||
});
|
|
||||||
res.json({ success: true, to, messageId: info?.messageId, smtp: { host: config.host, port: config.port, from: config.from } });
|
|
||||||
} catch (err) {
|
|
||||||
res.status(500).json({ success: false, error: err.message, code: err.code, smtp: { host: config.host, port: config.port } });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
// ─── SETUP ROUTES ───────────────────────────────────────────────
|
||||||
|
|
||||||
app.get('/api/setup/status', async (req, res) => {
|
app.get('/api/setup/status', async (req, res) => {
|
||||||
@@ -4364,9 +4344,10 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with names
|
// Enrich with names
|
||||||
const brandIds = new Set(), userIds = new Set();
|
const brandIds = new Set(), userIds = new Set(), postIds = new Set();
|
||||||
for (const t of translations) {
|
for (const t of translations) {
|
||||||
if (t.brand_id) brandIds.add(t.brand_id);
|
if (t.brand_id) brandIds.add(t.brand_id);
|
||||||
|
if (t.post_id) postIds.add(t.post_id);
|
||||||
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
|
if (t.created_by_user_id) userIds.add(t.created_by_user_id);
|
||||||
if (t.approver_ids) {
|
if (t.approver_ids) {
|
||||||
for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
|
for (const id of t.approver_ids.split(',').map(s => s.trim()).filter(Boolean)) {
|
||||||
@@ -4377,6 +4358,7 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
|||||||
const names = await batchResolveNames({
|
const names = await batchResolveNames({
|
||||||
brand: { table: 'Brands', ids: [...brandIds] },
|
brand: { table: 'Brands', ids: [...brandIds] },
|
||||||
user: { table: 'Users', ids: [...userIds] },
|
user: { table: 'Users', ids: [...userIds] },
|
||||||
|
post: { table: 'Posts', ids: [...postIds] },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Count translation texts per record
|
// Count translation texts per record
|
||||||
@@ -4393,6 +4375,7 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
|||||||
return {
|
return {
|
||||||
...t,
|
...t,
|
||||||
brand_name: names[`brand:${t.brand_id}`] || null,
|
brand_name: names[`brand:${t.brand_id}`] || null,
|
||||||
|
post_name: names[`post:${t.post_id}`] || null,
|
||||||
creator_name: names[`user:${t.created_by_user_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 })),
|
approvers: approverIdList.map(id => ({ id: Number(id), name: names[`user:${Number(id)}`] || null })),
|
||||||
translation_count: textCounts[t.Id] || 0,
|
translation_count: textCounts[t.Id] || 0,
|
||||||
@@ -4406,7 +4389,7 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
// Create translation
|
// Create translation
|
||||||
app.post('/api/translations', requireAuth, async (req, res) => {
|
app.post('/api/translations', requireAuth, async (req, res) => {
|
||||||
const { title, description, source_language, source_content, brand_id, approver_ids } = req.body;
|
const { title, source_language, source_content, brand_id, post_id, approver_ids } = req.body;
|
||||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
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_language) return res.status(400).json({ error: 'Source language is required' });
|
||||||
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
|
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
|
||||||
@@ -4414,11 +4397,11 @@ app.post('/api/translations', requireAuth, async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const created = await nocodb.create('Translations', {
|
const created = await nocodb.create('Translations', {
|
||||||
title,
|
title,
|
||||||
description: description || null,
|
|
||||||
source_language,
|
source_language,
|
||||||
source_content,
|
source_content,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
brand_id: brand_id ? Number(brand_id) : null,
|
brand_id: brand_id ? Number(brand_id) : null,
|
||||||
|
post_id: post_id ? Number(post_id) : null,
|
||||||
approver_ids: approver_ids || null,
|
approver_ids: approver_ids || null,
|
||||||
created_by_user_id: req.session.userId,
|
created_by_user_id: req.session.userId,
|
||||||
});
|
});
|
||||||
@@ -4432,6 +4415,7 @@ app.post('/api/translations', requireAuth, async (req, res) => {
|
|||||||
res.status(201).json({
|
res.status(201).json({
|
||||||
...record,
|
...record,
|
||||||
brand_name: await getRecordName('Brands', record.brand_id),
|
brand_name: await getRecordName('Brands', record.brand_id),
|
||||||
|
post_name: await getRecordName('Posts', record.post_id),
|
||||||
creator_name: await getRecordName('Users', record.created_by_user_id),
|
creator_name: await getRecordName('Users', record.created_by_user_id),
|
||||||
approvers,
|
approvers,
|
||||||
translation_count: 0,
|
translation_count: 0,
|
||||||
@@ -4470,10 +4454,11 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = {};
|
const data = {};
|
||||||
for (const f of ['title', 'description', 'source_language', 'source_content', 'status', 'feedback']) {
|
for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback']) {
|
||||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
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.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
||||||
|
if (req.body.post_id !== undefined) data.post_id = req.body.post_id ? Number(req.body.post_id) : null;
|
||||||
if (req.body.approver_ids !== undefined) data.approver_ids = req.body.approver_ids || 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' });
|
if (Object.keys(data).length === 0) return res.status(400).json({ error: 'No fields to update' });
|
||||||
@@ -4489,6 +4474,7 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
|||||||
res.json({
|
res.json({
|
||||||
...record,
|
...record,
|
||||||
brand_name: await getRecordName('Brands', record.brand_id),
|
brand_name: await getRecordName('Brands', record.brand_id),
|
||||||
|
post_name: await getRecordName('Posts', record.post_id),
|
||||||
creator_name: await getRecordName('Users', record.created_by_user_id),
|
creator_name: await getRecordName('Users', record.created_by_user_id),
|
||||||
approvers,
|
approvers,
|
||||||
});
|
});
|
||||||
@@ -4543,32 +4529,43 @@ app.post('/api/translations/:id/texts', requireAuth, async (req, res) => {
|
|||||||
const translation = await nocodb.get('Translations', req.params.id);
|
const translation = await nocodb.get('Translations', req.params.id);
|
||||||
if (!translation) return res.status(404).json({ error: 'Translation not found' });
|
if (!translation) return res.status(404).json({ error: 'Translation not found' });
|
||||||
|
|
||||||
// Check if text for this language already exists (upsert)
|
// Count existing options for this language to assign option_number
|
||||||
const existing = await nocodb.list('TranslationTexts', {
|
const existing = await nocodb.list('TranslationTexts', {
|
||||||
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
where: `(translation_id,eq,${sanitizeWhereValue(req.params.id)})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||||
limit: 1,
|
limit: QUERY_LIMITS.large,
|
||||||
});
|
});
|
||||||
|
const optionNumber = existing.length + 1;
|
||||||
|
|
||||||
let result;
|
const result = await nocodb.create('TranslationTexts', {
|
||||||
if (existing.length > 0) {
|
translation_id: Number(req.params.id),
|
||||||
await nocodb.update('TranslationTexts', existing[0].Id, { content, language_label: language_label || language_code });
|
language_code,
|
||||||
result = await nocodb.get('TranslationTexts', existing[0].Id);
|
language_label: language_label || language_code,
|
||||||
} else {
|
content,
|
||||||
result = await nocodb.create('TranslationTexts', {
|
option_number: optionNumber,
|
||||||
translation_id: Number(req.params.id),
|
is_selected: false,
|
||||||
language_code,
|
});
|
||||||
language_label: language_label || language_code,
|
|
||||||
content,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json(result);
|
res.json(result);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Add/update translation text error:', err);
|
console.error('Add translation text error:', err);
|
||||||
res.status(500).json({ error: 'Failed to save translation text' });
|
res.status(500).json({ error: 'Failed to save translation text' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update translation text content
|
||||||
|
app.patch('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
|
||||||
|
const { content } = req.body;
|
||||||
|
if (!content) return res.status(400).json({ error: 'Content is required' });
|
||||||
|
try {
|
||||||
|
await nocodb.update('TranslationTexts', req.params.textId, { content });
|
||||||
|
const result = await nocodb.get('TranslationTexts', req.params.textId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Update translation text error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to update translation text' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Delete translation text
|
// Delete translation text
|
||||||
app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
|
app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -4580,6 +4577,85 @@ app.delete('/api/translations/:id/texts/:textId', requireAuth, async (req, res)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Select a translation option (used by approver via public review)
|
||||||
|
app.post('/api/public/review-translation/:token/select', async (req, res) => {
|
||||||
|
const { text_id } = req.body;
|
||||||
|
if (!text_id) return res.status(400).json({ error: 'text_id is required' });
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the text being selected to find its language
|
||||||
|
const textToSelect = await nocodb.get('TranslationTexts', text_id);
|
||||||
|
if (!textToSelect || textToSelect.translation_id !== translation.Id) {
|
||||||
|
return res.status(400).json({ error: 'Invalid text selection' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deselect all options for this language, then select the chosen one
|
||||||
|
const sameLanguage = await nocodb.list('TranslationTexts', {
|
||||||
|
where: `(translation_id,eq,${translation.Id})~and(language_code,eq,${sanitizeWhereValue(textToSelect.language_code)})`,
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
});
|
||||||
|
for (const t of sameLanguage) {
|
||||||
|
await nocodb.update('TranslationTexts', t.Id, { is_selected: t.Id === Number(text_id) });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Select translation error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to select translation' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Public: Approver suggests a new/modified translation option
|
||||||
|
app.post('/api/public/review-translation/:token/suggest', async (req, res) => {
|
||||||
|
const { language_code, language_label, content, suggested_by } = req.body;
|
||||||
|
if (!language_code || !content) return res.status(400).json({ error: 'Language code and content are required' });
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count existing options for this language
|
||||||
|
const existing = await nocodb.list('TranslationTexts', {
|
||||||
|
where: `(translation_id,eq,${translation.Id})~and(language_code,eq,${sanitizeWhereValue(language_code)})`,
|
||||||
|
limit: QUERY_LIMITS.large,
|
||||||
|
});
|
||||||
|
const optionNumber = existing.length + 1;
|
||||||
|
|
||||||
|
const result = await nocodb.create('TranslationTexts', {
|
||||||
|
translation_id: translation.Id,
|
||||||
|
language_code,
|
||||||
|
language_label: language_label || language_code,
|
||||||
|
content: suggested_by ? `[Suggested by ${suggested_by}] ${content}` : content,
|
||||||
|
option_number: optionNumber,
|
||||||
|
is_selected: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Suggest translation error:', err);
|
||||||
|
res.status(500).json({ error: 'Failed to add suggestion' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Submit translation for review
|
// Submit translation for review
|
||||||
app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => {
|
app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user