feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s

- Auto-advance artefact to next working version on rejection/revision
- Add post_id field to artefact creation
- Add request timeout (20s) to NocoDB client
- Add POST /api/admin/test-email for diagnosing SMTP issues
- Fix FK column creation logging

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-05-11 12:27:23 +03:00
parent a67b2afb0d
commit 94ce012837
7 changed files with 391 additions and 276 deletions
+80 -63
View File
@@ -24,6 +24,10 @@ const TYPE_ICONS = {
other: Sparkles,
}
const parseApproverIds = (a) =>
a.approvers?.map(u => String(u.id)) ||
(a.approver_ids ? a.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDelete, assignableUsers = [] }) {
const { t } = useLanguage()
const { brands } = useContext(AppContext)
@@ -35,14 +39,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const [submitting, setSubmitting] = useState(false)
const [freshReviewUrl, setFreshReviewUrl] = useState('')
const [copied, setCopied] = useState(false)
const [activeTab, setActiveTab] = useState('details')
const [activeTab, setActiveTab] = useState(artefact.type === 'copy' ? 'versions' : 'details')
// Editable fields
// Editable fields — seeded from artefact prop; component is keyed by artefact._id at call site
const [editTitle, setEditTitle] = useState(artefact.title || '')
const [editDescription, setEditDescription] = useState(artefact.description || '')
const [editApproverIds, setEditApproverIds] = useState(
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
const [editApproverIds, setEditApproverIds] = useState(() => parseApproverIds(artefact))
const reviewUrl = freshReviewUrl || (artefact.approval_token ? `${window.location.origin}/review/${artefact.approval_token}` : '')
const [savingDraft, setSavingDraft] = useState(false)
const [deleting, setDeleting] = useState(false)
@@ -61,14 +63,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersions()
}, [artefact.Id])
useEffect(() => {
setEditTitle(artefact.title || '')
setEditDescription(artefact.description || '')
setEditApproverIds(
artefact.approvers?.map(a => String(a.id)) || (artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [])
)
}, [artefact.Id])
const loadVersions = async () => {
try {
const res = await api.get(`/artefacts/${artefact.Id}/versions`)
@@ -109,13 +103,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
loadVersionData(version.Id)
}
const handleCreateVersion = async ({ notes, copy_from_previous }) => {
await api.post(`/artefacts/${artefact.Id}/versions`, { notes, copy_from_previous })
toast.success(t('artefacts.versionCreated'))
loadVersions()
onUpdate()
}
const handleAddLanguage = async (languageForm) => {
await api.post(`/artefacts/${artefact.Id}/versions/${selectedVersion.Id}/texts`, languageForm)
toast.success(t('artefacts.languageAdded'))
@@ -249,6 +236,12 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
}
}
const handleUpdateLanguage = async (textId, content) => {
await api.patch(`/artefact-version-texts/${textId}`, { content })
toast.success(t('artefacts.languageAdded'))
loadVersionData(selectedVersion.Id)
}
const handleDeleteArtefact = async () => {
setDeleting(true)
try {
@@ -282,10 +275,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
const TypeIcon = TYPE_ICONS[artefact.type] || Sparkles
const tabs = [
{ key: 'details', label: t('artefacts.details') || 'Details', icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions') || 'Versions', icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments') || 'Discussion', icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review') || 'Review', icon: ShieldCheck },
{ key: 'details', label: t('artefacts.details'), icon: FileEdit },
{ key: 'versions', label: t('artefacts.versions'), icon: Layers, badge: versions.length },
{ key: 'discussion', label: t('artefacts.comments'), icon: MessageSquare, badge: comments.length },
{ key: 'review', label: t('artefacts.review'), icon: ShieldCheck },
]
if (loading) {
@@ -304,32 +297,30 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
onClose={onClose}
size="xl"
header={
<>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
<TypeIcon className="w-5 h-5 text-brand-primary" />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
/>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{artefact.status?.replace('_', ' ')}
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-brand-primary/10 flex items-center justify-center shrink-0">
<TypeIcon className="w-5 h-5 text-brand-primary" />
</div>
<div className="flex-1 min-w-0">
<input
type="text"
value={editTitle}
onChange={e => setEditTitle(e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 border-b border-transparent hover:border-border focus:border-brand-primary focus:outline-none focus:ring-0 px-0 py-0.5 transition-colors"
/>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_COLORS[artefact.status] || 'bg-surface-tertiary text-text-secondary'}`}>
{artefact.status?.replace('_', ' ')}
</span>
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
{artefact.creator_name && (
<span className="text-xs text-text-secondary font-medium">
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
</span>
<span className="text-xs text-text-tertiary capitalize">{artefact.type}</span>
{artefact.creator_name && (
<span className="text-xs text-text-secondary font-medium">
{t('review.createdBy')} <strong className="text-text-primary">{artefact.creator_name}</strong>
</span>
)}
</div>
)}
</div>
</div>
</>
</div>
}
tabs={tabs}
activeTab={activeTab}
@@ -349,15 +340,17 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</button>
)}
</div>
<button
onClick={handleSaveDraft}
disabled={savingDraft}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
title={t('artefacts.saveDraftTooltip')}
>
<Save className="w-3.5 h-3.5" />
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button>
{activeTab === 'details' && (
<button
onClick={handleSaveDraft}
disabled={savingDraft}
className="flex items-center gap-1.5 px-4 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light disabled:opacity-50 transition-colors"
title={t('artefacts.saveDraftTooltip')}
>
<Save className="w-3.5 h-3.5" />
{savingDraft ? t('artefacts.savingDraft') : t('artefacts.saveDraft')}
</button>
)}
</>
}
>
@@ -376,6 +369,32 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
/>
</div>
{/* Metadata row */}
<div className="grid grid-cols-2 gap-4 pt-1">
{/* Brand */}
{(artefact.brand_id || artefact.brandId) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('posts.brand')}</h4>
<p className="text-sm text-text-primary">
{brands.find(b => String(b._id) === String(artefact.brand_id || artefact.brandId))?.name || `#${artefact.brand_id || artefact.brandId}`}
</p>
</div>
)}
{/* Created date */}
{artefact.CreatedAt && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('common.created')}</h4>
<p className="text-sm text-text-secondary">{new Date(artefact.CreatedAt).toLocaleDateString()}</p>
</div>
)}
{/* Linked post */}
{(artefact.post_id || artefact.postId) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1">{t('artefacts.linkedPost')}</h4>
<p className="text-sm text-text-secondary">{t('artefacts.post')} #{artefact.post_id || artefact.postId}</p>
</div>
)}
</div>
</div>
)}
@@ -389,8 +408,8 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
uploading={uploading}
uploadProgress={uploadProgress}
onSelectVersion={handleSelectVersion}
onCreateVersion={handleCreateVersion}
onAddLanguage={handleAddLanguage}
onUpdateLanguage={handleUpdateLanguage}
onDeleteLanguage={handleDeleteLanguage}
onFileUpload={handleFileUpload}
onDeleteAttachment={handleDeleteAttachment}
@@ -451,7 +470,7 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</>
) : (
<div className="text-center py-8 text-sm text-text-tertiary">
{t('artefacts.selectVersionFirst') || 'Select a version first to view comments.'}
{t('artefacts.selectVersionFirst')}
</div>
)}
</div>
@@ -530,12 +549,10 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div>
)}
{/* Empty state when no review actions available */}
{!['draft', 'revision_requested', 'rejected'].includes(artefact.status) && !reviewUrl && !artefact.feedback && !(artefact.status === 'approved' && artefact.approved_by_name) && (
{/* Empty state: pending_review or unknown status with no review info */}
{artefact.status === 'pending_review' && !reviewUrl && !artefact.feedback && (
<div className="text-center py-8 text-sm text-text-tertiary">
{artefact.status === 'pending_review'
? t('artefacts.pendingReviewInfo') || 'This artefact is currently pending review.'
: t('artefacts.noReviewInfo') || 'No review information available.'}
{t('artefacts.pendingReviewInfo')}
</div>
)}
</div>
@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
import { Trash2, Globe, Image as ImageIcon, Pencil } from 'lucide-react'
import PortalSelect from './PortalSelect'
import UploadZone from './UploadZone'
import { useLanguage } from '../i18n/LanguageContext'
@@ -21,8 +21,8 @@ export function ArtefactDetailVersionsTab({
uploading,
uploadProgress,
onSelectVersion,
onCreateVersion,
onAddLanguage,
onUpdateLanguage,
onDeleteLanguage,
onFileUpload,
onDeleteAttachment,
@@ -31,11 +31,6 @@ export function ArtefactDetailVersionsTab({
}) {
const { t } = useLanguage()
const [showNewVersionModal, setShowNewVersionModal] = useState(false)
const [newVersionNotes, setNewVersionNotes] = useState('')
const [copyFromPrevious, setCopyFromPrevious] = useState(false)
const [creatingVersion, setCreatingVersion] = useState(false)
const [showLanguageModal, setShowLanguageModal] = useState(false)
const [languageForm, setLanguageForm] = useState({ language_code: '', language_label: '', content: '' })
const [savingLanguage, setSavingLanguage] = useState(false)
@@ -43,24 +38,13 @@ export function ArtefactDetailVersionsTab({
const [confirmDeleteLangId, setConfirmDeleteLangId] = useState(null)
const [confirmDeleteAttId, setConfirmDeleteAttId] = useState(null)
const [editingLangText, setEditingLangText] = useState(null) // { Id, language_code, language_label, content }
const [editLangContent, setEditLangContent] = useState('')
const [savingEditLang, setSavingEditLang] = useState(false)
const [dragOver, setDragOver] = useState(false)
const [driveUrl, setDriveUrl] = useState('')
const handleCreateVersion = async () => {
setCreatingVersion(true)
try {
await onCreateVersion({
notes: newVersionNotes || `Version ${(versions[versions.length - 1]?.version_number || 0) + 1}`,
copy_from_previous: artefact.type === 'copy' ? copyFromPrevious : false,
})
setShowNewVersionModal(false)
setNewVersionNotes('')
setCopyFromPrevious(false)
} finally {
setCreatingVersion(false)
}
}
const handleAddLanguage = async () => {
if (!languageForm.language_code || !languageForm.language_label || !languageForm.content) return
setSavingLanguage(true)
@@ -83,6 +67,17 @@ export function ArtefactDetailVersionsTab({
setConfirmDeleteAttId(null)
}
const handleSaveEditLang = async () => {
if (!editingLangText || !editLangContent.trim()) return
setSavingEditLang(true)
try {
await onUpdateLanguage(editingLangText.Id, editLangContent)
setEditingLangText(null)
} finally {
setSavingEditLang(false)
}
}
const handleVideoDrop = (e) => {
e.preventDefault()
setDragOver(false)
@@ -101,37 +96,18 @@ export function ArtefactDetailVersionsTab({
return (
<>
<div className="p-6 space-y-5">
{/* Version Timeline */}
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.versions')}</h4>
{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>
)}
{/* Version Timeline — only shown when there are multiple rounds */}
{versions.length > 1 && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.versions')}</h4>
<ArtefactVersionTimeline
versions={versions}
activeVersionId={selectedVersion?.Id}
onSelectVersion={onSelectVersion}
artefactType={artefact.type}
/>
</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}
onSelectVersion={onSelectVersion}
artefactType={artefact.type}
/>
</div>
)}
{/* Type-specific content */}
{versionData && selectedVersion && (
@@ -145,7 +121,7 @@ export function ArtefactDetailVersionsTab({
onClick={() => setShowLanguageModal(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" />
<Globe className="w-3 h-3" />
{t('artefacts.addLanguage')}
</button>
</div>
@@ -161,12 +137,21 @@ export function ArtefactDetailVersionsTab({
</span>
<span className="text-sm font-medium text-text-primary">{text.language_label}</span>
</div>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="text-red-600 hover:text-red-700"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-1">
<button
onClick={() => { setEditingLangText(text); setEditLangContent(text.content || '') }}
className="p-1 text-text-tertiary hover:text-brand-primary rounded transition-colors"
title={t('common.edit')}
>
<Pencil className="w-3.5 h-3.5" />
</button>
<button
onClick={() => setConfirmDeleteLangId(text.Id)}
className="p-1 text-red-500 hover:text-red-700 rounded transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="bg-surface rounded border border-border p-3 text-sm text-text-primary whitespace-pre-wrap font-sans">
{text.content}
@@ -345,43 +330,39 @@ export function ArtefactDetailVersionsTab({
</div>
</Modal>
{/* New Version Modal */}
<Modal isOpen={showNewVersionModal} onClose={() => setShowNewVersionModal(false)} title={t('artefacts.createNewVersion')} size="sm">
{/* Edit Language Modal */}
<Modal isOpen={!!editingLangText} onClose={() => setEditingLangText(null)} title={t('artefacts.editLanguage')} size="md">
<div className="space-y-4">
{editingLangText && (
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-xs font-mono font-medium">
{editingLangText.language_code}
</span>
<span className="text-sm font-medium text-text-primary">{editingLangText.language_label}</span>
</div>
)}
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.versionNotes')}</label>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')}</label>
<textarea
value={newVersionNotes}
onChange={e => setNewVersionNotes(e.target.value)}
rows={3}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('artefacts.whatChanged')}
value={editLangContent}
onChange={e => setEditLangContent(e.target.value)}
rows={8}
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 font-sans"
/>
</div>
{artefact.type === 'copy' && versions.length > 0 && (
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={copyFromPrevious}
onChange={e => setCopyFromPrevious(e.target.checked)}
className="w-4 h-4 text-brand-primary border-border rounded focus:ring-brand-primary"
/>
<span className="text-sm text-text-secondary">{t('artefacts.copyLanguages')}</span>
</label>
)}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
<button
onClick={() => setShowNewVersionModal(false)}
onClick={() => setEditingLangText(null)}
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
>
{t('common.cancel')}
</button>
<button
onClick={handleCreateVersion}
disabled={creatingVersion}
onClick={handleSaveEditLang}
disabled={savingEditLang}
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"
>
{creatingVersion ? t('artefacts.creatingVersion') : t('artefacts.createVersion')}
{savingEditLang ? t('header.saving') : t('common.save')}
</button>
</div>
</div>
+4
View File
@@ -31,6 +31,7 @@
"common.loading": "جاري التحميل...",
"common.unassigned": "غير مُسند",
"common.close": "إغلاق",
"common.created": "تاريخ الإنشاء",
"common.required": "مطلوب",
"common.saveFailed": "فشل الحفظ. حاول مجدداً.",
"common.updateFailed": "فشل التحديث. حاول مجدداً.",
@@ -761,6 +762,9 @@
"issues.trackingLinkCopied": "تم نسخ رابط التتبع!",
"issues.deleteAttachment": "حذف المرفق؟",
"issues.deleteAttachmentDesc": "لا يمكن التراجع عن هذا الإجراء.",
"artefacts.editLanguage": "تعديل اللغة",
"artefacts.linkedPost": "المنشور المرتبط",
"artefacts.post": "منشور",
"artefacts.deleteLanguage": "حذف هذه اللغة؟",
"artefacts.deleteLanguageDesc": "سيتم إزالة المحتوى لهذه اللغة.",
"artefacts.deleteAttachment": "حذف هذا المرفق؟",
+4
View File
@@ -31,6 +31,7 @@
"common.loading": "Loading...",
"common.unassigned": "Unassigned",
"common.close": "Close",
"common.created": "Created",
"common.required": "Required",
"common.saveFailed": "Failed to save. Please try again.",
"common.updateFailed": "Failed to update. Please try again.",
@@ -761,6 +762,9 @@
"issues.trackingLinkCopied": "Tracking link copied to clipboard!",
"issues.deleteAttachment": "Delete attachment?",
"issues.deleteAttachmentDesc": "This action cannot be undone.",
"artefacts.editLanguage": "Edit Language",
"artefacts.linkedPost": "Linked Post",
"artefacts.post": "Post",
"artefacts.deleteLanguage": "Delete this language?",
"artefacts.deleteLanguageDesc": "The content for this language will be removed.",
"artefacts.deleteAttachment": "Delete this attachment?",
+110 -114
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { useState, useEffect, useContext, useCallback, useRef } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X, ExternalLink } from 'lucide-react'
import { AppContext } from '../App'
@@ -14,6 +14,20 @@ import { useToast } from '../components/ToastContainer'
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
// Maps asset type key → composition field name
const PIECE_MAP = { caption: 'caption', body: 'body_copy', design: 'design', video: 'video' }
// Maps asset type key → i18n label key
const LABEL_KEYS = {
caption: 'postDetail.captionCopy',
body: 'postDetail.bodyCopy',
design: 'postDetail.design',
video: 'postDetail.video',
}
const ASSET_ICONS = { caption: Type, body: FileText, design: ImageIcon, video: Film }
const ASSET_TYPES = ['caption', 'body', 'design', 'video']
// Maps server-generated waiting_on labels → asset type key
const WAITING_TYPE_MAP = { Caption: 'caption', Copy: 'body', Design: 'design', Video: 'video' }
export default function PostDetail() {
const { id } = useParams()
const navigate = useNavigate()
@@ -37,21 +51,18 @@ export default function PostDetail() {
const [platforms, setPlatforms] = useState([])
const [scheduledDate, setScheduledDate] = useState('')
// Link pickers
// Link pickers / create
const [creating, setCreating] = useState(false)
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
const [pickerSearch, setPickerSearch] = useState('')
const [linkCandidates, setLinkCandidates] = useState([])
const [linking, setLinking] = useState(false)
const allArtefactsRef = useRef(null)
// Sub-panels
const [openArtefact, setOpenArtefact] = useState(null)
useEffect(() => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [id])
const loadPost = async () => {
const loadPost = useCallback(async () => {
try {
const [p, comp] = await Promise.all([
api.get(`/posts/${id}`),
@@ -73,7 +84,12 @@ export default function PostDetail() {
} finally {
setLoading(false)
}
}
}, [id])
useEffect(() => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [loadPost])
const loadComposition = useCallback(async () => {
try {
@@ -96,7 +112,8 @@ export default function PostDetail() {
scheduled_date: scheduledDate || null,
})
toast.success(t('posts.updated'))
loadPost()
// Update local post state — composition is unaffected by metadata changes
setPost(p => ({ ...p, title, status, brand_id: brandId, campaign_id: campaignId, assigned_to: assignedTo, platforms, scheduled_date: scheduledDate || null }))
} catch {
toast.error(t('common.saveFailed'))
} finally {
@@ -110,20 +127,22 @@ export default function PostDetail() {
// ─── Link / Unlink / Create ───
const TYPE_FILTERS = {
caption: a => a.type === 'copy' && a.copy_type === 'caption',
body: a => a.type === 'copy' && (a.copy_type === 'body' || !a.copy_type),
video: a => a.type === 'video',
design: a => (a.type || 'design') === 'design',
}
const openLinkPicker = async (type) => {
setActivePicker(type)
setPickerSearch('')
try {
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 => {
if (!allArtefactsRef.current) allArtefactsRef.current = await api.get('/artefacts')
const all = Array.isArray(allArtefactsRef.current) ? allArtefactsRef.current : []
setLinkCandidates(all.filter(a => {
const linkedTo = a.post_id || a.postId
return typeFilter(a) && (!linkedTo || String(linkedTo) !== String(id))
return TYPE_FILTERS[type](a) && (!linkedTo || String(linkedTo) !== String(id))
}))
} catch {
setLinkCandidates([])
@@ -135,6 +154,7 @@ export default function PostDetail() {
setLinking(true)
try {
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
allArtefactsRef.current = null
toast.success(t('posts.updated'))
setActivePicker(null)
loadComposition()
@@ -146,13 +166,11 @@ export default function PostDetail() {
}
const handleUnlink = async (type) => {
const piece = type === 'caption' ? composition?.caption
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
const piece = composition?.[PIECE_MAP[type]]
if (!piece) return
try {
await api.patch(`/artefacts/${piece.id}`, { post_id: null })
allArtefactsRef.current = null
toast.success(t('posts.updated'))
loadComposition()
} catch {
@@ -161,10 +179,7 @@ export default function PostDetail() {
}
const handleOpenPiece = async (type) => {
const piece = type === 'caption' ? composition?.caption
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
const piece = composition?.[PIECE_MAP[type]]
if (!piece) return
try {
const full = await api.get(`/artefacts/${piece.id}`)
@@ -173,21 +188,22 @@ export default function PostDetail() {
}
const handleCreate = async (type) => {
const label = type === 'caption' ? t('postDetail.captionCopy')
: type === 'body' ? t('postDetail.bodyCopy')
: type === 'design' ? t('postDetail.design')
: t('postDetail.video')
if (creating) return
setCreating(true)
try {
const created = await api.post('/artefacts', {
title: `${label}${title}`,
title: title.trim() ? `${t(LABEL_KEYS[type])}${title.trim()}` : t(LABEL_KEYS[type]),
type: type === 'caption' || type === 'body' ? 'copy' : type,
copy_type: type === 'caption' ? 'caption' : type === 'body' ? 'body' : undefined,
post_id: Number(id),
})
allArtefactsRef.current = null
setOpenArtefact(created)
loadComposition()
} catch (err) {
} catch {
toast.error(t('common.saveFailed'))
} finally {
setCreating(false)
}
}
@@ -224,6 +240,16 @@ export default function PostDetail() {
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
})
const isDirty = Boolean(post) && (
title !== (post.title || '') ||
status !== (post.status || 'draft') ||
String(brandId) !== String(post.brand_id || post.brandId || '') ||
String(campaignId) !== String(post.campaign_id || post.campaignId || '') ||
String(assignedTo) !== String(post.assigned_to || post.assignedTo || '') ||
JSON.stringify(platforms) !== JSON.stringify(Array.isArray(post.platforms) ? post.platforms : (post.platform ? [post.platform] : [])) ||
scheduledDate !== ((post.scheduled_date || post.scheduledDate) ? new Date(post.scheduled_date || post.scheduledDate).toISOString().slice(0, 10) : '')
)
const waitingOn = composition?.waiting_on || []
const piecesReady = composition?.pieces_ready || false
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
@@ -316,88 +342,42 @@ export default function PostDetail() {
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm disabled:opacity-50"
className={`flex items-center gap-1.5 px-4 py-1.5 rounded-lg text-sm font-medium shadow-sm disabled:opacity-50 transition-colors ${
isDirty ? 'bg-amber-500 hover:bg-amber-600 text-white' : 'bg-brand-primary hover:bg-brand-primary-light text-white'
}`}
>
<Save className="w-4 h-4" />
{saving ? t('common.loading') : t('common.save')}
{isDirty && !saving && <span className="w-1.5 h-1.5 rounded-full bg-white/70 ms-0.5" />}
</button>
</div>
</div>
{/* ─── ASSET CARDS ─── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AssetCard
type="caption"
label={t('postDetail.captionCopy')}
icon={Type}
piece={composition?.caption}
onCreate={() => handleCreate('caption')}
onOpen={() => handleOpenPiece('caption')}
onUnlink={() => handleUnlink('caption')}
onOpenPicker={() => openLinkPicker('caption')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="body"
label={t('postDetail.bodyCopy')}
icon={FileText}
piece={composition?.body_copy}
onCreate={() => handleCreate('body')}
onOpen={() => handleOpenPiece('body')}
onUnlink={() => handleUnlink('body')}
onOpenPicker={() => openLinkPicker('body')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="design"
label={t('postDetail.design')}
icon={ImageIcon}
piece={composition?.design}
onCreate={() => handleCreate('design')}
onOpen={() => handleOpenPiece('design')}
onUnlink={() => handleUnlink('design')}
onOpenPicker={() => openLinkPicker('design')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="video"
label={t('postDetail.video')}
icon={Film}
piece={composition?.video}
onCreate={() => handleCreate('video')}
onOpen={() => handleOpenPiece('video')}
onUnlink={() => handleUnlink('video')}
onOpenPicker={() => openLinkPicker('video')}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
{ASSET_TYPES.map(type => (
<AssetCard
key={type}
id={`asset-${type}`}
type={type}
label={t(LABEL_KEYS[type])}
icon={ASSET_ICONS[type]}
piece={composition?.[PIECE_MAP[type]]}
onCreate={() => handleCreate(type)}
creating={creating}
onOpen={() => handleOpenPiece(type)}
onUnlink={() => handleUnlink(type)}
onOpenPicker={() => openLinkPicker(type)}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
))}
</div>
{/* ─── READINESS ─── */}
@@ -411,9 +391,23 @@ export default function PostDetail() {
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-amber-600">
<Clock className="w-5 h-5" />
<span className="text-sm font-medium">{t('postDetail.waitingOn')}: {waitingOn.join(', ')}</span>
<div className="flex items-start gap-2 text-amber-600">
<Clock className="w-5 h-5 shrink-0 mt-0.5" />
<div className="flex flex-wrap gap-1.5 items-center">
<span className="text-sm font-medium">{t('postDetail.waitingOn')}:</span>
{waitingOn.map(label => {
const type = WAITING_TYPE_MAP[label]
return type ? (
<button
key={label}
onClick={() => document.getElementById(`asset-${type}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })}
className="text-xs px-2 py-0.5 rounded-full bg-amber-100 text-amber-700 hover:bg-amber-200 transition-colors font-medium"
>
{label}
</button>
) : <span key={label} className="text-sm">{label}</span>
})}
</div>
</div>
)}
</div>
@@ -427,6 +421,7 @@ export default function PostDetail() {
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
{openArtefact && (
<ArtefactDetailPanel
key={openArtefact._id}
artefact={openArtefact}
onClose={() => { setOpenArtefact(null); loadComposition() }}
onUpdate={loadComposition}
@@ -441,8 +436,8 @@ export default function PostDetail() {
// ─── Asset Card Component ───
function AssetCard({
type, label, icon: Icon, piece,
onCreate, onOpen, onUnlink,
id, type, label, icon: Icon, piece,
onCreate, creating, onOpen, onUnlink,
onOpenPicker, activePicker, pickerSearch, filteredCandidates, linking,
onLink, onPickerSearchChange, onClosePicker, t,
}) {
@@ -453,7 +448,7 @@ function AssetCard({
const isApproved = piece?.status === 'approved'
return (
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
<div id={id} className="bg-surface rounded-xl border border-border p-4 flex flex-col">
{/* Header */}
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-text-tertiary" />
@@ -561,10 +556,11 @@ function AssetCard({
</button>
<button
onClick={onCreate}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
disabled={creating}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Plus className="w-3.5 h-3.5" />
{t('postDetail.createNew')}
{creating ? t('common.loading') : t('postDetail.createNew')}
</button>
</div>
)}