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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-15 18:02:29 +03:00
parent e1d1c392eb
commit ce4d6025d7
50 changed files with 2616 additions and 229 deletions
+2
View File
@@ -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 />} />
+23 -13
View File
@@ -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" />
+1 -1
View File
@@ -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">
+1 -1
View File
@@ -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">
+2 -1
View File
@@ -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">
+3 -3
View File
@@ -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>
)
}
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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' },
],
},
{
+2 -2
View File
@@ -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>
+3 -5
View File
@@ -35,16 +35,14 @@ export default function SlidePanel({ onClose, maxWidth = '420px', header, footer
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm animate-backdrop-in z-[9998]" onClick={onClose} aria-label="Close panel" />
<div
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>
</>,
+4 -4
View File
@@ -56,9 +56,9 @@ export default function TabbedModal({
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[5vh] px-4" onKeyDown={handleKeyDown} ref={modalRef}>
<div className="fixed inset-0 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>
)}
+49 -1
View File
@@ -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": "إلغاء الربط"
}
+49 -1
View File
@@ -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"
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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 */}
+3 -33
View File
@@ -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
+1 -1
View File
@@ -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>
+1 -1
View File
@@ -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">
+655
View File
@@ -0,0 +1,655 @@
import { useState, useEffect, useContext, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { ArrowLeft, Save, FileText, Image as ImageIcon, Film, Type, Search, Link2, Unlink, Plus, CheckCircle, Clock, X } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import PlatformIcon from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import CommentsSection from '../components/CommentsSection'
import TranslationDetailPanel from '../components/TranslationDetailPanel'
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
import { useToast } from '../components/ToastContainer'
const STATUS_OPTS = ['draft', 'in_review', 'approved', 'rejected', 'scheduled', 'published']
export default function PostDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { brands, getBrandName, teamMembers } = useContext(AppContext)
const { t, lang } = useLanguage()
const { user } = useAuth()
const toast = useToast()
const [post, setPost] = useState(null)
const [composition, setComposition] = useState(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [campaigns, setCampaigns] = useState([])
// Editable form fields
const [title, setTitle] = useState('')
const [status, setStatus] = useState('draft')
const [brandId, setBrandId] = useState('')
const [campaignId, setCampaignId] = useState('')
const [assignedTo, setAssignedTo] = useState('')
const [platforms, setPlatforms] = useState([])
const [scheduledDate, setScheduledDate] = useState('')
// Link pickers
const [activePicker, setActivePicker] = useState(null) // 'caption' | 'body' | 'design' | 'video'
const [pickerSearch, setPickerSearch] = useState('')
const [linkCandidates, setLinkCandidates] = useState([])
const [linking, setLinking] = useState(false)
// Sub-panels
const [openTranslation, setOpenTranslation] = useState(null)
const [openArtefact, setOpenArtefact] = useState(null)
useEffect(() => {
loadPost()
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : [])).catch(() => {})
}, [id])
const loadPost = async () => {
try {
const [p, comp] = await Promise.all([
api.get(`/posts/${id}`),
api.get(`/posts/${id}/composition`),
])
setPost(p)
setComposition(comp)
setTitle(p.title || '')
setStatus(p.status || 'draft')
setBrandId(p.brand_id || p.brandId || '')
setCampaignId(p.campaign_id || p.campaignId || '')
setAssignedTo(p.assigned_to || p.assignedTo || '')
const plats = p.platforms || (p.platform ? [p.platform] : [])
setPlatforms(Array.isArray(plats) ? plats : [])
const sd = p.scheduled_date || p.scheduledDate
setScheduledDate(sd ? new Date(sd).toISOString().slice(0, 10) : '')
} catch (err) {
console.error('Failed to load post:', err)
} finally {
setLoading(false)
}
}
const loadComposition = useCallback(async () => {
try {
setComposition(await api.get(`/posts/${id}/composition`))
} catch (err) {
console.error('Failed to load composition:', err)
}
}, [id])
const handleSave = async () => {
setSaving(true)
try {
await api.patch(`/posts/${id}`, {
title,
status,
brand_id: brandId ? Number(brandId) : null,
campaign_id: campaignId ? Number(campaignId) : null,
assigned_to: assignedTo ? Number(assignedTo) : null,
platforms,
scheduled_date: scheduledDate || null,
})
toast.success(t('posts.updated'))
loadPost()
} catch {
toast.error(t('common.saveFailed'))
} finally {
setSaving(false)
}
}
const togglePlatform = (key) => {
setPlatforms(prev => prev.includes(key) ? prev.filter(p => p !== key) : [...prev, key])
}
// ─── Link / Unlink / Create ───
const openLinkPicker = async (type) => {
setActivePicker(type)
setPickerSearch('')
try {
if (type === 'caption' || type === 'body') {
const all = await api.get('/translations')
// Show all translations not already linked to THIS post
setLinkCandidates((Array.isArray(all) ? all : []).filter(t => {
const linkedTo = t.post_id || t.postId
return !linkedTo || String(linkedTo) !== String(id)
}))
} else {
const all = await api.get('/artefacts')
const at = type === 'video' ? 'video' : 'design'
setLinkCandidates((Array.isArray(all) ? all : []).filter(a => {
const linkedTo = a.post_id || a.postId
const matchesType = (a.type || 'design') === at
return matchesType && (!linkedTo || String(linkedTo) !== String(id))
}))
}
} catch {
setLinkCandidates([])
}
}
const handleLink = async (itemId) => {
setLinking(true)
try {
const copyType = activePicker === 'caption' || activePicker === 'body' ? activePicker : null
if (activePicker === 'caption' || activePicker === 'body') {
await api.patch(`/translations/${itemId}`, { post_id: Number(id), copy_type: copyType })
} else {
await api.patch(`/artefacts/${itemId}`, { post_id: Number(id) })
}
toast.success(t('posts.updated'))
setActivePicker(null)
loadComposition()
} catch {
toast.error(t('common.saveFailed'))
} finally {
setLinking(false)
}
}
const handleUnlink = async (type) => {
const piece = type === 'caption' ? composition?.caption
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
if (!piece) return
try {
const endpoint = (type === 'caption' || type === 'body') ? `/translations/${piece.id}` : `/artefacts/${piece.id}`
await api.patch(endpoint, { post_id: null })
toast.success(t('posts.updated'))
loadComposition()
} catch {
toast.error(t('common.saveFailed'))
}
}
const handleCreate = async (type) => {
try {
if (type === 'caption') {
await api.post('/translations', {
post_id: Number(id),
copy_type: 'caption',
title: (title || 'Post') + ' - Caption',
source_language: 'EN',
source_content: ' ',
})
} else if (type === 'body') {
await api.post('/translations', {
post_id: Number(id),
copy_type: 'body',
title: (title || 'Post') + ' - Copy',
source_language: 'EN',
source_content: ' ',
})
} else if (type === 'design') {
await api.post('/artefacts', {
post_id: Number(id),
type: 'design',
title: (title || 'Post') + ' - Design',
status: 'draft',
})
} else if (type === 'video') {
await api.post('/artefacts', {
post_id: Number(id),
type: 'video',
title: (title || 'Post') + ' - Video',
status: 'draft',
})
}
toast.success(t('posts.created'))
loadComposition()
} catch {
toast.error(t('common.saveFailed'))
}
}
const handleOpenPiece = async (type) => {
const piece = type === 'caption' ? composition?.caption
: type === 'body' ? composition?.body_copy
: type === 'design' ? composition?.design
: composition?.video
if (!piece) return
if (type === 'caption' || type === 'body') {
try {
const full = await api.get(`/translations/${piece.id}`)
setOpenTranslation(full)
} catch { toast.error(t('common.saveFailed')) }
} else {
try {
const full = await api.get(`/artefacts/${piece.id}`)
setOpenArtefact(full)
} catch { toast.error(t('common.saveFailed')) }
}
}
// ─── Rendering ───
if (loading) {
return (
<div className="space-y-6 animate-pulse">
<div className="flex items-start gap-4">
<div className="w-8 h-8 bg-surface-tertiary rounded-lg"></div>
<div className="flex-1">
<div className="h-6 bg-surface-tertiary rounded w-64 mb-2"></div>
<div className="h-4 bg-surface-tertiary rounded w-96"></div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{[1,2,3,4].map(i => <div key={i} className="h-40 bg-surface-tertiary rounded-xl"></div>)}
</div>
</div>
)
}
if (!post) {
return (
<div className="text-center py-12 text-text-tertiary">
{t('common.noResults')}{' '}
<button onClick={() => navigate('/posts')} className="text-brand-primary underline">{t('common.goBack')}</button>
</div>
)
}
const filteredCandidates = linkCandidates.filter(c => {
if (!pickerSearch) return true
return (c.title || '').toLowerCase().includes(pickerSearch.toLowerCase())
})
const waitingOn = composition?.waiting_on || []
const piecesReady = composition?.pieces_ready || false
const hasPieces = composition?.caption || composition?.body_copy || composition?.design || composition?.video
return (
<div className="space-y-6 animate-fade-in">
{/* ─── HEADER ─── */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<button onClick={() => navigate('/posts')} className="p-1.5 hover:bg-surface-tertiary rounded-lg">
<ArrowLeft className="w-5 h-5 text-text-secondary" />
</button>
<input
type="text"
value={title}
onChange={e => setTitle(e.target.value)}
className="flex-1 text-xl font-bold text-text-primary bg-transparent border-none outline-none focus:ring-0 placeholder:text-text-tertiary"
placeholder={t('posts.postTitlePlaceholder')}
/>
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
value={status}
onChange={e => setStatus(e.target.value)}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
{STATUS_OPTS.map(s => (
<option key={s} value={s}>{t(`posts.status.${s}`)}</option>
))}
</select>
<select
value={brandId}
onChange={e => setBrandId(e.target.value)}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.selectBrand')}</option>
{brands.map(b => <option key={b._id} value={b._id}>{lang === 'ar' && b.name_ar ? b.name_ar : b.name}</option>)}
</select>
<select
value={campaignId}
onChange={e => setCampaignId(e.target.value)}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('posts.noCampaign')}</option>
{campaigns.map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</option>)}
</select>
<select
value={assignedTo}
onChange={e => setAssignedTo(e.target.value)}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('common.unassigned')}</option>
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
</select>
</div>
{/* Platforms */}
<div className="flex items-center gap-1.5 flex-wrap">
{Object.entries(PLATFORMS).map(([key, p]) => (
<button
key={key}
onClick={() => togglePlatform(key)}
className={`flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-medium border transition-colors ${
platforms.includes(key)
? 'border-brand-primary bg-brand-primary/10 text-brand-primary'
: 'border-border text-text-tertiary hover:border-brand-primary/40'
}`}
>
<PlatformIcon platform={key} size={14} />
{p.label}
</button>
))}
</div>
{/* Date + Save */}
<div className="flex items-center gap-3">
<input
type="date"
value={scheduledDate}
onChange={e => setScheduledDate(e.target.value)}
className="text-xs border border-border rounded-lg px-2.5 py-1.5 bg-surface text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
/>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1.5 px-4 py-1.5 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm disabled:opacity-50"
>
<Save className="w-4 h-4" />
{saving ? t('common.loading') : t('common.save')}
</button>
</div>
</div>
{/* ─── ASSET CARDS ─── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<AssetCard
type="caption"
label={t('postDetail.captionCopy')}
icon={Type}
piece={composition?.caption}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('caption')}
onUnlink={() => handleUnlink('caption')}
onOpenPicker={() => openLinkPicker('caption')}
onCreate={() => handleCreate('caption')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="body"
label={t('postDetail.bodyCopy')}
icon={FileText}
piece={composition?.body_copy}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('body')}
onUnlink={() => handleUnlink('body')}
onOpenPicker={() => openLinkPicker('body')}
onCreate={() => handleCreate('body')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="design"
label={t('postDetail.design')}
icon={ImageIcon}
piece={composition?.design}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('design')}
onUnlink={() => handleUnlink('design')}
onOpenPicker={() => openLinkPicker('design')}
onCreate={() => handleCreate('design')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
<AssetCard
type="video"
label={t('postDetail.video')}
icon={Film}
piece={composition?.video}
activePicker={activePicker}
pickerSearch={pickerSearch}
filteredCandidates={filteredCandidates}
linking={linking}
onOpen={() => handleOpenPiece('video')}
onUnlink={() => handleUnlink('video')}
onOpenPicker={() => openLinkPicker('video')}
onCreate={() => handleCreate('video')}
onLink={handleLink}
onPickerSearchChange={setPickerSearch}
onClosePicker={() => setActivePicker(null)}
t={t}
/>
</div>
{/* ─── READINESS ─── */}
<div className="bg-surface rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('postDetail.readiness')}</h3>
{!hasPieces ? (
<p className="text-sm text-text-tertiary">{t('postDetail.noAssets')}</p>
) : piecesReady ? (
<div className="flex items-center gap-2 text-emerald-600">
<CheckCircle className="w-5 h-5" />
<span className="text-sm font-medium">{t('postDetail.allPiecesApproved')}</span>
</div>
) : (
<div className="flex items-center gap-2 text-amber-600">
<Clock className="w-5 h-5" />
<span className="text-sm font-medium">{t('postDetail.waitingOn')}: {waitingOn.join(', ')}</span>
</div>
)}
</div>
{/* ─── COMMENTS ─── */}
<div className="bg-surface rounded-xl border border-border p-5">
<h3 className="text-xs uppercase tracking-wider text-text-tertiary font-medium mb-3">{t('posts.discussion')}</h3>
<CommentsSection entityType="post" entityId={Number(id)} />
</div>
{/* ─── SUB-PANELS (they render their own SlidePanel internally) ─── */}
{openTranslation && (
<TranslationDetailPanel
translation={openTranslation}
onClose={() => { setOpenTranslation(null); loadComposition() }}
onUpdate={loadComposition}
onDelete={() => { setOpenTranslation(null); loadComposition() }}
assignableUsers={teamMembers}
/>
)}
{openArtefact && (
<ArtefactDetailPanel
artefact={openArtefact}
onClose={() => { setOpenArtefact(null); loadComposition() }}
onUpdate={loadComposition}
onDelete={() => { setOpenArtefact(null); loadComposition() }}
assignableUsers={teamMembers}
projects={[]}
campaigns={campaigns}
/>
)}
</div>
)
}
// ─── Asset Card Component ───
function AssetCard({
type, label, icon: Icon, piece,
activePicker, pickerSearch, filteredCandidates, linking,
onOpen, onUnlink, onOpenPicker, onCreate, onLink,
onPickerSearchChange, onClosePicker, t,
}) {
const isPickerOpen = activePicker === type
const isCopy = type === 'caption' || type === 'body'
return (
<div className="bg-surface rounded-xl border border-border p-4 flex flex-col">
<div className="flex items-center gap-2 mb-3">
<Icon className="w-4 h-4 text-text-tertiary" />
<h4 className="text-xs uppercase tracking-wider text-text-tertiary font-medium">{label}</h4>
</div>
{piece ? (
<>
<div className="flex-1">
{/* Thumbnail for design/video */}
{!isCopy && piece.thumbnail_url && (
<div className="mb-3 rounded-lg overflow-hidden border border-border-light bg-surface-secondary">
<img src={piece.thumbnail_url} alt={piece.title} className="w-full h-32 object-cover" loading="lazy" />
</div>
)}
{!isCopy && !piece.thumbnail_url && (
<div className="mb-3 rounded-lg border border-border-light bg-surface-secondary flex items-center justify-center h-24">
<Icon className="w-8 h-8 text-text-tertiary/30" />
</div>
)}
<div className="flex items-center gap-2 mb-1">
<span className="text-sm font-medium text-text-primary truncate">{piece.title}</span>
<StatusBadge status={piece.status} size="xs" />
</div>
{/* Copy: show content preview + languages */}
{isCopy && piece.content_preview && (
<p className="text-xs text-text-secondary mt-1 line-clamp-2">{piece.content_preview}</p>
)}
{isCopy && piece.languages && piece.languages.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{piece.languages.map((l, i) => (
<span key={i} className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${
l.status === 'approved' ? 'bg-emerald-100 text-emerald-700' :
l.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
'bg-surface-tertiary text-text-tertiary'
}`}>
{l.language}
</span>
))}
</div>
)}
{isCopy && (!piece.languages || piece.languages.length === 0) && piece.language && (
<p className="text-xs text-text-tertiary mt-1">{piece.language}</p>
)}
{/* Design/Video: version info */}
{!isCopy && piece.current_version && (
<p className="text-xs text-text-tertiary mt-1">v{piece.current_version}</p>
)}
</div>
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
<button
onClick={onOpen}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
>
<Link2 className="w-3.5 h-3.5" />
{t('postDetail.open')}
</button>
<button
onClick={onUnlink}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-red-500 hover:bg-red-50 rounded-lg transition-colors"
>
<Unlink className="w-3.5 h-3.5" />
{t('postDetail.unlink')}
</button>
</div>
</>
) : (
<>
<div className="flex-1 flex items-center justify-center py-4">
<p className="text-sm text-text-tertiary">{t('postDetail.notLinked')}</p>
</div>
{!isPickerOpen && (
<div className="flex items-center gap-2 mt-3 pt-3 border-t border-border-light">
<button
onClick={onOpenPicker}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
>
<Link2 className="w-3.5 h-3.5" />
{t('postDetail.linkExisting')}
</button>
<button
onClick={onCreate}
className="flex items-center gap-1 px-2.5 py-1 text-xs font-medium text-brand-primary hover:bg-brand-primary/10 rounded-lg transition-colors"
>
<Plus className="w-3.5 h-3.5" />
{t('postDetail.createNew')}
</button>
</div>
)}
</>
)}
{/* Inline picker */}
{isPickerOpen && (
<div className="mt-3 pt-3 border-t border-border-light animate-fade-in">
<div className="flex items-center gap-2 mb-2">
<div className="relative flex-1">
<Search className="absolute start-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-tertiary" />
<input
type="text"
value={pickerSearch}
onChange={e => onPickerSearchChange(e.target.value)}
placeholder={t('common.search')}
className="w-full ps-7 pe-2 py-1.5 text-xs border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
autoFocus
/>
</div>
<button onClick={onClosePicker} className="p-1 hover:bg-surface-tertiary rounded">
<X className="w-3.5 h-3.5 text-text-tertiary" />
</button>
</div>
<div className="max-h-32 overflow-y-auto space-y-1">
{filteredCandidates.length === 0 ? (
<p className="text-xs text-text-tertiary text-center py-2">{t('common.noResults')}</p>
) : (
filteredCandidates.slice(0, 10).map(c => (
<button
key={c._id || c.id}
onClick={() => onLink(c._id || c.id)}
disabled={linking}
className="w-full text-start px-2 py-2 text-xs rounded-lg hover:bg-surface-secondary transition-colors flex items-start gap-2 disabled:opacity-50"
>
{/* Thumbnail for artefacts */}
{!isCopy && (c.thumbnail_url || c.file_url) && (
<img src={c.thumbnail_url || c.file_url} alt="" className="w-10 h-10 rounded object-cover shrink-0" loading="lazy" />
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="truncate text-text-primary font-medium">{c.title || t('common.untitled')}</span>
<StatusBadge status={c.status} size="xs" />
</div>
{/* Copy: show source language + content preview */}
{isCopy && (
<p className="text-text-tertiary mt-0.5 truncate">
{c.source_language && <span className="uppercase">{c.source_language} · </span>}
{(c.source_content || '').slice(0, 60)}
</p>
)}
{/* Artefact: show type */}
{!isCopy && c.type && (
<p className="text-text-tertiary mt-0.5 capitalize">{c.type}</p>
)}
</div>
</button>
))
)}
</div>
</div>
)}
</div>
)
}
+11 -102
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useContext, useMemo } from 'react'
import { 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>
)
}
+3 -3
View File
@@ -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">
+97 -31
View File
@@ -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>
</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 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 || reviewerName || '—'}</span>
</div>
</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>
)}
+5 -5
View File
@@ -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" />
+1 -1
View File
@@ -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">
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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">
+39
View File
@@ -0,0 +1,39 @@
export const PLATFORM_FORMATS = {
instagram: [
{ key: 'ig_feed', label: 'Feed (1:1)', ratio: '1:1' },
{ key: 'ig_story', label: 'Story (9:16)', ratio: '9:16' },
{ key: 'ig_reel', label: 'Reel (9:16)', ratio: '9:16' },
],
tiktok: [
{ key: 'tt_video', label: 'TikTok (9:16)', ratio: '9:16' },
],
youtube: [
{ key: 'yt_video', label: 'YouTube (16:9)', ratio: '16:9' },
{ key: 'yt_short', label: 'Short (9:16)', ratio: '9:16' },
{ key: 'yt_thumb', label: 'Thumbnail (16:9)', ratio: '16:9' },
],
facebook: [
{ key: 'fb_post', label: 'Post (1:1)', ratio: '1:1' },
{ key: 'fb_story', label: 'Story (9:16)', ratio: '9:16' },
],
twitter: [
{ key: 'tw_post', label: 'Post (16:9)', ratio: '16:9' },
],
linkedin: [
{ key: 'li_post', label: 'Post (1:1)', ratio: '1:1' },
],
snapchat: [
{ key: 'sc_snap', label: 'Snap (9:16)', ratio: '9:16' },
],
}
export function getFormatsForPlatforms(platforms = []) {
const formats = []
const seen = new Set()
for (const p of platforms) {
for (const f of (PLATFORM_FORMATS[p] || [])) {
if (!seen.has(f.key)) { seen.add(f.key); formats.push(f) }
}
}
return formats
}