feat: post composition redesign + budget allocation + brand identity (Rawaj)

Post Workflow:
- PostDetail full page (/posts/:id) replaces slide panel approach
- Post = 1 Caption Copy + 1 Body Copy + 1 Design + 0-1 Video
- copy_type field on Translations (caption/body)
- Composition endpoint returns rich data (content preview, languages, thumbnails)
- Stage auto-advances on translation/artefact changes (both link and unlink)
- "Translations" renamed to "Copy" in navigation
- GET /api/posts/:id, /api/translations/:id, /api/artefacts/:id routes added
- PostProduction: "New Post" creates → navigates to full page
- CampaignDetail: click post → navigates to full page
- Inline link picker (no modals) with search + rich item display
- PostComposition sub-components for caption, copy, designs, video, formats, readiness

Budget Allocation:
- Single source of truth: BudgetEntries (Campaign.budget deprecated)
- Budget mutex for race conditions
- Validation at all levels (main → campaign → track, expenses)
- CEO approval workflow: BudgetRequests table, public approval page
- Finance page: request budget UI, budget requests section
- Settings: CEO email field
- All emails branded with "Rawaj —" prefix

Brand Identity:
- Name: Rawaj (رواج) — trending/virality
- Deep teal palette (#0d9488), forest-tinted dark mode
- DM Sans font, custom SVG logo
- Consistent across login, sidebar, emails, public pages

Approval Workflow:
- Single reviewer per artefact (not multi-select)
- Reviewer redirect on public review page
- Server blocks submit-review without reviewer
- Review URLs use APP_URL (not server URL)

UI/UX:
- Scroll clipping fix: Modal, TabbedModal, SlidePanel restructured
  to avoid overflow-y-auto clipping native select dropdowns
- section-card overflow-hidden → overflow-clip
- All page titles via Header.jsx (removed duplicate h1s)
- CampaignDetail redesigned: prominent budget card, compact team

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 18:02:29 +03:00
parent e1d1c392eb
commit ce4d6025d7
50 changed files with 2616 additions and 229 deletions
@@ -0,0 +1,4 @@
[ 433ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 434ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 516ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 520ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
@@ -0,0 +1,2 @@
[ 101ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
[ 107ms] [ERROR] Failed to load resource: the server responded with a status of 401 (Unauthorized) @ http://localhost:5173/api/auth/me:0
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

+2
View File
@@ -15,6 +15,7 @@ import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShor
// Lazy-loaded page components // Lazy-loaded page components
const Dashboard = lazy(() => import('./pages/Dashboard')) const Dashboard = lazy(() => import('./pages/Dashboard'))
const PostProduction = lazy(() => import('./pages/PostProduction')) const PostProduction = lazy(() => import('./pages/PostProduction'))
const PostDetail = lazy(() => import('./pages/PostDetail'))
const Assets = lazy(() => import('./pages/Assets')) const Assets = lazy(() => import('./pages/Assets'))
const Campaigns = lazy(() => import('./pages/Campaigns')) const Campaigns = lazy(() => import('./pages/Campaigns'))
const CampaignDetail = lazy(() => import('./pages/CampaignDetail')) const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
@@ -303,6 +304,7 @@ function AppContent() {
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}> <Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
{hasModule('marketing') && <> {hasModule('marketing') && <>
<Route path="posts/:id" element={<PostDetail />} />
<Route path="posts" element={<PostProduction />} /> <Route path="posts" element={<PostProduction />} />
<Route path="calendar" element={<PostCalendar />} /> <Route path="calendar" element={<PostCalendar />} />
<Route path="artefacts" element={<Artefacts />} /> <Route path="artefacts" element={<Artefacts />} />
+23 -13
View File
@@ -412,18 +412,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
</div> </div>
</div> </div>
{/* Approvers */}
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.approversLabel')}</h4>
<ApproverMultiSelect
users={assignableUsers}
selected={editApproverIds}
onChange={ids => {
setEditApproverIds(ids)
handleUpdateField('approver_ids', ids.length > 0 ? ids.join(',') : '')
}}
/>
</div>
</div> </div>
)} )}
@@ -508,11 +496,33 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{/* Review Tab */} {/* Review Tab */}
{activeTab === 'review' && ( {activeTab === 'review' && (
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{/* Reviewer Selection (single) */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.reviewer')}</h4>
<select
value={editApproverIds[0] || ''}
onChange={e => {
const val = e.target.value
const ids = val ? [val] : []
setEditApproverIds(ids)
handleUpdateField('approver_ids', val || '')
}}
className="w-full px-3 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"
>
<option value="">{t('artefacts.selectReviewer')}</option>
{assignableUsers.map(u => (
<option key={u.id || u.Id} value={u.id || u.Id}>{u.name}</option>
))}
</select>
</div>
)}
{/* Submit for Review */} {/* Submit for Review */}
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && ( {['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
<button <button
onClick={handleSubmitReview} onClick={handleSubmitReview}
disabled={submitting} disabled={submitting || editApproverIds.length === 0}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50" className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-amber-500 text-white rounded-lg hover:bg-amber-600 transition-colors font-medium disabled:opacity-50"
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
+1 -1
View File
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
return ( return (
<div <div
onClick={() => onClick?.(asset)} onClick={() => onClick?.(asset)}
className="bg-surface rounded-xl border border-border overflow-hidden card-hover cursor-pointer group" className="bg-surface rounded-xl border border-border overflow-clip card-hover cursor-pointer group"
> >
{/* Thumbnail */} {/* Thumbnail */}
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative"> <div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
+1 -1
View File
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
} }
return ( return (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<h3 className="text-lg font-semibold text-text-primary"> <h3 className="text-lg font-semibold text-text-primary">
+2 -1
View File
@@ -22,7 +22,7 @@ const PAGE_TITLE_KEYS = {
'/issues': 'header.issues', '/issues': 'header.issues',
'/team': 'header.team', '/team': 'header.team',
'/settings': 'header.settings', '/settings': 'header.settings',
'/translations': 'header.translations', '/translations': 'header.copy',
} }
const ROLE_INFO = { const ROLE_INFO = {
@@ -45,6 +45,7 @@ export default function Header() {
function getPageTitle(pathname) { function getPageTitle(pathname) {
if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname]) if (PAGE_TITLE_KEYS[pathname]) return t(PAGE_TITLE_KEYS[pathname])
if (pathname.startsWith('/posts/')) return t('header.postDetails')
if (pathname.startsWith('/projects/')) return t('header.projectDetails') if (pathname.startsWith('/projects/')) return t('header.projectDetails')
if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails') if (pathname.startsWith('/campaigns/')) return t('header.campaignDetails')
return t('header.page') return t('header.page')
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
} }
return ( return (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Toolbar */} {/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+3 -3
View File
@@ -125,8 +125,8 @@ export default function Modal({
aria-label="Close dialog" aria-label="Close dialog"
/> />
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title"> <div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0"> <div className="sticky top-0 z-10 bg-surface rounded-t-2xl flex items-center justify-between px-6 py-4 border-b border-border">
<h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3> <h3 id="modal-title" className="text-lg font-semibold text-text-primary">{title}</h3>
<button <button
onClick={onClose} onClick={onClose}
@@ -137,7 +137,7 @@ export default function Modal({
</button> </button>
</div> </div>
<div className="px-6 py-4 overflow-y-auto flex-1"> <div className="px-6 py-4">
{children} {children}
</div> </div>
</div> </div>
@@ -0,0 +1,29 @@
import { useLanguage } from '../i18n/LanguageContext'
const CAPTION_LIMITS = { instagram: 2200, tiktok: 4000, twitter: 280, linkedin: 3000, facebook: 63206, youtube: 5000, snapchat: 250 }
export default function PostCompositionCaption({ caption, onChange, disabled, platforms = [] }) {
const { t } = useLanguage()
const len = (caption || '').length
const minLimit = platforms.length > 0
? Math.min(...platforms.map(p => CAPTION_LIMITS[p] || 5000))
: null
return (
<div>
<textarea
value={caption || ''}
onChange={e => onChange(e.target.value)}
disabled={disabled}
placeholder={t('post.captionPlaceholder')}
rows={5}
className="w-full rounded-lg border border-border bg-surface px-3 py-2 text-sm text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-primary/30 focus:border-brand-primary disabled:opacity-50 disabled:cursor-not-allowed resize-y"
/>
{len > 0 && (
<div className={`flex justify-end mt-1 text-[10px] ${minLimit && len > minLimit ? 'text-red-500' : 'text-text-tertiary'}`}>
{len}{minLimit ? ` / ${minLimit}` : ''}
</div>
)}
</div>
)
}
@@ -0,0 +1,89 @@
import { useState } from 'react'
import { Check, Clock, Pencil, Link, Plus } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { AVAILABLE_LANGUAGES } from '../utils/translations'
const STATUS_ICON = {
approved: { Icon: Check, color: 'text-emerald-500' },
in_review: { Icon: Clock, color: 'text-amber-500' },
pending_review: { Icon: Clock, color: 'text-amber-500' },
draft: { Icon: Pencil, color: 'text-text-tertiary' },
}
export default function PostCompositionCopy({ copy = [], onLink, onCreate, onOpen }) {
const { t } = useLanguage()
const [showCreate, setShowCreate] = useState(false)
const [newLang, setNewLang] = useState('')
const existingLangs = copy.map(c => c.language?.toUpperCase())
const handleCreate = () => {
if (!newLang) return
onCreate?.(newLang)
setShowCreate(false)
setNewLang('')
}
return (
<div className="space-y-2">
{copy.length > 0 && (
<div className="flex flex-wrap gap-2">
{copy.map(item => {
const { Icon, color } = STATUS_ICON[item.status] || STATUS_ICON.draft
return (
<button
key={item.id}
onClick={() => onOpen?.(item.id)}
className="inline-flex items-center gap-1.5 rounded-full bg-surface-secondary border border-border-light px-3 py-1 text-xs font-medium text-text-primary hover:border-brand-primary transition-colors"
>
<span className="uppercase">{item.language}</span>
<Icon className={`w-3 h-3 ${color}`} />
{item.is_original && (
<span className="w-1.5 h-1.5 rounded-full bg-brand-primary" title="Original" />
)}
</button>
)
})}
</div>
)}
{/* Action row */}
<div className="flex items-center gap-3">
<button onClick={onLink}
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors">
<Link className="w-3 h-3" /> {t('post.linkTranslation')}
</button>
{!showCreate ? (
<button onClick={() => setShowCreate(true)}
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors">
<Plus className="w-3 h-3" /> {t('post.createNew')}
</button>
) : (
<div className="inline-flex items-center gap-1.5">
<select value={newLang} onChange={e => setNewLang(e.target.value)}
className="text-xs border border-border rounded px-2 py-1 bg-surface text-text-secondary focus:outline-none" autoFocus>
<option value="">{t('post.selectLanguage') || 'Language...'}</option>
{AVAILABLE_LANGUAGES.filter(l => !existingLangs.includes(l.code)).map(l => (
<option key={l.code} value={l.code}>{l.label}</option>
))}
</select>
<button onClick={handleCreate} disabled={!newLang}
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium disabled:opacity-40 transition-colors">
{t('common.create')}
</button>
<button onClick={() => { setShowCreate(false); setNewLang('') }}
className="text-xs text-text-tertiary hover:text-text-secondary transition-colors">
{t('common.cancel')}
</button>
</div>
)}
</div>
{/* Empty state */}
{copy.length === 0 && !showCreate && (
<p className="text-xs text-text-tertiary text-center py-2">{t('post.noCopyLinked')}</p>
)}
</div>
)
}
@@ -0,0 +1,75 @@
import { Image, Link, Plus } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from './StatusBadge'
export default function PostCompositionDesigns({ designs = [], onLink, onCreate, onOpen }) {
const { t } = useLanguage()
const total = designs.length
return (
<div className="space-y-2">
{total > 0 ? (
<div className="space-y-2">
<div className="flex flex-wrap gap-2">
{designs.map((design, idx) => (
<button
key={design.id}
onClick={() => onOpen?.(design.id)}
className="flex items-start gap-2 bg-surface-secondary rounded-lg border border-border-light p-2 hover:border-brand-primary transition-colors text-start"
>
<div className="w-16 aspect-square rounded bg-surface flex-shrink-0 overflow-hidden relative">
{design.thumbnail_url ? (
<img
src={design.thumbnail_url}
alt={design.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Image className="w-5 h-5 text-text-quaternary" />
</div>
)}
<span className="absolute top-0.5 end-0.5 text-[9px] font-mono bg-black/60 text-white rounded px-1">
{idx + 1}/{total}
</span>
</div>
<div className="min-w-0 py-0.5">
<p className="text-xs font-medium text-text-primary truncate max-w-[120px]">
{design.title}
</p>
<StatusBadge status={design.status} />
</div>
</button>
))}
</div>
<button
onClick={onLink}
className="text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
+ {t('post.addDesign')}
</button>
</div>
) : (
<div className="bg-surface-secondary rounded-lg border border-border-light p-3 text-center">
<p className="text-xs text-text-tertiary mb-2">{t('post.noDesignsLinked')}</p>
<div className="flex items-center justify-center gap-3">
<button
onClick={onLink}
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
<Link className="w-3 h-3" />
{t('post.addDesign')}
</button>
<button
onClick={onCreate}
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
<Plus className="w-3 h-3" />
{t('post.createNew')}
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,57 @@
import { useState } from 'react'
import { useLanguage } from '../i18n/LanguageContext'
import { getFormatsForPlatforms } from '../utils/platformFormats'
export default function PostCompositionFormats({ platforms = [] }) {
const { t } = useLanguage()
const formats = getFormatsForPlatforms(platforms)
const [checked, setChecked] = useState(new Set())
const toggle = (key) => {
setChecked(prev => {
const next = new Set(prev)
if (next.has(key)) next.delete(key)
else next.add(key)
return next
})
}
if (formats.length === 0) {
return <p className="text-xs text-text-tertiary italic">{t('post.selectPlatforms')}</p>
}
return (
<div className="space-y-1">
{formats.map(f => {
const isChecked = checked.has(f.key)
return (
<button
key={f.key}
onClick={() => toggle(f.key)}
className={`flex items-center gap-2 w-full rounded-lg px-2 py-1.5 transition-colors text-start ${
isChecked ? 'bg-brand-primary/10' : 'hover:bg-surface-secondary'
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center flex-shrink-0 transition-colors ${
isChecked
? 'bg-brand-primary border-brand-primary'
: 'border-border'
}`}>
{isChecked && (
<svg className="w-2.5 h-2.5 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</div>
<span className={`text-sm ${isChecked ? 'text-brand-primary font-medium' : 'text-text-primary'}`}>
{f.label}
</span>
<span className="ms-auto text-[10px] font-mono text-text-quaternary bg-surface-secondary rounded px-1.5 py-0.5">
{f.ratio}
</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { Search, X } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function PostCompositionLinkPicker({ items = [], onSelect, onCancel, searchPlaceholder, loading }) {
const { t } = useLanguage()
const [search, setSearch] = useState('')
const filtered = items.filter(c =>
!search || (c.title || c.name || '').toLowerCase().includes(search.toLowerCase())
)
return (
<div className="mt-2 rounded-lg border border-border bg-surface-secondary p-2 space-y-2">
<div className="relative">
<Search className="absolute start-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder={searchPlaceholder || t('common.search')}
className="w-full ps-8 pe-3 py-1.5 text-xs border border-border rounded-lg bg-surface text-text-primary placeholder-text-tertiary focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
autoFocus
/>
</div>
<div className="max-h-48 overflow-y-auto divide-y divide-border-light">
{filtered.length === 0 ? (
<p className="text-xs text-text-tertiary py-3 text-center">{t('common.noResults')}</p>
) : (
filtered.map(item => (
<button
key={item.Id || item.id}
onClick={() => onSelect(item.Id || item.id)}
disabled={loading}
className="w-full flex items-center justify-between px-2 py-2 hover:bg-surface transition-colors text-start"
>
<div className="min-w-0">
<p className="text-xs font-medium text-text-primary truncate">{item.title || item.name}</p>
<div className="flex items-center gap-1.5 mt-0.5">
{item.language && (
<span className="text-[10px] uppercase font-medium text-text-tertiary bg-surface rounded px-1 py-0.5">{item.language}</span>
)}
{item.type && (
<span className="text-[10px] text-text-tertiary bg-surface rounded px-1 py-0.5">{item.type}</span>
)}
</div>
</div>
<span className="text-[11px] text-brand-primary font-medium shrink-0 ms-2">{t('post.linkExisting')}</span>
</button>
))
)}
</div>
<button
onClick={onCancel}
className="flex items-center gap-1 text-xs text-text-tertiary hover:text-text-secondary transition-colors"
>
<X className="w-3 h-3" />
{t('common.cancel')}
</button>
</div>
)
}
@@ -0,0 +1,294 @@
import { useState, useEffect, useCallback } from 'react'
import { Trash2, Save, FileText, Image as ImageIcon, Film, LayoutGrid, CheckCircle, Calendar } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import { useToast } from './ToastContainer'
import SlidePanel from './SlidePanel'
import CommentsSection from './CommentsSection'
import TranslationDetailPanel from './TranslationDetailPanel'
import ArtefactDetailPanel from './ArtefactDetailPanel'
import PostCompositionCaption from './PostCompositionCaption'
import PostCompositionCopy from './PostCompositionCopy'
import PostCompositionDesigns from './PostCompositionDesigns'
import PostCompositionVideo from './PostCompositionVideo'
import PostCompositionFormats from './PostCompositionFormats'
import PostCompositionReadiness from './PostCompositionReadiness'
import PostCompositionLinkPicker from './PostCompositionLinkPicker'
const STAGES = ['copy', 'translate', 'design', 'post']
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
const selectCls = 'border border-border rounded px-2 py-1 bg-surface text-text-secondary text-xs focus:outline-none'
function Section({ icon: Icon, label, children }) {
return (
<div className="px-5 py-4">
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-2 flex items-center gap-1.5">
{Icon && <Icon className="w-3.5 h-3.5" />} {label}
</h4>
{children}
</div>
)
}
export default function PostCompositionPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const toast = useToast()
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [composition, setComposition] = useState(null)
const [postId, setPostId] = useState(null)
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [activePicker, setActivePicker] = useState(null)
const [linkCandidates, setLinkCandidates] = useState([])
const [linking, setLinking] = useState(false)
const [openTranslation, setOpenTranslation] = useState(null)
const [openArtefact, setOpenArtefact] = useState(null)
const isCreateMode = !postId
useEffect(() => {
if (!post) return
const id = post._id || post.id || null
setPostId(id)
setForm({
title: post.title || '', brand_id: post.brandId || post.brand_id || '',
campaign_id: post.campaignId || post.campaign_id || '',
assigned_to: post.assignedTo || post.assigned_to || '',
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft', caption: post.caption || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 10) : (post.scheduled_date ? new Date(post.scheduled_date).toISOString().slice(0, 10) : ''),
stage: post.stage || 'copy',
})
setDirty(!id); setComposition(null); setActivePicker(null)
if (id) loadComposition(id)
}, [post])
const loadComposition = useCallback(async (id) => {
const pid = id || postId
if (!pid) return
try { setComposition(await api.get(`/posts/${pid}/composition`)) }
catch (err) { console.error('Failed to load composition:', err) }
}, [postId])
const update = (field, value) => { setForm(f => ({ ...f, [field]: value })); setDirty(true) }
const togglePlatform = (key) => {
setForm(f => ({ ...f, platforms: f.platforms.includes(key) ? f.platforms.filter(p => p !== key) : [...f.platforms, key] }))
setDirty(true)
}
const handleSave = async () => {
setSaving(true)
try {
const data = {
title: form.title, brand_id: form.brand_id ? Number(form.brand_id) : null,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
assigned_to: form.assigned_to ? Number(form.assigned_to) : null,
status: form.status, platforms: form.platforms || [],
caption: form.caption || '', scheduled_date: form.scheduled_date || null,
}
const result = await onSave(isCreateMode ? null : postId, data)
setDirty(false)
if (isCreateMode && result) {
const newId = result._id || result.id
setPostId(newId)
setForm(f => ({ ...f, stage: result.stage || 'copy' }))
loadComposition(newId)
toast.success(t('posts.created'))
}
} catch { toast.error(t('common.saveFailed')) }
finally { setSaving(false) }
}
const createAsset = async (endpoint, body) => {
if (!postId) return
try { await api.post(endpoint, body); loadComposition(); toast.success(t('common.success')) }
catch { toast.error(t('common.saveFailed')) }
}
const openLinkPicker = async (type) => {
setActivePicker(type)
try {
if (type === 'copy') {
const all = await api.get('/translations')
setLinkCandidates((Array.isArray(all) ? all : []).filter(t => !t.post_id))
} else {
const all = await api.get('/artefacts')
const at = type === 'video' ? 'video' : 'design'
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => !a.post_id && (a.type || 'design') === at))
}
} catch { setLinkCandidates([]) }
}
const handleLink = async (itemId) => {
setLinking(true)
try {
await api.patch(activePicker === 'copy' ? `/translations/${itemId}` : `/artefacts/${itemId}`, { post_id: postId })
toast.success(t('common.success')); setActivePicker(null); loadComposition()
} catch { toast.error(t('common.error')) }
finally { setLinking(false) }
}
const handleOpenCopy = async (id) => {
try { setOpenTranslation(await api.get(`/translations/${id}`)) } catch { toast.error(t('common.error')) }
}
const handleOpenAsset = async (id) => {
try { setOpenArtefact(await api.get(`/artefacts/${id}`)) } catch { toast.error(t('common.error')) }
}
const handleSignOff = async () => {
setSaving(true)
try { await onSave(postId, { ...form, status: 'approved' }); setForm(f => ({ ...f, status: 'approved' })); setDirty(false) }
finally { setSaving(false) }
}
const waitingOn = composition ? [
...(composition.copy?.length === 0 ? [t('post.copy')] : []),
...(composition.designs?.length === 0 ? [t('post.designs')] : []),
...(composition.waiting_on || []),
] : []
if (!post) return null
const picker = (type, placeholder) => activePicker === type && (
<PostCompositionLinkPicker items={linkCandidates} onSelect={handleLink}
onCancel={() => setActivePicker(null)} searchPlaceholder={placeholder} loading={linking} />
)
const header = (
<div className="px-5 py-4 border-b border-border bg-surface sticky top-0 z-10">
<div className="flex items-center gap-2 mb-3">
<input type="text" value={form.title || ''} onChange={e => update('title', e.target.value)}
className="flex-1 text-base font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('posts.postTitlePlaceholder')} />
</div>
<div className="flex flex-wrap items-center gap-1.5 text-xs">
<select value={form.status || 'draft'} onChange={e => update('status', e.target.value)} className={selectCls}>
{STATUS_OPTS.map(s => <option key={s} value={s}>{t(`posts.status.${s}`)}</option>)}
</select>
<select value={form.brand_id || ''} onChange={e => update('brand_id', e.target.value)} className={selectCls}>
<option value="">{t('posts.brand')}</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select value={form.campaign_id || ''} onChange={e => update('campaign_id', e.target.value)} className={selectCls}>
<option value="">{t('campaigns.title')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
<select value={form.assigned_to || ''} onChange={e => update('assigned_to', e.target.value)} className={selectCls}>
<option value="">{t('common.unassigned')}</option>
{(teamMembers || []).map(m => <option key={m._id || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
</div>
<div className="flex flex-wrap gap-1 mt-2">
{Object.entries(PLATFORMS).map(([key, p]) => (
<button key={key} onClick={() => togglePlatform(key)}
className={`text-[11px] px-2 py-0.5 rounded-full border transition-colors ${
(form.platforms || []).includes(key)
? 'border-brand-primary bg-brand-primary/10 text-brand-primary font-medium'
: 'border-border-light text-text-tertiary hover:border-brand-primary/30'
}`}>{p.label}</button>
))}
</div>
<div className="flex items-center gap-2 mt-2">
<div className="flex items-center gap-1 border border-border rounded px-2 py-1 bg-surface text-text-secondary">
<Calendar className="w-3 h-3" />
<input type="date" value={form.scheduled_date || ''} onChange={e => update('scheduled_date', e.target.value)}
className="bg-transparent text-xs border-0 p-0 focus:outline-none w-24" />
</div>
<div className="flex-1" />
{onDelete && !isCreateMode && (
<button onClick={showDeleteConfirm ? async () => { setShowDeleteConfirm(false); await onDelete(postId); onClose() } : () => setShowDeleteConfirm(true)}
className={`p-1.5 rounded-lg transition-colors ${showDeleteConfirm ? 'text-red-500 bg-red-50' : 'text-text-tertiary hover:text-red-500 hover:bg-red-50'}`}
title={showDeleteConfirm ? t('common.confirm') : t('common.delete')}>
<Trash2 className="w-4 h-4" />
</button>
)}
<button onClick={handleSave} disabled={!form.title || saving || !dirty}
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-brand-primary text-white rounded-lg text-xs font-medium hover:bg-brand-primary-light disabled:opacity-40 disabled:cursor-not-allowed transition-colors">
<Save className="w-3.5 h-3.5" />
{isCreateMode ? t('posts.createPost') : t('common.save')}
</button>
</div>
</div>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
<div className="divide-y divide-border">
{!isCreateMode && (
<div className="px-5 py-3 flex items-center gap-1">
{STAGES.map((stage, idx) => {
const ci = STAGES.indexOf(form.stage || 'copy')
return (
<div key={stage} className="flex items-center gap-1">
{idx > 0 && <div className={`w-4 h-px ${idx <= ci ? 'bg-brand-primary' : 'bg-border'}`} />}
<span className={`text-[11px] px-2 py-0.5 rounded-full capitalize ${
idx === ci ? 'bg-brand-primary text-white font-medium'
: idx < ci ? 'bg-brand-primary/10 text-brand-primary'
: 'bg-surface-secondary text-text-tertiary'}`}>{stage}</span>
</div>
)
})}
</div>
)}
<Section label={t('post.caption')}>
<PostCompositionCaption caption={form.caption} onChange={v => update('caption', v)} disabled={false} platforms={form.platforms || []} />
</Section>
{!isCreateMode && composition && (
<>
<Section icon={FileText} label={t('post.copy')}>
<PostCompositionCopy copy={composition.copy || []} onLink={() => openLinkPicker('copy')}
onCreate={(lang) => createAsset('/translations', { post_id: postId, language: lang, is_original: (composition.copy || []).length === 0, title: form.title })}
onOpen={handleOpenCopy} />
{picker('copy', t('post.linkTranslation'))}
</Section>
<Section icon={ImageIcon} label={t('post.designs')}>
<PostCompositionDesigns designs={composition.designs || []} onLink={() => openLinkPicker('design')}
onCreate={() => createAsset('/artefacts', { post_id: postId, type: 'design', title: 'Design for ' + form.title })}
onOpen={handleOpenAsset} />
{picker('design', t('post.addDesign'))}
</Section>
<Section icon={Film} label={t('post.video')}>
<PostCompositionVideo video={composition.video || null} onLink={() => openLinkPicker('video')}
onCreate={() => createAsset('/artefacts', { post_id: postId, type: 'video', title: 'Video for ' + form.title })}
onOpen={handleOpenAsset} />
{picker('video', t('post.addVideo'))}
</Section>
{(form.platforms || []).length > 0 && (
<Section icon={LayoutGrid} label={t('post.formatChecklist')}>
<PostCompositionFormats platforms={form.platforms} />
</Section>
)}
<Section icon={CheckCircle} label={t('post.readiness')}>
<PostCompositionReadiness piecesReady={!isCreateMode && composition?.pieces_ready}
waitingOn={waitingOn} onSignOff={handleSignOff} />
</Section>
<div className="px-5 py-4">
<CommentsSection entityType="post" entityId={postId} />
</div>
</>
)}
</div>
</SlidePanel>
{openTranslation && (
<TranslationDetailPanel translation={openTranslation}
onClose={() => { setOpenTranslation(null); loadComposition() }} onUpdate={() => loadComposition()} />
)}
{openArtefact && (
<ArtefactDetailPanel artefact={openArtefact}
onClose={() => { setOpenArtefact(null); loadComposition() }} onUpdate={() => loadComposition()}
projects={[]} campaigns={campaigns || []} />
)}
</>
)
}
@@ -0,0 +1,69 @@
import { useState } from 'react'
import { CheckCircle, AlertCircle } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function PostCompositionReadiness({ piecesReady, waitingOn = [], onSignOff }) {
const { t } = useLanguage()
const [showConfirm, setShowConfirm] = useState(false)
const handleSignOff = () => {
setShowConfirm(false)
onSignOff?.()
}
if (piecesReady) {
return (
<div className="rounded-lg border border-brand-primary/30 bg-brand-primary/5 p-3 space-y-3">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-brand-primary" />
<p className="text-sm font-medium text-brand-primary">
{t('post.allPiecesReady')}
</p>
</div>
{showConfirm ? (
<div className="flex items-center gap-2">
<p className="text-xs text-text-secondary">{t('post.signOffConfirm')}</p>
<button
onClick={handleSignOff}
className="px-3 py-1 rounded-lg bg-brand-primary text-white text-xs font-medium hover:bg-brand-primary-light transition-colors"
>
{t('common.confirm')}
</button>
<button
onClick={() => setShowConfirm(false)}
className="px-3 py-1 rounded-lg border border-border text-xs text-text-secondary hover:bg-surface-secondary transition-colors"
>
{t('common.cancel')}
</button>
</div>
) : (
<button
onClick={() => setShowConfirm(true)}
className="px-5 py-2 rounded-lg bg-brand-primary text-sm font-medium text-white hover:bg-brand-primary-light transition-colors"
>
{t('post.signOff')}
</button>
)}
</div>
)
}
return (
<div className="rounded-lg border border-amber-200/60 bg-amber-50/50 dark:border-amber-800/40 dark:bg-amber-950/20 p-3">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-amber-500" />
<p className="text-sm font-medium text-amber-700 dark:text-amber-300">
{t('post.waitingOn')}
</p>
</div>
<ul className="space-y-1">
{waitingOn.map((item, i) => (
<li key={i} className="flex items-center gap-2 text-xs text-text-secondary">
<span className="w-1.5 h-1.5 rounded-full bg-amber-400 flex-shrink-0" />
{item}
</li>
))}
</ul>
</div>
)
}
@@ -0,0 +1,61 @@
import { Video, Link, Plus, Play } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import StatusBadge from './StatusBadge'
export default function PostCompositionVideo({ video, onLink, onCreate, onOpen }) {
const { t } = useLanguage()
return (
<div className="space-y-2">
{video ? (
<button
onClick={() => onOpen?.(video.id)}
className="flex items-start gap-2 w-full bg-surface-secondary rounded-lg border border-border-light p-2 hover:border-brand-primary transition-colors text-start"
>
<div className="w-16 aspect-square rounded bg-surface flex-shrink-0 overflow-hidden relative">
{video.thumbnail_url ? (
<img
src={video.thumbnail_url}
alt={video.title}
className="w-full h-full object-cover"
/>
) : (
<div className="w-full h-full flex items-center justify-center">
<Video className="w-5 h-5 text-text-quaternary" />
</div>
)}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-6 h-6 rounded-full bg-black/50 flex items-center justify-center">
<Play className="w-3 h-3 text-white fill-white" />
</div>
</div>
</div>
<div className="min-w-0 py-0.5">
<p className="text-xs font-medium text-text-primary truncate">{video.title}</p>
<StatusBadge status={video.status} />
</div>
</button>
) : (
<div className="bg-surface-secondary rounded-lg border border-border-light p-3 text-center">
<p className="text-xs text-text-tertiary mb-2">{t('post.noVideoLinked')}</p>
<div className="flex items-center justify-center gap-3">
<button
onClick={onLink}
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
<Link className="w-3 h-3" />
{t('post.addVideo')}
</button>
<button
onClick={onCreate}
className="inline-flex items-center gap-1 text-xs text-brand-primary hover:text-brand-primary-light font-medium transition-colors"
>
<Plus className="w-3 h-3" />
{t('post.createNew')}
</button>
</div>
</div>
)}
</div>
)
}
+2 -2
View File
@@ -173,7 +173,7 @@ export function PostDetailVersions({
{versionData.texts.map(text => { {versionData.texts.map(text => {
const tId = text.Id || text.id || text._id const tId = text.Id || text.id || text._id
return ( return (
<div key={tId} className="rounded-xl border border-border overflow-hidden"> <div key={tId} className="rounded-xl border border-border overflow-clip">
<div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2.5 bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-surface border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span> <span className="px-2 py-0.5 bg-surface border border-border rounded text-[11px] font-semibold uppercase text-text-secondary">{text.language_code}</span>
@@ -243,7 +243,7 @@ export function PostDetailVersions({
const isImage = mime.startsWith('image/') const isImage = mime.startsWith('image/')
const isVideo = mime.startsWith('video/') const isVideo = mime.startsWith('video/')
return ( return (
<div key={attId} className="relative group rounded-xl border border-border overflow-hidden bg-surface hover:shadow-md transition-shadow"> <div key={attId} className="relative group rounded-xl border border-border overflow-clip bg-surface hover:shadow-md transition-shadow">
{isImage ? ( {isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer"> <a href={attUrl} target="_blank" rel="noopener noreferrer">
<img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" /> <img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" />
+1 -1
View File
@@ -35,7 +35,7 @@ const moduleGroups = [
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' }, { to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' }, { to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' }, { to: '/brands', icon: Tag, labelKey: 'nav.brands' },
{ to: '/translations', icon: Languages, labelKey: 'nav.translations' }, { to: '/translations', icon: Languages, labelKey: 'nav.copy' },
], ],
}, },
{ {
+2 -2
View File
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
export function SkeletonTable({ rows = 5, cols = 6 }) { export function SkeletonTable({ rows = 5, cols = 6 }) {
return ( return (
<div className="bg-surface rounded-xl border border-border overflow-hidden animate-pulse"> <div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
<div className="border-b border-border bg-surface-secondary p-4"> <div className="border-b border-border bg-surface-secondary p-4">
<div className="flex gap-4"> <div className="flex gap-4">
{[...Array(cols)].map((_, i) => ( {[...Array(cols)].map((_, i) => (
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
export function SkeletonCalendar() { export function SkeletonCalendar() {
return ( return (
<div className="bg-surface rounded-xl border border-border overflow-hidden animate-pulse"> <div className="bg-surface rounded-xl border border-border overflow-clip animate-pulse">
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="h-6 bg-surface-tertiary rounded w-40"></div> <div className="h-6 bg-surface-tertiary rounded w-40"></div>
<div className="h-8 bg-surface-tertiary rounded w-20"></div> <div className="h-8 bg-surface-tertiary rounded w-20"></div>
+3 -5
View File
@@ -35,16 +35,14 @@ export default function SlidePanel({ onClose, maxWidth = '420px', header, footer
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" /> <div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
<div <div
ref={panelRef} ref={panelRef}
className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] flex flex-col animate-slide-in-right overflow-hidden" className="fixed top-0 right-0 h-full w-full bg-surface shadow-2xl z-[9998] animate-slide-in-right overflow-y-auto"
style={{ maxWidth }} style={{ maxWidth }}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
> >
{header} <div className="sticky top-0 z-10 bg-surface">{header}</div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1">{children}</div>
{children}
</div>
{footer} {footer}
</div> </div>
</>, </>,
+4 -4
View File
@@ -56,9 +56,9 @@ export default function TabbedModal({
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}> <div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" /> <div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in" onClick={onClose} aria-label="Close dialog" />
<div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] flex flex-col animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title"> <div className={`relative bg-surface rounded-2xl shadow-2xl w-full ${SIZE_CLASSES[size] || SIZE_CLASSES.md} max-h-[90vh] overflow-y-auto animate-scale-in`} role="dialog" aria-modal="true" aria-labelledby="tabbed-modal-title">
{/* Header */} {/* Header */}
<div className="shrink-0"> <div className="sticky top-0 z-10 bg-surface rounded-t-2xl">
<div className="px-6 pt-5 pb-3"> <div className="px-6 pt-5 pb-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div id="tabbed-modal-title" className="flex-1 min-w-0"> <div id="tabbed-modal-title" className="flex-1 min-w-0">
@@ -111,13 +111,13 @@ export default function TabbedModal({
</div> </div>
{/* Body */} {/* Body */}
<div className="flex-1 overflow-y-auto" role="tabpanel"> <div role="tabpanel">
{children} {children}
</div> </div>
{/* Footer */} {/* Footer */}
{footer && ( {footer && (
<div className="border-t border-border px-6 py-3.5 flex items-center justify-between shrink-0 rounded-b-2xl bg-surface"> <div className="border-t border-border px-6 py-3.5 flex items-center justify-between rounded-b-2xl bg-surface">
{footer} {footer}
</div> </div>
)} )}
+49 -1
View File
@@ -78,6 +78,29 @@
"posts.saveChanges": "حفظ التغييرات", "posts.saveChanges": "حفظ التغييرات",
"posts.postTitle": "العنوان", "posts.postTitle": "العنوان",
"posts.description": "الوصف", "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.brand": "العلامة التجارية",
"posts.platforms": "المنصات", "posts.platforms": "المنصات",
"posts.status": "الحالة", "posts.status": "الحالة",
@@ -701,6 +724,11 @@
"review.confirmReject": "هل تريد رفض هذا المحتوى؟", "review.confirmReject": "هل تريد رفض هذا المحتوى؟",
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل", "review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
"review.contentLanguages": "لغات المحتوى", "review.contentLanguages": "لغات المحتوى",
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
"review.redirect": "إعادة توجيه",
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
"review.content": "المحتوى", "review.content": "المحتوى",
"review.designFiles": "ملفات التصميم", "review.designFiles": "ملفات التصميم",
"review.videos": "الفيديوهات", "review.videos": "الفيديوهات",
@@ -783,6 +811,8 @@
"header.issues": "البلاغات", "header.issues": "البلاغات",
"header.settings": "الإعدادات", "header.settings": "الإعدادات",
"header.translations": "الترجمات", "header.translations": "الترجمات",
"header.copy": "النسخ",
"header.postDetails": "تفاصيل المنشور",
"calendar.unscheduledPosts": "منشورات غير مجدولة", "calendar.unscheduledPosts": "منشورات غير مجدولة",
"calendar.statusLegend": "دليل الحالات", "calendar.statusLegend": "دليل الحالات",
"header.users": "إدارة المستخدمين", "header.users": "إدارة المستخدمين",
@@ -882,6 +912,8 @@
"artefacts.descriptionLabel": "الوصف", "artefacts.descriptionLabel": "الوصف",
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...", "artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
"artefacts.approversLabel": "المعتمدون", "artefacts.approversLabel": "المعتمدون",
"artefacts.reviewer": "المراجع",
"artefacts.selectReviewer": "اختر مراجعاً...",
"artefacts.versions": "الإصدارات", "artefacts.versions": "الإصدارات",
"artefacts.newVersion": "إصدار جديد", "artefacts.newVersion": "إصدار جديد",
"artefacts.languages": "اللغات", "artefacts.languages": "اللغات",
@@ -1133,5 +1165,21 @@
"translations.createPost": "منشور جديد", "translations.createPost": "منشور جديد",
"translations.newPostTitle": "عنوان المنشور...", "translations.newPostTitle": "عنوان المنشور...",
"translations.postCreated": "تم إنشاء المنشور!", "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": "إلغاء الربط"
} }
+49 -1
View File
@@ -78,6 +78,29 @@
"posts.saveChanges": "Save Changes", "posts.saveChanges": "Save Changes",
"posts.postTitle": "Title", "posts.postTitle": "Title",
"posts.description": "Description", "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.brand": "Brand",
"posts.platforms": "Platforms", "posts.platforms": "Platforms",
"posts.status": "Status", "posts.status": "Status",
@@ -701,6 +724,11 @@
"review.confirmReject": "Reject this artefact?", "review.confirmReject": "Reject this artefact?",
"review.feedbackRequired": "Please provide feedback for revision request", "review.feedbackRequired": "Please provide feedback for revision request",
"review.contentLanguages": "Content Languages", "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.content": "Content",
"review.designFiles": "Design Files", "review.designFiles": "Design Files",
"review.videos": "Videos", "review.videos": "Videos",
@@ -783,6 +811,8 @@
"header.issues": "Issues", "header.issues": "Issues",
"header.settings": "Settings", "header.settings": "Settings",
"header.translations": "Translations", "header.translations": "Translations",
"header.copy": "Copy",
"header.postDetails": "Post Details",
"calendar.unscheduledPosts": "Unscheduled Posts", "calendar.unscheduledPosts": "Unscheduled Posts",
"calendar.statusLegend": "Status Legend", "calendar.statusLegend": "Status Legend",
"header.users": "User Management", "header.users": "User Management",
@@ -882,6 +912,8 @@
"artefacts.descriptionLabel": "Description", "artefacts.descriptionLabel": "Description",
"artefacts.descriptionFieldPlaceholder": "Add a description...", "artefacts.descriptionFieldPlaceholder": "Add a description...",
"artefacts.approversLabel": "Approvers", "artefacts.approversLabel": "Approvers",
"artefacts.reviewer": "Reviewer",
"artefacts.selectReviewer": "Select a reviewer...",
"artefacts.versions": "Versions", "artefacts.versions": "Versions",
"artefacts.newVersion": "New Version", "artefacts.newVersion": "New Version",
"artefacts.languages": "Languages", "artefacts.languages": "Languages",
@@ -1133,5 +1165,21 @@
"translations.createPost": "New Post", "translations.createPost": "New Post",
"translations.newPostTitle": "Post title...", "translations.newPostTitle": "Post title...",
"translations.postCreated": "Post created!", "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"
} }
+1 -1
View File
@@ -504,7 +504,7 @@ textarea {
background: white; background: white;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 1rem; border-radius: 1rem;
overflow: hidden; overflow: clip;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
transition: box-shadow 0.3s ease; transition: box-shadow 0.3s ease;
} }
+1 -1
View File
@@ -154,7 +154,7 @@ export default function Brands() {
return ( return (
<div <div
key={getBrandId(brand)} key={getBrandId(brand)}
className={`bg-surface rounded-xl border border-border overflow-hidden hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`} className={`bg-surface rounded-xl border border-border overflow-clip hover:shadow-md transition-all aspect-square flex flex-col ${isSuperadminOrManager ? 'cursor-pointer' : ''}`}
onClick={() => isSuperadminOrManager && openEditBrand(brand)} onClick={() => isSuperadminOrManager && openEditBrand(brand)}
> >
{/* Logo area */} {/* Logo area */}
+3 -33
View File
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
import CommentsSection from '../components/CommentsSection' import CommentsSection from '../components/CommentsSection'
import CampaignDetailPanel from '../components/CampaignDetailPanel' import CampaignDetailPanel from '../components/CampaignDetailPanel'
import TrackDetailPanel from '../components/TrackDetailPanel' import TrackDetailPanel from '../components/TrackDetailPanel'
import PostDetailPanel from '../components/PostDetailPanel'
const TRACK_TYPES = { const TRACK_TYPES = {
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false }, 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 [budgetValue, setBudgetValue] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
const [trackToDelete, setTrackToDelete] = useState(null) const [trackToDelete, setTrackToDelete] = useState(null)
const [selectedPost, setSelectedPost] = useState(null)
const [showDiscussion, setShowDiscussion] = useState(false) const [showDiscussion, setShowDiscussion] = useState(false)
const [allCampaigns, setAllCampaigns] = useState([]) const [allCampaigns, setAllCampaigns] = useState([])
@@ -153,21 +151,6 @@ export default function CampaignDetail() {
loadAll() 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) => { const deleteTrack = async (trackId) => {
setTrackToDelete(trackId) setTrackToDelete(trackId)
setShowDeleteConfirm(true) setShowDeleteConfirm(true)
@@ -301,7 +284,7 @@ export default function CampaignDetail() {
</div> </div>
{/* Tracks */} {/* Tracks */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center justify-between px-5 py-4 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3> <h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
{canManage && ( {canManage && (
@@ -434,7 +417,7 @@ export default function CampaignDetail() {
{/* Linked Posts */} {/* Linked Posts */}
{posts.length > 0 && ( {posts.length > 0 && (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3> <h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
</div> </div>
@@ -442,7 +425,7 @@ export default function CampaignDetail() {
{posts.map(post => ( {posts.map(post => (
<div <div
key={post.id} key={post.id}
onClick={() => 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" className="flex items-center gap-3 px-5 py-3 hover:bg-surface-secondary cursor-pointer transition-colors"
> >
{post.thumbnail_url && ( {post.thumbnail_url && (
@@ -589,19 +572,6 @@ export default function CampaignDetail() {
</div> </div>
</Modal> </Modal>
{/* Post Detail Panel */}
{selectedPost && (
<PostDetailPanel
post={selectedPost}
onClose={() => setSelectedPost(null)}
onSave={handlePostPanelSave}
onDelete={handlePostPanelDelete}
brands={brands}
teamMembers={teamMembers}
campaigns={allCampaigns}
/>
)}
{/* Campaign Edit Panel */} {/* Campaign Edit Panel */}
{panelCampaign && ( {panelCampaign && (
<CampaignDetailPanel <CampaignDetailPanel
+1 -1
View File
@@ -264,7 +264,7 @@ export default function Campaigns() {
/> />
{/* Campaign list */} {/* Campaign list */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-5 py-4 border-b border-border"> <div className="px-5 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">All Campaigns</h3> <h3 className="font-semibold text-text-primary">All Campaigns</h3>
</div> </div>
+1 -1
View File
@@ -194,7 +194,7 @@ export default function PostCalendar() {
</div> </div>
{/* Calendar */} {/* Calendar */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Nav */} {/* Nav */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
+655
View File
@@ -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 (
<div className="space-y-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-8 h-8 bg-surface-tertiary rounded-lg"></div>
<div className="flex-1">
<div className="h-6 bg-surface-tertiary rounded w-64 mb-2"></div>
<div className="h-4 bg-surface-tertiary rounded w-96"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1,2,3,4].map(i => <div key={i} className="h-40 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
if (!post) {
return (
<div className="text-center py-12 text-text-tertiary">
{t('common.noResults')}{' '}
<button onClick={() => navigate('/posts')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div>
)
}
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 (
<div className="space-y-6 animate-fade-in">
{/* ─── HEADER ─── */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<button onClick={() => navigate('/posts')} className="p-1.5 hover:bg-surface-tertiary rounded-lg">
<ArrowLeft className="w-5 h-5 text-text-secondary" />
</button>
<input
type="text"
value={title}
onChange={e => 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')}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
value={status}
onChange={e => setStatus(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"
>
{STATUS_OPTS.map(s => (
<option key={s} value={s}>{t(`posts.status.${s}`)}</option>
))}
</select>
<select
value={brandId}
onChange={e => setBrandId(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"
>
<option value="">{t('posts.selectBrand')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
value={campaignId}
onChange={e => setCampaignId(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"
>
<option value="">{t('posts.noCampaign')}</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
<select
value={assignedTo}
onChange={e => setAssignedTo(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"
>
<option value="">{t('common.unassigned')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
{/* Platforms */}
<div className="flex items-center gap-1.5 flex-wrap">
{Object.entries(PLATFORMS).map(([key, p]) => (
<button
key={key}
onClick={() => togglePlatform(key)}
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors ${
platforms.includes(key)
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
: 'border-border text-text-tertiary hover:border-brand-primary/40'
}`}
>
<PlatformIcon platform={key} size={14} />
{p.label}
</button>
))}
</div>
{/* Date + Save */}
<div className="flex items-center gap-3">
<input
type="date"
value={scheduledDate}
onChange={e => 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"
/>
<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"
>
<Save className="w-4 h-4" />
{saving ? t('common.loading') : t('common.save')}
</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}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('caption')}
onUnlink={() => handleUnlink('caption')}
onOpenPicker={() => openLinkPicker('caption')}
onCreate={() => handleCreate('caption')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="body"
label={t('postDetail.bodyCopy')}
icon={FileText}
piece={composition?.body_copy}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('body')}
onUnlink={() => handleUnlink('body')}
onOpenPicker={() => openLinkPicker('body')}
onCreate={() => handleCreate('body')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="design"
label={t('postDetail.design')}
icon={ImageIcon}
piece={composition?.design}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('design')}
onUnlink={() => handleUnlink('design')}
onOpenPicker={() => openLinkPicker('design')}
onCreate={() => handleCreate('design')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="video"
label={t('postDetail.video')}
icon={Film}
piece={composition?.video}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('video')}
onUnlink={() => handleUnlink('video')}
onOpenPicker={() => openLinkPicker('video')}
onCreate={() => handleCreate('video')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
</div>
{/* ─── READINESS ─── */}
<div className="bg-surface rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('postDetail.readiness')}</h3>
{!hasPieces ? (
<p className="text-sm text-text-tertiary">{t('postDetail.noAssets')}</p>
) : piecesReady ? (
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle className="w-5 h-5" />
<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>
)}
</div>
{/* ─── COMMENTS ─── */}
<div className="bg-surface rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('posts.discussion')}</h3>
<CommentsSection entityType="post" entityId={Number(id)} />
</div>
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
{openTranslation && (
<TranslationDetailPanel
translation={openTranslation}
onClose={() => { setOpenTranslation(null); loadComposition() }}
onUpdate={loadComposition}
onDelete={() => { setOpenTranslation(null); loadComposition() }}
assignableUsers={teamMembers}
/>
)}
{openArtefact && (
<ArtefactDetailPanel
artefact={openArtefact}
onClose={() => { setOpenArtefact(null); loadComposition() }}
onUpdate={loadComposition}
onDelete={() => { setOpenArtefact(null); loadComposition() }}
assignableUsers={teamMembers}
projects={[]}
campaigns={campaigns}
/>
)}
</div>
)
}
// ─── 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 (
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</h4>
</div>
{piece ? (
<>
<div className="flex-1">
{/* Thumbnail for design/video */}
{!isCopy && piece.thumbnail_url && (
<div className="mb-3 rounded-lg overflow-hidden border border-border-light bg-surface-secondary">
<img src={piece.thumbnail_url} alt={piece.title} className="w-full h-32 object-cover" loading="lazy" />
</div>
)}
{!isCopy && !piece.thumbnail_url && (
<div className="mb-3 rounded-lg border border-border-light bg-surface-secondary flex items-center justify-center h-24">
<Icon className="w-8 h-8 text-text-tertiary/30" />
</div>
)}
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-text-primary truncate">{piece.title}</span>
<StatusBadge status={piece.status} size="xs" />
</div>
{/* Copy: show content preview + languages */}
{isCopy && piece.content_preview && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{piece.content_preview}</p>
)}
{isCopy && piece.languages && piece.languages.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{piece.languages.map((l, i) => (
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
l.status === 'approved' ? 'bg-emerald-100 text-emerald-700' :
l.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
'bg-surface-tertiary text-text-tertiary'
}`}>
{l.language}
</span>
))}
</div>
)}
{isCopy && (!piece.languages || piece.languages.length === 0) && piece.language && (
<p className="text-xs text-text-tertiary mt-1">{piece.language}</p>
)}
{/* Design/Video: version info */}
{!isCopy && piece.current_version && (
<p className="text-xs text-text-tertiary mt-1">v{piece.current_version}</p>
)}
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
<button
onClick={onOpen}
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"
>
<Link2 className="w-3.5 h-3.5" />
{t('postDetail.open')}
</button>
<button
onClick={onUnlink}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Unlink className="w-3.5 h-3.5" />
{t('postDetail.unlink')}
</button>
</div>
</>
) : (
<>
<div className="flex-1 flex items-center justify-center py-4">
<p className="text-sm text-text-tertiary">{t('postDetail.notLinked')}</p>
</div>
{!isPickerOpen && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
<button
onClick={onOpenPicker}
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"
>
<Link2 className="w-3.5 h-3.5" />
{t('postDetail.linkExisting')}
</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"
>
<Plus className="w-3.5 h-3.5" />
{t('postDetail.createNew')}
</button>
</div>
)}
</>
)}
{/* Inline picker */}
{isPickerOpen && (
<div className="mt-3 pt-3 border-t border-border-light animate-fade-in">
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="absolute start-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input
type="text"
value={pickerSearch}
onChange={e => 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
/>
</div>
<button onClick={onClosePicker} className="p-1 hover:bg-surface-tertiary rounded">
<X className="w-3.5 h-3.5 text-text-tertiary" />
</button>
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{filteredCandidates.length === 0 ? (
<p className="text-xs text-text-tertiary text-center py-2">{t('common.noResults')}</p>
) : (
filteredCandidates.slice(0, 10).map(c => (
<button
key={c._id || c.id}
onClick={() => onLink(c._id || c.id)}
disabled={linking}
className="w-full text-start px-2 py-2 text-xs rounded-lg hover:bg-surface-secondary transition-colors flex items-start gap-2 disabled:opacity-50"
>
{/* Thumbnail for artefacts */}
{!isCopy && (c.thumbnail_url || c.file_url) && (
<img src={c.thumbnail_url || c.file_url} alt="" className="w-10 h-10 rounded object-cover shrink-0" loading="lazy" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-text-primary font-medium">{c.title || t('common.untitled')}</span>
<StatusBadge status={c.status} size="xs" />
</div>
{/* Copy: show source language + content preview */}
{isCopy && (
<p className="text-text-tertiary mt-0.5 truncate">
{c.source_language && <span className="uppercase">{c.source_language} · </span>}
{(c.source_content || '').slice(0, 60)}
</p>
)}
{/* Artefact: show type */}
{!isCopy && c.type && (
<p className="text-text-tertiary mt-0.5 capitalize">{c.type}</p>
)}
</div>
</button>
))
)}
</div>
</div>
)}
</div>
)
}
+11 -102
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useContext, useMemo } from 'react' 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 { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
import { AppContext } from '../App' import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext' import { useAuth } from '../contexts/AuthContext'
@@ -7,12 +8,11 @@ import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard' import KanbanBoard from '../components/KanbanBoard'
import KanbanCard from '../components/KanbanCard' import KanbanCard from '../components/KanbanCard'
import PostCard from '../components/PostCard' import PostCard from '../components/PostCard'
import PostDetailPanel from '../components/PostDetailPanel'
import DatePresetPicker from '../components/DatePresetPicker' import DatePresetPicker from '../components/DatePresetPicker'
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader' import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
import EmptyState from '../components/EmptyState' import EmptyState from '../components/EmptyState'
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal' import Modal from '../components/Modal'
import BulkSelectBar from '../components/BulkSelectBar'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
const EMPTY_POST = { const EMPTY_POST = {
@@ -23,13 +23,13 @@ const EMPTY_POST = {
export default function PostProduction() { export default function PostProduction() {
const { t, lang } = useLanguage() const { t, lang } = useLanguage()
const navigate = useNavigate()
const { teamMembers, brands, getBrandName } = useContext(AppContext) const { teamMembers, brands, getBrandName } = useContext(AppContext)
const { canEditResource } = useAuth() const { canEditResource } = useAuth()
const toast = useToast() const toast = useToast()
const [posts, setPosts] = useState([]) const [posts, setPosts] = useState([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [view, setView] = useState('kanban') const [view, setView] = useState('kanban')
const [panelPost, setPanelPost] = useState(null)
const [campaigns, setCampaigns] = useState([]) const [campaigns, setCampaigns] = useState([])
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' }) const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
const [searchTerm, setSearchTerm] = useState('') const [searchTerm, setSearchTerm] = useState('')
@@ -38,9 +38,6 @@ export default function PostProduction() {
const [selectedIds, setSelectedIds] = useState(new Set()) const [selectedIds, setSelectedIds] = useState(new Set())
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false) const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
const [createSaving, setCreateSaving] = useState(false)
useEffect(() => { useEffect(() => {
loadPosts() 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) => { const handlePanelDelete = async (postId) => {
try { try {
await api.delete(`/posts/${postId}`) await api.delete(`/posts/${postId}`)
@@ -131,39 +114,18 @@ export default function PostProduction() {
} }
const openEdit = (post) => { const openEdit = (post) => {
if (!canEditResource('post', post)) { const postId = post._id || post.id || post.Id
toast.error(t('posts.canOnlyEditOwn')) navigate(`/posts/${postId}`)
return
}
setPanelPost(post)
} }
const openNew = () => { const openNew = async () => {
setCreateForm({ ...EMPTY_POST })
setShowCreateModal(true)
}
const handleCreate = async () => {
setCreateSaving(true)
try { try {
const data = { const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
title: createForm.title, const newId = result._id || result.id || result.Id
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)
toast.success(t('posts.created')) toast.success(t('posts.created'))
loadPosts() navigate(`/posts/${newId}`)
// Open the detail panel for further editing } catch {
if (created) setPanelPost(created)
} catch (err) {
console.error('Create post failed:', err)
toast.error(t('common.saveFailed')) toast.error(t('common.saveFailed'))
} finally {
setCreateSaving(false)
} }
} }
@@ -334,7 +296,7 @@ export default function PostProduction() {
}} }}
/> />
) : ( ) : (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{filteredPosts.length === 0 ? ( {filteredPosts.length === 0 ? (
<EmptyState <EmptyState
icon={FileText} icon={FileText}
@@ -401,59 +363,6 @@ export default function PostProduction() {
{t('common.bulkDeleteDesc')} {t('common.bulkDeleteDesc')}
</Modal> </Modal>
{/* Create Post Modal */}
<Modal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} title={t('posts.newPost')} size="md">
<div className="space-y-4">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.postTitle')} *</label>
<input type="text" value={createForm.title} onChange={e => 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 />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.brand')}</label>
<select value={createForm.brand_id} onChange={e => setCreateForm(f => ({ ...f, brand_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value="">{t('posts.allBrands')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.campaign')}</label>
<select value={createForm.campaign_id} onChange={e => setCreateForm(f => ({ ...f, campaign_id: e.target.value }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary">
<option value=""></option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.assignedTo')}</label>
<select value={createForm.assigned_to} onChange={e => setCreateForm(f => ({ ...f, assigned_to: 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">
<option value="">{t('common.unassigned')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
<button onClick={handleCreate} disabled={!createForm.title || createSaving}
className={`w-full px-4 py-2.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light disabled:opacity-50 disabled:cursor-not-allowed shadow-sm ${createSaving ? 'btn-loading' : ''}`}>
{t('posts.newPost')}
</button>
</div>
</Modal>
{/* Post Detail Panel (edit only) */}
{panelPost && (
<PostDetailPanel
post={panelPost}
onClose={() => setPanelPost(null)}
onSave={handlePanelSave}
onDelete={handlePanelDelete}
brands={brands}
teamMembers={teamMembers}
campaigns={campaigns}
/>
)}
</div> </div>
) )
} }
+3 -3
View File
@@ -223,7 +223,7 @@ export default function ProjectDetail() {
</button> </button>
{/* Project header */} {/* Project header */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Thumbnail banner */} {/* Thumbnail banner */}
{(project.thumbnail_url || project.thumbnailUrl) && ( {(project.thumbnail_url || project.thumbnailUrl) && (
<div className="relative w-full h-40 overflow-hidden"> <div className="relative w-full h-40 overflow-hidden">
@@ -411,7 +411,7 @@ export default function ProjectDetail() {
{/* ─── LIST VIEW ─── */} {/* ─── LIST VIEW ─── */}
{view === 'list' && ( {view === 'list' && (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
} }
return ( return (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
{/* Zoom toolbar */} {/* Zoom toolbar */}
<div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary"> <div className="flex items-center justify-between px-4 py-2 border-b border-border bg-surface-secondary">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
+97 -31
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom' 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 { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer' import { useToast } from '../components/ToastContainer'
import Modal from '../components/Modal' import Modal from '../components/Modal'
@@ -21,8 +21,13 @@ export default function PublicReview() {
const [error, setError] = useState('') const [error, setError] = useState('')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
const [reviewerName, setReviewerName] = useState('') const [reviewerName, setReviewerName] = useState('')
const [feedback, setFeedback] = 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 [selectedLanguage, setSelectedLanguage] = useState(0)
const [pendingAction, setPendingAction] = useState(null) const [pendingAction, setPendingAction] = useState(null)
@@ -41,8 +46,8 @@ export default function PublicReview() {
} }
const data = await res.json() const data = await res.json()
setArtefact(data) setArtefact(data)
// Auto-set reviewer name if there's exactly one approver // Auto-set reviewer name from the selected approver
if (data.approvers?.length === 1 && data.approvers[0].name) { if (data.approvers?.length > 0 && data.approvers[0].name) {
setReviewerName(data.approvers[0].name) setReviewerName(data.approvers[0].name)
} }
} catch (err) { } 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 extractDriveFileId = (url) => {
const patterns = [ const patterns = [
/\/file\/d\/([^\/]+)/, /\/file\/d\/([^\/]+)/,
@@ -157,10 +197,15 @@ export default function PublicReview() {
return ( return (
<div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4"> <div className="min-h-screen bg-surface-secondary flex items-center justify-center p-4">
<div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center"> <div className="max-w-md w-full bg-surface rounded-2xl shadow-sm p-8 text-center">
<div className="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4"> <div className={`w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4 ${successType === 'redirect' ? 'bg-blue-100' : 'bg-emerald-100'}`}>
<CheckCircle className="w-8 h-8 text-emerald-600" /> {successType === 'redirect'
? <ArrowRightLeft className="w-8 h-8 text-blue-600" />
: <CheckCircle className="w-8 h-8 text-emerald-600" />
}
</div> </div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('review.thankYou')}</h2> <h2 className="text-2xl font-bold text-text-primary mb-2">
{successType === 'redirect' ? t('review.redirected') : t('review.thankYou')}
</h2>
<p className="text-text-secondary">{success}</p> <p className="text-text-secondary">{success}</p>
</div> </div>
</div> </div>
@@ -418,31 +463,10 @@ export default function PublicReview() {
{/* Reviewer identity */} {/* Reviewer identity */}
<div> <div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label> <label className="block text-sm font-medium text-text-primary mb-1">{t('review.reviewer')}</label>
{artefact.approvers?.length === 1 ? ( <div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg">
<div className="flex items-center gap-2 px-4 py-2 bg-surface-secondary border border-border rounded-lg"> <User className="w-4 h-4 text-text-tertiary" />
<User className="w-4 h-4 text-text-tertiary" /> <span className="text-sm text-text-primary">{artefact.approvers?.[0]?.name || reviewerName || '—'}</span>
<span className="text-sm text-text-primary">{artefact.approvers[0].name}</span> </div>
</div>
) : artefact.approvers?.length > 1 ? (
<select
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
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"
>
<option value="">{t('review.selectYourName')}</option>
{artefact.approvers.map(a => (
<option key={a.id} value={a.name}>{a.name}</option>
))}
</select>
) : (
<input
type="text"
value={reviewerName}
onChange={e => 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"
/>
)}
</div> </div>
<div> <div>
@@ -483,6 +507,48 @@ export default function PublicReview() {
{t('review.reject')} {t('review.reject')}
</button> </button>
</div> </div>
{/* Redirect to another reviewer */}
<div className="pt-3 border-t border-border-light">
{!showRedirect ? (
<button
onClick={handleOpenRedirect}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 text-sm text-text-secondary hover:text-text-primary hover:bg-surface-secondary rounded-lg transition-colors"
>
<ArrowRightLeft className="w-4 h-4" />
{t('review.redirectReview')}
</button>
) : (
<div className="space-y-3">
<p className="text-sm text-text-secondary">{t('review.redirectDesc')}</p>
<select
value={redirectTo}
onChange={e => setRedirectTo(e.target.value)}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface text-text-primary"
>
<option value="">{t('review.selectNewReviewer')}</option>
{teamMembers.map(u => (
<option key={u.id} value={u.id}>{u.name}</option>
))}
</select>
<div className="flex gap-2">
<button
onClick={() => setShowRedirect(false)}
className="flex-1 px-3 py-2 text-sm text-text-secondary hover:bg-surface-secondary rounded-lg transition-colors"
>
{t('common.cancel')}
</button>
<button
onClick={handleRedirect}
disabled={!redirectTo || redirecting}
className="flex-1 px-3 py-2 text-sm bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors disabled:opacity-50"
>
{redirecting ? '...' : t('review.redirect')}
</button>
</div>
</div>
)}
</div>
</div> </div>
)} )}
+5 -5
View File
@@ -71,7 +71,7 @@ export default function Settings() {
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p> <p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
{/* General Settings */} {/* General Settings */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3> <h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
</div> </div>
@@ -115,7 +115,7 @@ export default function Settings() {
</div> </div>
{/* Uploads Section */} {/* Uploads Section */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Upload className="w-5 h-5 text-brand-primary" /> <Upload className="w-5 h-5 text-brand-primary" />
@@ -153,7 +153,7 @@ export default function Settings() {
</div> </div>
{/* Tutorial Section */} {/* Tutorial Section */}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3> <h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
</div> </div>
@@ -188,7 +188,7 @@ export default function Settings() {
{/* Budget Approval (Superadmin only) */} {/* Budget Approval (Superadmin only) */}
{user?.role === 'superadmin' && ( {user?.role === 'superadmin' && (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border"> <div className="px-6 py-4 border-b border-border">
<h3 className="font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Mail className="w-5 h-5 text-brand-primary" /> <Mail className="w-5 h-5 text-brand-primary" />
@@ -291,7 +291,7 @@ function RolesSection({ roles, loadRoles, t, toast }) {
return ( return (
<> <>
<div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-hidden"> <div className="bg-surface dark:bg-surface-primary rounded-xl border border-border overflow-clip">
<div className="px-6 py-4 border-b border-border flex items-center justify-between"> <div className="px-6 py-4 border-b border-border flex items-center justify-between">
<h3 className="font-semibold text-text-primary flex items-center gap-2"> <h3 className="font-semibold text-text-primary flex items-center gap-2">
<Tag className="w-5 h-5 text-brand-primary" /> <Tag className="w-5 h-5 text-brand-primary" />
+1 -1
View File
@@ -599,7 +599,7 @@ export default function Tasks() {
onDelete={() => setShowBulkDeleteConfirm(true)} onDelete={() => setShowBulkDeleteConfirm(true)}
/> />
)} )}
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary/50"> <tr className="border-b border-border bg-surface-secondary/50">
+2 -2
View File
@@ -533,7 +533,7 @@ export default function Team() {
const tid = team.id || team._id const tid = team.id || team._id
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid)) const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
return ( return (
<div key={tid} className="bg-surface rounded-xl border border-border overflow-hidden"> <div key={tid} className="bg-surface rounded-xl border border-border overflow-clip">
{/* Team header */} {/* Team header */}
<div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border"> <div className="flex items-center justify-between px-5 py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -603,7 +603,7 @@ export default function Team() {
{/* Unassigned members */} {/* Unassigned members */}
{unassignedMembers.length > 0 && ( {unassignedMembers.length > 0 && (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border"> <div className="flex items-center gap-3 px-5 py-4 bg-gray-50 border-b border-border">
<div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white"> <div className="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
<UserIcon className="w-5 h-5" /> <UserIcon className="w-5 h-5" />
+1 -1
View File
@@ -352,7 +352,7 @@ export default function Translations() {
<p className="text-text-secondary">{t('translations.noTranslations')}</p> <p className="text-text-secondary">{t('translations.noTranslations')}</p>
</div> </div>
) : ( ) : (
<div className="bg-surface rounded-xl border border-border overflow-hidden"> <div className="bg-surface rounded-xl border border-border overflow-clip">
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b border-border bg-surface-secondary"> <tr className="border-b border-border bg-surface-secondary">
+39
View File
@@ -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
}
@@ -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**
@@ -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)
+75
View File
@@ -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 };
+202 -11
View File
@@ -155,7 +155,7 @@ const FK_COLUMNS = {
TaskAttachments: ['task_id'], TaskAttachments: ['task_id'],
Comments: ['user_id'], Comments: ['user_id'],
BudgetEntries: ['campaign_id', 'project_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'], PostVersions: ['post_id', 'created_by_user_id'],
PostVersionTexts: ['version_id'], PostVersionTexts: ['version_id'],
Issues: ['brand_id', 'assigned_to_id', 'team_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' }], BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
Comments: [{ name: 'version_number', uidt: 'Number' }], Comments: [{ name: 'version_number', uidt: 'Number' }],
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }], 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: [ Posts: [
{ name: 'approver_ids', uidt: 'SingleLineText' }, { name: 'approver_ids', uidt: 'SingleLineText' },
{ name: 'approval_token', uidt: 'SingleLineText' }, { name: 'approval_token', uidt: 'SingleLineText' },
@@ -526,6 +530,8 @@ const TEXT_COLUMNS = {
{ name: 'feedback', uidt: 'LongText' }, { name: 'feedback', uidt: 'LongText' },
{ name: 'current_version', uidt: 'Number' }, { name: 'current_version', uidt: 'Number' },
{ name: 'review_version', uidt: 'Number' }, { name: 'review_version', uidt: 'Number' },
{ name: 'caption', uidt: 'LongText' },
{ name: 'stage', uidt: 'SingleLineText' },
], ],
PostAttachments: [{ name: 'version_id', uidt: 'Number' }], PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
BudgetRequests: [ 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) => { 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; const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids, caption } = req.body;
if (!title) return res.status(400).json({ error: 'Title is required' });
const platformsArr = platforms || (platform ? [platform] : []); const platformsArr = platforms || (platform ? [platform] : []);
try { try {
const created = await nocodb.create('Posts', { const created = await nocodb.create('Posts', {
title, description: description || null, title: title || 'Untitled', description: description || null,
status: status || 'draft', status: status || 'draft',
platform: platformsArr[0] || null, platform: platformsArr[0] || null,
platforms: JSON.stringify(platformsArr), 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, assigned_to_id: assigned_to ? Number(assigned_to) : null,
campaign_id: campaign_id ? Number(campaign_id) : null, campaign_id: campaign_id ? Number(campaign_id) : null,
approver_ids: approver_ids || null, approver_ids: approver_ids || null,
caption: caption || '',
stage: 'copy',
created_by_user_id: req.session.userId, 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' }); if (!existing) return res.status(404).json({ error: 'Post not found' });
const data = {}; 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[f] !== undefined) data[f] = req.body[f];
} }
if (req.body.platforms !== undefined) { if (req.body.platforms !== undefined) {
@@ -1401,6 +1427,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
await nocodb.update('Posts', id, data); 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 post = await nocodb.get('Posts', id);
const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approverNames = {}; 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) => { app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
try { try {
await nocodb.delete('Posts', req.params.id); 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); 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() }); res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl }); notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl });
} catch (err) { } 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) => { app.post('/api/artefacts', requireAuth, async (req, res) => {
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids } = req.body; 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' }); 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)); console.log(`[PATCH /artefacts/${req.params.id}] Updating:`, JSON.stringify(data));
await nocodb.update('Artefacts', req.params.id, 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); const artefact = await nocodb.get('Artefacts', req.params.id);
console.log(`[PATCH /artefacts/${req.params.id}] After re-read: approver_ids=${artefact.approver_ids}`); 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) : []; 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' }); 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 token = require('crypto').randomUUID();
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays); 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, 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() }); res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl }); notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl });
} catch (err) { } 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) => { app.post('/api/public/review/:token/comment', async (req, res) => {
const { comment, author_name } = req.body; const { comment, author_name } = req.body;
if (!comment) return res.status(400).json({ error: 'Comment is required' }); 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 // Create translation
app.post('/api/translations', requireAuth, async (req, res) => { 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 (!title) return res.status(400).json({ error: 'Title is required' });
if (!source_language) return res.status(400).json({ error: 'Source language is required' }); if (!source_language) return res.status(400).json({ error: 'Source language is required' });
if (!source_content) return res.status(400).json({ error: 'Source content is required' }); if (!source_content) return res.status(400).json({ error: 'Source content is required' });
@@ -4801,6 +4975,7 @@ app.post('/api/translations', requireAuth, async (req, res) => {
brand_id: brand_id ? Number(brand_id) : null, brand_id: brand_id ? Number(brand_id) : null,
post_id: post_id ? Number(post_id) : null, post_id: post_id ? Number(post_id) : null,
approver_ids: approver_ids || null, approver_ids: approver_ids || null,
copy_type: copy_type || null,
created_by_user_id: req.session.userId, created_by_user_id: req.session.userId,
}); });
@@ -4852,7 +5027,7 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
} }
const data = {}; 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[f] !== undefined) data[f] = req.body[f];
} }
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null; if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
@@ -4863,6 +5038,21 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
await nocodb.update('Translations', req.params.id, data); 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 record = await nocodb.get('Translations', req.params.id);
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : []; const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
const approvers = []; const approvers = [];
@@ -5074,7 +5264,8 @@ app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) =>
token_expires_at: expiresAt.toISOString(), 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() }); res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl }); notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
} catch (err) { } catch (err) {