+
{footer}
)}
diff --git a/client/src/i18n/ar.json b/client/src/i18n/ar.json
index f967e9b..ed76831 100644
--- a/client/src/i18n/ar.json
+++ b/client/src/i18n/ar.json
@@ -78,6 +78,29 @@
"posts.saveChanges": "حفظ التغييرات",
"posts.postTitle": "العنوان",
"posts.description": "الوصف",
+ "post.caption": "التعليق",
+ "post.captionPlaceholder": "اكتب تعليق المنشور...",
+ "post.copy": "النص (داخل التصميم)",
+ "post.designs": "التصاميم",
+ "post.video": "الفيديو",
+ "post.formatChecklist": "قائمة الأحجام المطلوبة",
+ "post.formatsNeeded": "الأحجام المطلوبة بناءً على المنصات المختارة",
+ "post.selectPlatforms": "اختر المنصات لعرض الأحجام المطلوبة",
+ "post.readiness": "الجاهزية",
+ "post.allPiecesReady": "جميع العناصر جاهزة — بانتظار الاعتماد",
+ "post.waitingOn": "بانتظار",
+ "post.signOff": "اعتماد وجدولة",
+ "post.signOffConfirm": "هل تريد اعتماد هذا المنشور وتجهيزه للجدولة؟",
+ "common.confirm": "تأكيد",
+ "post.linkExisting": "ربط موجود",
+ "post.createNew": "إنشاء جديد",
+ "post.addDesign": "إضافة تصميم",
+ "post.addVideo": "إضافة فيديو",
+ "post.linkTranslation": "ربط ترجمة",
+ "post.selectLanguage": "اللغة...",
+ "post.noCopyLinked": "لا يوجد نص مرتبط بعد",
+ "post.noDesignsLinked": "لا توجد تصاميم مرتبطة بعد",
+ "post.noVideoLinked": "لا يوجد فيديو مرتبط بعد",
"posts.brand": "العلامة التجارية",
"posts.platforms": "المنصات",
"posts.status": "الحالة",
@@ -701,6 +724,11 @@
"review.confirmReject": "هل تريد رفض هذا المحتوى؟",
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
"review.contentLanguages": "لغات المحتوى",
+ "review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
+ "review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
+ "review.selectNewReviewer": "اختر مراجعاً جديداً...",
+ "review.redirect": "إعادة توجيه",
+ "review.redirected": "تم إعادة توجيه المراجعة بنجاح",
"review.content": "المحتوى",
"review.designFiles": "ملفات التصميم",
"review.videos": "الفيديوهات",
@@ -783,6 +811,8 @@
"header.issues": "البلاغات",
"header.settings": "الإعدادات",
"header.translations": "الترجمات",
+ "header.copy": "النسخ",
+ "header.postDetails": "تفاصيل المنشور",
"calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين",
@@ -882,6 +912,8 @@
"artefacts.descriptionLabel": "الوصف",
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
"artefacts.approversLabel": "المعتمدون",
+ "artefacts.reviewer": "المراجع",
+ "artefacts.selectReviewer": "اختر مراجعاً...",
"artefacts.versions": "الإصدارات",
"artefacts.newVersion": "إصدار جديد",
"artefacts.languages": "اللغات",
@@ -1133,5 +1165,21 @@
"translations.createPost": "منشور جديد",
"translations.newPostTitle": "عنوان المنشور...",
"translations.postCreated": "تم إنشاء المنشور!",
- "translations.postCreateFailed": "فشل إنشاء المنشور"
+ "translations.postCreateFailed": "فشل إنشاء المنشور",
+
+ "nav.copy": "النسخ",
+
+ "postDetail.captionCopy": "نص التسمية التوضيحية",
+ "postDetail.bodyCopy": "النص الرئيسي",
+ "postDetail.design": "التصميم",
+ "postDetail.video": "الفيديو",
+ "postDetail.readiness": "الجاهزية",
+ "postDetail.noAssets": "لا توجد أصول مرتبطة بعد",
+ "postDetail.allPiecesApproved": "جميع العناصر معتمدة",
+ "postDetail.waitingOn": "بانتظار",
+ "postDetail.notLinked": "غير مرتبط",
+ "postDetail.linkExisting": "ربط موجود",
+ "postDetail.createNew": "إنشاء جديد",
+ "postDetail.open": "فتح",
+ "postDetail.unlink": "إلغاء الربط"
}
\ No newline at end of file
diff --git a/client/src/i18n/en.json b/client/src/i18n/en.json
index ab62b79..222a678 100644
--- a/client/src/i18n/en.json
+++ b/client/src/i18n/en.json
@@ -78,6 +78,29 @@
"posts.saveChanges": "Save Changes",
"posts.postTitle": "Title",
"posts.description": "Description",
+ "post.caption": "Caption",
+ "post.captionPlaceholder": "Write your social media caption...",
+ "post.copy": "Copy (In-Design Text)",
+ "post.designs": "Designs",
+ "post.video": "Video",
+ "post.formatChecklist": "Format Checklist",
+ "post.formatsNeeded": "Formats needed based on selected platforms",
+ "post.selectPlatforms": "Select platforms to see required formats",
+ "post.readiness": "Readiness",
+ "post.allPiecesReady": "All pieces ready — awaiting sign-off",
+ "post.waitingOn": "Waiting on",
+ "post.signOff": "Approve & Schedule",
+ "post.signOffConfirm": "Mark this post as approved and ready for scheduling?",
+ "common.confirm": "Confirm",
+ "post.linkExisting": "Link existing",
+ "post.createNew": "Create new",
+ "post.addDesign": "Add Design",
+ "post.addVideo": "Add Video",
+ "post.linkTranslation": "Link Translation",
+ "post.selectLanguage": "Language...",
+ "post.noCopyLinked": "No copy linked yet",
+ "post.noDesignsLinked": "No designs linked yet",
+ "post.noVideoLinked": "No video linked yet",
"posts.brand": "Brand",
"posts.platforms": "Platforms",
"posts.status": "Status",
@@ -701,6 +724,11 @@
"review.confirmReject": "Reject this artefact?",
"review.feedbackRequired": "Please provide feedback for revision request",
"review.contentLanguages": "Content Languages",
+ "review.redirectReview": "Not the right reviewer? Redirect to someone else",
+ "review.redirectDesc": "Select a team member to redirect this review to:",
+ "review.selectNewReviewer": "Select new reviewer...",
+ "review.redirect": "Redirect",
+ "review.redirected": "Review redirected successfully",
"review.content": "Content",
"review.designFiles": "Design Files",
"review.videos": "Videos",
@@ -783,6 +811,8 @@
"header.issues": "Issues",
"header.settings": "Settings",
"header.translations": "Translations",
+ "header.copy": "Copy",
+ "header.postDetails": "Post Details",
"calendar.unscheduledPosts": "Unscheduled Posts",
"calendar.statusLegend": "Status Legend",
"header.users": "User Management",
@@ -882,6 +912,8 @@
"artefacts.descriptionLabel": "Description",
"artefacts.descriptionFieldPlaceholder": "Add a description...",
"artefacts.approversLabel": "Approvers",
+ "artefacts.reviewer": "Reviewer",
+ "artefacts.selectReviewer": "Select a reviewer...",
"artefacts.versions": "Versions",
"artefacts.newVersion": "New Version",
"artefacts.languages": "Languages",
@@ -1133,5 +1165,21 @@
"translations.createPost": "New Post",
"translations.newPostTitle": "Post title...",
"translations.postCreated": "Post created!",
- "translations.postCreateFailed": "Failed to create post"
+ "translations.postCreateFailed": "Failed to create post",
+
+ "nav.copy": "Copy",
+
+ "postDetail.captionCopy": "Caption Copy",
+ "postDetail.bodyCopy": "Body Copy",
+ "postDetail.design": "Design",
+ "postDetail.video": "Video",
+ "postDetail.readiness": "Readiness",
+ "postDetail.noAssets": "No assets linked yet",
+ "postDetail.allPiecesApproved": "All pieces approved",
+ "postDetail.waitingOn": "Waiting on",
+ "postDetail.notLinked": "Not linked",
+ "postDetail.linkExisting": "Link existing",
+ "postDetail.createNew": "Create new",
+ "postDetail.open": "Open",
+ "postDetail.unlink": "Unlink"
}
\ No newline at end of file
diff --git a/client/src/index.css b/client/src/index.css
index 6bc3497..d61ce9c 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -504,7 +504,7 @@ textarea {
background: white;
border: 1px solid var(--color-border);
border-radius: 1rem;
- overflow: hidden;
+ overflow: clip;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease;
}
diff --git a/client/src/pages/Brands.jsx b/client/src/pages/Brands.jsx
index f8a284f..910eed7 100644
--- a/client/src/pages/Brands.jsx
+++ b/client/src/pages/Brands.jsx
@@ -154,7 +154,7 @@ export default function Brands() {
return (
isSuperadminOrManager && openEditBrand(brand)}
>
{/* Logo area */}
diff --git a/client/src/pages/CampaignDetail.jsx b/client/src/pages/CampaignDetail.jsx
index d22fc93..91904d6 100644
--- a/client/src/pages/CampaignDetail.jsx
+++ b/client/src/pages/CampaignDetail.jsx
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
import CommentsSection from '../components/CommentsSection'
import CampaignDetailPanel from '../components/CampaignDetailPanel'
import TrackDetailPanel from '../components/TrackDetailPanel'
-import PostDetailPanel from '../components/PostDetailPanel'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
@@ -46,7 +45,6 @@ export default function CampaignDetail() {
const [budgetValue, setBudgetValue] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null)
- const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false)
const [allCampaigns, setAllCampaigns] = useState([])
@@ -153,21 +151,6 @@ export default function CampaignDetail() {
loadAll()
}
- const handlePostPanelSave = async (postId, data) => {
- if (postId) {
- await api.patch(`/posts/${postId}`, data)
- } else {
- await api.post('/posts', data)
- }
- loadAll()
- }
-
- const handlePostPanelDelete = async (postId) => {
- await api.delete(`/posts/${postId}`)
- setSelectedPost(null)
- loadAll()
- }
-
const deleteTrack = async (trackId) => {
setTrackToDelete(trackId)
setShowDeleteConfirm(true)
@@ -301,7 +284,7 @@ export default function CampaignDetail() {
{/* Tracks */}
-
+
{t('campaigns.tracks')}
{canManage && (
@@ -434,7 +417,7 @@ export default function CampaignDetail() {
{/* Linked Posts */}
{posts.length > 0 && (
-
+
{t('campaigns.linkedPosts')} ({posts.length})
@@ -442,7 +425,7 @@ export default function CampaignDetail() {
{posts.map(post => (
setSelectedPost(post)}
+ onClick={() => navigate(`/posts/${post._id || post.id || post.Id}`)}
className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
>
{post.thumbnail_url && (
@@ -589,19 +572,6 @@ export default function CampaignDetail() {
- {/* Post Detail Panel */}
- {selectedPost && (
-
setSelectedPost(null)}
- onSave={handlePostPanelSave}
- onDelete={handlePostPanelDelete}
- brands={brands}
- teamMembers={teamMembers}
- campaigns={allCampaigns}
- />
- )}
-
{/* Campaign Edit Panel */}
{panelCampaign && (
{/* Campaign list */}
-
+
All Campaigns
diff --git a/client/src/pages/PostCalendar.jsx b/client/src/pages/PostCalendar.jsx
index 967fd30..b3130dd 100644
--- a/client/src/pages/PostCalendar.jsx
+++ b/client/src/pages/PostCalendar.jsx
@@ -194,7 +194,7 @@ export default function PostCalendar() {
{/* Calendar */}
-
+
{/* Nav */}
diff --git a/client/src/pages/PostDetail.jsx b/client/src/pages/PostDetail.jsx
new file mode 100644
index 0000000..4c080fa
--- /dev/null
+++ b/client/src/pages/PostDetail.jsx
@@ -0,0 +1,655 @@
+import { useState, useEffect, useContext, useCallback } 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 } from 'lucide-react'
+import { AppContext } from '../App'
+import { useAuth } from '../contexts/AuthContext'
+import { useLanguage } from '../i18n/LanguageContext'
+import { api, PLATFORMS } from '../utils/api'
+import PlatformIcon from '../components/PlatformIcon'
+import StatusBadge from '../components/StatusBadge'
+import CommentsSection from '../components/CommentsSection'
+import TranslationDetailPanel from '../components/TranslationDetailPanel'
+import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
+import { useToast } from '../components/ToastContainer'
+
+const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
+
+export default function PostDetail() {
+ const { id } = useParams()
+ const navigate = useNavigate()
+ const { brands, getBrandName, teamMembers } = useContext(AppContext)
+ const { t, lang } = useLanguage()
+ const { user } = useAuth()
+ const toast = useToast()
+
+ const [post, setPost] = useState(null)
+ const [composition, setComposition] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [saving, setSaving] = useState(false)
+ const [campaigns, setCampaigns] = useState([])
+
+ // Editable form fields
+ const [title, setTitle] = useState('')
+ const [status, setStatus] = useState('draft')
+ const [brandId, setBrandId] = useState('')
+ const [campaignId, setCampaignId] = useState('')
+ const [assignedTo, setAssignedTo] = useState('')
+ const [platforms, setPlatforms] = useState([])
+ const [scheduledDate, setScheduledDate] = useState('')
+
+ // Link pickers
+ const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
+ const [pickerSearch, setPickerSearch] = useState('')
+ const [linkCandidates, setLinkCandidates] = useState([])
+ const [linking, setLinking] = useState(false)
+
+ // Sub-panels
+ const [openTranslation, setOpenTranslation] = useState(null)
+ const [openArtefact, setOpenArtefact] = useState(null)
+
+ useEffect(() => {
+ loadPost()
+ api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
+ }, [id])
+
+ const loadPost = async () => {
+ try {
+ const [p, comp] = await Promise.all([
+ api.get(`/posts/${id}`),
+ api.get(`/posts/${id}/composition`),
+ ])
+ setPost(p)
+ setComposition(comp)
+ setTitle(p.title || '')
+ setStatus(p.status || 'draft')
+ setBrandId(p.brand_id || p.brandId || '')
+ setCampaignId(p.campaign_id || p.campaignId || '')
+ setAssignedTo(p.assigned_to || p.assignedTo || '')
+ const plats = p.platforms || (p.platform ? [p.platform] : [])
+ setPlatforms(Array.isArray(plats) ? plats : [])
+ const sd = p.scheduled_date || p.scheduledDate
+ setScheduledDate(sd ? new Date(sd).toISOString().slice(0, 10) : '')
+ } catch (err) {
+ console.error('Failed to load post:', err)
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ const loadComposition = useCallback(async () => {
+ try {
+ setComposition(await api.get(`/posts/${id}/composition`))
+ } catch (err) {
+ console.error('Failed to load composition:', err)
+ }
+ }, [id])
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ await api.patch(`/posts/${id}`, {
+ title,
+ status,
+ brand_id: brandId ? Number(brandId) : null,
+ campaign_id: campaignId ? Number(campaignId) : null,
+ assigned_to: assignedTo ? Number(assignedTo) : null,
+ platforms,
+ scheduled_date: scheduledDate || null,
+ })
+ toast.success(t('posts.updated'))
+ loadPost()
+ } catch {
+ toast.error(t('common.saveFailed'))
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const togglePlatform = (key) => {
+ setPlatforms(prev => prev.includes(key) ? prev.filter(p => p !== key) : [...prev, key])
+ }
+
+ // ─── Link / Unlink / Create ───
+
+ const openLinkPicker = async (type) => {
+ 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))
+ }))
+ }
+ } catch {
+ setLinkCandidates([])
+ }
+ }
+
+ 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) })
+ }
+ toast.success(t('posts.updated'))
+ setActivePicker(null)
+ loadComposition()
+ } catch {
+ toast.error(t('common.saveFailed'))
+ } finally {
+ setLinking(false)
+ }
+ }
+
+ const handleUnlink = async (type) => {
+ const piece = type === 'caption' ? composition?.caption
+ : type === 'body' ? composition?.body_copy
+ : type === 'design' ? composition?.design
+ : 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 })
+ toast.success(t('posts.updated'))
+ loadComposition()
+ } catch {
+ toast.error(t('common.saveFailed'))
+ }
+ }
+
+ const handleCreate = async (type) => {
+ try {
+ if (type === 'caption') {
+ await api.post('/translations', {
+ post_id: Number(id),
+ copy_type: 'caption',
+ title: (title || 'Post') + ' - Caption',
+ source_language: 'EN',
+ source_content: ' ',
+ })
+ } else if (type === 'body') {
+ await api.post('/translations', {
+ post_id: Number(id),
+ copy_type: 'body',
+ title: (title || 'Post') + ' - Copy',
+ source_language: 'EN',
+ source_content: ' ',
+ })
+ } else if (type === 'design') {
+ await api.post('/artefacts', {
+ post_id: Number(id),
+ type: 'design',
+ title: (title || 'Post') + ' - Design',
+ status: 'draft',
+ })
+ } else if (type === 'video') {
+ await api.post('/artefacts', {
+ post_id: Number(id),
+ type: 'video',
+ title: (title || 'Post') + ' - Video',
+ status: 'draft',
+ })
+ }
+ toast.success(t('posts.created'))
+ loadComposition()
+ } catch {
+ toast.error(t('common.saveFailed'))
+ }
+ }
+
+ const handleOpenPiece = async (type) => {
+ const piece = type === 'caption' ? composition?.caption
+ : type === 'body' ? composition?.body_copy
+ : 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')) }
+ }
+ }
+
+ // ─── Rendering ───
+
+ if (loading) {
+ return (
+
+
+
+ {[1,2,3,4].map(i =>
)}
+
+
+ )
+ }
+
+ if (!post) {
+ return (
+
+ {t('common.noResults')}{' '}
+
+
+ )
+ }
+
+ const filteredCandidates = linkCandidates.filter(c => {
+ if (!pickerSearch) return true
+ return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
+ })
+
+ const waitingOn = composition?.waiting_on || []
+ const piecesReady = composition?.pieces_ready || false
+ const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
+
+ return (
+
+ {/* ─── HEADER ─── */}
+
+
+
+
setTitle(e.target.value)}
+ className="flex-1 text-xl font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 placeholder:text-text-tertiary"
+ placeholder={t('posts.postTitlePlaceholder')}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Platforms */}
+
+ {Object.entries(PLATFORMS).map(([key, p]) => (
+
+ ))}
+
+
+ {/* Date + Save */}
+
+ setScheduledDate(e.target.value)}
+ className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
+ />
+
+
+
+
+ {/* ─── ASSET CARDS ─── */}
+
+
handleOpenPiece('caption')}
+ onUnlink={() => handleUnlink('caption')}
+ onOpenPicker={() => openLinkPicker('caption')}
+ onCreate={() => handleCreate('caption')}
+ onLink={handleLink}
+ onPickerSearchChange={setPickerSearch}
+ onClosePicker={() => setActivePicker(null)}
+ t={t}
+ />
+ handleOpenPiece('body')}
+ onUnlink={() => handleUnlink('body')}
+ onOpenPicker={() => openLinkPicker('body')}
+ onCreate={() => handleCreate('body')}
+ onLink={handleLink}
+ onPickerSearchChange={setPickerSearch}
+ onClosePicker={() => setActivePicker(null)}
+ t={t}
+ />
+ handleOpenPiece('design')}
+ onUnlink={() => handleUnlink('design')}
+ onOpenPicker={() => openLinkPicker('design')}
+ onCreate={() => handleCreate('design')}
+ onLink={handleLink}
+ onPickerSearchChange={setPickerSearch}
+ onClosePicker={() => setActivePicker(null)}
+ t={t}
+ />
+ handleOpenPiece('video')}
+ onUnlink={() => handleUnlink('video')}
+ onOpenPicker={() => openLinkPicker('video')}
+ onCreate={() => handleCreate('video')}
+ onLink={handleLink}
+ onPickerSearchChange={setPickerSearch}
+ onClosePicker={() => setActivePicker(null)}
+ t={t}
+ />
+
+
+ {/* ─── READINESS ─── */}
+
+
{t('postDetail.readiness')}
+ {!hasPieces ? (
+
{t('postDetail.noAssets')}
+ ) : piecesReady ? (
+
+
+ {t('postDetail.allPiecesApproved')}
+
+ ) : (
+
+
+ {t('postDetail.waitingOn')}: {waitingOn.join(', ')}
+
+ )}
+
+
+ {/* ─── COMMENTS ─── */}
+
+
{t('posts.discussion')}
+
+
+
+ {/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
+ {openTranslation && (
+
{ setOpenTranslation(null); loadComposition() }}
+ onUpdate={loadComposition}
+ onDelete={() => { setOpenTranslation(null); loadComposition() }}
+ assignableUsers={teamMembers}
+ />
+ )}
+
+ {openArtefact && (
+ { setOpenArtefact(null); loadComposition() }}
+ onUpdate={loadComposition}
+ onDelete={() => { setOpenArtefact(null); loadComposition() }}
+ assignableUsers={teamMembers}
+ projects={[]}
+ campaigns={campaigns}
+ />
+ )}
+
+ )
+}
+
+// ─── Asset Card Component ───
+
+function AssetCard({
+ type, label, icon: Icon, piece,
+ activePicker, pickerSearch, filteredCandidates, linking,
+ onOpen, onUnlink, onOpenPicker, onCreate, onLink,
+ onPickerSearchChange, onClosePicker, t,
+}) {
+ const isPickerOpen = activePicker === type
+ const isCopy = type === 'caption' || type === 'body'
+
+ return (
+
+
+
+
{label}
+
+
+ {piece ? (
+ <>
+
+ {/* Thumbnail for design/video */}
+ {!isCopy && piece.thumbnail_url && (
+
+

+
+ )}
+ {!isCopy && !piece.thumbnail_url && (
+
+
+
+ )}
+
+
+ {piece.title}
+
+
+
+ {/* Copy: show content preview + languages */}
+ {isCopy && piece.content_preview && (
+
{piece.content_preview}
+ )}
+ {isCopy && piece.languages && piece.languages.length > 0 && (
+
+ {piece.languages.map((l, i) => (
+
+ {l.language}
+
+ ))}
+
+ )}
+ {isCopy && (!piece.languages || piece.languages.length === 0) && piece.language && (
+
{piece.language}
+ )}
+
+ {/* Design/Video: version info */}
+ {!isCopy && piece.current_version && (
+
v{piece.current_version}
+ )}
+
+
+
+
+
+ >
+ ) : (
+ <>
+
+
{t('postDetail.notLinked')}
+
+ {!isPickerOpen && (
+
+
+
+
+ )}
+ >
+ )}
+
+ {/* Inline picker */}
+ {isPickerOpen && (
+
+
+
+
+ onPickerSearchChange(e.target.value)}
+ placeholder={t('common.search')}
+ className="w-full ps-7 pe-2 py-1.5 text-xs border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
+ autoFocus
+ />
+
+
+
+
+ {filteredCandidates.length === 0 ? (
+
{t('common.noResults')}
+ ) : (
+ filteredCandidates.slice(0, 10).map(c => (
+
+ ))
+ )}
+
+
+ )}
+
+ )
+}
diff --git a/client/src/pages/PostProduction.jsx b/client/src/pages/PostProduction.jsx
index 7da0f49..b7aeada 100644
--- a/client/src/pages/PostProduction.jsx
+++ b/client/src/pages/PostProduction.jsx
@@ -1,4 +1,5 @@
import { useState, useEffect, useContext, useMemo } from 'react'
+import { useNavigate } from 'react-router-dom'
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
@@ -7,12 +8,11 @@ import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard'
import PostCard from '../components/PostCard'
-import PostDetailPanel from '../components/PostDetailPanel'
import DatePresetPicker from '../components/DatePresetPicker'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import EmptyState from '../components/EmptyState'
-import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal'
+import BulkSelectBar from '../components/BulkSelectBar'
import { useToast } from '../components/ToastContainer'
const EMPTY_POST = {
@@ -23,13 +23,13 @@ const EMPTY_POST = {
export default function PostProduction() {
const { t, lang } = useLanguage()
+ const navigate = useNavigate()
const { teamMembers, brands, getBrandName } = useContext(AppContext)
const { canEditResource } = useAuth()
const toast = useToast()
const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban')
- const [panelPost, setPanelPost] = useState(null)
const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
const [searchTerm, setSearchTerm] = useState('')
@@ -38,9 +38,6 @@ export default function PostProduction() {
const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showFilters, setShowFilters] = useState(false)
- const [showCreateModal, setShowCreateModal] = useState(false)
- const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
- const [createSaving, setCreateSaving] = useState(false)
useEffect(() => {
loadPosts()
@@ -78,20 +75,6 @@ export default function PostProduction() {
}
}
- const handlePanelSave = async (postId, data) => {
- let result
- if (postId) {
- result = await api.patch(`/posts/${postId}`, data)
- toast.success(t('posts.updated'))
- } else {
- result = await api.post('/posts', data)
- toast.success(t('posts.created'))
- }
- loadPosts()
- // Update panel with fresh server data so form stays in sync
- if (result && postId) setPanelPost(result)
- }
-
const handlePanelDelete = async (postId) => {
try {
await api.delete(`/posts/${postId}`)
@@ -131,39 +114,18 @@ export default function PostProduction() {
}
const openEdit = (post) => {
- if (!canEditResource('post', post)) {
- toast.error(t('posts.canOnlyEditOwn'))
- return
- }
- setPanelPost(post)
+ const postId = post._id || post.id || post.Id
+ navigate(`/posts/${postId}`)
}
- const openNew = () => {
- setCreateForm({ ...EMPTY_POST })
- setShowCreateModal(true)
- }
-
- const handleCreate = async () => {
- setCreateSaving(true)
+ const openNew = async () => {
try {
- const data = {
- title: createForm.title,
- brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
- campaign_id: createForm.campaign_id ? Number(createForm.campaign_id) : null,
- assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
- status: 'draft',
- }
- const created = await api.post('/posts', data)
- setShowCreateModal(false)
+ const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
+ const newId = result._id || result.id || result.Id
toast.success(t('posts.created'))
- loadPosts()
- // Open the detail panel for further editing
- if (created) setPanelPost(created)
- } catch (err) {
- console.error('Create post failed:', err)
+ navigate(`/posts/${newId}`)
+ } catch {
toast.error(t('common.saveFailed'))
- } finally {
- setCreateSaving(false)
}
}
@@ -334,7 +296,7 @@ export default function PostProduction() {
}}
/>
) : (
-
+
{filteredPosts.length === 0 ? (
- {/* Create Post Modal */}
- setShowCreateModal(false)} title={t('posts.newPost')} size="md">
-
-
-
- setCreateForm(f => ({ ...f, title: 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 focus:border-brand-primary" autoFocus />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/* Post Detail Panel (edit only) */}
- {panelPost && (
- setPanelPost(null)}
- onSave={handlePanelSave}
- onDelete={handlePanelDelete}
- brands={brands}
- teamMembers={teamMembers}
- campaigns={campaigns}
- />
- )}
)
}
diff --git a/client/src/pages/ProjectDetail.jsx b/client/src/pages/ProjectDetail.jsx
index 4f76ded..f4aee36 100644
--- a/client/src/pages/ProjectDetail.jsx
+++ b/client/src/pages/ProjectDetail.jsx
@@ -223,7 +223,7 @@ export default function ProjectDetail() {
{/* Project header */}
-
+
{/* Thumbnail banner */}
{(project.thumbnail_url || project.thumbnailUrl) && (
@@ -411,7 +411,7 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */}
{view === 'list' && (
-
+
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
}
return (
-
+
{/* Zoom toolbar */}
diff --git a/client/src/pages/PublicReview.jsx b/client/src/pages/PublicReview.jsx
index d26ba35..fcba5fe 100644
--- a/client/src/pages/PublicReview.jsx
+++ b/client/src/pages/PublicReview.jsx
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
-import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
+import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User, ArrowRightLeft } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal'
@@ -21,8 +21,13 @@ export default function PublicReview() {
const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('')
+ const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = useState('')
+ const [showRedirect, setShowRedirect] = useState(false)
+ const [redirectTo, setRedirectTo] = useState('')
+ const [teamMembers, setTeamMembers] = useState([])
+ const [redirecting, setRedirecting] = useState(false)
const [selectedLanguage, setSelectedLanguage] = useState(0)
const [pendingAction, setPendingAction] = useState(null)
@@ -41,8 +46,8 @@ export default function PublicReview() {
}
const data = await res.json()
setArtefact(data)
- // Auto-set reviewer name if there's exactly one approver
- if (data.approvers?.length === 1 && data.approvers[0].name) {
+ // Auto-set reviewer name from the selected approver
+ if (data.approvers?.length > 0 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name)
}
} catch (err) {
@@ -102,6 +107,41 @@ export default function PublicReview() {
}
}
+ const handleOpenRedirect = async () => {
+ try {
+ const res = await fetch(`/api/public/review-redirect/${token}/team`)
+ const data = await res.json()
+ setTeamMembers(Array.isArray(data) ? data : [])
+ setShowRedirect(true)
+ } catch {
+ toast.error(t('review.actionFailed'))
+ }
+ }
+
+ const handleRedirect = async () => {
+ if (!redirectTo) return
+ setRedirecting(true)
+ try {
+ const res = await fetch(`/api/public/review-redirect/${token}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ new_approver_id: Number(redirectTo) }),
+ })
+ const data = await res.json()
+ if (!res.ok) {
+ toast.error(data.error || t('review.actionFailed'))
+ return
+ }
+ setSuccessType('redirect')
+ setSuccess(data.message || t('review.redirected'))
+ setShowRedirect(false)
+ } catch {
+ toast.error(t('review.actionFailed'))
+ } finally {
+ setRedirecting(false)
+ }
+ }
+
const extractDriveFileId = (url) => {
const patterns = [
/\/file\/d\/([^\/]+)/,
@@ -157,10 +197,15 @@ export default function PublicReview() {
return (
-
-
+
+ {successType === 'redirect'
+ ?
+ :
+ }
-
{t('review.thankYou')}
+
+ {successType === 'redirect' ? t('review.redirected') : t('review.thankYou')}
+
{success}
@@ -418,31 +463,10 @@ export default function PublicReview() {
{/* Reviewer identity */}
- {artefact.approvers?.length === 1 ? (
-
-
- {artefact.approvers[0].name}
-
- ) : artefact.approvers?.length > 1 ? (
-
- ) : (
-
setReviewerName(e.target.value)}
- placeholder={t('review.enterYourName')}
- className="w-full px-4 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary transition-colors"
- />
- )}
+
+
+ {artefact.approvers?.[0]?.name || reviewerName || '—'}
+
@@ -483,6 +507,48 @@ export default function PublicReview() {
{t('review.reject')}
+
+ {/* Redirect to another reviewer */}
+
+ {!showRedirect ? (
+
+ ) : (
+
+
{t('review.redirectDesc')}
+
+
+
+
+
+
+ )}
+
)}
diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx
index 3d70fa0..f6a77db 100644
--- a/client/src/pages/Settings.jsx
+++ b/client/src/pages/Settings.jsx
@@ -71,7 +71,7 @@ export default function Settings() {
{t('settings.preferences')}
{/* General Settings */}
-
+
{t('settings.general')}
@@ -115,7 +115,7 @@ export default function Settings() {
{/* Uploads Section */}
-
+
@@ -153,7 +153,7 @@ export default function Settings() {
{/* Tutorial Section */}
-
+
{t('settings.onboardingTutorial')}
@@ -188,7 +188,7 @@ export default function Settings() {
{/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && (
-
+
@@ -291,7 +291,7 @@ function RolesSection({ roles, loadRoles, t, toast }) {
return (
<>
-
+
diff --git a/client/src/pages/Tasks.jsx b/client/src/pages/Tasks.jsx
index 0241812..b8a473b 100644
--- a/client/src/pages/Tasks.jsx
+++ b/client/src/pages/Tasks.jsx
@@ -599,7 +599,7 @@ export default function Tasks() {
onDelete={() => setShowBulkDeleteConfirm(true)}
/>
)}
-
+
diff --git a/client/src/pages/Team.jsx b/client/src/pages/Team.jsx
index 422482a..c26ec4f 100644
--- a/client/src/pages/Team.jsx
+++ b/client/src/pages/Team.jsx
@@ -533,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return (
-
+
{/* Team header */}
@@ -603,7 +603,7 @@ export default function Team() {
{/* Unassigned members */}
{unassignedMembers.length > 0 && (
-
+
diff --git a/client/src/pages/Translations.jsx b/client/src/pages/Translations.jsx
index 46e5517..2a82a72 100644
--- a/client/src/pages/Translations.jsx
+++ b/client/src/pages/Translations.jsx
@@ -352,7 +352,7 @@ export default function Translations() {
{t('translations.noTranslations')}
) : (
-
+
diff --git a/client/src/utils/platformFormats.js b/client/src/utils/platformFormats.js
new file mode 100644
index 0000000..2992c55
--- /dev/null
+++ b/client/src/utils/platformFormats.js
@@ -0,0 +1,39 @@
+export const PLATFORM_FORMATS = {
+ instagram: [
+ { key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
+ { key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
+ { key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
+ ],
+ tiktok: [
+ { key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
+ ],
+ youtube: [
+ { key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
+ { key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
+ { key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
+ ],
+ facebook: [
+ { key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
+ { key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
+ ],
+ twitter: [
+ { key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
+ ],
+ linkedin: [
+ { key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
+ ],
+ snapchat: [
+ { key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
+ ],
+}
+
+export function getFormatsForPlatforms(platforms = []) {
+ const formats = []
+ const seen = new Set()
+ for (const p of platforms) {
+ for (const f of (PLATFORM_FORMATS[p] || [])) {
+ if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
+ }
+ }
+ return formats
+}
diff --git a/docs/superpowers/plans/2026-03-15-post-composition-redesign.md b/docs/superpowers/plans/2026-03-15-post-composition-redesign.md
new file mode 100644
index 0000000..f87e09c
--- /dev/null
+++ b/docs/superpowers/plans/2026-03-15-post-composition-redesign.md
@@ -0,0 +1,405 @@
+# Post Composition Redesign — Implementation Plan
+
+> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Transform Posts from flat entities into composition orchestrators that assemble Caption + Copy (Translations) + Designs (Artefacts) + Video into a publishable unit with auto-computed readiness.
+
+**Architecture:** Add `caption` and `stage` fields to Posts table. New `/api/posts/:id/composition` endpoint aggregates linked Translations + Artefacts with approval statuses. PostDetailPanel is rewritten as a composition workspace (single scroll, no tabs). Platform→format mapping is a client-side constant.
+
+**Tech Stack:** Express.js, NocoDB, React, Tailwind CSS
+
+**Spec:** `docs/superpowers/specs/2026-03-15-post-composition-redesign.md`
+
+---
+
+## File Structure
+
+**Server:**
+- Modify: `server/server.js` — add `caption`/`stage` to Posts TEXT_COLUMNS, new composition endpoint, update POST/PATCH handlers
+- Create: `server/post-composition.js` — helper to compute composition, readiness, and stage auto-advance
+
+**Client — New:**
+- Create: `client/src/components/PostCompositionPanel.jsx` — the new composition workspace (replaces PostDetailPanel usage)
+- Create: `client/src/components/PostCompositionCaption.jsx` — caption section
+- Create: `client/src/components/PostCompositionCopy.jsx` — linked translations section
+- Create: `client/src/components/PostCompositionDesigns.jsx` — linked design artefacts section
+- Create: `client/src/components/PostCompositionVideo.jsx` — linked video artefact section
+- Create: `client/src/components/PostCompositionFormats.jsx` — platform format checklist
+- Create: `client/src/components/PostCompositionReadiness.jsx` — readiness summary + sign-off
+- Create: `client/src/utils/platformFormats.js` — PLATFORM_FORMATS constant
+
+**Client — Modify:**
+- Modify: `client/src/pages/PostProduction.jsx` — use PostCompositionPanel instead of PostDetailPanel
+- Modify: `client/src/pages/CampaignDetail.jsx` — same
+- Modify: `client/src/i18n/en.json` / `ar.json` — new i18n keys
+
+**Client — Keep (unchanged):**
+- `PostDetailVersions.jsx`, `PostDetailPlatforms.jsx`, `PostDetailApproval.jsx`, `PostDetailAttachments.jsx` — kept for backward compat, old PostDetailPanel still importable
+
+---
+
+## Chunk 1: Server — Schema + Composition Endpoint
+
+### Task 1: Add caption and stage to Posts schema
+
+**Files:**
+- Modify: `server/server.js` — TEXT_COLUMNS for Posts (~line 520)
+
+- [ ] **Step 1: Add new columns to TEXT_COLUMNS**
+
+Add to the Posts array in TEXT_COLUMNS:
+```javascript
+{ name: 'caption', uidt: 'LongText' },
+{ name: 'stage', uidt: 'SingleLineText' },
+```
+
+- [ ] **Step 2: Update POST /api/posts to accept caption and stage**
+
+In the POST handler, add `caption` and `stage` to the create payload:
+```javascript
+caption: caption || '',
+stage: stage || 'copy',
+```
+
+- [ ] **Step 3: Update PATCH /api/posts/:id to accept caption**
+
+Add `caption` to the allowed update fields.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/server.js
+git commit -m "feat: add caption and stage fields to Posts schema"
+```
+
+### Task 2: Create post-composition helper
+
+**Files:**
+- Create: `server/post-composition.js`
+
+- [ ] **Step 1: Create the helper module**
+
+```javascript
+// server/post-composition.js
+const nocodb = require('./nocodb');
+
+// Compute full composition for a post
+async function getPostComposition(postId) {
+ const post = await nocodb.get('Posts', postId);
+ if (!post) return null;
+
+ // Linked translations (copy)
+ const allTranslations = await nocodb.list('Translations', {
+ where: `(post_id,eq,${postId})`,
+ limit: 100,
+ });
+ const copy = allTranslations.map(t => ({
+ id: t.Id,
+ language: t.language,
+ status: t.status || 'draft',
+ is_original: t.is_original,
+ title: t.title,
+ }));
+
+ // Linked artefacts (designs + video)
+ const allArtefacts = await nocodb.list('Artefacts', {
+ where: `(post_id,eq,${postId})`,
+ limit: 100,
+ });
+ const designs = allArtefacts
+ .filter(a => (a.type || 'design') === 'design')
+ .map(a => ({
+ id: a.Id,
+ title: a.title,
+ status: a.status || 'draft',
+ thumbnail_url: a.thumbnail_url || null,
+ }));
+ const videoArtefact = allArtefacts.find(a => a.type === 'video');
+ const video = videoArtefact ? {
+ id: videoArtefact.Id,
+ title: videoArtefact.title,
+ status: videoArtefact.status || 'draft',
+ thumbnail_url: videoArtefact.thumbnail_url || null,
+ } : null;
+
+ // Platforms and formats
+ let platforms = [];
+ try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
+
+ // Readiness
+ const waitingOn = [];
+ const copyNotApproved = copy.filter(c => c.status !== 'approved');
+ if (copyNotApproved.length > 0) waitingOn.push(...copyNotApproved.map(c => `Copy (${c.language})`));
+ const designsNotApproved = designs.filter(d => d.status !== 'approved');
+ if (designsNotApproved.length > 0) waitingOn.push(...designsNotApproved.map(d => `Design: ${d.title}`));
+ if (video && video.status !== 'approved') waitingOn.push('Video');
+
+ const piecesReady = copy.length > 0 && waitingOn.length === 0;
+
+ return {
+ caption: post.caption || '',
+ copy,
+ designs,
+ video,
+ platforms,
+ pieces_ready: piecesReady,
+ waiting_on: waitingOn,
+ };
+}
+
+// Auto-compute stage from linked pieces
+function computeStage(composition) {
+ const { copy, designs, video, pieces_ready } = composition;
+ if (pieces_ready) return 'post';
+ if (designs.length > 0 || video) return 'design';
+ if (copy.length > 1 || copy.some(c => !c.is_original)) return 'translate';
+ return 'copy';
+}
+
+module.exports = { getPostComposition, computeStage };
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add server/post-composition.js
+git commit -m "feat: add post composition helper (readiness, stage auto-compute)"
+```
+
+### Task 3: Add composition API endpoint
+
+**Files:**
+- Modify: `server/server.js` — add GET /api/posts/:id/composition
+
+- [ ] **Step 1: Add the endpoint**
+
+After the existing GET /api/posts/:id route, add:
+```javascript
+app.get('/api/posts/:id/composition', requireAuth, async (req, res) => {
+ try {
+ const { getPostComposition } = require('./post-composition');
+ const composition = await getPostComposition(req.params.id);
+ if (!composition) return res.status(404).json({ error: 'Post not found' });
+ res.json(composition);
+ } catch (err) {
+ console.error('Composition error:', err);
+ res.status(500).json({ error: 'Failed to load composition' });
+ }
+});
+```
+
+- [ ] **Step 2: Auto-update stage on PATCH /api/posts/:id**
+
+In the existing PATCH handler, after saving, re-compute and update stage:
+```javascript
+const { getPostComposition, computeStage } = require('./post-composition');
+const composition = await getPostComposition(req.params.id);
+if (composition) {
+ const newStage = computeStage(composition);
+ await nocodb.update('Posts', Number(req.params.id), { stage: newStage });
+}
+```
+
+- [ ] **Step 3: Also auto-update post stage when Translation or Artefact status changes**
+
+In PATCH /api/translations/:id and PATCH /api/artefacts/:id — if the record has a `post_id`, re-compute the post's stage after saving.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add server/server.js
+git commit -m "feat: add /posts/:id/composition endpoint + stage auto-update"
+```
+
+---
+
+## Chunk 2: Client — Platform Formats + Composition Sub-Components
+
+### Task 4: Create platform formats constant
+
+**Files:**
+- Create: `client/src/utils/platformFormats.js`
+
+- [ ] **Step 1: Create the file**
+
+```javascript
+export const PLATFORM_FORMATS = {
+ instagram: [
+ { key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
+ { key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
+ { key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
+ ],
+ tiktok: [
+ { key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
+ ],
+ youtube: [
+ { key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
+ { key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
+ { key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
+ ],
+ facebook: [
+ { key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
+ { key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
+ ],
+ twitter: [
+ { key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
+ ],
+ linkedin: [
+ { key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
+ ],
+ snapchat: [
+ { key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
+ ],
+}
+
+export function getFormatsForPlatforms(platforms = []) {
+ const formats = []
+ const seen = new Set()
+ for (const p of platforms) {
+ for (const f of (PLATFORM_FORMATS[p] || [])) {
+ if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
+ }
+ }
+ return formats
+}
+```
+
+- [ ] **Step 2: Commit**
+
+```bash
+git add client/src/utils/platformFormats.js
+git commit -m "feat: add platform format mapping constant"
+```
+
+### Task 5: Create composition sub-components
+
+**Files:**
+- Create: `client/src/components/PostCompositionCaption.jsx`
+- Create: `client/src/components/PostCompositionCopy.jsx`
+- Create: `client/src/components/PostCompositionDesigns.jsx`
+- Create: `client/src/components/PostCompositionVideo.jsx`
+- Create: `client/src/components/PostCompositionFormats.jsx`
+- Create: `client/src/components/PostCompositionReadiness.jsx`
+
+Each is a small focused component (~40-80 lines) rendering one section of the composition workspace.
+
+- [ ] **Step 1: Caption section**
+
+PostCompositionCaption.jsx — textarea for the social media caption. Props: `caption`, `onChange`, `disabled`.
+
+- [ ] **Step 2: Copy section**
+
+PostCompositionCopy.jsx — shows linked translations as language pills with status icons. Props: `copy` (array from composition), `onLink` (opens translation picker), `onCreate` (creates new translation for this post). Each pill is clickable to open the TranslationDetailPanel.
+
+- [ ] **Step 3: Designs section**
+
+PostCompositionDesigns.jsx — shows linked design artefacts as thumbnail cards with status badges. Props: `designs` (array), `onLink`, `onCreate`, `onOpen` (opens ArtefactDetailPanel). Shows "+ Add Design" button.
+
+- [ ] **Step 4: Video section**
+
+PostCompositionVideo.jsx — shows linked video artefact (0 or 1) as a card. Props: `video` (object or null), `onLink`, `onCreate`, `onOpen`.
+
+- [ ] **Step 5: Formats checklist**
+
+PostCompositionFormats.jsx — reads `platforms` from the post, computes needed formats via `getFormatsForPlatforms()`, renders as a checkbox list. This is informational only — checkboxes are manual (designer checks off what they've produced). No database storage for checked state (tracked visually only).
+
+- [ ] **Step 6: Readiness summary**
+
+PostCompositionReadiness.jsx — shows bullet list of what's ready and what's blocking. Props: `piecesReady`, `waitingOn` (array of strings), `onSignOff` (callback for approve/schedule button). Sign-off button disabled until `piecesReady` is true.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add client/src/components/PostComposition*.jsx
+git commit -m "feat: add composition sub-components (caption, copy, designs, video, formats, readiness)"
+```
+
+---
+
+## Chunk 3: Client — Main Composition Panel + Page Integration
+
+### Task 6: Create PostCompositionPanel
+
+**Files:**
+- Create: `client/src/components/PostCompositionPanel.jsx`
+
+- [ ] **Step 1: Build the panel**
+
+This is a SlidePanel-based component (like existing detail panels) but with a composition layout instead of tabs:
+
+```
+Header (title input, status, brand, campaign, platforms, assigned_to, close/save/delete)
+─────────
+Scrollable body:
+ PostCompositionCaption
+ PostCompositionCopy
+ PostCompositionDesigns
+ PostCompositionVideo
+ PostCompositionFormats
+ PostCompositionReadiness
+ CommentsSection
+```
+
+Key behavior:
+- On mount: fetches composition via `GET /api/posts/:id/composition`
+- Caption changes are saved with the post (dirty tracking + save button)
+- Copy/Design/Video sections have "Link existing" and "Create new" actions
+- "Link existing" opens a small picker modal (list of unlinked translations/artefacts)
+- "Create new" calls the create API with `post_id` pre-set, then refreshes composition
+- Readiness section shows sign-off button (sets post status to `approved`)
+- Each section is a collapsible card (use CollapsibleSection component)
+
+- [ ] **Step 2: Add i18n keys**
+
+Add to en.json and ar.json:
+- `post.caption`, `post.captionPlaceholder`, `post.copy`, `post.copyInDesign`, `post.designs`, `post.video`, `post.formatChecklist`, `post.formatsNeeded`, `post.readiness`, `post.allPiecesReady`, `post.waitingOn`, `post.signOff`, `post.approveAndSchedule`, `post.linkExisting`, `post.createNew`, `post.addDesign`, `post.addVideo`, `post.linkTranslation`, `post.noCopyLinked`, `post.noDesignsLinked`, `post.noVideoLinked`
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add client/src/components/PostCompositionPanel.jsx client/src/i18n/en.json client/src/i18n/ar.json
+git commit -m "feat: add PostCompositionPanel — composition workspace"
+```
+
+### Task 7: Wire up PostCompositionPanel in pages
+
+**Files:**
+- Modify: `client/src/pages/PostProduction.jsx`
+- Modify: `client/src/pages/CampaignDetail.jsx`
+
+- [ ] **Step 1: Update PostProduction.jsx**
+
+Replace `PostDetailPanel` import and usage with `PostCompositionPanel`. The panel receives the same props (post, onClose, onSave, onDelete, brands, teamMembers, campaigns) plus the new composition data fetching happens inside the panel.
+
+- [ ] **Step 2: Update CampaignDetail.jsx**
+
+Same — replace PostDetailPanel with PostCompositionPanel for post detail views.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add client/src/pages/PostProduction.jsx client/src/pages/CampaignDetail.jsx
+git commit -m "feat: wire PostCompositionPanel into PostProduction and CampaignDetail"
+```
+
+### Task 8: Final verification
+
+- [ ] **Step 1: Build check**
+
+```bash
+cd client && npx vite build --logLevel error
+```
+
+- [ ] **Step 2: Manual test checklist**
+
+1. Open a post → composition panel shows caption, copy, designs, video, formats, readiness
+2. Edit caption → save → caption persists
+3. Link an existing translation → appears in copy section with status
+4. Link an existing artefact → appears in designs section with thumbnail
+5. Create new design artefact from panel → auto-linked to post
+6. Select platforms → format checklist updates
+7. Approve all pieces → readiness shows "All pieces ready"
+8. Sign off → post status changes to approved
+9. Stage auto-advances as pieces are linked
+
+- [ ] **Step 3: Commit any fixes**
diff --git a/docs/superpowers/specs/2026-03-15-post-composition-redesign.md b/docs/superpowers/specs/2026-03-15-post-composition-redesign.md
new file mode 100644
index 0000000..f1d19ce
--- /dev/null
+++ b/docs/superpowers/specs/2026-03-15-post-composition-redesign.md
@@ -0,0 +1,226 @@
+# Post Composition Redesign — Post as Orchestrator
+
+**Date:** 2026-03-15
+**Status:** Draft
+
+## Problem
+
+The current Post model is flat — a post has a title, description, status, attachments, and platforms. There's no structure showing that a social media post is actually a **composition** of distinct production pieces (caption, in-design copy, design assets, video). The Post detail panel is a disorganized form with tabs that don't map to how content is actually produced.
+
+Additionally, ContentItems (from the UX overhaul pipeline) duplicates Post metadata and adds confusion about where to create content.
+
+## Design
+
+### Post = Orchestrator
+
+A Post is a container that assembles independently-produced pieces into a publishable unit:
+
+```
+Post "Summer Sale Launch"
+ ├─ Caption (text field on Post, one base version, minor platform tweaks)
+ ├─ Copy (in-design text): linked Translation(s) — approved via Translation flow
+ ├─ Design(s): linked Artefact(s) — approved via Artefact flow
+ ├─ Video: linked Artefact (optional) — approved via Artefact flow
+ ├─ Platforms: [IG, TikTok, YouTube]
+ └─ Format checklist: auto-derived from platforms
+```
+
+### Composition Pieces
+
+| Piece | Storage | Approval | Notes |
+|-------|---------|----------|-------|
+| **Caption** | `Post.caption` field (text) | Part of final Post sign-off | The text posted WITH the content (IG caption, tweet text, etc.). One base version with minor platform tweaks (hashtags). Multilingual via existing Translation system if needed. |
+| **In-design copy** | Translation record (`post_id` FK, `is_original=true`) | Translation approval flow | Text that goes INSIDE the design (overlaid on image/video). Already exists. |
+| **Design(s)** | Artefact(s) linked via `post_id` FK, `type='design'` | Artefact approval flow | 1..N designs per post (carousel = multiple). Each artefact can have versions. |
+| **Video** | Artefact linked via `post_id` FK, `type='video'` | Artefact approval flow | 0..1 video per post. Has its own versions/approval. |
+| **Format specs** | Derived from `Post.platforms` | None (production checklist) | System maps platforms → required formats (IG→1:1, TikTok→9:16, etc.). Designer uses as a guide. |
+
+### Platform → Format Mapping
+
+```javascript
+const PLATFORM_FORMATS = {
+ instagram: [
+ { key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
+ { key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
+ { key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
+ ],
+ tiktok: [
+ { key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
+ ],
+ youtube: [
+ { key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
+ { key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
+ { key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
+ ],
+ facebook: [
+ { key: 'fb_post', label: 'Post (1:1 or 16:9)', ratio: '1:1' },
+ { key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
+ ],
+ twitter: [
+ { key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
+ ],
+ linkedin: [
+ { key: 'li_post', label: 'Post (1:1 or 1.91:1)', ratio: '1:1' },
+ ],
+ snapchat: [
+ { key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
+ ],
+}
+```
+
+This is a **checklist** for the designer, not enforced entities. The Post detail panel shows "Formats needed" based on selected platforms. No separate database records per format.
+
+### Post Status & Readiness
+
+**Post status field** (unchanged): `idea` | `in_progress` | `in_review` | `approved` | `rejected` | `scheduled` | `published`
+
+**Readiness is auto-computed** from pieces:
+- `pieces_ready`: true when ALL linked Translations are approved AND ALL linked Artefacts are approved
+- Displayed as: "Ready for sign-off" or "Waiting on: Copy (AR), Video"
+
+**Final publish flow:**
+1. All pieces get approved through their own flows
+2. Post auto-shows "All pieces ready — awaiting sign-off"
+3. Someone manually moves Post to `approved` or `scheduled`
+4. Published when scheduled date arrives (or manually)
+
+### ContentItems Merge
+
+ContentItems table is removed. Its fields map to Post:
+- `ContentItems.stage` → `Post.stage` (copy / translate / design / post / published)
+- `ContentItems.title` → already `Post.title`
+- `ContentItems.campaign_id` → already `Post.campaign_id`
+- `ContentItems.brand_id` → already `Post.brand_id`
+- `ContentItems.assignee_id` → already `Post.assigned_to`
+
+Stage auto-advances based on what exists:
+- Post created → stage = `copy`
+- Translation linked → stage = `translate` (if multiple languages)
+- Artefact (design) linked → stage = `design`
+- All pieces approved → stage = `post`
+- Published → stage = `published`
+
+### Post Detail Panel — Composition View
+
+Replace the current tabbed panel with a **composition workspace**:
+
+```
+┌─────────────────────────────────────────┐
+│ Header: Title, Status, Brand, Campaign │
+│ Platforms: [IG] [TikTok] [YouTube] │
+├─────────────────────────────────────────┤
+│ │
+│ CAPTION │
+│ ┌─────────────────────────────────────┐ │
+│ │ Textarea: "🔥 Summer deals..." │ │
+│ │ Platform hashtags: #summer #sale │ │
+│ └─────────────────────────────────────┘ │
+│ │
+│ COPY (in-design text) │
+│ ┌────────┐ ┌────────┐ ┌────────┐ │
+│ │ EN ✓ │ │ AR ✓ │ │ FR ⏳ │ │
+│ └────────┘ └────────┘ └────────┘ │
+│ [Link Translation] or [Create New] │
+│ │
+│ DESIGNS │
+│ ┌──────────────┐ ┌──────────────┐ │
+│ │ Slide 1 │ │ Slide 2 │ │
+│ │ [thumbnail] │ │ [thumbnail] │ │
+│ │ ✓ Approved │ │ ✓ Approved │ │
+│ └──────────────┘ └──────────────┘ │
+│ [Link Artefact] or [Create New] │
+│ │
+│ VIDEO (optional) │
+│ ┌──────────────────────────────────┐ │
+│ │ [video thumbnail] Reel v2 │ │
+│ │ ⏳ In Review │ │
+│ └──────────────────────────────────┘ │
+│ [Link Artefact] or [Create New] │
+│ │
+│ FORMAT CHECKLIST │
+│ ☑ IG Feed 1:1 ☑ IG Story 9:16 │
+│ ☑ TikTok 9:16 ☐ YT 16:9 │
+│ │
+│ READINESS │
+│ ● Copy: 2/3 languages approved │
+│ ● Design: 2/2 approved │
+│ ● Video: In review │
+│ [Approve & Schedule] (disabled until │
+│ all pieces ready) │
+│ │
+│ DISCUSSION │
+│ [comments section] │
+└─────────────────────────────────────────┘
+```
+
+This is a **single scrollable view**, not tabs. Each section is a collapsible card. The readiness summary at the bottom gives a clear picture of what's blocking publication.
+
+### Schema Changes
+
+**Post table — add:**
+- `caption` (LongText) — the social media caption
+- `stage` (SingleLineText) — pipeline stage: copy/translate/design/post/published
+
+**Post table — remove:**
+- `description` (deprecated — copy lives in Translations)
+
+**Artefact table — ensure:**
+- `post_id` FK already exists
+- `type` field already exists (design/video/copy)
+
+**Translation table — ensure:**
+- `post_id` FK already exists
+
+**ContentItems table:**
+- Delete after migration
+
+### Migration
+
+1. For each ContentItem: if no Post exists with matching title + campaign_id, create a Post from it
+2. Move `stage` values to the new Post.stage field
+3. Relink any Translations/Artefacts that referenced ContentItem IDs
+4. Drop ContentItems table (or leave empty, mark deprecated)
+
+### API Changes
+
+**POST /api/posts** — add `caption` field
+**PATCH /api/posts/:id** — add `caption` field, auto-update `stage` based on linked pieces
+**GET /api/posts/:id** — include linked Translations (with approval status), linked Artefacts (with type + approval status), computed `pieces_ready` boolean, computed `waiting_on` array
+
+**New helper endpoint:**
+**GET /api/posts/:id/composition** — returns the full composition view:
+```json
+{
+ "caption": "🔥 Summer deals...",
+ "copy": [
+ { "id": 1, "language": "EN", "status": "approved" },
+ { "id": 2, "language": "AR", "status": "approved" },
+ { "id": 3, "language": "FR", "status": "in_review" }
+ ],
+ "designs": [
+ { "id": 10, "title": "Slide 1", "status": "approved", "thumbnail_url": "..." },
+ { "id": 11, "title": "Slide 2", "status": "approved", "thumbnail_url": "..." }
+ ],
+ "video": { "id": 20, "title": "Reel v2", "status": "in_review", "thumbnail_url": "..." },
+ "platforms": ["instagram", "tiktok", "youtube"],
+ "formats_needed": ["ig_feed", "ig_story", "ig_reel", "tt_video", "yt_video", "yt_short", "yt_thumb"],
+ "pieces_ready": false,
+ "waiting_on": ["Copy (FR)", "Video"]
+}
+```
+
+### What Stays the Same
+
+- Artefact approval flow (unchanged)
+- Translation approval flow (unchanged)
+- Post review via public link (unchanged — now reviews the full composition)
+- Campaign/brand/platform selection on Posts (unchanged)
+- KanbanBoard for pipeline view (unchanged — works with `stage` or `status`)
+
+## Out of Scope
+
+- Auto-publishing to social media platforms
+- Caption AI generation
+- Design template system
+- Format-specific cropping tool
+- Per-platform caption variations (just one caption with manual tweaks)
diff --git a/server/post-composition.js b/server/post-composition.js
new file mode 100644
index 0000000..2466b7a
--- /dev/null
+++ b/server/post-composition.js
@@ -0,0 +1,75 @@
+const nocodb = require('./nocodb');
+
+async function getPostComposition(postId) {
+ const post = await nocodb.get('Posts', postId);
+ if (!post) return null;
+
+ const translations = await nocodb.list('Translations', {
+ where: `(post_id,eq,${postId})`, limit: 100,
+ });
+ const caption = translations.find(t => t.copy_type === 'caption') || null;
+ const bodyCopy = translations.find(t => t.copy_type === 'body' || !t.copy_type) || null;
+
+ const artefacts = await nocodb.list('Artefacts', {
+ where: `(post_id,eq,${postId})`, limit: 100,
+ });
+ const design = artefacts.find(a => (a.type || 'design') === 'design') || null;
+ const video = artefacts.find(a => a.type === 'video') || null;
+
+ let platforms = [];
+ try { platforms = JSON.parse(post.platforms || '[]'); } catch { platforms = post.platform ? [post.platform] : []; }
+
+ const waitingOn = [];
+ if (caption && caption.status !== 'approved') waitingOn.push('Caption');
+ if (bodyCopy && bodyCopy.status !== 'approved') waitingOn.push('Copy');
+ if (design && design.status !== 'approved') waitingOn.push('Design');
+ if (video && video.status !== 'approved') waitingOn.push('Video');
+
+ const hasPieces = caption || bodyCopy || design || video;
+ const piecesReady = hasPieces && waitingOn.length === 0;
+
+ // Get translation texts for languages preview
+ const getTexts = async (translationId) => {
+ try {
+ const texts = await nocodb.list('TranslationTexts', { where: `(translation_id,eq,${translationId})`, limit: 20 });
+ return texts.map(tt => ({ language: tt.language_code || tt.language, status: tt.status || 'draft' }));
+ } catch { return []; }
+ };
+ const captionTexts = caption ? await getTexts(caption.Id) : [];
+ const bodyTexts = bodyCopy ? await getTexts(bodyCopy.Id) : [];
+
+ // Get first attachment for design/video thumbnail
+ const getFirstAttachment = async (artefactId) => {
+ try {
+ const versions = await nocodb.list('ArtefactVersions', { where: `(artefact_id,eq,${artefactId})`, sort: '-version_number', limit: 1 });
+ if (versions.length === 0) return null;
+ const attachments = await nocodb.list('ArtefactAttachments', { where: `(version_id,eq,${versions[0].Id})`, limit: 1 });
+ return attachments.length > 0 ? (attachments[0].url || attachments[0].file_url || null) : null;
+ } catch { return null; }
+ };
+ const designThumb = design ? (design.thumbnail_url || await getFirstAttachment(design.Id)) : null;
+ const videoThumb = video ? (video.thumbnail_url || await getFirstAttachment(video.Id)) : null;
+
+ return {
+ caption: caption ? { id: caption.Id, title: caption.title, status: caption.status, language: caption.source_language, content_preview: (caption.source_content || '').slice(0, 120), languages: captionTexts } : null,
+ body_copy: bodyCopy ? { id: bodyCopy.Id, title: bodyCopy.title, status: bodyCopy.status, language: bodyCopy.source_language, content_preview: (bodyCopy.source_content || '').slice(0, 120), languages: bodyTexts } : null,
+ design: design ? { id: design.Id, title: design.title, status: design.status, thumbnail_url: designThumb, current_version: design.current_version } : null,
+ video: video ? { id: video.Id, title: video.title, status: video.status, thumbnail_url: videoThumb, current_version: video.current_version } : null,
+ platforms,
+ pieces_ready: piecesReady,
+ waiting_on: waitingOn,
+ stage: post.stage || 'copy',
+ };
+}
+
+function computeStage(composition) {
+ const { caption, body_copy, design, video, pieces_ready } = composition;
+ if (pieces_ready) return 'post';
+ if (design || video) return 'design';
+ // Check if we have any copy at all
+ const hasCopy = caption || body_copy;
+ if (!hasCopy) return 'copy';
+ return 'copy';
+}
+
+module.exports = { getPostComposition, computeStage };
diff --git a/server/server.js b/server/server.js
index 0920063..0ad6154 100644
--- a/server/server.js
+++ b/server/server.js
@@ -155,7 +155,7 @@ const FK_COLUMNS = {
TaskAttachments: ['task_id'],
Comments: ['user_id'],
BudgetEntries: ['campaign_id', 'project_id'],
- Artefacts: ['project_id', 'campaign_id'],
+ Artefacts: ['project_id', 'campaign_id', 'post_id'],
PostVersions: ['post_id', 'created_by_user_id'],
PostVersionTexts: ['version_id'],
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
@@ -516,7 +516,11 @@ const TEXT_COLUMNS = {
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
- Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
+ Translations: [{ name: 'copy_type', uidt: 'SingleLineText' }],
+ Artefacts: [
+ { name: 'approver_ids', uidt: 'SingleLineText' },
+ { name: 'thumbnail_url', uidt: 'SingleLineText' },
+ ],
Posts: [
{ name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'approval_token', uidt: 'SingleLineText' },
@@ -526,6 +530,8 @@ const TEXT_COLUMNS = {
{ name: 'feedback', uidt: 'LongText' },
{ name: 'current_version', uidt: 'Number' },
{ name: 'review_version', uidt: 'Number' },
+ { name: 'caption', uidt: 'LongText' },
+ { name: 'stage', uidt: 'SingleLineText' },
],
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
BudgetRequests: [
@@ -1286,14 +1292,32 @@ app.get('/api/posts', requireAuth, async (req, res) => {
}
});
+// Get single post
+app.get('/api/posts/:id', requireAuth, async (req, res) => {
+ try {
+ const post = await nocodb.get('Posts', req.params.id);
+ if (!post) return res.status(404).json({ error: 'Post not found' });
+ const enriched = { ...post };
+ enriched.brand_name = await getRecordName('Brands', post.brand_id);
+ enriched.assigned_name = await getRecordName('Users', post.assigned_to_id || post.assigned_to);
+ enriched.campaign_name = await getRecordName('Campaigns', post.campaign_id);
+ enriched.creator_name = await getRecordName('Users', post.created_by_user_id);
+ // Parse platforms
+ try { enriched.platforms = JSON.parse(post.platforms || '[]'); } catch { enriched.platforms = post.platform ? [post.platform] : []; }
+ res.json(enriched);
+ } catch (err) {
+ console.error('GET /posts/:id error:', err);
+ res.status(500).json({ error: 'Failed to load post' });
+ }
+});
+
app.post('/api/posts', requireAuth, async (req, res) => {
- const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids } = req.body;
- if (!title) return res.status(400).json({ error: 'Title is required' });
+ const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids, caption } = req.body;
const platformsArr = platforms || (platform ? [platform] : []);
try {
const created = await nocodb.create('Posts', {
- title, description: description || null,
+ title: title || 'Untitled', description: description || null,
status: status || 'draft',
platform: platformsArr[0] || null,
platforms: JSON.stringify(platformsArr),
@@ -1305,6 +1329,8 @@ app.post('/api/posts', requireAuth, async (req, res) => {
assigned_to_id: assigned_to ? Number(assigned_to) : null,
campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null,
+ caption: caption || '',
+ stage: 'copy',
created_by_user_id: req.session.userId,
});
@@ -1354,7 +1380,7 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
if (!existing) return res.status(404).json({ error: 'Post not found' });
const data = {};
- for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes']) {
+ for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'caption']) {
if (req.body[f] !== undefined) data[f] = req.body[f];
}
if (req.body.platforms !== undefined) {
@@ -1401,6 +1427,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
await nocodb.update('Posts', id, data);
+ // Auto-update stage
+ try {
+ const { getPostComposition, computeStage } = require('./post-composition');
+ const composition = await getPostComposition(req.params.id);
+ if (composition) {
+ const newStage = computeStage(composition);
+ await nocodb.update('Posts', Number(req.params.id), { stage: newStage });
+ }
+ } catch (stageErr) {
+ console.error('Stage auto-update error:', stageErr);
+ }
+
const post = await nocodb.get('Posts', id);
const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approverNames = {};
@@ -1422,6 +1460,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
}
});
+app.get('/api/posts/:id/composition', requireAuth, async (req, res) => {
+ try {
+ const { getPostComposition } = require('./post-composition');
+ const composition = await getPostComposition(req.params.id);
+ if (!composition) return res.status(404).json({ error: 'Post not found' });
+ res.json(composition);
+ } catch (err) {
+ console.error('Composition error:', err);
+ res.status(500).json({ error: 'Failed to load composition' });
+ }
+});
+
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
try {
await nocodb.delete('Posts', req.params.id);
@@ -1578,7 +1628,8 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts'
}
await nocodb.update('Posts', req.params.id, updateData);
- const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
+ const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
+ const reviewUrl = `${appUrl}/review-post/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl });
} catch (err) {
@@ -3913,6 +3964,28 @@ app.get('/api/artefacts', requireAuth, async (req, res) => {
}
});
+// Get single artefact
+app.get('/api/artefacts/:id', requireAuth, async (req, res) => {
+ try {
+ const artefact = await nocodb.get('Artefacts', req.params.id);
+ if (!artefact) return res.status(404).json({ error: 'Artefact not found' });
+ const enriched = { ...artefact };
+ enriched.brand_name = await getRecordName('Brands', artefact.brand_id);
+ enriched.creator_name = await getRecordName('Users', artefact.created_by_user_id);
+ enriched.project_name = await getRecordName('Projects', artefact.project_id);
+ enriched.campaign_name = await getRecordName('Campaigns', artefact.campaign_id);
+ const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
+ enriched.approvers = [];
+ for (const aid of approverIdList) {
+ enriched.approvers.push({ id: Number(aid), name: await getRecordName('Users', Number(aid)) });
+ }
+ res.json(enriched);
+ } catch (err) {
+ console.error('GET /artefacts/:id error:', err);
+ res.status(500).json({ error: 'Failed to load artefact' });
+ }
+});
+
app.post('/api/artefacts', requireAuth, async (req, res) => {
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
@@ -4021,6 +4094,21 @@ app.patch('/api/artefacts/:id', requireAuth, async (req, res) => {
console.log(`[PATCH /artefacts/${req.params.id}] Updating:`, JSON.stringify(data));
await nocodb.update('Artefacts', req.params.id, data);
+ // Auto-update linked post stage (both old and new post if post_id changed)
+ const oldPostId = existing.post_id ? Number(existing.post_id) : null;
+ const updatedArtefact = await nocodb.get('Artefacts', Number(req.params.id));
+ const newPostId = updatedArtefact?.post_id ? Number(updatedArtefact.post_id) : null;
+ const postIdsToUpdate = [...new Set([oldPostId, newPostId].filter(Boolean))];
+ for (const pid of postIdsToUpdate) {
+ try {
+ const { getPostComposition, computeStage } = require('./post-composition');
+ const composition = await getPostComposition(pid);
+ if (composition) {
+ await nocodb.update('Posts', pid, { stage: computeStage(composition) });
+ }
+ } catch (e) { console.error('Post stage update error:', e); }
+ }
+
const artefact = await nocodb.get('Artefacts', req.params.id);
console.log(`[PATCH /artefacts/${req.params.id}] After re-read: approver_ids=${artefact.approver_ids}`);
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
@@ -4085,6 +4173,11 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => {
return res.status(403).json({ error: 'You can only submit your own artefacts' });
}
+ const approverIds = parseApproverIds(existing.approver_ids);
+ if (approverIds.length === 0) {
+ return res.status(400).json({ error: 'Select a reviewer before submitting for review' });
+ }
+
const token = require('crypto').randomUUID();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
@@ -4096,7 +4189,8 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => {
review_version: existing.current_version || 1,
});
- const reviewUrl = `${req.protocol}://${req.get('host')}/review/${token}`;
+ const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
+ const reviewUrl = `${appUrl}/review/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl });
} catch (err) {
@@ -4689,6 +4783,65 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
}
});
+// Public: Get team members for redirect (must be BEFORE /:token routes)
+app.get('/api/public/review-redirect/:token/team', async (req, res) => {
+ try {
+ const artefacts = await nocodb.list('Artefacts', {
+ where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
+ limit: 1,
+ });
+ if (artefacts.length === 0) return res.status(404).json({ error: 'Not found' });
+
+ const users = await nocodb.list('Users', { limit: 200 });
+ const artefact = artefacts[0];
+ const currentApproverId = artefact.approver_ids ? Number(artefact.approver_ids) : null;
+ const creatorId = artefact.created_by_user_id ? Number(artefact.created_by_user_id) : null;
+ res.json(users
+ .filter(u => u.Id !== currentApproverId && u.Id !== creatorId)
+ .map(u => ({ id: u.Id, name: u.name }))
+ );
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to load team' });
+ }
+});
+
+app.post('/api/public/review-redirect/:token', async (req, res) => {
+ const { new_approver_id, reason } = req.body;
+ if (!new_approver_id) return res.status(400).json({ error: 'New approver ID is required' });
+
+ try {
+ const artefacts = await nocodb.list('Artefacts', {
+ where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
+ limit: 1,
+ });
+ if (artefacts.length === 0) return res.status(404).json({ error: 'Review link not found' });
+ const artefact = artefacts[0];
+
+ if (artefact.token_expires_at && new Date(artefact.token_expires_at) < new Date()) {
+ return res.status(410).json({ error: 'Review link has expired' });
+ }
+ if (artefact.status !== 'pending_review') {
+ return res.status(400).json({ error: 'This artefact is no longer pending review' });
+ }
+
+ // Update approver
+ await nocodb.update('Artefacts', artefact.Id, {
+ approver_ids: String(new_approver_id),
+ });
+
+ // Notify the new approver
+ const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
+ const reviewUrl = `${appUrl}/review/${artefact.approval_token}`;
+ notify.notifyReviewSubmitted({ type: 'artefact', record: { ...artefact, approver_ids: String(new_approver_id) }, reviewUrl });
+
+ const newApproverName = await getRecordName('Users', Number(new_approver_id));
+ res.json({ success: true, message: `Review redirected to ${newApproverName}` });
+ } catch (err) {
+ console.error('Redirect review error:', err);
+ res.status(500).json({ error: 'Failed to redirect review' });
+ }
+});
+
app.post('/api/public/review/:token/comment', async (req, res) => {
const { comment, author_name } = req.body;
if (!comment) return res.status(400).json({ error: 'Comment is required' });
@@ -4785,9 +4938,30 @@ app.get('/api/translations', requireAuth, async (req, res) => {
}
});
+// Get single translation
+app.get('/api/translations/:id', requireAuth, async (req, res) => {
+ try {
+ const translation = await nocodb.get('Translations', req.params.id);
+ if (!translation) return res.status(404).json({ error: 'Translation not found' });
+ const enriched = { ...translation };
+ enriched.brand_name = await getRecordName('Brands', translation.brand_id);
+ enriched.creator_name = await getRecordName('Users', translation.created_by_user_id);
+ // Parse approvers
+ const approverIdList = translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
+ enriched.approvers = [];
+ for (const aid of approverIdList) {
+ enriched.approvers.push({ id: Number(aid), name: await getRecordName('Users', Number(aid)) });
+ }
+ res.json(enriched);
+ } catch (err) {
+ console.error('GET /translations/:id error:', err);
+ res.status(500).json({ error: 'Failed to load translation' });
+ }
+});
+
// Create translation
app.post('/api/translations', requireAuth, async (req, res) => {
- const { title, source_language, source_content, brand_id, post_id, approver_ids } = req.body;
+ const { title, source_language, source_content, brand_id, post_id, approver_ids, copy_type } = req.body;
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_content) return res.status(400).json({ error: 'Source content is required' });
@@ -4801,6 +4975,7 @@ app.post('/api/translations', requireAuth, async (req, res) => {
brand_id: brand_id ? Number(brand_id) : null,
post_id: post_id ? Number(post_id) : null,
approver_ids: approver_ids || null,
+ copy_type: copy_type || null,
created_by_user_id: req.session.userId,
});
@@ -4852,7 +5027,7 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
}
const data = {};
- for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback']) {
+ for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback', 'copy_type']) {
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;
@@ -4863,6 +5038,21 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
await nocodb.update('Translations', req.params.id, data);
+ // Auto-update linked post stage (both old and new post if post_id changed)
+ const oldTransPostId = existing.post_id ? Number(existing.post_id) : null;
+ const updated = await nocodb.get('Translations', Number(req.params.id));
+ const newTransPostId = updated?.post_id ? Number(updated.post_id) : null;
+ const transPostIds = [...new Set([oldTransPostId, newTransPostId].filter(Boolean))];
+ for (const pid of transPostIds) {
+ try {
+ const { getPostComposition, computeStage } = require('./post-composition');
+ const composition = await getPostComposition(pid);
+ if (composition) {
+ await nocodb.update('Posts', pid, { stage: computeStage(composition) });
+ }
+ } catch (e) { console.error('Post stage update error:', e); }
+ }
+
const record = await nocodb.get('Translations', req.params.id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = [];
@@ -5074,7 +5264,8 @@ app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) =>
token_expires_at: expiresAt.toISOString(),
});
- const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`;
+ const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
+ const reviewUrl = `${appUrl}/review-translation/${token}`;
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
} catch (err) {