fix: code review — security, dead code, performance, consistency

Critical fixes:
- XSS: escapeHtml() on all user-supplied text in email notifications
- Budget PATCH: added mutex lock + availability validation (prevents corruption)
- batchResolveNames: fixed wrong signature for budget request earmark names

Dead code cleanup:
- Deleted 8 unused PostComposition* files (replaced by PostDetail full page)

Performance:
- budget-helpers: single-fetch with computeFromEntries(), optional prefetch param
- post-composition: parallelized text + thumbnail fetches with Promise.all

Consistency:
- PostDetail.jsx: native <select> → PortalSelect (matches all panels)
- Finance.jsx: 11 hardcoded English table headers → t() with i18n keys
- PostCalendar.jsx: day names, Month/Week labels → t() with i18n keys
- App.jsx Suspense: "Loading..." → brand spinner (can't use i18n in fallback)
- UploadZone: proper useRef pattern, no vanilla JS document.createElement
- All file inputs: className="hidden" → absolute w-0 h-0 opacity-0
- ArtefactDetailPanel: removed campaign/project selects (inherited from post)
- TranslationDetailPanel: removed brand/linked post selects (inherited from post)
- ApproverMultiSelect: portal-based dropdown (fixes clipping in modals)
- Thumbnail fix: post-composition constructs URL from filename (was undefined)
- Upload fix: UploadZone with drag-and-drop for design + video artefacts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-16 14:17:08 +03:00
parent ce4d6025d7
commit 49e1a796ed
34 changed files with 622 additions and 1172 deletions
+1 -1
View File
@@ -290,7 +290,7 @@ function AppContent() {
{showTutorial && <Tutorial onComplete={handleTutorialComplete} />}
<ErrorBoundary>
<Suspense fallback={<div className="min-h-screen bg-surface-secondary flex items-center justify-center"><div className="animate-pulse text-text-tertiary">Loading...</div></div>}>
<Suspense fallback={<div className="min-h-screen flex items-center justify-center"><div className="w-8 h-8 border-2 border-brand-primary/30 border-t-brand-primary rounded-full animate-spin" /></div>}>
<Routes>
<Route path="/login" element={user ? <Navigate to="/" replace /> : <Login />} />
<Route path="/forgot-password" element={user ? <Navigate to="/" replace /> : <ForgotPassword />} />
+53 -25
View File
@@ -1,30 +1,51 @@
import { useState, useRef, useEffect } from 'react'
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { Check, ChevronDown, X } from 'lucide-react'
export default function ApproverMultiSelect({ users = [], selected = [], onChange }) {
const [open, setOpen] = useState(false)
const [dropUp, setDropUp] = useState(false)
const wrapperRef = useRef(null)
const triggerRef = useRef(null)
const dropdownRef = useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropdownHeight = Math.min(users.length * 40 + 8, 220)
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
setPos({
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
width: rect.width,
})
}, [users.length])
// Close dropdown when clicking outside
useEffect(() => {
if (!open) return
const handleClick = (e) => {
if (wrapperRef.current && !wrapperRef.current.contains(e.target)) {
setOpen(false)
}
if (triggerRef.current?.contains(e.target)) return
if (dropdownRef.current?.contains(e.target)) return
setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
const handleScroll = () => updatePosition()
// Detect if dropdown should open upward
useEffect(() => {
if (!open || !wrapperRef.current) return
const rect = wrapperRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
setDropUp(spaceBelow < 220)
}, [open])
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open, updatePosition])
const handleOpen = () => {
updatePosition()
setOpen(!open)
}
const toggle = (userId) => {
const id = String(userId)
@@ -39,9 +60,10 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
const selectedUsers = selected.map(id => users.find(u => String(u._id || u.id || u.Id) === String(id))).filter(Boolean)
return (
<div className="relative" ref={wrapperRef}>
<>
<div
onClick={() => setOpen(!open)}
ref={triggerRef}
onClick={handleOpen}
className={`w-full min-h-[38px] px-3 py-1.5 text-sm border rounded-lg bg-surface cursor-pointer flex items-center flex-wrap gap-1.5 transition-colors ${
open ? 'border-brand-primary ring-2 ring-brand-primary/20' : 'border-border'
}`}
@@ -58,7 +80,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
<button
type="button"
onClick={e => { e.stopPropagation(); remove(u._id || u.id || u.Id) }}
className="hover:text-amber-950"
className="hover:text-amber-950 transition-colors"
>
<X className="w-3 h-3" />
</button>
@@ -66,8 +88,13 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
))}
<ChevronDown className={`w-4 h-4 text-text-tertiary ms-auto shrink-0 transition-transform ${open ? 'rotate-180' : ''}`} />
</div>
{open && (
<div className={`absolute z-50 w-full bg-surface border border-border rounded-lg shadow-lg max-h-48 overflow-y-auto ${dropUp ? 'bottom-full mb-1' : 'top-full mt-1'}`}>
{open && createPortal(
<div
ref={dropdownRef}
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg max-h-[220px] overflow-y-auto"
style={{ top: pos.top, left: pos.left, width: pos.width }}
>
{users.map(u => {
const uid = String(u._id || u.id || u.Id)
const isSelected = selected.includes(uid)
@@ -76,7 +103,7 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
key={uid}
type="button"
onClick={() => toggle(uid)}
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between ${
className={`w-full text-start px-3 py-2 text-sm hover:bg-surface-secondary flex items-center justify-between transition-colors ${
isSelected ? 'text-brand-primary font-medium' : 'text-text-primary'
}`}
>
@@ -88,8 +115,9 @@ export default function ApproverMultiSelect({ users = [], selected = [], onChang
{users.length === 0 && (
<div className="px-3 py-4 text-sm text-text-tertiary text-center">No users available</div>
)}
</div>
</div>,
document.body
)}
</div>
</>
)
}
+5 -41
View File
@@ -7,6 +7,7 @@ import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import ApproverMultiSelect from './ApproverMultiSelect'
import PortalSelect from './PortalSelect'
import { ArtefactDetailVersionsTab } from './ArtefactDetailVersionsTab'
const STATUS_COLORS = {
@@ -380,38 +381,6 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
/>
</div>
{/* Project & Campaign dropdowns */}
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.project')}</h4>
<select
value={editProjectId}
onChange={e => {
setEditProjectId(e.target.value)
handleUpdateField('project_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 bg-surface"
>
<option value=""></option>
{projects.map(p => <option key={p.Id || p._id || p.id} value={p.Id || p._id || p.id}>{p.name || p.title}</option>)}
</select>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('artefacts.campaign')}</h4>
<select
value={editCampaignId}
onChange={e => {
setEditCampaignId(e.target.value)
handleUpdateField('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 bg-surface"
>
<option value=""></option>
{campaigns.map(c => <option key={c.Id || c._id || c.id} value={c.Id || c._id || c.id}>{c.name || c.title}</option>)}
</select>
</div>
</div>
</div>
)}
@@ -500,21 +469,16 @@ export default function ArtefactDetailPanel({ artefact, onClose, onUpdate, onDel
{['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
<PortalSelect
value={editApproverIds[0] || ''}
onChange={e => {
const val = e.target.value
onChange={val => {
const ids = val ? [val] : []
setEditApproverIds(ids)
handleUpdateField('approver_ids', val || '')
}}
options={[{ value: '', label: t('artefacts.selectReviewer') }, ...assignableUsers.map(u => ({ value: u.id || u.Id, label: u.name }))]}
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>
)}
@@ -1,5 +1,7 @@
import { useState } from 'react'
import { Plus, Upload, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
import { Plus, Trash2, Globe, Image as ImageIcon } from 'lucide-react'
import PortalSelect from './PortalSelect'
import UploadZone from './UploadZone'
import { useLanguage } from '../i18n/LanguageContext'
import Modal from './Modal'
import ArtefactVersionTimeline from './ArtefactVersionTimeline'
@@ -172,29 +174,17 @@ export function ArtefactDetailVersionsTab({
{/* DESIGN TYPE: Image gallery */}
{artefact.type === 'design' && (
<div>
<div className="flex items-center justify-between mb-3">
<h4 className="text-xs font-semibold text-text-tertiary uppercase">{t('artefacts.imagesLabel')}</h4>
<label className="flex items-center gap-1 px-3 py-1.5 text-xs font-medium bg-brand-primary text-white rounded-lg hover:bg-brand-primary-light transition-colors cursor-pointer">
<Upload className="w-3 h-3" />
{uploading ? t('artefacts.uploading') : t('artefacts.uploadImage')}
<input
type="file"
className="hidden"
accept="image/*"
onChange={onFileUpload}
disabled={uploading}
/>
</label>
</div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-3">{t('artefacts.imagesLabel')}</h4>
{versionData.attachments && versionData.attachments.length > 0 ? (
<div className="grid grid-cols-2 gap-3">
{versionData.attachments && versionData.attachments.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-3">
{versionData.attachments.map(att => (
<div key={att.Id} className="relative group">
<img
src={att.url}
alt={att.original_name}
className="w-full h-48 object-cover rounded-lg border border-border"
loading="lazy"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors rounded-lg flex items-center justify-center">
<button
@@ -210,12 +200,16 @@ export function ArtefactDetailVersionsTab({
</div>
))}
</div>
) : (
<div className="text-center py-8 bg-surface-secondary rounded-lg border-2 border-dashed border-border">
<ImageIcon className="w-8 h-8 text-text-tertiary mx-auto mb-2" />
<p className="text-sm text-text-secondary">{t('artefacts.noImages')}</p>
</div>
)}
<UploadZone
onUpload={onFileUpload}
accept="image/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickImage') || 'Drop images here or click to upload'}
hint={t('artefacts.imageFormats') || 'PNG, JPG, WebP'}
compact={versionData.attachments?.length > 0}
/>
</div>
)}
@@ -256,30 +250,14 @@ export function ArtefactDetailVersionsTab({
)}
{/* Drag-and-drop / click-to-upload zone */}
<label
className={`flex flex-col items-center gap-2 px-6 py-6 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${uploading ? 'pointer-events-none opacity-60' : ''}`}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleVideoDrop}
>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div className="bg-brand-primary h-full rounded-full transition-all duration-300" style={{ width: `${uploadProgress}%` }} />
</div>
<span className="text-sm text-text-secondary">{t('artefacts.uploading')} {uploadProgress}%</span>
</>
) : (
<>
<Upload className="w-7 h-7 text-text-tertiary" />
<span className="text-sm font-medium text-text-primary">{t('artefacts.dropOrClickVideo')}</span>
<span className="text-xs text-text-tertiary">{t('artefacts.videoFormats')}</span>
</>
)}
<input type="file" className="hidden" accept="video/*" onChange={onFileUpload} disabled={uploading} />
</label>
<UploadZone
onUpload={onFileUpload}
accept="video/*"
uploading={uploading}
progress={uploadProgress}
label={t('artefacts.dropOrClickVideo')}
hint={t('artefacts.videoFormats')}
/>
{/* Google Drive URL inline input */}
<div className="flex items-center gap-2 mt-3">
@@ -311,23 +289,21 @@ export function ArtefactDetailVersionsTab({
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.languageLabel')} *</label>
<select
<PortalSelect
value={languageForm.language_code}
onChange={e => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === e.target.value)
onChange={val => {
const lang = AVAILABLE_LANGUAGES.find(l => l.code === val)
if (lang) setLanguageForm(f => ({ ...f, language_code: lang.code, language_label: lang.label }))
else setLanguageForm(f => ({ ...f, language_code: '', language_label: '' }))
}}
options={[
{ value: '', label: t('artefacts.selectLanguage') },
...AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => ({ value: lang.code, label: `${lang.label} (${lang.code})` }))
]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value="">{t('artefacts.selectLanguage')}</option>
{AVAILABLE_LANGUAGES
.filter(lang => !(versionData?.texts || []).some(t => t.language_code === lang.code))
.map(lang => (
<option key={lang.code} value={lang.code}>{lang.label} ({lang.code})</option>
))
}
</select>
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('artefacts.contentLabel')} *</label>
+13 -17
View File
@@ -6,6 +6,7 @@ import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App'
export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelete, brands, permissions }) {
@@ -189,38 +190,33 @@ export default function CampaignDetailPanel({ campaign, onClose, onSave, onDelet
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('campaigns.brand')}</label>
<select
<PortalSelect
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
onChange={val => update('brand_id', val)}
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b.id || b._id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
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="">Select brand</option>
{(brands || []).map(b => <option key={b.id || b._id} value={b.id || b._id}>{b.icon} {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('campaigns.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
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"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
/>
</div>
</div>
{/* Team */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select
<PortalSelect
value={form.team_id}
onChange={e => update('team_id', e.target.value)}
onChange={val => update('team_id', val)}
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
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.noTeam')}</option>
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
/>
</div>
{/* Platforms */}
+26 -41
View File
@@ -1,11 +1,13 @@
import { useState, useEffect, useContext } from 'react'
import { Copy, Eye, Lock, Send, Upload, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import { Copy, Eye, Lock, Send, FileText, Trash2, Check, Clock, CheckCircle2, XCircle, FileEdit, Wrench, MessageSquare, Paperclip } from 'lucide-react'
import UploadZone from './UploadZone'
import { api, STATUS_CONFIG, PRIORITY_CONFIG } from '../utils/api'
import TabbedModal from './TabbedModal'
import Modal from './Modal'
import { useToast } from './ToastContainer'
import { AppContext } from '../App'
import { useLanguage } from '../i18n/LanguageContext'
import PortalSelect from './PortalSelect'
export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers, teams = [] }) {
const { brands } = useContext(AppContext)
@@ -284,67 +286,53 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{/* Assigned To */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.assignedTo')}</label>
<select
<PortalSelect
value={assignedTo}
onChange={(e) => handleAssignmentChange(e.target.value)}
onChange={val => handleAssignmentChange(val)}
options={[{ value: '', label: t('issues.unassigned') }, ...teamMembers.map(member => ({ value: member.id || member._id, label: member.name }))]}
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"
>
<option value="">{t('issues.unassigned')}</option>
{teamMembers.map((member) => (
<option key={member.id || member._id} value={member.id || member._id}>
{member.name}
</option>
))}
</select>
/>
</div>
{/* Team */}
{teams.length > 0 && (
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.team')}</label>
<select
<PortalSelect
value={teamId}
onChange={async (e) => {
const val = e.target.value || null
setTeamId(val || '')
onChange={async (val) => {
const resolvedVal = val || null
setTeamId(resolvedVal || '')
try {
await api.patch(`/issues/${issueId}`, { team_id: val })
await api.patch(`/issues/${issueId}`, { team_id: resolvedVal })
await onUpdate()
await loadIssueDetails()
} catch (err) {
console.error('Failed to update team:', err)
}
}}
options={[{ value: '', label: t('issues.allTeams') }, ...teams.map(team => ({ value: team.id || team._id, label: team.name }))]}
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"
>
<option value="">{t('issues.allTeams')}</option>
{teams.map((team) => (
<option key={team.id || team._id} value={team.id || team._id}>{team.name}</option>
))}
</select>
/>
</div>
)}
{/* Brand */}
<div>
<label className="block text-sm font-semibold text-text-primary mb-2">{t('issues.brandLabel')}</label>
<select
<PortalSelect
value={issueData.brand_id || ''}
onChange={async (e) => {
const val = e.target.value || null;
onChange={async (val) => {
const resolvedVal = val || null;
try {
await api.patch(`/issues/${issueId}`, { brand_id: val });
await api.patch(`/issues/${issueId}`, { brand_id: resolvedVal });
loadIssueDetails();
onUpdate();
} catch {}
}}
options={[{ value: '', label: t('issues.noBrand') }, ...(brands || []).map(b => ({ value: b._id || b.Id, label: b.name }))]}
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"
>
<option value="">{t('issues.noBrand')}</option>
{(brands || []).map((b) => (
<option key={b._id || b.Id} value={b._id || b.Id}>{b.name}</option>
))}
</select>
/>
</div>
{/* Internal Notes */}
@@ -501,15 +489,12 @@ export default function IssueDetailPanel({ issue, onClose, onUpdate, teamMembers
{activeTab === 'attachments' && (
<div className="p-6 space-y-5">
{/* Upload */}
<label className="block">
<input type="file" onChange={handleFileUpload} disabled={uploadingFile} className="hidden" />
<div className="border-2 border-dashed border-border rounded-lg p-4 text-center cursor-pointer hover:bg-surface-secondary transition-colors">
<Upload className="w-6 h-6 mx-auto mb-2 text-text-tertiary" />
<p className="text-sm text-text-secondary">
{uploadingFile ? t('issues.uploading') : t('issues.clickToUpload')}
</p>
</div>
</label>
<UploadZone
onUpload={handleFileUpload}
uploading={uploadingFile}
label={t('issues.clickToUpload')}
compact
/>
{/* Attachments List */}
<div className="space-y-2">
+117
View File
@@ -0,0 +1,117 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Check } from 'lucide-react'
/**
* Portal-based select dropdown that renders options outside any overflow/stacking context.
* Drop-in replacement for <select> inside SlidePanel/TabbedModal/Modal.
*
* Props:
* value - current value
* onChange - (value) => void
* options - [{ value, label }] or children-based (fallback to native if no options)
* placeholder - text when no value selected
* className - additional classes on the trigger button
* disabled - boolean
*/
export default function PortalSelect({ value, onChange, options = [], placeholder = '—', className = '', disabled = false }) {
const [open, setOpen] = useState(false)
const triggerRef = useRef(null)
const dropdownRef = useRef(null)
const [pos, setPos] = useState({ top: 0, left: 0, width: 0 })
const selectedOption = options.find(o => String(o.value) === String(value))
const displayText = selectedOption?.label || placeholder
const updatePosition = useCallback(() => {
if (!triggerRef.current) return
const rect = triggerRef.current.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropdownHeight = Math.min(options.length * 32 + 8, 240)
const showAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight
setPos({
top: showAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
left: rect.left,
width: Math.max(rect.width, 160),
})
}, [options.length])
const handleOpen = () => {
if (disabled) return
updatePosition()
setOpen(true)
}
const handleSelect = (val) => {
onChange(val)
setOpen(false)
}
// Close on outside click
useEffect(() => {
if (!open) return
const handleClick = (e) => {
if (triggerRef.current?.contains(e.target)) return
if (dropdownRef.current?.contains(e.target)) return
setOpen(false)
}
const handleEsc = (e) => { if (e.key === 'Escape') setOpen(false) }
const handleScroll = () => updatePosition()
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleEsc)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleEsc)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open, updatePosition])
return (
<>
<button
ref={triggerRef}
type="button"
onClick={handleOpen}
disabled={disabled}
className={`flex items-center justify-between gap-1 text-start ${className} ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
>
<span className={`truncate ${selectedOption ? '' : 'text-text-tertiary'}`}>{displayText}</span>
<ChevronDown className={`w-3 h-3 shrink-0 text-text-tertiary transition-transform ${open ? 'rotate-180' : ''}`} />
</button>
{open && createPortal(
<div
ref={dropdownRef}
className="fixed z-[99999] bg-surface border border-border rounded-lg shadow-lg overflow-y-auto animate-scale-in"
style={{ top: pos.top, left: pos.left, width: pos.width, maxHeight: 240 }}
>
{options.map(opt => {
const isSelected = String(opt.value) === String(value)
return (
<button
key={opt.value}
type="button"
onClick={() => handleSelect(opt.value)}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-start transition-colors ${
isSelected
? 'bg-brand-primary/10 text-brand-primary font-medium'
: 'text-text-primary hover:bg-surface-secondary'
}`}
>
<span className="flex-1 truncate">{opt.label}</span>
{isSelected && <Check className="w-3 h-3 shrink-0" />}
</button>
)
})}
{options.length === 0 && (
<div className="px-3 py-2 text-xs text-text-tertiary text-center"></div>
)}
</div>,
document.body
)}
</>
)
}
@@ -1,29 +0,0 @@
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>
)
}
@@ -1,89 +0,0 @@
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>
)
}
@@ -1,75 +0,0 @@
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>
)
}
@@ -1,57 +0,0 @@
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>
)
}
@@ -1,64 +0,0 @@
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>
)
}
@@ -1,294 +0,0 @@
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 || []} />
)}
</>
)
}
@@ -1,69 +0,0 @@
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>
)
}
@@ -1,61 +0,0 @@
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>
)
}
+19 -24
View File
@@ -5,6 +5,7 @@ import { api, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
import { AppContext } from '../App'
export default function ProjectEditPanel({ project, onClose, onSave, onDelete, brands, teamMembers }) {
@@ -186,49 +187,42 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.brand')}</label>
<select
<PortalSelect
value={form.brand_id}
onChange={e => update('brand_id', e.target.value)}
onChange={val => update('brand_id', val)}
options={[{ value: '', label: 'Select brand' }, ...(brands || []).map(b => ({ value: b._id || b.id, label: `${b.icon || ''} ${lang === 'ar' && b.name_ar ? b.name_ar : b.name}`.trim() }))]}
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="">Select brand</option>
{(brands || []).map(b => <option key={b._id || b.id} value={b._id || b.id}>{b.icon} {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('projects.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
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"
>
{statusOptions.map(s => <option key={s.value} value={s.value}>{s.label}</option>)}
</select>
/>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('projects.owner')}</label>
<select
<PortalSelect
value={form.owner_id}
onChange={e => update('owner_id', e.target.value)}
onChange={val => update('owner_id', val)}
options={[{ value: '', label: t('common.unassigned') }, ...(teamMembers || []).map(m => ({ value: m._id || m.id, label: m.name }))]}
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 || m.id} value={m._id || m.id}>{m.name}</option>)}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('common.team')}</label>
<select
<PortalSelect
value={form.team_id}
onChange={e => update('team_id', e.target.value)}
onChange={val => update('team_id', val)}
options={[{ value: '', label: t('common.noTeam') }, ...(teams || []).map(tm => ({ value: tm.id || tm._id, label: tm.name }))]}
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.noTeam')}</option>
{(teams || []).map(t => <option key={t.id || t._id} value={t.id || t._id}>{t.name}</option>)}
</select>
/>
</div>
</div>
@@ -289,7 +283,8 @@ export default function ProjectEditPanel({ project, onClose, onSave, onDelete, b
ref={thumbnailInputRef}
type="file"
accept="image/*"
className="hidden"
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => { handleThumbnailUpload(e.target.files[0]); e.target.value = '' }}
/>
</div>
+19 -31
View File
@@ -5,6 +5,7 @@ import { useLanguage } from '../i18n/LanguageContext'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import PortalSelect from './PortalSelect'
const API_BASE = '/api'
@@ -293,16 +294,12 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.project')}</label>
<div className="flex items-center gap-2">
<select
<PortalSelect
value={form.project_id}
onChange={e => update('project_id', e.target.value)}
onChange={val => update('project_id', val)}
options={[{ value: '', label: t('tasks.noProject') }, ...(projects || []).map(p => ({ value: p._id || p.id, label: p.name || p.title }))]}
className="flex-1 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('tasks.noProject')}</option>
{(projects || []).map(p => (
<option key={p._id || p.id} value={p._id || p.id}>{p.name || p.title}</option>
))}
</select>
/>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded shrink-0 ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
@@ -314,43 +311,33 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
{/* Assignee */}
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.assignee')}</label>
<select
<PortalSelect
value={form.assigned_to}
onChange={e => update('assigned_to', e.target.value)}
onChange={val => update('assigned_to', val)}
options={[{ value: '', label: t('common.unassigned') }, ...(users || []).map(m => ({ value: m._id || m.team_member_id, label: m.name }))]}
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>
{(users || []).map(m => (
<option key={m._id || m.team_member_id} value={m._id || m.team_member_id}>{m.name}</option>
))}
</select>
/>
</div>
{/* Priority & Status */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.priority')}</label>
<select
<PortalSelect
value={form.priority}
onChange={e => update('priority', e.target.value)}
onChange={val => update('priority', val)}
options={priorityOptions}
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"
>
{priorityOptions.map(p => (
<option key={p.value} value={p.value}>{p.label}</option>
))}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tasks.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={statusOptions}
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"
>
{statusOptions.map(s => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
/>
</div>
</div>
@@ -494,7 +481,8 @@ export default function TaskDetailPanel({ task, onClose, onSave, onDelete, proje
ref={fileInputRef}
type="file"
multiple
className="hidden"
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
onChange={e => {
setUploadError(null)
const files = Array.from(e.target.files || [])
+9 -11
View File
@@ -6,6 +6,7 @@ import { useToast } from './ToastContainer'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import StatusBadge from './StatusBadge'
import PortalSelect from './PortalSelect'
import { AppContext, PERMISSION_LEVELS } from '../App'
const ALL_MODULES = ['marketing', 'projects', 'finance']
@@ -231,13 +232,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
{userRole === 'superadmin' && !isEditingSelf && (
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('team.permissionLevel')}</label>
<select
<PortalSelect
value={form.permission_level}
onChange={e => update('permission_level', e.target.value)}
onChange={val => update('permission_level', val)}
options={PERMISSION_LEVELS.map(p => ({ value: p.value, label: p.label }))}
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"
>
{PERMISSION_LEVELS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
</select>
/>
</div>
)}
@@ -252,14 +252,12 @@ export default function TeamMemberPanel({ member, isEditingSelf, onClose, onSave
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-surface-tertiary text-text-tertiary cursor-not-allowed"
/>
) : (
<select
<PortalSelect
value={form.role_id || ''}
onChange={e => update('role_id', e.target.value ? Number(e.target.value) : null)}
onChange={val => update('role_id', val ? Number(val) : null)}
options={[{ value: '', label: t('team.selectRole') }, ...roles.map(r => ({ value: r.Id || r.id, label: r.name }))]}
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('team.selectRole')}</option>
{roles.map(r => <option key={r.Id || r.id} value={r.Id || r.id}>{r.name}</option>)}
</select>
/>
)}
</div>
+13 -23
View File
@@ -5,6 +5,7 @@ import { PLATFORMS } from '../utils/api'
import Modal from './Modal'
import TabbedModal from './TabbedModal'
import BudgetBar from './BudgetBar'
import PortalSelect from './PortalSelect'
const TRACK_TYPES = {
organic_social: { label: 'Organic Social' },
@@ -156,29 +157,21 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.type')}</label>
<select
<PortalSelect
value={form.type}
onChange={e => update('type', e.target.value)}
onChange={val => update('type', val)}
options={Object.entries(TRACK_TYPES).map(([k, v]) => ({ value: k, label: v.label }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{Object.entries(TRACK_TYPES).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
</select>
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.platform')}</label>
<select
<PortalSelect
value={form.platform}
onChange={e => update('platform', e.target.value)}
onChange={val => update('platform', val)}
options={[{ value: '', label: 'All / Multiple' }, ...Object.entries(PLATFORMS).map(([k, v]) => ({ value: k, label: v.label })), { value: 'google_ads', label: 'Google Ads' }]}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
<option value="">All / Multiple</option>
{Object.entries(PLATFORMS).map(([k, v]) => (
<option key={k} value={k}>{v.label}</option>
))}
<option value="google_ads">Google Ads</option>
</select>
/>
</div>
</div>
@@ -195,15 +188,12 @@ export default function TrackDetailPanel({ track, campaignId, onClose, onSave, o
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('tracks.status')}</label>
<select
<PortalSelect
value={form.status}
onChange={e => update('status', e.target.value)}
onChange={val => update('status', val)}
options={TRACK_STATUSES.map(s => ({ value: s, label: s.charAt(0).toUpperCase() + s.slice(1) }))}
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none"
>
{TRACK_STATUSES.map(s => (
<option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>
))}
</select>
/>
</div>
</div>
@@ -8,6 +8,7 @@ import Modal from './Modal'
import TabbedModal from './TabbedModal'
import { useToast } from './ToastContainer'
import ApproverMultiSelect from './ApproverMultiSelect'
import PortalSelect from './PortalSelect'
export default function TranslationDetailPanel({ translation, onClose, onUpdate, onDelete, assignableUsers = [], posts: externalPosts }) {
const { t } = useLanguage()
@@ -296,14 +297,13 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="p-6 space-y-5">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-2">{t('translations.sourceLanguage')}</h4>
<select
<PortalSelect
value={editSourceLanguage}
onChange={e => setEditSourceLanguage(e.target.value)}
onChange={val => setEditSourceLanguage(val)}
disabled={isApproved}
options={AVAILABLE_LANGUAGES.map(l => ({ value: l.code, label: `${l.label} (${l.code})` }))}
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 disabled:opacity-60 disabled:cursor-default"
>
{AVAILABLE_LANGUAGES.map(l => <option key={l.code} value={l.code}>{l.label} ({l.code})</option>)}
</select>
/>
</div>
<div>
@@ -317,66 +317,6 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.brand')}</h4>
<select
value={translation.brand_id || ''}
onChange={e => handleFieldUpdate('brand_id', e.target.value)}
disabled={isApproved}
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 disabled:opacity-60 disabled:cursor-default"
>
<option value=""></option>
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
</select>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.linkedPost')}</h4>
{isApproved ? (
<p className="px-3 py-2 text-sm text-text-secondary">{translation.post_name || '—'}</p>
) : showCreatePost ? (
<div className="flex items-center gap-2">
<input
type="text"
value={newPostTitle}
onChange={e => setNewPostTitle(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleCreatePost()}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
placeholder={t('translations.newPostTitle')}
autoFocus
/>
<button
onClick={handleCreatePost}
disabled={creatingPost || !newPostTitle.trim()}
className="px-2 py-2 bg-brand-primary text-white text-xs rounded-lg hover:bg-brand-primary-light disabled:opacity-50"
>
{creatingPost ? '...' : t('common.create')}
</button>
<button onClick={() => setShowCreatePost(false)} className="text-xs text-text-secondary hover:text-text-primary">
{t('common.cancel')}
</button>
</div>
) : (
<div className="flex items-center gap-1">
<select
value={translation.post_id || ''}
onChange={e => handleFieldUpdate('post_id', e.target.value)}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
>
<option value=""></option>
{posts.map(p => <option key={p.Id || p.id || p._id} value={p.Id || p.id || p._id}>{p.title}</option>)}
</select>
<button
onClick={() => setShowCreatePost(true)}
className="p-2 text-brand-primary hover:text-brand-primary/80"
title={t('translations.createPost')}
>
<Plus className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
<div>
<h4 className="text-xs font-semibold text-text-tertiary uppercase mb-1.5">{t('translations.approversLabel')}</h4>
@@ -574,17 +514,15 @@ export default function TranslationDetailPanel({ translation, onClose, onUpdate,
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.languageLabel')} *</label>
<select
<PortalSelect
value={langForm.language_code}
onChange={e => setLangForm(f => ({ ...f, language_code: 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"
>
<option value="">{t('translations.selectLanguage')}</option>
{targetLanguages.map(l => {
onChange={val => setLangForm(f => ({ ...f, language_code: val }))}
options={[{ value: '', label: t('translations.selectLanguage') }, ...targetLanguages.map(l => {
const count = textsByLanguage[l.code]?.length || 0
return <option key={l.code} value={l.code}>{l.label} ({l.code}){count > 0 ? `${count} ${t('translations.existing')}` : ''}</option>
})}
</select>
return { value: l.code, label: `${l.label} (${l.code})${count > 0 ? `${count} ${t('translations.existing')}` : ''}` }
})]}
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"
/>
</div>
<div>
<label className="block text-sm font-medium text-text-primary mb-1">{t('translations.translatedContent')} *</label>
+92
View File
@@ -0,0 +1,92 @@
import { useState, useRef } from 'react'
import { Upload } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
export default function UploadZone({
onUpload,
accept = '*',
uploading = false,
progress = 0,
label,
hint,
compact = false,
multiple = false,
disabled = false,
}) {
const { t } = useLanguage()
const [dragOver, setDragOver] = useState(false)
const inputRef = useRef(null)
const processFiles = (files) => {
const list = Array.from(files)
const filtered = accept === '*' ? list : list.filter(f => {
if (accept.endsWith('/*')) return f.type.startsWith(accept.replace('/*', '/'))
return f.type === accept
})
if (filtered.length === 0) return
if (multiple) filtered.forEach(f => onUpload(f))
else onUpload(filtered[0])
}
const handleClick = () => {
if (uploading || disabled) return
inputRef.current?.click()
}
const handleChange = (e) => {
processFiles(e.target.files || [])
e.target.value = ''
}
const handleDrop = (e) => {
e.preventDefault()
setDragOver(false)
if (uploading || disabled) return
processFiles(e.dataTransfer.files || [])
}
return (
<div
onClick={handleClick}
onDragOver={(e) => { e.preventDefault(); setDragOver(true) }}
onDragLeave={() => setDragOver(false)}
onDrop={handleDrop}
className={`flex flex-col items-center gap-2 border-2 border-dashed rounded-lg cursor-pointer transition-colors ${
compact ? 'px-4 py-4' : 'px-6 py-6'
} ${
dragOver ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/30'
} ${
(uploading || disabled) ? 'pointer-events-none opacity-60' : ''
}`}
>
<input
ref={inputRef}
type="file"
accept={accept}
multiple={multiple}
onChange={handleChange}
className="absolute w-0 h-0 opacity-0 pointer-events-none"
tabIndex={-1}
/>
{uploading ? (
<>
<div className="w-full max-w-xs bg-surface-tertiary rounded-full h-2 overflow-hidden">
<div
className="bg-brand-primary h-full rounded-full transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-text-secondary">
{t('artefacts.uploading')} {progress}%
</span>
</>
) : (
<>
<Upload className={compact ? 'w-5 h-5 text-text-tertiary' : 'w-7 h-7 text-text-tertiary'} />
{label && <span className="text-sm font-medium text-text-primary">{label}</span>}
{hint && <span className="text-xs text-text-tertiary">{hint}</span>}
</>
)}
</div>
)
}
+24 -1
View File
@@ -786,6 +786,7 @@
"common.team": "الفريق",
"common.noTeam": "بدون فريق",
"common.none": "بدون",
"common.untitled": "بدون عنوان",
"common.success": "تم بنجاح",
"common.error": "حدث خطأ",
"settings.roles": "الأدوار",
@@ -922,6 +923,8 @@
"artefacts.imagesLabel": "الصور",
"artefacts.uploadImage": "رفع صورة",
"artefacts.uploading": "جاري الرفع...",
"artefacts.dropOrClickImage": "اسحب الصور هنا أو انقر للرفع",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "لم يتم رفع صور بعد",
"artefacts.videosLabel": "الفيديوهات",
"artefacts.addVideoBtn": "إضافة فيديو",
@@ -1181,5 +1184,25 @@
"postDetail.linkExisting": "ربط موجود",
"postDetail.createNew": "إنشاء جديد",
"postDetail.open": "فتح",
"postDetail.unlink": "إلغاء الربط"
"postDetail.unlink": "إلغاء الربط",
"finance.campaign": "الحملة",
"finance.budgetAssigned": "الميزانية المخصصة",
"finance.trackAllocated": "المسار المخصص",
"finance.spent": "المنفق",
"finance.roi": "العائد",
"finance.workOrder": "أمر العمل",
"finance.budgetAllocated": "الميزانية المخصصة",
"finance.of": "من",
"finance.campaignCount": "{{count}} حملات · توزيع ميزانية على مستوى المسار",
"finance.workOrderCount": "{{count}} أوامر عمل بميزانية مخصصة",
"calendar.sun": "أحد",
"calendar.mon": "إثن",
"calendar.tue": "ثلا",
"calendar.wed": "أرب",
"calendar.thu": "خمي",
"calendar.fri": "جمع",
"calendar.sat": "سبت",
"calendar.month": "شهر",
"calendar.week": "أسبوع",
"calendar.today": "اليوم"
}
+24 -1
View File
@@ -786,6 +786,7 @@
"common.team": "Team",
"common.noTeam": "No team",
"common.none": "None",
"common.untitled": "Untitled",
"common.success": "Success",
"common.error": "An error occurred",
"settings.roles": "Roles",
@@ -922,6 +923,8 @@
"artefacts.imagesLabel": "Images",
"artefacts.uploadImage": "Upload Image",
"artefacts.uploading": "Uploading...",
"artefacts.dropOrClickImage": "Drop images here or click to upload",
"artefacts.imageFormats": "PNG, JPG, WebP",
"artefacts.noImages": "No images uploaded yet",
"artefacts.videosLabel": "Videos",
"artefacts.addVideoBtn": "Add Video",
@@ -1181,5 +1184,25 @@
"postDetail.linkExisting": "Link existing",
"postDetail.createNew": "Create new",
"postDetail.open": "Open",
"postDetail.unlink": "Unlink"
"postDetail.unlink": "Unlink",
"finance.campaign": "Campaign",
"finance.budgetAssigned": "Budget Assigned",
"finance.trackAllocated": "Track Allocated",
"finance.spent": "Spent",
"finance.roi": "ROI",
"finance.workOrder": "Work Order",
"finance.budgetAllocated": "Budget Allocated",
"finance.of": "of",
"finance.campaignCount": "{{count}} campaigns · Track-level budget allocation",
"finance.workOrderCount": "{{count}} work orders with assigned budget",
"calendar.sun": "Sun",
"calendar.mon": "Mon",
"calendar.tue": "Tue",
"calendar.wed": "Wed",
"calendar.thu": "Thu",
"calendar.fri": "Fri",
"calendar.sat": "Sat",
"calendar.month": "Month",
"calendar.week": "Week",
"calendar.today": "Today"
}
+15 -15
View File
@@ -222,7 +222,7 @@ export default function Finance() {
color={spendPct > 90 ? '#ef4444' : spendPct > 70 ? '#f59e0b' : '#10b981'}
/>
<div className="text-xs text-text-tertiary mt-3">
{totalSpent.toLocaleString()} of {totalReceived.toLocaleString()} {currencySymbol}
{totalSpent.toLocaleString()} {t('finance.of')} {totalReceived.toLocaleString()} {currencySymbol}
</div>
</div>
@@ -272,21 +272,21 @@ export default function Finance() {
</div>
<div>
<h3 className="font-semibold text-text-primary">{t('finance.campaignBreakdown')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.campaigns.length} campaigns &middot; Track-level budget allocation</p>
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.campaignCount').replace('{{count}}', s.campaigns.length)}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Campaign</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Assigned</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Track Allocated</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Spent</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Revenue</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">ROI</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.campaign')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAssigned')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.trackAllocated')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.spent')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.revenue')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.roi')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
@@ -335,17 +335,17 @@ export default function Finance() {
</div>
<div>
<h3 className="font-semibold text-text-primary">{t('finance.allocatedFunds')}</h3>
<p className="text-xs text-text-tertiary mt-0.5">{s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length} work orders with assigned budget</p>
<p className="text-xs text-text-tertiary mt-0.5">{t('finance.workOrderCount').replace('{{count}}', s.projects.filter(p => p.budget_allocated > 0 || p.expenses > 0).length)}</p>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-surface-secondary">
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">Work Order</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Budget Allocated</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">Expenses</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">Status</th>
<th className="px-4 py-3 text-start text-xs font-medium text-text-tertiary">{t('finance.workOrder')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.budgetAllocated')}</th>
<th className="px-4 py-3 text-end text-xs font-medium text-text-tertiary">{t('finance.expenses')}</th>
<th className="px-4 py-3 text-center text-xs font-medium text-text-tertiary">{t('common.status')}</th>
</tr>
</thead>
<tbody className="divide-y divide-border-light">
+7 -7
View File
@@ -6,7 +6,7 @@ import { api, PLATFORMS } from '../utils/api'
import PostDetailPanel from '../components/PostDetailPanel'
import { SkeletonCalendar } from '../components/SkeletonLoader'
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
const DAY_KEYS = ['calendar.sun', 'calendar.mon', 'calendar.tue', 'calendar.wed', 'calendar.thu', 'calendar.fri', 'calendar.sat']
const STATUS_COLORS = {
draft: 'bg-surface-tertiary text-text-secondary',
@@ -215,27 +215,27 @@ export default function PostCalendar() {
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'month' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarIcon className="w-3.5 h-3.5" />
Month
{t('calendar.month')}
</button>
<button
onClick={() => setCalView('week')}
className={`px-3 py-1.5 text-xs font-medium rounded-md transition-colors flex items-center gap-1.5 ${calView === 'week' ? 'bg-surface shadow-sm text-text-primary' : 'text-text-tertiary'}`}
>
<CalendarDays className="w-3.5 h-3.5" />
Week
{t('calendar.week')}
</button>
</div>
<button onClick={goToday} className="px-4 py-2 text-sm font-medium text-brand-primary hover:bg-brand-primary/5 rounded-lg transition-colors">
Today
{t('calendar.today')}
</button>
</div>
</div>
{/* Day headers */}
<div className="grid grid-cols-7 border-b border-border bg-surface-secondary">
{DAYS.map(d => (
<div key={d} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{d}
{DAY_KEYS.map(k => (
<div key={k} className="text-center text-xs font-semibold text-text-tertiary uppercase py-3">
{t(k)}
</div>
))}
</div>
+33 -29
View File
@@ -7,6 +7,7 @@ import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import PlatformIcon from '../components/PlatformIcon'
import StatusBadge from '../components/StatusBadge'
import PortalSelect from '../components/PortalSelect'
import CommentsSection from '../components/CommentsSection'
import TranslationDetailPanel from '../components/TranslationDetailPanel'
import ArtefactDetailPanel from '../components/ArtefactDetailPanel'
@@ -285,42 +286,45 @@ export default function PostDetail() {
</div>
<div className="flex items-center gap-2 flex-wrap">
<select
<PortalSelect
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>
onChange={val => setStatus(val)}
options={STATUS_OPTS.map(s => ({ value: s, label: t(`posts.status.${s}`) }))}
className="text-xs"
/>
<select
<PortalSelect
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>
onChange={val => setBrandId(val)}
options={[
{ value: '', label: t('posts.selectBrand') },
...brands.map(b => ({ value: String(b._id), label: lang === 'ar' && b.name_ar ? b.name_ar : b.name }))
]}
placeholder={t('posts.selectBrand')}
className="text-xs"
/>
<select
<PortalSelect
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>
onChange={val => setCampaignId(val)}
options={[
{ value: '', label: t('posts.noCampaign') },
...campaigns.map(c => ({ value: String(c._id || c.id), label: c.name }))
]}
placeholder={t('posts.noCampaign')}
className="text-xs"
/>
<select
<PortalSelect
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>
onChange={val => setAssignedTo(val)}
options={[
{ value: '', label: t('common.unassigned') },
...teamMembers.map(m => ({ value: String(m._id), label: m.name }))
]}
placeholder={t('common.unassigned')}
className="text-xs"
/>
</div>
{/* Platforms */}