Marketing Hub: RBAC, i18n (AR/EN), tasks overhaul, team/user merge, tutorial
Features: - Full RBAC with 3 roles (superadmin/manager/contributor) - Ownership tracking on posts, tasks, campaigns, projects - Task system: assign to anyone, filter combobox, visibility scoping - Team members merged into users table (single source of truth) - Post thumbnails on kanban cards from attachments - Publication link validation before publishing - Interactive onboarding tutorial with Settings restart - Full Arabic/English i18n with RTL layout support - Language toggle in sidebar, IBM Plex Sans Arabic font - Brand-based visibility filtering for non-superadmins - Manager can only create contributors - Profile completion flow for new users - Cookie-based sessions (express-session + SQLite)
This commit is contained in:
669
client/src/pages/PostProduction.jsx
Normal file
669
client/src/pages/PostProduction.jsx
Normal file
@@ -0,0 +1,669 @@
|
||||
import { useState, useEffect, useContext, useRef } from 'react'
|
||||
import { Plus, LayoutGrid, List, Search, Filter, Upload, X, Paperclip, Link2, FileText, Image as ImageIcon, Trash2, ExternalLink } from 'lucide-react'
|
||||
import { AppContext } from '../App'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
import { api, PLATFORMS } from '../utils/api'
|
||||
import KanbanBoard from '../components/KanbanBoard'
|
||||
import PostCard from '../components/PostCard'
|
||||
import StatusBadge from '../components/StatusBadge'
|
||||
import Modal from '../components/Modal'
|
||||
|
||||
const EMPTY_POST = {
|
||||
title: '', description: '', brand_id: '', platforms: [],
|
||||
status: 'draft', assigned_to: '', scheduled_date: '', notes: '', campaign_id: '',
|
||||
publication_links: [],
|
||||
}
|
||||
|
||||
export default function PostProduction() {
|
||||
const { t } = useLanguage()
|
||||
const { teamMembers, brands } = useContext(AppContext)
|
||||
const { canEditResource, canDeleteResource } = useAuth()
|
||||
const [posts, setPosts] = useState([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [view, setView] = useState('kanban')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
const [editingPost, setEditingPost] = useState(null)
|
||||
const [formData, setFormData] = useState(EMPTY_POST)
|
||||
const [campaigns, setCampaigns] = useState([])
|
||||
const [filters, setFilters] = useState({ brand: '', platform: '', assignedTo: '', campaign: '' })
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const [attachments, setAttachments] = useState([])
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [publishError, setPublishError] = useState('')
|
||||
const [dragActive, setDragActive] = useState(false)
|
||||
const fileInputRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadPosts()
|
||||
api.get('/campaigns').then(r => setCampaigns(Array.isArray(r) ? r : (r.data || []))).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const loadPosts = async () => {
|
||||
try {
|
||||
const res = await api.get('/posts')
|
||||
setPosts(res.data || res || [])
|
||||
} catch (err) {
|
||||
console.error('Failed to load posts:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
setPublishError('')
|
||||
try {
|
||||
const data = {
|
||||
title: formData.title,
|
||||
description: formData.description,
|
||||
brand_id: formData.brand_id ? Number(formData.brand_id) : null,
|
||||
assigned_to: formData.assigned_to ? Number(formData.assigned_to) : null,
|
||||
status: formData.status,
|
||||
platforms: formData.platforms || [],
|
||||
scheduled_date: formData.scheduled_date || null,
|
||||
notes: formData.notes,
|
||||
campaign_id: formData.campaign_id ? Number(formData.campaign_id) : null,
|
||||
publication_links: formData.publication_links || [],
|
||||
}
|
||||
|
||||
// Client-side validation: check publication links before publishing
|
||||
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}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (editingPost) {
|
||||
await api.patch(`/posts/${editingPost._id}`, data)
|
||||
} else {
|
||||
await api.post('/posts', data)
|
||||
}
|
||||
setShowModal(false)
|
||||
setEditingPost(null)
|
||||
setFormData(EMPTY_POST)
|
||||
setAttachments([])
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
setPublishError(err.message.replace(/.*: /, ''))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleMovePost = async (postId, newStatus) => {
|
||||
try {
|
||||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Move failed:', err)
|
||||
if (err.message?.includes('Cannot publish')) {
|
||||
alert('Cannot publish: all platform publication links must be filled first.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadAttachments = async (postId) => {
|
||||
try {
|
||||
const data = await api.get(`/posts/${postId}/attachments`)
|
||||
setAttachments(Array.isArray(data) ? data : (data.data || []))
|
||||
} catch (err) {
|
||||
console.error('Failed to load attachments:', err)
|
||||
setAttachments([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (files) => {
|
||||
if (!editingPost || !files?.length) return
|
||||
setUploading(true)
|
||||
setUploadProgress(0)
|
||||
const postId = editingPost._id || editingPost.id
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const fd = new FormData()
|
||||
fd.append('file', files[i])
|
||||
try {
|
||||
await api.upload(`/posts/${postId}/attachments`, fd)
|
||||
setUploadProgress(Math.round(((i + 1) / files.length) * 100))
|
||||
} catch (err) {
|
||||
console.error('Upload failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
setUploading(false)
|
||||
setUploadProgress(0)
|
||||
loadAttachments(postId)
|
||||
}
|
||||
|
||||
const handleDeleteAttachment = async (attachmentId) => {
|
||||
try {
|
||||
await api.delete(`/attachments/${attachmentId}`)
|
||||
if (editingPost) loadAttachments(editingPost._id || editingPost.id)
|
||||
} catch (err) {
|
||||
console.error('Delete attachment failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnter = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(true) }
|
||||
const handleDragLeaveZone = (e) => { e.preventDefault(); e.stopPropagation(); setDragActive(false) }
|
||||
const handleDragOverZone = (e) => { e.preventDefault(); e.stopPropagation() }
|
||||
const handleDropFiles = (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); setDragActive(false)
|
||||
if (e.dataTransfer.files?.length) handleFileUpload(e.dataTransfer.files)
|
||||
}
|
||||
|
||||
const updatePublicationLink = (platform, url) => {
|
||||
setFormData(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 }
|
||||
})
|
||||
}
|
||||
|
||||
const openEdit = (post) => {
|
||||
if (!canEditResource('post', post)) {
|
||||
alert('You can only edit your own posts')
|
||||
return
|
||||
}
|
||||
setEditingPost(post)
|
||||
setPublishError('')
|
||||
setFormData({
|
||||
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 || [],
|
||||
})
|
||||
loadAttachments(post._id || post.id)
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const openNew = () => {
|
||||
setEditingPost(null)
|
||||
setFormData(EMPTY_POST)
|
||||
setAttachments([])
|
||||
setPublishError('')
|
||||
setShowModal(true)
|
||||
}
|
||||
|
||||
const filteredPosts = posts.filter(p => {
|
||||
if (filters.brand && String(p.brandId || p.brand_id) !== filters.brand) return false
|
||||
if (filters.platform && !(p.platforms || []).includes(filters.platform) && p.platform !== filters.platform) return false
|
||||
if (filters.assignedTo && String(p.assignedTo || p.assigned_to) !== filters.assignedTo) return false
|
||||
if (filters.campaign && String(p.campaignId || p.campaign_id) !== filters.campaign) return false
|
||||
if (searchTerm && !p.title?.toLowerCase().includes(searchTerm.toLowerCase())) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-4 animate-pulse">
|
||||
<div className="h-12 bg-surface-tertiary rounded-xl"></div>
|
||||
<div className="flex gap-4">
|
||||
{[...Array(5)].map((_, i) => <div key={i} className="w-72 h-96 bg-surface-tertiary rounded-xl"></div>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4 animate-fade-in">
|
||||
{/* Toolbar */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 min-w-[200px] max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('posts.searchPosts')}
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div data-tutorial="filters" className="flex gap-3">
|
||||
<select
|
||||
value={filters.brand}
|
||||
onChange={e => setFilters(f => ({ ...f, brand: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allBrands')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.name}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.platform}
|
||||
onChange={e => setFilters(f => ({ ...f, platform: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allPlatforms')}</option>
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={filters.assignedTo}
|
||||
onChange={e => setFilters(f => ({ ...f, assignedTo: e.target.value }))}
|
||||
className="text-sm border border-border rounded-lg px-3 py-2 bg-white text-text-secondary focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
||||
>
|
||||
<option value="">{t('posts.allPeople')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={String(m._id)}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* View toggle */}
|
||||
<div className="flex bg-surface-tertiary rounded-lg p-0.5 ml-auto">
|
||||
<button
|
||||
onClick={() => setView('kanban')}
|
||||
className={`p-2 rounded-md ${view === 'kanban' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setView('list')}
|
||||
className={`p-2 rounded-md ${view === 'list' ? 'bg-white shadow-sm text-text-primary' : 'text-text-tertiary'}`}
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New post */}
|
||||
<button
|
||||
data-tutorial="new-post"
|
||||
onClick={openNew}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('posts.newPost')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{view === 'kanban' ? (
|
||||
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
|
||||
) : (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-border bg-surface-secondary">
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.postTitle')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.brand')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.status')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.platforms')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.assignTo')}</th>
|
||||
<th className="text-left px-4 py-3 text-xs font-semibold text-text-tertiary uppercase tracking-wider">{t('posts.scheduledDate')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border-light">
|
||||
{filteredPosts.map(post => (
|
||||
<PostCard key={post._id} post={post} onClick={() => openEdit(post)} />
|
||||
))}
|
||||
{filteredPosts.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="py-12 text-center text-sm text-text-tertiary">
|
||||
{t('posts.noPostsFound')}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create/Edit Modal */}
|
||||
<Modal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditingPost(null) }}
|
||||
title={editingPost ? t('posts.editPost') : t('posts.createPost')}
|
||||
size="lg"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.postTitle')} *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={e => setFormData(f => ({ ...f, title: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
placeholder={t('posts.postTitlePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.description')}</label>
|
||||
<textarea
|
||||
value={formData.description}
|
||||
onChange={e => setFormData(f => ({ ...f, description: e.target.value }))}
|
||||
rows={4}
|
||||
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>
|
||||
|
||||
{/* Campaign */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.campaign')}</label>
|
||||
<select
|
||||
value={formData.campaign_id}
|
||||
onChange={e => setFormData(f => ({ ...f, campaign_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.noCampaign')}</option>
|
||||
{campaigns.map(c => <option key={c._id} value={c._id}>{c.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.brand')}</label>
|
||||
<select
|
||||
value={formData.brand_id}
|
||||
onChange={e => setFormData(f => ({ ...f, brand_id: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('posts.selectBrand')}</option>
|
||||
{brands.map(b => <option key={b._id} value={b._id}>{b.icon} {b.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.platforms')}</label>
|
||||
<div className="flex flex-wrap gap-2 p-2 border border-border rounded-lg bg-white min-h-[42px]">
|
||||
{Object.entries(PLATFORMS).map(([k, v]) => {
|
||||
const checked = (formData.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={() => {
|
||||
setFormData(f => ({
|
||||
...f,
|
||||
platforms: checked
|
||||
? f.platforms.filter(p => p !== k)
|
||||
: [...(f.platforms || []), k]
|
||||
}))
|
||||
}}
|
||||
className="sr-only"
|
||||
/>
|
||||
{v.label}
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.status')}</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={e => setFormData(f => ({ ...f, 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"
|
||||
>
|
||||
<option value="draft">{t('posts.status.draft')}</option>
|
||||
<option value="in_review">{t('posts.status.in_review')}</option>
|
||||
<option value="approved">{t('posts.status.approved')}</option>
|
||||
<option value="scheduled">{t('posts.status.scheduled')}</option>
|
||||
<option value="published">{t('posts.status.published')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.assignTo')}</label>
|
||||
<select
|
||||
value={formData.assigned_to}
|
||||
onChange={e => setFormData(f => ({ ...f, assigned_to: e.target.value }))}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-brand-primary/20 focus:border-brand-primary"
|
||||
>
|
||||
<option value="">{t('common.unassigned')}</option>
|
||||
{teamMembers.map(m => <option key={m._id} value={m._id}>{m.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-1">{t('posts.scheduledDate')}</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={formData.scheduled_date}
|
||||
onChange={e => setFormData(f => ({ ...f, 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-sm font-medium text-text-primary mb-1">{t('posts.notes')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.notes}
|
||||
onChange={e => setFormData(f => ({ ...f, 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>
|
||||
|
||||
{/* Publication Links */}
|
||||
{(formData.platforms || []).length > 0 && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Link2 className="w-4 h-4" />
|
||||
{t('posts.publicationLinks')}
|
||||
</span>
|
||||
</label>
|
||||
<div className="space-y-2 border border-border rounded-lg p-3 bg-surface-secondary">
|
||||
{(formData.platforms || []).map(platformKey => {
|
||||
const platformInfo = PLATFORMS[platformKey] || { label: platformKey }
|
||||
const existingLink = (formData.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>
|
||||
)
|
||||
})}
|
||||
{formData.status === 'published' && (formData.platforms || []).some(p => {
|
||||
const link = (formData.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>
|
||||
)}
|
||||
|
||||
{/* Attachments (only for existing posts) */}
|
||||
{editingPost && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-text-primary mb-2">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Paperclip className="w-4 h-4" />
|
||||
{t('posts.attachments')}
|
||||
<span className="text-xs text-text-tertiary font-normal">({attachments.length})</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{/* Existing attachments */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 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
|
||||
return (
|
||||
<div key={att.id || att._id} className="relative group/att border border-border rounded-lg overflow-hidden bg-white">
|
||||
{isImage ? (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer">
|
||||
<img
|
||||
src={`http://localhost:3001${attUrl}`}
|
||||
alt={name}
|
||||
className="w-full h-24 object-cover"
|
||||
/>
|
||||
</a>
|
||||
) : (
|
||||
<a href={`http://localhost:3001${attUrl}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 p-3 h-24">
|
||||
<FileText className="w-8 h-8 text-text-tertiary shrink-0" />
|
||||
<span className="text-xs text-text-secondary truncate">{name}</span>
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDeleteAttachment(att.id || att._id)}
|
||||
className="absolute top-1 right-1 p-1 bg-red-500 text-white rounded-full opacity-0 group-hover/att:opacity-100 transition-opacity shadow-sm"
|
||||
title={t('posts.deleteAttachment')}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
<div className="px-2 py-1 text-[10px] text-text-tertiary truncate border-t border-border-light">
|
||||
{name}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload area */}
|
||||
<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={handleDragEnter}
|
||||
onDragLeave={handleDragLeaveZone}
|
||||
onDragOver={handleDragOverZone}
|
||||
onDrop={handleDropFiles}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => handleFileUpload(e.target.files)}
|
||||
/>
|
||||
<Upload className="w-6 h-6 text-text-tertiary mx-auto mb-1" />
|
||||
<p className="text-xs text-text-secondary">
|
||||
{dragActive ? t('posts.dropFiles') : t('posts.uploadFiles')}
|
||||
</p>
|
||||
<p className="text-[10px] text-text-tertiary mt-0.5">{t('posts.maxSize')}</p>
|
||||
</div>
|
||||
|
||||
{/* Upload progress */}
|
||||
{uploading && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-xs text-text-secondary mb-1">
|
||||
<span>{t('posts.uploading')}</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-surface-tertiary rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-brand-primary h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Publish validation error */}
|
||||
{publishError && (
|
||||
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
|
||||
{publishError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-4 border-t border-border">
|
||||
{editingPost && canDeleteResource('post', editingPost) && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="px-4 py-2 text-sm font-medium text-red-600 hover:bg-red-50 rounded-lg mr-auto"
|
||||
>
|
||||
{t('common.delete')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setShowModal(false); setEditingPost(null) }}
|
||||
className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg"
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!formData.title}
|
||||
className="px-5 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"
|
||||
>
|
||||
{editingPost ? t('posts.saveChanges') : t('posts.createPost')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
<Modal
|
||||
isOpen={showDeleteConfirm}
|
||||
onClose={() => setShowDeleteConfirm(false)}
|
||||
title={t('posts.deletePost')}
|
||||
isConfirm
|
||||
danger
|
||||
confirmText={t('posts.deletePost')}
|
||||
onConfirm={async () => {
|
||||
if (editingPost) {
|
||||
try {
|
||||
await api.delete(`/posts/${editingPost._id || editingPost.id}`)
|
||||
setShowModal(false)
|
||||
setEditingPost(null)
|
||||
loadPosts()
|
||||
} catch (err) {
|
||||
console.error('Delete failed:', err)
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('posts.deleteConfirm')}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user