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:
fahed
2026-02-08 20:46:58 +03:00
commit 35d84b6bff
2240 changed files with 846749 additions and 0 deletions

View File

@@ -0,0 +1,121 @@
import { format } from 'date-fns'
import { ArrowRight } from 'lucide-react'
import { useLanguage } from '../i18n/LanguageContext'
import BrandBadge from './BrandBadge'
import StatusBadge from './StatusBadge'
import PlatformIcon, { PlatformIcons } from './PlatformIcon'
export default function PostCard({ post, onClick, onMove, compact = false }) {
const { t } = useLanguage()
// Support both single platform and platforms array
const platforms = post.platforms?.length > 0
? post.platforms
: (post.platform ? [post.platform] : [])
const getInitials = (name) => {
if (!name) return '?'
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
}
const assigneeName = post.assignedToName || post.assignedName || post.assigned_name || (typeof post.assignedTo === 'object' ? post.assignedTo?.name : null)
if (compact) {
return (
<div
onClick={onClick}
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 hover:shadow-md cursor-pointer transition-all group"
>
{post.thumbnail_url && (
<div className="w-full h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
<img src={`http://localhost:3001${post.thumbnail_url}`} alt="" className="w-full h-full object-cover" />
</div>
)}
<div className="flex items-start justify-between gap-2 mb-2">
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug">{post.title}</h5>
<div className="shrink-0 mt-0.5">
<PlatformIcons platforms={platforms} size={14} />
</div>
</div>
<div className="flex items-center gap-2 flex-wrap">
{post.brand && <BrandBadge brand={post.brand} />}
</div>
<div className="flex items-center justify-between mt-3 pt-2 border-t border-border-light">
{assigneeName ? (
<div className="flex items-center gap-1.5">
<div className="w-5 h-5 rounded-full bg-brand-primary/10 text-brand-primary flex items-center justify-center text-[10px] font-semibold">
{getInitials(assigneeName)}
</div>
<span className="text-xs text-text-tertiary">{assigneeName}</span>
</div>
) : (
<span className="text-xs text-text-tertiary">{t('common.unassigned')}</span>
)}
{post.scheduledDate && (
<span className="text-[10px] text-text-tertiary">
{format(new Date(post.scheduledDate), 'MMM d')}
</span>
)}
</div>
{/* Quick move buttons */}
{onMove && (
<div className="hidden group-hover:flex items-center gap-1 mt-2 pt-2 border-t border-border-light">
{post.status === 'draft' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'in_review') }}
className="text-[10px] text-amber-600 hover:bg-amber-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.sendToReview')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'in_review' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'approved') }}
className="text-[10px] text-blue-600 hover:bg-blue-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.approve')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'approved' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'scheduled') }}
className="text-[10px] text-purple-600 hover:bg-purple-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.schedule')} <ArrowRight className="w-3 h-3" />
</button>
)}
{post.status === 'scheduled' && (
<button onClick={(e) => { e.stopPropagation(); onMove(post._id, 'published') }}
className="text-[10px] text-emerald-600 hover:bg-emerald-50 px-2 py-0.5 rounded-full flex items-center gap-1">
{t('posts.publish')} <ArrowRight className="w-3 h-3" />
</button>
)}
</div>
)}
</div>
)
}
// Table row view
return (
<tr onClick={onClick} className="hover:bg-surface-secondary cursor-pointer group">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="shrink-0">
<PlatformIcons platforms={platforms} size={16} />
</div>
<span className="text-sm font-medium text-text-primary">{post.title}</span>
</div>
</td>
<td className="px-4 py-3">{post.brand && <BrandBadge brand={post.brand} />}</td>
<td className="px-4 py-3"><StatusBadge status={post.status} /></td>
<td className="px-4 py-3">
<PlatformIcons platforms={platforms} size={16} gap="gap-1.5" />
</td>
<td className="px-4 py-3">
<span className="text-sm text-text-secondary">{assigneeName || '—'}</span>
</td>
<td className="px-4 py-3 text-sm text-text-tertiary">
{post.scheduledDate ? format(new Date(post.scheduledDate), 'MMM d, yyyy') : '—'}
</td>
</tr>
)
}