refactor: unify post composition — all assets are artefacts
PostDetail now uses Artefacts exclusively for all 4 asset types: - Caption copy = Artefact with type='copy', copy_type='caption' - Body copy = Artefact with type='copy', copy_type='body' - Design = Artefact with type='design' - Video = Artefact with type='video' Removed TranslationDetailPanel from PostDetail entirely. Same ArtefactDetailPanel, same workflow, same version management for all asset types. Link picker searches artefacts only. Server: copy_type added to Artefacts schema, accepted in POST/PATCH. post-composition.js rewritten to use Artefacts table for all pieces. Content preview fetched from ArtefactVersionTexts. Translations entity still exists for the standalone Copy page. Also: version creation rules, submit-review content validation, reviewer mandatory for translations, artefact creation simplified. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -105,14 +105,26 @@ export function ArtefactDetailVersionsTab({
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('artefacts.newVersion')}
|
||||
</button>
|
||||
{artefact.status === 'rejected' && (
|
||||
<button
|
||||
onClick={() => setShowNewVersionModal(true)}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
{t('artefacts.newVersion')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{artefact.status === 'rejected' && (
|
||||
<div className="mb-3 px-3 py-2 bg-red-50 border border-red-200 rounded-lg text-xs text-red-700">
|
||||
{t('artefacts.rejectedMustCreateNewVersion')}
|
||||
</div>
|
||||
)}
|
||||
{artefact.status === 'revision_requested' && (
|
||||
<div className="mb-3 px-3 py-2 bg-amber-50 border border-amber-200 rounded-lg text-xs text-amber-700">
|
||||
{t('artefacts.revisionEditCurrentVersion')}
|
||||
</div>
|
||||
)}
|
||||
<ArtefactVersionTimeline
|
||||
versions={versions}
|
||||
activeVersionId={selectedVersion?.Id}
|
||||
|
||||
@@ -609,6 +609,8 @@
|
||||
"artefacts.selectVersionFirst": "اختر إصداراً لعرض التعليقات.",
|
||||
"artefacts.pendingReviewInfo": "هذا العنصر قيد المراجعة حالياً.",
|
||||
"artefacts.noReviewInfo": "لا توجد معلومات مراجعة متاحة.",
|
||||
"artefacts.rejectedMustCreateNewVersion": "تم رفض هذا العنصر. أنشئ إصداراً جديداً لمعالجة الملاحظات.",
|
||||
"artefacts.revisionEditCurrentVersion": "طُلب تعديل — عدّل الإصدار الحالي وأعد إرساله للمراجعة.",
|
||||
"artefacts.grid": "شبكة",
|
||||
"artefacts.list": "قائمة",
|
||||
"artefacts.allCreators": "جميع المنشئين",
|
||||
|
||||
@@ -609,6 +609,8 @@
|
||||
"artefacts.selectVersionFirst": "Select a version to view comments.",
|
||||
"artefacts.pendingReviewInfo": "This artefact is currently pending review.",
|
||||
"artefacts.noReviewInfo": "No review information available.",
|
||||
"artefacts.rejectedMustCreateNewVersion": "This artefact was rejected. Create a new version to address the feedback.",
|
||||
"artefacts.revisionEditCurrentVersion": "Revision requested — edit the current version and resubmit for review.",
|
||||
"artefacts.grid": "Grid",
|
||||
"artefacts.list": "List",
|
||||
"artefacts.allCreators": "All Creators",
|
||||
|
||||
@@ -9,7 +9,6 @@ import PlatformIcon from '../components/PlatformIcon'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import PortalSelect from '../components/PortalSelect'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import TranslationDetailPanel from '../components/TranslationDetailPanel'
|
||||
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
@@ -45,7 +44,6 @@ export default function PostDetail() {
|
||||
const [linking, setLinking] = useState(false)
|
||||
|
||||
// Sub-panels
|
||||
const [openTranslation, setOpenTranslation] = useState(null)
|
||||
const [openArtefact, setOpenArtefact] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -116,22 +114,17 @@ export default function PostDetail() {
|
||||
setActivePicker(type)
|
||||
setPickerSearch('')
|
||||
try {
|
||||
if (type === 'caption' || type === 'body') {
|
||||
const all = await api.get('/translations')
|
||||
// Show all translations not already linked to THIS post
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(t => {
|
||||
const linkedTo = t.post_id || t.postId
|
||||
return !linkedTo || String(linkedTo) !== String(id)
|
||||
}))
|
||||
} else {
|
||||
const all = await api.get('/artefacts')
|
||||
const at = type === 'video' ? 'video' : 'design'
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
|
||||
const linkedTo = a.post_id || a.postId
|
||||
const matchesType = (a.type || 'design') === at
|
||||
return matchesType && (!linkedTo || String(linkedTo) !== String(id))
|
||||
}))
|
||||
}
|
||||
const all = await api.get('/artefacts')
|
||||
let typeFilter
|
||||
if (type === 'caption') typeFilter = a => a.type === 'copy' && a.copy_type === 'caption'
|
||||
else if (type === 'body') typeFilter = a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type)
|
||||
else if (type === 'video') typeFilter = a => a.type === 'video'
|
||||
else typeFilter = a => (a.type || 'design') === 'design'
|
||||
|
||||
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
|
||||
const linkedTo = a.post_id || a.postId
|
||||
return typeFilter(a) && (!linkedTo || String(linkedTo) !== String(id))
|
||||
}))
|
||||
} catch {
|
||||
setLinkCandidates([])
|
||||
toast.error(t('common.error'))
|
||||
@@ -141,12 +134,7 @@ export default function PostDetail() {
|
||||
const handleLink = async (itemId) => {
|
||||
setLinking(true)
|
||||
try {
|
||||
const copyType = activePicker === 'caption' || activePicker === 'body' ? activePicker : null
|
||||
if (activePicker === 'caption' || activePicker === 'body') {
|
||||
await api.patch(`/translations/${itemId}`, { post_id: Number(id), copy_type: copyType })
|
||||
} else {
|
||||
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
||||
}
|
||||
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
|
||||
toast.success(t('posts.updated'))
|
||||
setActivePicker(null)
|
||||
loadComposition()
|
||||
@@ -164,8 +152,7 @@ export default function PostDetail() {
|
||||
: composition?.video
|
||||
if (!piece) return
|
||||
try {
|
||||
const endpoint = (type === 'caption' || type === 'body') ? `/translations/${piece.id}` : `/artefacts/${piece.id}`
|
||||
await api.patch(endpoint, { post_id: null })
|
||||
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
|
||||
toast.success(t('posts.updated'))
|
||||
loadComposition()
|
||||
} catch {
|
||||
@@ -179,39 +166,25 @@ export default function PostDetail() {
|
||||
: type === 'design' ? composition?.design
|
||||
: composition?.video
|
||||
if (!piece) return
|
||||
if (type === 'caption' || type === 'body') {
|
||||
try {
|
||||
const full = await api.get(`/translations/${piece.id}`)
|
||||
setOpenTranslation(full)
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
} else {
|
||||
try {
|
||||
const full = await api.get(`/artefacts/${piece.id}`)
|
||||
setOpenArtefact(full)
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
}
|
||||
try {
|
||||
const full = await api.get(`/artefacts/${piece.id}`)
|
||||
setOpenArtefact(full)
|
||||
} catch { toast.error(t('common.saveFailed')) }
|
||||
}
|
||||
|
||||
const handleCreate = async (type) => {
|
||||
const isCopy = type === 'caption' || type === 'body'
|
||||
const label = type === 'caption' ? t('postDetail.captionCopy')
|
||||
: type === 'body' ? t('postDetail.bodyCopy')
|
||||
: type === 'design' ? t('postDetail.design')
|
||||
: t('postDetail.video')
|
||||
try {
|
||||
if (isCopy) {
|
||||
const created = await api.post('/translations', {
|
||||
title: `${title} — ${type === 'caption' ? t('postDetail.captionCopy') : t('postDetail.bodyCopy')}`,
|
||||
source_language: 'AR',
|
||||
source_content: '',
|
||||
post_id: Number(id),
|
||||
copy_type: type,
|
||||
})
|
||||
setOpenTranslation(created)
|
||||
} else {
|
||||
const created = await api.post('/artefacts', {
|
||||
title: `${type === 'design' ? t('postDetail.design') : t('postDetail.video')} — ${title}`,
|
||||
type: type,
|
||||
post_id: Number(id),
|
||||
})
|
||||
setOpenArtefact(created)
|
||||
}
|
||||
const created = await api.post('/artefacts', {
|
||||
title: `${label} — ${title}`,
|
||||
type: type === 'caption' || type === 'body' ? 'copy' : type,
|
||||
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
|
||||
post_id: Number(id),
|
||||
})
|
||||
setOpenArtefact(created)
|
||||
loadComposition()
|
||||
} catch (err) {
|
||||
toast.error(t('common.saveFailed'))
|
||||
@@ -452,16 +425,6 @@ export default function PostDetail() {
|
||||
</div>
|
||||
|
||||
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
|
||||
{openTranslation && (
|
||||
<TranslationDetailPanel
|
||||
translation={openTranslation}
|
||||
onClose={() => { setOpenTranslation(null); loadComposition() }}
|
||||
onUpdate={loadComposition}
|
||||
onDelete={() => { setOpenTranslation(null); loadComposition() }}
|
||||
assignableUsers={teamMembers}
|
||||
/>
|
||||
)}
|
||||
|
||||
{openArtefact && (
|
||||
<ArtefactDetailPanel
|
||||
artefact={openArtefact}
|
||||
|
||||
Reference in New Issue
Block a user