feat: artefact version auto-advance, post_id on artefacts, test-email endpoint
Deploy / deploy (push) Successful in 13s
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:
+110
-114
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user