Files
marketing-app/client/src/components/PostDetailPanel.jsx
fahed 4522edeea8 feat: slide panels, task calendar, team management, project editing, collapsible sections
- Add SlidePanel, TaskDetailPanel, PostDetailPanel, TeamPanel, TeamMemberPanel
- Add ProjectEditPanel, CollapsibleSection, DatePresetPicker, TaskCalendarView
- Update App, AuthContext, i18n (ar/en), PostProduction, ProjectDetail, Projects
- Update Settings, Tasks, Team pages
- Update InteractiveTimeline, MemberCard, ProjectCard, TaskCard components
- Update server API utilities
- Remove tracked server/node_modules (now properly gitignored)
2026-02-19 11:35:42 +03:00

583 lines
26 KiB
JavaScript

import { useState, useEffect, useRef } from 'react'
import { X, Trash2, Upload, FileText, Link2, ExternalLink, FolderOpen } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS, getBrandColor } from '../utils/api'
import CommentsSection from './CommentsSection'
import Modal from './Modal'
import SlidePanel from './SlidePanel'
import CollapsibleSection from './CollapsibleSection'
export default function PostDetailPanel({ post, onClose, onSave, onDelete, brands, teamMembers, campaigns }) {
const { t, lang } = useLanguage()
const fileInputRef = useRef(null)
const [form, setForm] = useState({})
const [dirty, setDirty] = useState(false)
const [saving, setSaving] = useState(false)
const [publishError, setPublishError] = useState('')
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
// Attachments state
const [attachments, setAttachments] = useState([])
const [uploading, setUploading] = useState(false)
const [dragActive, setDragActive] = useState(false)
const [showAssetPicker, setShowAssetPicker] = useState(false)
const [availableAssets, setAvailableAssets] = useState([])
const [assetSearch, setAssetSearch] = useState('')
const postId = post?._id || post?.id
const isCreateMode = !postId
useEffect(() => {
if (post) {
setForm({
title: post.title || '',
description: post.description || '',
brand_id: post.brandId || post.brand_id || '',
platforms: post.platforms || (post.platform ? [post.platform] : []),
status: post.status || 'draft',
assigned_to: post.assignedTo || post.assigned_to || '',
scheduled_date: post.scheduledDate ? new Date(post.scheduledDate).toISOString().slice(0, 16) : '',
notes: post.notes || '',
campaign_id: post.campaignId || post.campaign_id || '',
publication_links: post.publication_links || post.publicationLinks || [],
})
setDirty(isCreateMode)
setPublishError('')
if (!isCreateMode) loadAttachments()
}
}, [post])
if (!post) return null
const statusOptions = [
{ value: 'draft', label: t('posts.status.draft') },
{ value: 'in_review', label: t('posts.status.in_review') },
{ value: 'approved', label: t('posts.status.approved') },
{ value: 'scheduled', label: t('posts.status.scheduled') },
{ value: 'published', label: t('posts.status.published') },
]
const update = (field, value) => {
setForm(f => ({ ...f, [field]: value }))
setDirty(true)
}
const updatePublicationLink = (platform, url) => {
setForm(f => {
const links = [...(f.publication_links || [])]
const idx = links.findIndex(l => l.platform === platform)
if (idx >= 0) {
links[idx] = { ...links[idx], url }
} else {
links.push({ platform, url })
}
return { ...f, publication_links: links }
})
setDirty(true)
}
const handleSave = async () => {
setPublishError('')
setSaving(true)
try {
const data = {
title: form.title,
description: form.description,
brand_id: form.brand_id ? Number(form.brand_id) : null,
assigned_to: form.assigned_to ? Number(form.assigned_to) : null,
status: form.status,
platforms: form.platforms || [],
scheduled_date: form.scheduled_date || null,
notes: form.notes,
campaign_id: form.campaign_id ? Number(form.campaign_id) : null,
publication_links: form.publication_links || [],
}
if (data.status === 'published' && data.platforms.length > 0) {
const missingPlatforms = data.platforms.filter(platform => {
const link = (data.publication_links || []).find(l => l.platform === platform)
return !link || !link.url || !link.url.trim()
})
if (missingPlatforms.length > 0) {
const names = missingPlatforms.map(p => PLATFORMS[p]?.label || p).join(', ')
setPublishError(`${t('posts.publishMissing')} ${names}`)
setSaving(false)
return
}
}
await onSave(isCreateMode ? null : postId, data)
setDirty(false)
if (isCreateMode) onClose()
} catch (err) {
if (err.message?.includes('Cannot publish')) {
setPublishError(err.message.replace(/.*: /, ''))
}
} finally {
setSaving(false)
}
}
const confirmDelete = async () => {
setShowDeleteConfirm(false)
await onDelete(postId)
onClose()
}
// ─── Attachments ──────────────────────────────
async function loadAttachments() {
if (!postId) return
try {
const data = await api.get(`/posts/${postId}/attachments`)
setAttachments(Array.isArray(data) ? data : (data.data || []))
} catch {
setAttachments([])
}
}
const handleFileUpload = async (files) => {
if (!postId || !files?.length) return
setUploading(true)
for (const file of files) {
const fd = new FormData()
fd.append('file', file)
try {
await api.upload(`/posts/${postId}/attachments`, fd)
} catch (err) {
console.error('Upload failed:', err)
}
}
setUploading(false)
loadAttachments()
}
const handleDeleteAttachment = async (attId) => {
try {
await api.delete(`/attachments/${attId}`)
loadAttachments()
} catch (err) {
console.error('Delete attachment failed:', err)
}
}
const openAssetPicker = async () => {
try {
const data = await api.get('/assets')
setAvailableAssets(Array.isArray(data) ? data : (data.data || []))
} catch {
setAvailableAssets([])
}
setAssetSearch('')
setShowAssetPicker(true)
}
const handleAttachAsset = async (assetId) => {
if (!postId) return
try {
await api.post(`/posts/${postId}/attachments/from-asset`, { asset_id: assetId })
loadAttachments()
setShowAssetPicker(false)
} catch (err) {
console.error('Attach asset failed:', err)
}
}
const handleDrop = (e) => {
e.preventDefault(); e.stopPropagation(); setDragActive(false)
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
}
const brandName = (() => {
if (form.brand_id) {
const b = brands?.find(b => String(b._id || b.id) === String(form.brand_id))
return b ? (lang === 'ar' && b.name_ar ? b.name_ar : b.name) : null
}
return post.brand_name || post.brandName || null
})()
const header = (
<div className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<input
type="text"
value={form.title}
onChange={e => update('title', e.target.value)}
className="w-full text-lg font-semibold text-text-primary bg-transparent border-0 p-0 focus:outline-none focus:ring-0"
placeholder={t('posts.postTitlePlaceholder')}
/>
<div className="flex items-center gap-2 mt-2">
<span className={`text-[11px] px-2 py-0.5 rounded-full font-medium ${
form.status === 'published' ? 'bg-emerald-100 text-emerald-700' :
form.status === 'scheduled' ? 'bg-purple-100 text-purple-700' :
form.status === 'approved' ? 'bg-blue-100 text-blue-700' :
form.status === 'in_review' ? 'bg-amber-100 text-amber-700' :
'bg-gray-100 text-gray-600'
}`}>
{statusOptions.find(s => s.value === form.status)?.label}
</span>
{brandName && (
<span className={`text-[10px] px-1.5 py-0.5 rounded ${getBrandColor(brandName).bg} ${getBrandColor(brandName).text}`}>
{brandName}
</span>
)}
</div>
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors shrink-0"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
)
return (
<>
<SlidePanel onClose={onClose} maxWidth="520px" header={header}>
{/* Details Section */}
<CollapsibleSection title={t('posts.details')}>
<div className="px-5 pb-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.description')}</label>
<textarea
value={form.description}
onChange={e => update('description', e.target.value)}
rows={3}
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 resize-none"
placeholder={t('posts.postDescPlaceholder')}
/>
</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={form.brand_id}
onChange={e => update('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.selectBrand')}</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('posts.campaign')}</label>
<select
value={form.campaign_id}
onChange={e => update('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="">{t('posts.noCampaign')}</option>
{(campaigns || []).map(c => <option key={c._id || c.id} value={c._id || c.id}>{c.name}</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('posts.assignTo')}</label>
<select
value={form.assigned_to}
onChange={e => update('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 || 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('posts.status')}</label>
<select
value={form.status}
onChange={e => update('status', 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"
>
{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('posts.scheduledDate')}</label>
<input
type="datetime-local"
value={form.scheduled_date}
onChange={e => update('scheduled_date', 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.notes')}</label>
<input
type="text"
value={form.notes}
onChange={e => update('notes', 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"
placeholder={t('posts.additionalNotes')}
/>
</div>
</div>
{publishError && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{publishError}
</div>
)}
<div className="flex items-center gap-2 pt-2">
{dirty && (
<button
onClick={handleSave}
disabled={!form.title || saving}
className={`flex-1 px-4 py-2 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 ${saving ? 'btn-loading' : ''}`}
>
{isCreateMode ? t('posts.createPost') : t('posts.saveChanges')}
</button>
)}
{onDelete && !isCreateMode && (
<button
onClick={() => setShowDeleteConfirm(true)}
className="p-2 text-text-tertiary hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title={t('common.delete')}
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</div>
</CollapsibleSection>
{/* Platforms & Links Section */}
<CollapsibleSection title={t('posts.platformsLinks')}>
<div className="px-5 pb-4 space-y-3">
<div>
<label className="block text-xs font-medium text-text-tertiary mb-1">{t('posts.platforms')}</label>
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[38px]">
{Object.entries(PLATFORMS).map(([k, v]) => {
const checked = (form.platforms || []).includes(k)
return (
<label
key={k}
className={`flex items-center gap-1.5 text-xs px-2.5 py-1.5 rounded-full cursor-pointer border transition-colors ${
checked
? 'bg-brand-primary/10 border-brand-primary/30 text-brand-primary font-medium'
: 'bg-surface-tertiary border-transparent text-text-secondary hover:bg-surface-tertiary/80'
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => {
update('platforms', checked
? form.platforms.filter(p => p !== k)
: [...(form.platforms || []), k]
)
}}
className="sr-only"
/>
{v.label}
</label>
)
})}
</div>
</div>
{(form.platforms || []).length > 0 && (
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center gap-1.5 text-xs font-medium text-text-secondary mb-1">
<Link2 className="w-3.5 h-3.5" />
{t('posts.publicationLinks')}
</div>
{(form.platforms || []).map(platformKey => {
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
const existingLink = (form.publication_links || []).find(l => l.platform === platformKey)
const linkUrl = existingLink?.url || ''
return (
<div key={platformKey} className="flex items-center gap-2">
<span className="text-xs font-medium text-text-secondary w-24 shrink-0 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: platformInfo.color || '#888' }} />
{platformInfo.label}
</span>
<input
type="url"
value={linkUrl}
onChange={e => updatePublicationLink(platformKey, e.target.value)}
className="flex-1 px-3 py-1.5 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
placeholder="https://..."
/>
{linkUrl && (
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="p-1.5 text-text-tertiary hover:text-brand-primary">
<ExternalLink className="w-3.5 h-3.5" />
</a>
)}
</div>
)
})}
{form.status === 'published' && (form.platforms || []).some(p => {
const link = (form.publication_links || []).find(l => l.platform === p)
return !link || !link.url?.trim()
}) && (
<p className="text-xs text-amber-600 mt-1">{t('posts.publishRequired')}</p>
)}
</div>
)}
</div>
</CollapsibleSection>
{/* Attachments Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection
title={t('posts.attachments')}
badge={attachments.length > 0 ? (
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-1.5 py-0.5 rounded-full">
{attachments.length}
</span>
) : null}
>
<div className="px-5 pb-4">
{attachments.length > 0 && (
<div className="grid grid-cols-2 gap-2 mb-3">
{attachments.map(att => {
const isImage = att.mime_type?.startsWith('image/') || att.mimeType?.startsWith('image/')
const attUrl = att.url || `/api/uploads/${att.filename}`
const name = att.original_name || att.originalName || att.filename
const attId = att.id || att._id
return (
<div key={attId} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
<div className="h-20 relative">
{isImage ? (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="block h-full">
<img src={attUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
</a>
) : (
<a href={attUrl} target="_blank" rel="noopener noreferrer" className="absolute inset-0 flex items-center gap-2 p-3">
<FileText className="w-6 h-6 text-text-tertiary shrink-0" />
<span className="text-xs text-text-secondary truncate">{name}</span>
</a>
)}
<button
onClick={() => handleDeleteAttachment(attId)}
className="absolute top-1 right-1 p-1 bg-black/50 hover:bg-red-500 rounded-full text-white opacity-0 group-hover/att:opacity-100 transition-opacity"
title={t('common.delete')}
>
<X className="w-2.5 h-2.5" />
</button>
</div>
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</div>
)
})}
</div>
)}
<div
className={`border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors ${
dragActive ? 'border-brand-primary bg-brand-primary/5' : 'border-border hover:border-brand-primary/40'
}`}
onClick={() => fileInputRef.current?.click()}
onDragEnter={e => { e.preventDefault(); setDragActive(true) }}
onDragLeave={e => { e.preventDefault(); setDragActive(false) }}
onDragOver={e => e.preventDefault()}
onDrop={handleDrop}
>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => { handleFileUpload(e.target.files); e.target.value = '' }}
/>
<Upload className={`w-5 h-5 text-text-tertiary mx-auto mb-1 ${uploading ? 'animate-pulse' : ''}`} />
<p className="text-xs text-text-secondary">
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
</p>
</div>
<button
type="button"
onClick={openAssetPicker}
className="mt-2 flex items-center gap-2 px-3 py-2 text-sm text-text-secondary border border-border rounded-lg hover:bg-surface-tertiary transition-colors w-full justify-center"
>
<FolderOpen className="w-4 h-4" />
{t('posts.attachFromAssets')}
</button>
{showAssetPicker && (
<div className="mt-2 border border-border rounded-lg p-3 bg-surface-secondary">
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-medium text-text-secondary">{t('posts.selectAssets')}</p>
<button onClick={() => setShowAssetPicker(false)} className="p-1 text-text-tertiary hover:text-text-primary">
<X className="w-3.5 h-3.5" />
</button>
</div>
<input
type="text"
value={assetSearch}
onChange={e => setAssetSearch(e.target.value)}
placeholder={t('common.search')}
className="w-full px-3 py-1.5 text-xs border border-border rounded-lg mb-2 focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
/>
<div className="grid grid-cols-3 gap-2 max-h-48 overflow-y-auto">
{availableAssets
.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase()))
.map(asset => {
const isImage = asset.mime_type?.startsWith('image/')
const assetUrl = `/api/uploads/${asset.filename}`
const name = asset.original_name || asset.filename
return (
<button
key={asset.id || asset._id}
onClick={() => handleAttachAsset(asset.id || asset._id)}
className="block w-full border border-border rounded-lg overflow-hidden bg-white hover:border-brand-primary hover:ring-2 hover:ring-brand-primary/20 transition-all text-left"
>
<div className="aspect-square relative">
{isImage ? (
<img src={assetUrl} alt={name} className="absolute inset-0 w-full h-full object-cover" />
) : (
<div className="absolute inset-0 flex items-center justify-center bg-surface-tertiary">
<FileText className="w-6 h-6 text-text-tertiary" />
</div>
)}
</div>
<div className="px-1.5 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">{name}</div>
</button>
)
})}
</div>
{availableAssets.filter(a => !assetSearch || (a.original_name || a.filename || '').toLowerCase().includes(assetSearch.toLowerCase())).length === 0 && (
<p className="text-xs text-text-tertiary text-center py-4">{t('posts.noAssetsFound')}</p>
)}
</div>
)}
</div>
</CollapsibleSection>
)}
{/* Discussion Section (hidden in create mode) */}
{!isCreateMode && (
<CollapsibleSection title={t('posts.discussion')} noBorder>
<div className="px-5 pb-5">
<CommentsSection entityType="post" entityId={postId} />
</div>
</CollapsibleSection>
)}
</SlidePanel>
<Modal
isOpen={showDeleteConfirm}
onClose={() => setShowDeleteConfirm(false)}
title={t('posts.deletePost')}
isConfirm
danger
confirmText={t('posts.deletePost')}
onConfirm={confirmDelete}
>
{t('posts.deleteConfirm')}
</Modal>
</>
)
}