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>
@@ -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
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 207 KiB |
@@ -15,6 +15,7 @@ import { useKeyboardShortcuts, DEFAULT_SHORTCUTS } from './hooks/useKeyboardShor
|
||||
// Lazy-loaded page components
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const PostProduction = lazy(() => import('./pages/PostProduction'))
|
||||
const PostDetail = lazy(() => import('./pages/PostDetail'))
|
||||
const Assets = lazy(() => import('./pages/Assets'))
|
||||
const Campaigns = lazy(() => import('./pages/Campaigns'))
|
||||
const CampaignDetail = lazy(() => import('./pages/CampaignDetail'))
|
||||
@@ -303,6 +304,7 @@ function AppContent() {
|
||||
<Route path="/" element={user ? <Layout /> : <Navigate to="/login" replace />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
{hasModule('marketing') && <>
|
||||
<Route path="posts/:id" element={<PostDetail />} />
|
||||
<Route path="posts" element={<PostProduction />} />
|
||||
<Route path="calendar" element={<PostCalendar />} />
|
||||
<Route path="artefacts" element={<Artefacts />} />
|
||||
|
||||
@@ -412,18 +412,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -508,11 +496,33 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
|
||||
{/* Review Tab */}
|
||||
{activeTab === 'review' && (
|
||||
<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 */}
|
||||
{['draft', 'revision_requested', 'rejected'].includes(artefact.status) && (
|
||||
<button
|
||||
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"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function AssetCard({ asset, onClick }) {
|
||||
return (
|
||||
<div
|
||||
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 */}
|
||||
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
|
||||
|
||||
@@ -41,7 +41,7 @@ export default function CampaignCalendar({ campaigns = [] }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
|
||||
@@ -22,7 +22,7 @@ const PAGE_TITLE_KEYS = {
|
||||
'/issues': 'header.issues',
|
||||
'/team': 'header.team',
|
||||
'/settings': 'header.settings',
|
||||
'/translations': 'header.translations',
|
||||
'/translations': 'header.copy',
|
||||
}
|
||||
|
||||
const ROLE_INFO = {
|
||||
@@ -45,6 +45,7 @@ export default function Header() {
|
||||
|
||||
function getPageTitle(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('/campaigns/')) return t('header.campaignDetails')
|
||||
return t('header.page')
|
||||
|
||||
@@ -246,7 +246,7 @@ export default function InteractiveTimeline({ items = [], mapItem, onDateChange,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Toolbar */}
|
||||
<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">
|
||||
|
||||
@@ -125,8 +125,8 @@ export default function Modal({
|
||||
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="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<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="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>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -137,7 +137,7 @@ export default function Modal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
<div className="px-6 py-4">
|
||||
{children}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function PostDetailVersions({
|
||||
{versionData.texts.map(text => {
|
||||
const tId = text.Id || text.id || text._id
|
||||
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 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>
|
||||
@@ -243,7 +243,7 @@ export function PostDetailVersions({
|
||||
const isImage = mime.startsWith('image/')
|
||||
const isVideo = mime.startsWith('video/')
|
||||
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 ? (
|
||||
<a href={attUrl} target="_blank" rel="noopener noreferrer">
|
||||
<img src={attUrl} alt={name} className="w-full h-44 object-cover" loading="lazy" />
|
||||
|
||||
@@ -35,7 +35,7 @@ const moduleGroups = [
|
||||
{ to: '/artefacts', icon: Palette, labelKey: 'nav.artefacts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/brands', icon: Tag, labelKey: 'nav.brands' },
|
||||
{ to: '/translations', icon: Languages, labelKey: 'nav.translations' },
|
||||
{ to: '/translations', icon: Languages, labelKey: 'nav.copy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -25,7 +25,7 @@ export function SkeletonStatCard() {
|
||||
|
||||
export function SkeletonTable({ rows = 5, cols = 6 }) {
|
||||
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="flex gap-4">
|
||||
{[...Array(cols)].map((_, i) => (
|
||||
@@ -78,7 +78,7 @@ export function SkeletonKanbanBoard() {
|
||||
|
||||
export function SkeletonCalendar() {
|
||||
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="h-6 bg-surface-tertiary rounded w-40"></div>
|
||||
<div className="h-8 bg-surface-tertiary rounded w-20"></div>
|
||||
|
||||
@@ -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
|
||||
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 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{header}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
<div className="sticky top-0 z-10 bg-surface">{header}</div>
|
||||
<div className="flex-1">{children}</div>
|
||||
{footer}
|
||||
</div>
|
||||
</>,
|
||||
|
||||
@@ -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 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 */}
|
||||
<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="flex items-start justify-between gap-4">
|
||||
<div id="tabbed-modal-title" className="flex-1 min-w-0">
|
||||
@@ -111,13 +111,13 @@ export default function TabbedModal({
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto" role="tabpanel">
|
||||
<div role="tabpanel">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -78,6 +78,29 @@
|
||||
"posts.saveChanges": "حفظ التغييرات",
|
||||
"posts.postTitle": "العنوان",
|
||||
"posts.description": "الوصف",
|
||||
"post.caption": "التعليق",
|
||||
"post.captionPlaceholder": "اكتب تعليق المنشور...",
|
||||
"post.copy": "النص (داخل التصميم)",
|
||||
"post.designs": "التصاميم",
|
||||
"post.video": "الفيديو",
|
||||
"post.formatChecklist": "قائمة الأحجام المطلوبة",
|
||||
"post.formatsNeeded": "الأحجام المطلوبة بناءً على المنصات المختارة",
|
||||
"post.selectPlatforms": "اختر المنصات لعرض الأحجام المطلوبة",
|
||||
"post.readiness": "الجاهزية",
|
||||
"post.allPiecesReady": "جميع العناصر جاهزة — بانتظار الاعتماد",
|
||||
"post.waitingOn": "بانتظار",
|
||||
"post.signOff": "اعتماد وجدولة",
|
||||
"post.signOffConfirm": "هل تريد اعتماد هذا المنشور وتجهيزه للجدولة؟",
|
||||
"common.confirm": "تأكيد",
|
||||
"post.linkExisting": "ربط موجود",
|
||||
"post.createNew": "إنشاء جديد",
|
||||
"post.addDesign": "إضافة تصميم",
|
||||
"post.addVideo": "إضافة فيديو",
|
||||
"post.linkTranslation": "ربط ترجمة",
|
||||
"post.selectLanguage": "اللغة...",
|
||||
"post.noCopyLinked": "لا يوجد نص مرتبط بعد",
|
||||
"post.noDesignsLinked": "لا توجد تصاميم مرتبطة بعد",
|
||||
"post.noVideoLinked": "لا يوجد فيديو مرتبط بعد",
|
||||
"posts.brand": "العلامة التجارية",
|
||||
"posts.platforms": "المنصات",
|
||||
"posts.status": "الحالة",
|
||||
@@ -701,6 +724,11 @@
|
||||
"review.confirmReject": "هل تريد رفض هذا المحتوى؟",
|
||||
"review.feedbackRequired": "يرجى تقديم ملاحظات لطلب التعديل",
|
||||
"review.contentLanguages": "لغات المحتوى",
|
||||
"review.redirectReview": "لست المراجع المناسب؟ أعد التوجيه لشخص آخر",
|
||||
"review.redirectDesc": "اختر عضو فريق لإعادة توجيه المراجعة إليه:",
|
||||
"review.selectNewReviewer": "اختر مراجعاً جديداً...",
|
||||
"review.redirect": "إعادة توجيه",
|
||||
"review.redirected": "تم إعادة توجيه المراجعة بنجاح",
|
||||
"review.content": "المحتوى",
|
||||
"review.designFiles": "ملفات التصميم",
|
||||
"review.videos": "الفيديوهات",
|
||||
@@ -783,6 +811,8 @@
|
||||
"header.issues": "البلاغات",
|
||||
"header.settings": "الإعدادات",
|
||||
"header.translations": "الترجمات",
|
||||
"header.copy": "النسخ",
|
||||
"header.postDetails": "تفاصيل المنشور",
|
||||
"calendar.unscheduledPosts": "منشورات غير مجدولة",
|
||||
"calendar.statusLegend": "دليل الحالات",
|
||||
"header.users": "إدارة المستخدمين",
|
||||
@@ -882,6 +912,8 @@
|
||||
"artefacts.descriptionLabel": "الوصف",
|
||||
"artefacts.descriptionFieldPlaceholder": "أضف وصفاً...",
|
||||
"artefacts.approversLabel": "المعتمدون",
|
||||
"artefacts.reviewer": "المراجع",
|
||||
"artefacts.selectReviewer": "اختر مراجعاً...",
|
||||
"artefacts.versions": "الإصدارات",
|
||||
"artefacts.newVersion": "إصدار جديد",
|
||||
"artefacts.languages": "اللغات",
|
||||
@@ -1133,5 +1165,21 @@
|
||||
"translations.createPost": "منشور جديد",
|
||||
"translations.newPostTitle": "عنوان المنشور...",
|
||||
"translations.postCreated": "تم إنشاء المنشور!",
|
||||
"translations.postCreateFailed": "فشل إنشاء المنشور"
|
||||
"translations.postCreateFailed": "فشل إنشاء المنشور",
|
||||
|
||||
"nav.copy": "النسخ",
|
||||
|
||||
"postDetail.captionCopy": "نص التسمية التوضيحية",
|
||||
"postDetail.bodyCopy": "النص الرئيسي",
|
||||
"postDetail.design": "التصميم",
|
||||
"postDetail.video": "الفيديو",
|
||||
"postDetail.readiness": "الجاهزية",
|
||||
"postDetail.noAssets": "لا توجد أصول مرتبطة بعد",
|
||||
"postDetail.allPiecesApproved": "جميع العناصر معتمدة",
|
||||
"postDetail.waitingOn": "بانتظار",
|
||||
"postDetail.notLinked": "غير مرتبط",
|
||||
"postDetail.linkExisting": "ربط موجود",
|
||||
"postDetail.createNew": "إنشاء جديد",
|
||||
"postDetail.open": "فتح",
|
||||
"postDetail.unlink": "إلغاء الربط"
|
||||
}
|
||||
@@ -78,6 +78,29 @@
|
||||
"posts.saveChanges": "Save Changes",
|
||||
"posts.postTitle": "Title",
|
||||
"posts.description": "Description",
|
||||
"post.caption": "Caption",
|
||||
"post.captionPlaceholder": "Write your social media caption...",
|
||||
"post.copy": "Copy (In-Design Text)",
|
||||
"post.designs": "Designs",
|
||||
"post.video": "Video",
|
||||
"post.formatChecklist": "Format Checklist",
|
||||
"post.formatsNeeded": "Formats needed based on selected platforms",
|
||||
"post.selectPlatforms": "Select platforms to see required formats",
|
||||
"post.readiness": "Readiness",
|
||||
"post.allPiecesReady": "All pieces ready — awaiting sign-off",
|
||||
"post.waitingOn": "Waiting on",
|
||||
"post.signOff": "Approve & Schedule",
|
||||
"post.signOffConfirm": "Mark this post as approved and ready for scheduling?",
|
||||
"common.confirm": "Confirm",
|
||||
"post.linkExisting": "Link existing",
|
||||
"post.createNew": "Create new",
|
||||
"post.addDesign": "Add Design",
|
||||
"post.addVideo": "Add Video",
|
||||
"post.linkTranslation": "Link Translation",
|
||||
"post.selectLanguage": "Language...",
|
||||
"post.noCopyLinked": "No copy linked yet",
|
||||
"post.noDesignsLinked": "No designs linked yet",
|
||||
"post.noVideoLinked": "No video linked yet",
|
||||
"posts.brand": "Brand",
|
||||
"posts.platforms": "Platforms",
|
||||
"posts.status": "Status",
|
||||
@@ -701,6 +724,11 @@
|
||||
"review.confirmReject": "Reject this artefact?",
|
||||
"review.feedbackRequired": "Please provide feedback for revision request",
|
||||
"review.contentLanguages": "Content Languages",
|
||||
"review.redirectReview": "Not the right reviewer? Redirect to someone else",
|
||||
"review.redirectDesc": "Select a team member to redirect this review to:",
|
||||
"review.selectNewReviewer": "Select new reviewer...",
|
||||
"review.redirect": "Redirect",
|
||||
"review.redirected": "Review redirected successfully",
|
||||
"review.content": "Content",
|
||||
"review.designFiles": "Design Files",
|
||||
"review.videos": "Videos",
|
||||
@@ -783,6 +811,8 @@
|
||||
"header.issues": "Issues",
|
||||
"header.settings": "Settings",
|
||||
"header.translations": "Translations",
|
||||
"header.copy": "Copy",
|
||||
"header.postDetails": "Post Details",
|
||||
"calendar.unscheduledPosts": "Unscheduled Posts",
|
||||
"calendar.statusLegend": "Status Legend",
|
||||
"header.users": "User Management",
|
||||
@@ -882,6 +912,8 @@
|
||||
"artefacts.descriptionLabel": "Description",
|
||||
"artefacts.descriptionFieldPlaceholder": "Add a description...",
|
||||
"artefacts.approversLabel": "Approvers",
|
||||
"artefacts.reviewer": "Reviewer",
|
||||
"artefacts.selectReviewer": "Select a reviewer...",
|
||||
"artefacts.versions": "Versions",
|
||||
"artefacts.newVersion": "New Version",
|
||||
"artefacts.languages": "Languages",
|
||||
@@ -1133,5 +1165,21 @@
|
||||
"translations.createPost": "New Post",
|
||||
"translations.newPostTitle": "Post title...",
|
||||
"translations.postCreated": "Post created!",
|
||||
"translations.postCreateFailed": "Failed to create post"
|
||||
"translations.postCreateFailed": "Failed to create post",
|
||||
|
||||
"nav.copy": "Copy",
|
||||
|
||||
"postDetail.captionCopy": "Caption Copy",
|
||||
"postDetail.bodyCopy": "Body Copy",
|
||||
"postDetail.design": "Design",
|
||||
"postDetail.video": "Video",
|
||||
"postDetail.readiness": "Readiness",
|
||||
"postDetail.noAssets": "No assets linked yet",
|
||||
"postDetail.allPiecesApproved": "All pieces approved",
|
||||
"postDetail.waitingOn": "Waiting on",
|
||||
"postDetail.notLinked": "Not linked",
|
||||
"postDetail.linkExisting": "Link existing",
|
||||
"postDetail.createNew": "Create new",
|
||||
"postDetail.open": "Open",
|
||||
"postDetail.unlink": "Unlink"
|
||||
}
|
||||
@@ -504,7 +504,7 @@ textarea {
|
||||
background: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
|
||||
transition: box-shadow 0.3s ease;
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ export default function Brands() {
|
||||
return (
|
||||
<div
|
||||
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)}
|
||||
>
|
||||
{/* Logo area */}
|
||||
|
||||
@@ -14,7 +14,6 @@ import BudgetBar from '../components/BudgetBar'
|
||||
import CommentsSection from '../components/CommentsSection'
|
||||
import CampaignDetailPanel from '../components/CampaignDetailPanel'
|
||||
import TrackDetailPanel from '../components/TrackDetailPanel'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
|
||||
const TRACK_TYPES = {
|
||||
organic_social: { label: 'Organic Social', icon: Megaphone, color: 'text-green-600 bg-green-50', hasBudget: false },
|
||||
@@ -46,7 +45,6 @@ export default function CampaignDetail() {
|
||||
const [budgetValue, setBudgetValue] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [trackToDelete, setTrackToDelete] = useState(null)
|
||||
const [selectedPost, setSelectedPost] = useState(null)
|
||||
const [showDiscussion, setShowDiscussion] = useState(false)
|
||||
const [allCampaigns, setAllCampaigns] = useState([])
|
||||
|
||||
@@ -153,21 +151,6 @@ export default function CampaignDetail() {
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const handlePostPanelSave = async (postId, data) => {
|
||||
if (postId) {
|
||||
await api.patch(`/posts/${postId}`, data)
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
}
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const handlePostPanelDelete = async (postId) => {
|
||||
await api.delete(`/posts/${postId}`)
|
||||
setSelectedPost(null)
|
||||
loadAll()
|
||||
}
|
||||
|
||||
const deleteTrack = async (trackId) => {
|
||||
setTrackToDelete(trackId)
|
||||
setShowDeleteConfirm(true)
|
||||
@@ -301,7 +284,7 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="font-semibold text-text-primary">{t('campaigns.tracks')}</h3>
|
||||
{canManage && (
|
||||
@@ -434,7 +417,7 @@ export default function CampaignDetail() {
|
||||
|
||||
{/* Linked Posts */}
|
||||
{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">
|
||||
<h3 className="font-semibold text-text-primary">{t('campaigns.linkedPosts')} ({posts.length})</h3>
|
||||
</div>
|
||||
@@ -442,7 +425,7 @@ export default function CampaignDetail() {
|
||||
{posts.map(post => (
|
||||
<div
|
||||
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"
|
||||
>
|
||||
{post.thumbnail_url && (
|
||||
@@ -589,19 +572,6 @@ export default function CampaignDetail() {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Post Detail Panel */}
|
||||
{selectedPost && (
|
||||
<PostDetailPanel
|
||||
post={selectedPost}
|
||||
onClose={() => setSelectedPost(null)}
|
||||
onSave={handlePostPanelSave}
|
||||
onDelete={handlePostPanelDelete}
|
||||
brands={brands}
|
||||
teamMembers={teamMembers}
|
||||
campaigns={allCampaigns}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Campaign Edit Panel */}
|
||||
{panelCampaign && (
|
||||
<CampaignDetailPanel
|
||||
|
||||
@@ -264,7 +264,7 @@ export default function Campaigns() {
|
||||
/>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="font-semibold text-text-primary">All Campaigns</h3>
|
||||
</div>
|
||||
|
||||
@@ -194,7 +194,7 @@ export default function PostCalendar() {
|
||||
</div>
|
||||
|
||||
{/* Calendar */}
|
||||
<div className="bg-surface rounded-xl border border-border overflow-hidden">
|
||||
<div className="bg-surface rounded-xl border border-border overflow-clip">
|
||||
{/* Nav */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Plus, LayoutGrid, List, Search, X, FileText, Filter } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
@@ -7,12 +8,11 @@ import { api, PLATFORMS } from '../utils/api'
|
||||
import KanbanBoard from '../components/KanbanBoard'
|
||||
import KanbanCard from '../components/KanbanCard'
|
||||
import PostCard from '../components/PostCard'
|
||||
import PostDetailPanel from '../components/PostDetailPanel'
|
||||
import DatePresetPicker from '../components/DatePresetPicker'
|
||||
import { SkeletonKanbanBoard, SkeletonTable } from '../components/SkeletonLoader'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import Modal from '../components/Modal'
|
||||
import BulkSelectBar from '../components/BulkSelectBar'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
|
||||
const EMPTY_POST = {
|
||||
@@ -23,13 +23,13 @@ const EMPTY_POST = {
|
||||
|
||||
export default function PostProduction() {
|
||||
const { t, lang } = useLanguage()
|
||||
const navigate = useNavigate()
|
||||
const { teamMembers, brands, getBrandName } = useContext(AppContext)
|
||||
const { canEditResource } = useAuth()
|
||||
const toast = useToast()
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [panelPost, setPanelPost] = useState(null)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '', periodFrom: '', periodTo: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
@@ -38,9 +38,6 @@ export default function PostProduction() {
|
||||
const [selectedIds, setSelectedIds] = useState(new Set())
|
||||
const [showBulkDeleteConfirm, setShowBulkDeleteConfirm] = useState(false)
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ ...EMPTY_POST })
|
||||
const [createSaving, setCreateSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
@@ -78,20 +75,6 @@ export default function PostProduction() {
|
||||
}
|
||||
}
|
||||
|
||||
const handlePanelSave = async (postId, data) => {
|
||||
let result
|
||||
if (postId) {
|
||||
result = await api.patch(`/posts/${postId}`, data)
|
||||
toast.success(t('posts.updated'))
|
||||
} else {
|
||||
result = await api.post('/posts', data)
|
||||
toast.success(t('posts.created'))
|
||||
}
|
||||
loadPosts()
|
||||
// Update panel with fresh server data so form stays in sync
|
||||
if (result && postId) setPanelPost(result)
|
||||
}
|
||||
|
||||
const handlePanelDelete = async (postId) => {
|
||||
try {
|
||||
await api.delete(`/posts/${postId}`)
|
||||
@@ -131,39 +114,18 @@ export default function PostProduction() {
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
toast.error(t('posts.canOnlyEditOwn'))
|
||||
return
|
||||
}
|
||||
setPanelPost(post)
|
||||
const postId = post._id || post.id || post.Id
|
||||
navigate(`/posts/${postId}`)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setCreateForm({ ...EMPTY_POST })
|
||||
setShowCreateModal(true)
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
setCreateSaving(true)
|
||||
const openNew = async () => {
|
||||
try {
|
||||
const data = {
|
||||
title: createForm.title,
|
||||
brand_id: createForm.brand_id ? Number(createForm.brand_id) : null,
|
||||
campaign_id: createForm.campaign_id ? Number(createForm.campaign_id) : null,
|
||||
assigned_to: createForm.assigned_to ? Number(createForm.assigned_to) : null,
|
||||
status: 'draft',
|
||||
}
|
||||
const created = await api.post('/posts', data)
|
||||
setShowCreateModal(false)
|
||||
const result = await api.post('/posts', { title: '', status: 'draft', platforms: [] })
|
||||
const newId = result._id || result.id || result.Id
|
||||
toast.success(t('posts.created'))
|
||||
loadPosts()
|
||||
// Open the detail panel for further editing
|
||||
if (created) setPanelPost(created)
|
||||
} catch (err) {
|
||||
console.error('Create post failed:', err)
|
||||
navigate(`/posts/${newId}`)
|
||||
} catch {
|
||||
toast.error(t('common.saveFailed'))
|
||||
} finally {
|
||||
setCreateSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -334,7 +296,7 @@ export default function PostProduction() {
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<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 ? (
|
||||
<EmptyState
|
||||
icon={FileText}
|
||||
@@ -401,59 +363,6 @@ export default function PostProduction() {
|
||||
{t('common.bulkDeleteDesc')}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -223,7 +223,7 @@ export default function ProjectDetail() {
|
||||
</button>
|
||||
|
||||
{/* 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 */}
|
||||
{(project.thumbnail_url || project.thumbnailUrl) && (
|
||||
<div className="relative w-full h-40 overflow-hidden">
|
||||
@@ -411,7 +411,7 @@ export default function ProjectDetail() {
|
||||
|
||||
{/* ─── LIST VIEW ─── */}
|
||||
{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">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
@@ -666,7 +666,7 @@ function GanttView({ tasks, project, onEditTask, onTaskColorChange }) {
|
||||
}
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User } from 'lucide-react'
|
||||
import { CheckCircle, XCircle, AlertCircle, FileText, Image as ImageIcon, Film, Sparkles, Globe, User, ArrowRightLeft } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { useToast } from '../components/ToastContainer'
|
||||
import Modal from '../components/Modal'
|
||||
@@ -21,8 +21,13 @@ export default function PublicReview() {
|
||||
const [error, setError] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [success, setSuccess] = useState('')
|
||||
const [successType, setSuccessType] = useState('review') // 'review' | 'redirect'
|
||||
const [reviewerName, setReviewerName] = useState('')
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [showRedirect, setShowRedirect] = useState(false)
|
||||
const [redirectTo, setRedirectTo] = useState('')
|
||||
const [teamMembers, setTeamMembers] = useState([])
|
||||
const [redirecting, setRedirecting] = useState(false)
|
||||
const [selectedLanguage, setSelectedLanguage] = useState(0)
|
||||
const [pendingAction, setPendingAction] = useState(null)
|
||||
|
||||
@@ -41,8 +46,8 @@ export default function PublicReview() {
|
||||
}
|
||||
const data = await res.json()
|
||||
setArtefact(data)
|
||||
// Auto-set reviewer name if there's exactly one approver
|
||||
if (data.approvers?.length === 1 && data.approvers[0].name) {
|
||||
// Auto-set reviewer name from the selected approver
|
||||
if (data.approvers?.length > 0 && data.approvers[0].name) {
|
||||
setReviewerName(data.approvers[0].name)
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -102,6 +107,41 @@ export default function PublicReview() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleOpenRedirect = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-redirect/${token}/team`)
|
||||
const data = await res.json()
|
||||
setTeamMembers(Array.isArray(data) ? data : [])
|
||||
setShowRedirect(true)
|
||||
} catch {
|
||||
toast.error(t('review.actionFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
const handleRedirect = async () => {
|
||||
if (!redirectTo) return
|
||||
setRedirecting(true)
|
||||
try {
|
||||
const res = await fetch(`/api/public/review-redirect/${token}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ new_approver_id: Number(redirectTo) }),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!res.ok) {
|
||||
toast.error(data.error || t('review.actionFailed'))
|
||||
return
|
||||
}
|
||||
setSuccessType('redirect')
|
||||
setSuccess(data.message || t('review.redirected'))
|
||||
setShowRedirect(false)
|
||||
} catch {
|
||||
toast.error(t('review.actionFailed'))
|
||||
} finally {
|
||||
setRedirecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const extractDriveFileId = (url) => {
|
||||
const patterns = [
|
||||
/\/file\/d\/([^\/]+)/,
|
||||
@@ -157,10 +197,15 @@ export default function PublicReview() {
|
||||
return (
|
||||
<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="w-16 h-16 rounded-full bg-emerald-100 flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
<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'}`}>
|
||||
{successType === 'redirect'
|
||||
? <ArrowRightLeft className="w-8 h-8 text-blue-600" />
|
||||
: <CheckCircle className="w-8 h-8 text-emerald-600" />
|
||||
}
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -418,31 +463,10 @@ export default function PublicReview() {
|
||||
{/* Reviewer identity */}
|
||||
<div>
|
||||
<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">
|
||||
<User className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">{artefact.approvers[0].name}</span>
|
||||
<span className="text-sm text-text-primary">{artefact.approvers?.[0]?.name || reviewerName || '—'}</span>
|
||||
</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>
|
||||
@@ -483,6 +507,48 @@ export default function PublicReview() {
|
||||
{t('review.reject')}
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function Settings() {
|
||||
<p className="text-sm text-text-tertiary">{t('settings.preferences')}</p>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="font-semibold text-text-primary">{t('settings.general')}</h3>
|
||||
</div>
|
||||
@@ -115,7 +115,7 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Upload className="w-5 h-5 text-brand-primary" />
|
||||
@@ -153,7 +153,7 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="font-semibold text-text-primary">{t('settings.onboardingTutorial')}</h3>
|
||||
</div>
|
||||
@@ -188,7 +188,7 @@ export default function Settings() {
|
||||
|
||||
{/* Budget Approval (Superadmin only) */}
|
||||
{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">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Mail className="w-5 h-5 text-brand-primary" />
|
||||
@@ -291,7 +291,7 @@ function RolesSection({ roles, loadRoles, t, toast }) {
|
||||
|
||||
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">
|
||||
<h3 className="font-semibold text-text-primary flex items-center gap-2">
|
||||
<Tag className="w-5 h-5 text-brand-primary" />
|
||||
|
||||
@@ -599,7 +599,7 @@ export default function Tasks() {
|
||||
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">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary/50">
|
||||
|
||||
@@ -533,7 +533,7 @@ export default function Team() {
|
||||
const tid = team.id || team._id
|
||||
const members = teamMembers.filter(m => m.teams?.some(t => t.id === tid))
|
||||
return (
|
||||
<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 */}
|
||||
<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">
|
||||
@@ -603,7 +603,7 @@ export default function Team() {
|
||||
|
||||
{/* Unassigned members */}
|
||||
{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="w-10 h-10 rounded-lg bg-gray-400 flex items-center justify-center text-white">
|
||||
<UserIcon className="w-5 h-5" />
|
||||
|
||||
@@ -352,7 +352,7 @@ export default function Translations() {
|
||||
<p className="text-text-secondary">{t('translations.noTranslations')}</p>
|
||||
</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">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
|
||||
@@ -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)
|
||||
@@ -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 };
|
||||
@@ -155,7 +155,7 @@ const FK_COLUMNS = {
|
||||
TaskAttachments: ['task_id'],
|
||||
Comments: ['user_id'],
|
||||
BudgetEntries: ['campaign_id', 'project_id'],
|
||||
Artefacts: ['project_id', 'campaign_id'],
|
||||
Artefacts: ['project_id', 'campaign_id', 'post_id'],
|
||||
PostVersions: ['post_id', 'created_by_user_id'],
|
||||
PostVersionTexts: ['version_id'],
|
||||
Issues: ['brand_id', 'assigned_to_id', 'team_id'],
|
||||
@@ -516,7 +516,11 @@ const TEXT_COLUMNS = {
|
||||
BudgetEntries: [{ name: 'project_id', uidt: 'Number' }, { name: 'destination', uidt: 'SingleLineText' }, { name: 'type', uidt: 'SingleLineText' }],
|
||||
Comments: [{ name: 'version_number', uidt: 'Number' }],
|
||||
Issues: [{ name: 'thumbnail', uidt: 'SingleLineText' }],
|
||||
Artefacts: [{ name: 'approver_ids', uidt: 'SingleLineText' }],
|
||||
Translations: [{ name: 'copy_type', uidt: 'SingleLineText' }],
|
||||
Artefacts: [
|
||||
{ name: 'approver_ids', uidt: 'SingleLineText' },
|
||||
{ name: 'thumbnail_url', uidt: 'SingleLineText' },
|
||||
],
|
||||
Posts: [
|
||||
{ name: 'approver_ids', uidt: 'SingleLineText' },
|
||||
{ name: 'approval_token', uidt: 'SingleLineText' },
|
||||
@@ -526,6 +530,8 @@ const TEXT_COLUMNS = {
|
||||
{ name: 'feedback', uidt: 'LongText' },
|
||||
{ name: 'current_version', uidt: 'Number' },
|
||||
{ name: 'review_version', uidt: 'Number' },
|
||||
{ name: 'caption', uidt: 'LongText' },
|
||||
{ name: 'stage', uidt: 'SingleLineText' },
|
||||
],
|
||||
PostAttachments: [{ name: 'version_id', uidt: 'Number' }],
|
||||
BudgetRequests: [
|
||||
@@ -1286,14 +1292,32 @@ app.get('/api/posts', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get single post
|
||||
app.get('/api/posts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const post = await nocodb.get('Posts', req.params.id);
|
||||
if (!post) return res.status(404).json({ error: 'Post not found' });
|
||||
const enriched = { ...post };
|
||||
enriched.brand_name = await getRecordName('Brands', post.brand_id);
|
||||
enriched.assigned_name = await getRecordName('Users', post.assigned_to_id || post.assigned_to);
|
||||
enriched.campaign_name = await getRecordName('Campaigns', post.campaign_id);
|
||||
enriched.creator_name = await getRecordName('Users', post.created_by_user_id);
|
||||
// Parse platforms
|
||||
try { enriched.platforms = JSON.parse(post.platforms || '[]'); } catch { enriched.platforms = post.platform ? [post.platform] : []; }
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
console.error('GET /posts/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load post' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/posts', requireAuth, async (req, res) => {
|
||||
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
const { title, description, brand_id, assigned_to, status, platform, platforms, content_type, scheduled_date, notes, campaign_id, approver_ids, caption } = req.body;
|
||||
|
||||
const platformsArr = platforms || (platform ? [platform] : []);
|
||||
try {
|
||||
const created = await nocodb.create('Posts', {
|
||||
title, description: description || null,
|
||||
title: title || 'Untitled', description: description || null,
|
||||
status: status || 'draft',
|
||||
platform: platformsArr[0] || null,
|
||||
platforms: JSON.stringify(platformsArr),
|
||||
@@ -1305,6 +1329,8 @@ app.post('/api/posts', requireAuth, async (req, res) => {
|
||||
assigned_to_id: assigned_to ? Number(assigned_to) : null,
|
||||
campaign_id: campaign_id ? Number(campaign_id) : null,
|
||||
approver_ids: approver_ids || null,
|
||||
caption: caption || '',
|
||||
stage: 'copy',
|
||||
created_by_user_id: req.session.userId,
|
||||
});
|
||||
|
||||
@@ -1354,7 +1380,7 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
|
||||
if (!existing) return res.status(404).json({ error: 'Post not found' });
|
||||
|
||||
const data = {};
|
||||
for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes']) {
|
||||
for (const f of ['title', 'description', 'status', 'platform', 'content_type', 'scheduled_date', 'published_date', 'notes', 'caption']) {
|
||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||
}
|
||||
if (req.body.platforms !== undefined) {
|
||||
@@ -1401,6 +1427,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
|
||||
|
||||
await nocodb.update('Posts', id, data);
|
||||
|
||||
// Auto-update stage
|
||||
try {
|
||||
const { getPostComposition, computeStage } = require('./post-composition');
|
||||
const composition = await getPostComposition(req.params.id);
|
||||
if (composition) {
|
||||
const newStage = computeStage(composition);
|
||||
await nocodb.update('Posts', Number(req.params.id), { stage: newStage });
|
||||
}
|
||||
} catch (stageErr) {
|
||||
console.error('Stage auto-update error:', stageErr);
|
||||
}
|
||||
|
||||
const post = await nocodb.get('Posts', id);
|
||||
const approverIdList = post.approver_ids ? post.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const approverNames = {};
|
||||
@@ -1422,6 +1460,18 @@ app.patch('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/posts/:id/composition', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const { getPostComposition } = require('./post-composition');
|
||||
const composition = await getPostComposition(req.params.id);
|
||||
if (!composition) return res.status(404).json({ error: 'Post not found' });
|
||||
res.json(composition);
|
||||
} catch (err) {
|
||||
console.error('Composition error:', err);
|
||||
res.status(500).json({ error: 'Failed to load composition' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/posts/:id', requireAuth, requireOwnerOrRole('posts', 'superadmin', 'manager'), async (req, res) => {
|
||||
try {
|
||||
await nocodb.delete('Posts', req.params.id);
|
||||
@@ -1578,7 +1628,8 @@ app.post('/api/posts/:id/submit-review', requireAuth, requireOwnerOrRole('posts'
|
||||
}
|
||||
await nocodb.update('Posts', req.params.id, updateData);
|
||||
|
||||
const reviewUrl = `${req.protocol}://${req.get('host')}/review-post/${token}`;
|
||||
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
|
||||
const reviewUrl = `${appUrl}/review-post/${token}`;
|
||||
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
||||
notify.notifyReviewSubmitted({ type: 'post', record: { ...existing, ...updateData }, reviewUrl });
|
||||
} catch (err) {
|
||||
@@ -3913,6 +3964,28 @@ app.get('/api/artefacts', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get single artefact
|
||||
app.get('/api/artefacts/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const artefact = await nocodb.get('Artefacts', req.params.id);
|
||||
if (!artefact) return res.status(404).json({ error: 'Artefact not found' });
|
||||
const enriched = { ...artefact };
|
||||
enriched.brand_name = await getRecordName('Brands', artefact.brand_id);
|
||||
enriched.creator_name = await getRecordName('Users', artefact.created_by_user_id);
|
||||
enriched.project_name = await getRecordName('Projects', artefact.project_id);
|
||||
enriched.campaign_name = await getRecordName('Campaigns', artefact.campaign_id);
|
||||
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
enriched.approvers = [];
|
||||
for (const aid of approverIdList) {
|
||||
enriched.approvers.push({ id: Number(aid), name: await getRecordName('Users', Number(aid)) });
|
||||
}
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
console.error('GET /artefacts/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load artefact' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/artefacts', requireAuth, async (req, res) => {
|
||||
const { title, description, type, brand_id, content, project_id, campaign_id, approver_ids } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
@@ -4021,6 +4094,21 @@ app.patch('/api/artefacts/:id', requireAuth, async (req, res) => {
|
||||
console.log(`[PATCH /artefacts/${req.params.id}] Updating:`, JSON.stringify(data));
|
||||
await nocodb.update('Artefacts', req.params.id, data);
|
||||
|
||||
// Auto-update linked post stage (both old and new post if post_id changed)
|
||||
const oldPostId = existing.post_id ? Number(existing.post_id) : null;
|
||||
const updatedArtefact = await nocodb.get('Artefacts', Number(req.params.id));
|
||||
const newPostId = updatedArtefact?.post_id ? Number(updatedArtefact.post_id) : null;
|
||||
const postIdsToUpdate = [...new Set([oldPostId, newPostId].filter(Boolean))];
|
||||
for (const pid of postIdsToUpdate) {
|
||||
try {
|
||||
const { getPostComposition, computeStage } = require('./post-composition');
|
||||
const composition = await getPostComposition(pid);
|
||||
if (composition) {
|
||||
await nocodb.update('Posts', pid, { stage: computeStage(composition) });
|
||||
}
|
||||
} catch (e) { console.error('Post stage update error:', e); }
|
||||
}
|
||||
|
||||
const artefact = await nocodb.get('Artefacts', req.params.id);
|
||||
console.log(`[PATCH /artefacts/${req.params.id}] After re-read: approver_ids=${artefact.approver_ids}`);
|
||||
const approverIdList = artefact.approver_ids ? artefact.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
@@ -4085,6 +4173,11 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => {
|
||||
return res.status(403).json({ error: 'You can only submit your own artefacts' });
|
||||
}
|
||||
|
||||
const approverIds = parseApproverIds(existing.approver_ids);
|
||||
if (approverIds.length === 0) {
|
||||
return res.status(400).json({ error: 'Select a reviewer before submitting for review' });
|
||||
}
|
||||
|
||||
const token = require('crypto').randomUUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + DEFAULTS.tokenExpiryDays);
|
||||
@@ -4096,7 +4189,8 @@ app.post('/api/artefacts/:id/submit-review', requireAuth, async (req, res) => {
|
||||
review_version: existing.current_version || 1,
|
||||
});
|
||||
|
||||
const reviewUrl = `${req.protocol}://${req.get('host')}/review/${token}`;
|
||||
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
|
||||
const reviewUrl = `${appUrl}/review/${token}`;
|
||||
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
||||
notify.notifyReviewSubmitted({ type: 'artefact', record: existing, reviewUrl });
|
||||
} catch (err) {
|
||||
@@ -4689,6 +4783,65 @@ app.post('/api/public/review/:token/revision', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Public: Get team members for redirect (must be BEFORE /:token routes)
|
||||
app.get('/api/public/review-redirect/:token/team', async (req, res) => {
|
||||
try {
|
||||
const artefacts = await nocodb.list('Artefacts', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (artefacts.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
|
||||
const users = await nocodb.list('Users', { limit: 200 });
|
||||
const artefact = artefacts[0];
|
||||
const currentApproverId = artefact.approver_ids ? Number(artefact.approver_ids) : null;
|
||||
const creatorId = artefact.created_by_user_id ? Number(artefact.created_by_user_id) : null;
|
||||
res.json(users
|
||||
.filter(u => u.Id !== currentApproverId && u.Id !== creatorId)
|
||||
.map(u => ({ id: u.Id, name: u.name }))
|
||||
);
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to load team' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/public/review-redirect/:token', async (req, res) => {
|
||||
const { new_approver_id, reason } = req.body;
|
||||
if (!new_approver_id) return res.status(400).json({ error: 'New approver ID is required' });
|
||||
|
||||
try {
|
||||
const artefacts = await nocodb.list('Artefacts', {
|
||||
where: `(approval_token,eq,${sanitizeWhereValue(req.params.token)})`,
|
||||
limit: 1,
|
||||
});
|
||||
if (artefacts.length === 0) return res.status(404).json({ error: 'Review link not found' });
|
||||
const artefact = artefacts[0];
|
||||
|
||||
if (artefact.token_expires_at && new Date(artefact.token_expires_at) < new Date()) {
|
||||
return res.status(410).json({ error: 'Review link has expired' });
|
||||
}
|
||||
if (artefact.status !== 'pending_review') {
|
||||
return res.status(400).json({ error: 'This artefact is no longer pending review' });
|
||||
}
|
||||
|
||||
// Update approver
|
||||
await nocodb.update('Artefacts', artefact.Id, {
|
||||
approver_ids: String(new_approver_id),
|
||||
});
|
||||
|
||||
// Notify the new approver
|
||||
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
|
||||
const reviewUrl = `${appUrl}/review/${artefact.approval_token}`;
|
||||
notify.notifyReviewSubmitted({ type: 'artefact', record: { ...artefact, approver_ids: String(new_approver_id) }, reviewUrl });
|
||||
|
||||
const newApproverName = await getRecordName('Users', Number(new_approver_id));
|
||||
res.json({ success: true, message: `Review redirected to ${newApproverName}` });
|
||||
} catch (err) {
|
||||
console.error('Redirect review error:', err);
|
||||
res.status(500).json({ error: 'Failed to redirect review' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/public/review/:token/comment', async (req, res) => {
|
||||
const { comment, author_name } = req.body;
|
||||
if (!comment) return res.status(400).json({ error: 'Comment is required' });
|
||||
@@ -4785,9 +4938,30 @@ app.get('/api/translations', requireAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Get single translation
|
||||
app.get('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
try {
|
||||
const translation = await nocodb.get('Translations', req.params.id);
|
||||
if (!translation) return res.status(404).json({ error: 'Translation not found' });
|
||||
const enriched = { ...translation };
|
||||
enriched.brand_name = await getRecordName('Brands', translation.brand_id);
|
||||
enriched.creator_name = await getRecordName('Users', translation.created_by_user_id);
|
||||
// Parse approvers
|
||||
const approverIdList = translation.approver_ids ? translation.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
enriched.approvers = [];
|
||||
for (const aid of approverIdList) {
|
||||
enriched.approvers.push({ id: Number(aid), name: await getRecordName('Users', Number(aid)) });
|
||||
}
|
||||
res.json(enriched);
|
||||
} catch (err) {
|
||||
console.error('GET /translations/:id error:', err);
|
||||
res.status(500).json({ error: 'Failed to load translation' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create translation
|
||||
app.post('/api/translations', requireAuth, async (req, res) => {
|
||||
const { title, source_language, source_content, brand_id, post_id, approver_ids } = req.body;
|
||||
const { title, source_language, source_content, brand_id, post_id, approver_ids, copy_type } = req.body;
|
||||
if (!title) return res.status(400).json({ error: 'Title is required' });
|
||||
if (!source_language) return res.status(400).json({ error: 'Source language is required' });
|
||||
if (!source_content) return res.status(400).json({ error: 'Source content is required' });
|
||||
@@ -4801,6 +4975,7 @@ app.post('/api/translations', requireAuth, async (req, res) => {
|
||||
brand_id: brand_id ? Number(brand_id) : null,
|
||||
post_id: post_id ? Number(post_id) : null,
|
||||
approver_ids: approver_ids || null,
|
||||
copy_type: copy_type || null,
|
||||
created_by_user_id: req.session.userId,
|
||||
});
|
||||
|
||||
@@ -4852,7 +5027,7 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
}
|
||||
|
||||
const data = {};
|
||||
for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback']) {
|
||||
for (const f of ['title', 'source_language', 'source_content', 'status', 'feedback', 'copy_type']) {
|
||||
if (req.body[f] !== undefined) data[f] = req.body[f];
|
||||
}
|
||||
if (req.body.brand_id !== undefined) data.brand_id = req.body.brand_id ? Number(req.body.brand_id) : null;
|
||||
@@ -4863,6 +5038,21 @@ app.patch('/api/translations/:id', requireAuth, async (req, res) => {
|
||||
|
||||
await nocodb.update('Translations', req.params.id, data);
|
||||
|
||||
// Auto-update linked post stage (both old and new post if post_id changed)
|
||||
const oldTransPostId = existing.post_id ? Number(existing.post_id) : null;
|
||||
const updated = await nocodb.get('Translations', Number(req.params.id));
|
||||
const newTransPostId = updated?.post_id ? Number(updated.post_id) : null;
|
||||
const transPostIds = [...new Set([oldTransPostId, newTransPostId].filter(Boolean))];
|
||||
for (const pid of transPostIds) {
|
||||
try {
|
||||
const { getPostComposition, computeStage } = require('./post-composition');
|
||||
const composition = await getPostComposition(pid);
|
||||
if (composition) {
|
||||
await nocodb.update('Posts', pid, { stage: computeStage(composition) });
|
||||
}
|
||||
} catch (e) { console.error('Post stage update error:', e); }
|
||||
}
|
||||
|
||||
const record = await nocodb.get('Translations', req.params.id);
|
||||
const approverIdList = record.approver_ids ? record.approver_ids.split(',').map(s => s.trim()).filter(Boolean) : [];
|
||||
const approvers = [];
|
||||
@@ -5074,7 +5264,8 @@ app.post('/api/translations/:id/submit-review', requireAuth, async (req, res) =>
|
||||
token_expires_at: expiresAt.toISOString(),
|
||||
});
|
||||
|
||||
const reviewUrl = `${req.protocol}://${req.get('host')}/review-translation/${token}`;
|
||||
const appUrl = process.env.APP_URL || process.env.CORS_ORIGIN || `${req.protocol}://${req.get('host')}`;
|
||||
const reviewUrl = `${appUrl}/review-translation/${token}`;
|
||||
res.json({ success: true, token, reviewUrl, expiresAt: expiresAt.toISOString() });
|
||||
notify.notifyReviewSubmitted({ type: 'translation', record: existing, reviewUrl });
|
||||
} catch (err) {
|
||||
|
||||