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:
86
client/src/components/AssetCard.jsx
Normal file
86
client/src/components/AssetCard.jsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { format } from 'date-fns'
|
||||
import { Image, FileText, Film, Music, File, Download } from 'lucide-react'
|
||||
|
||||
const typeIcons = {
|
||||
image: Image,
|
||||
document: FileText,
|
||||
video: Film,
|
||||
audio: Music,
|
||||
}
|
||||
|
||||
const formatFileSize = (bytes) => {
|
||||
if (!bytes) return '—'
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export default function AssetCard({ asset, onClick }) {
|
||||
const TypeIcon = typeIcons[asset.type] || File
|
||||
const isImage = asset.type === 'image'
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(asset)}
|
||||
className="bg-white rounded-xl border border-border overflow-hidden card-hover cursor-pointer group"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square bg-surface-tertiary flex items-center justify-center overflow-hidden relative">
|
||||
{isImage && asset.url ? (
|
||||
<img
|
||||
src={asset.url}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
e.target.nextSibling.style.display = 'flex'
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 ${isImage && asset.url ? 'hidden' : ''}`}
|
||||
style={{ display: isImage && asset.url ? 'none' : 'flex' }}
|
||||
>
|
||||
<TypeIcon className="w-10 h-10 text-text-tertiary" />
|
||||
<span className="text-xs text-text-tertiary uppercase font-medium">
|
||||
{asset.fileType || asset.type}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hover overlay */}
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors flex items-center justify-center">
|
||||
<button className="opacity-0 group-hover:opacity-100 bg-white/90 backdrop-blur-sm rounded-full p-2 shadow-lg transition-opacity">
|
||||
<Download className="w-4 h-4 text-text-primary" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-3">
|
||||
<h5 className="text-sm font-medium text-text-primary truncate">{asset.name}</h5>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-xs text-text-tertiary">{formatFileSize(asset.size)}</span>
|
||||
{asset.createdAt && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
{format(new Date(asset.createdAt), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{asset.tags && asset.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{asset.tags.slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
{asset.tags.length > 3 && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 text-text-tertiary">
|
||||
+{asset.tags.length - 3}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
12
client/src/components/BrandBadge.jsx
Normal file
12
client/src/components/BrandBadge.jsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { getBrandColor } from '../utils/api'
|
||||
|
||||
export default function BrandBadge({ brand }) {
|
||||
const color = getBrandColor(brand)
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 text-xs font-medium px-2 py-0.5 rounded-full ${color.bg} ${color.text}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${color.dot}`}></span>
|
||||
{brand}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
132
client/src/components/CampaignCalendar.jsx
Normal file
132
client/src/components/CampaignCalendar.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
startOfMonth, endOfMonth, startOfWeek, endOfWeek,
|
||||
eachDayOfInterval, format, isSameMonth, isToday,
|
||||
addMonths, subMonths, isBefore, isAfter, isSameDay
|
||||
} from 'date-fns'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { getBrandColor } from '../utils/api'
|
||||
|
||||
const CAMPAIGN_COLORS = [
|
||||
'bg-indigo-400', 'bg-pink-400', 'bg-emerald-400', 'bg-amber-400',
|
||||
'bg-purple-400', 'bg-cyan-400', 'bg-rose-400', 'bg-teal-400',
|
||||
]
|
||||
|
||||
export default function CampaignCalendar({ campaigns = [] }) {
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
|
||||
const days = useMemo(() => {
|
||||
const monthStart = startOfMonth(currentMonth)
|
||||
const monthEnd = endOfMonth(currentMonth)
|
||||
const calStart = startOfWeek(monthStart, { weekStartsOn: 0 })
|
||||
const calEnd = endOfWeek(monthEnd, { weekStartsOn: 0 })
|
||||
return eachDayOfInterval({ start: calStart, end: calEnd })
|
||||
}, [currentMonth])
|
||||
|
||||
const getCampaignsForDay = (day) => {
|
||||
return campaigns.filter((c) => {
|
||||
const start = new Date(c.startDate)
|
||||
const end = new Date(c.endDate)
|
||||
return (isSameDay(day, start) || isAfter(day, start)) &&
|
||||
(isSameDay(day, end) || isBefore(day, end))
|
||||
})
|
||||
}
|
||||
|
||||
const isStartOfCampaign = (day, campaign) => {
|
||||
return isSameDay(day, new Date(campaign.startDate))
|
||||
}
|
||||
|
||||
const isEndOfCampaign = (day, campaign) => {
|
||||
return isSameDay(day, new Date(campaign.endDate))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||
<h3 className="text-lg font-semibold text-text-primary">
|
||||
{format(currentMonth, 'MMMM yyyy')}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentMonth(new Date())}
|
||||
className="px-3 py-1 text-sm font-medium rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
||||
>
|
||||
Today
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-secondary"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Day names */}
|
||||
<div className="calendar-grid border-b border-border">
|
||||
{['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d) => (
|
||||
<div key={d} className="px-2 py-2 text-center text-xs font-semibold text-text-tertiary uppercase tracking-wider">
|
||||
{d}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Calendar grid */}
|
||||
<div className="calendar-grid">
|
||||
{days.map((day, i) => {
|
||||
const dayCampaigns = getCampaignsForDay(day)
|
||||
const inMonth = isSameMonth(day, currentMonth)
|
||||
const today = isToday(day)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`min-h-[80px] p-1 border-b border-r border-border-light relative ${
|
||||
!inMonth ? 'bg-surface-secondary/50' : ''
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-medium inline-flex items-center justify-center w-6 h-6 rounded-full ${
|
||||
today ? 'bg-brand-primary text-white' :
|
||||
inMonth ? 'text-text-primary' : 'text-text-tertiary'
|
||||
}`}>
|
||||
{format(day, 'd')}
|
||||
</span>
|
||||
|
||||
<div className="space-y-0.5 mt-0.5">
|
||||
{dayCampaigns.slice(0, 3).map((campaign, ci) => {
|
||||
const colorIndex = campaigns.indexOf(campaign) % CAMPAIGN_COLORS.length
|
||||
const isStart = isStartOfCampaign(day, campaign)
|
||||
const isEnd = isEndOfCampaign(day, campaign)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={campaign._id || ci}
|
||||
className={`h-5 text-[10px] font-medium text-white flex items-center px-1 truncate ${CAMPAIGN_COLORS[colorIndex]} ${
|
||||
isStart ? 'rounded-l-full ml-0' : '-ml-1'
|
||||
} ${isEnd ? 'rounded-r-full mr-0' : '-mr-1'}`}
|
||||
title={campaign.name}
|
||||
>
|
||||
{isStart ? campaign.name : ''}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{dayCampaigns.length > 3 && (
|
||||
<div className="text-[10px] text-text-tertiary px-1">
|
||||
+{dayCampaigns.length - 3} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
client/src/components/Header.jsx
Normal file
134
client/src/components/Header.jsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { Bell, ChevronDown, LogOut, Settings, User, Shield } from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
const pageTitles = {
|
||||
'/': 'Dashboard',
|
||||
'/posts': 'Post Production',
|
||||
'/assets': 'Assets',
|
||||
'/campaigns': 'Campaigns',
|
||||
'/finance': 'Finance',
|
||||
'/projects': 'Projects',
|
||||
'/tasks': 'My Tasks',
|
||||
'/team': 'Team',
|
||||
'/users': 'User Management',
|
||||
}
|
||||
|
||||
const ROLE_INFO = {
|
||||
superadmin: { label: 'Superadmin', color: 'bg-purple-100 text-purple-700', icon: '👑' },
|
||||
manager: { label: 'Manager', color: 'bg-blue-100 text-blue-700', icon: '📊' },
|
||||
contributor: { label: 'Contributor', color: 'bg-green-100 text-green-700', icon: '✏️' },
|
||||
}
|
||||
|
||||
export default function Header() {
|
||||
const { user, logout } = useAuth()
|
||||
const [showDropdown, setShowDropdown] = useState(false)
|
||||
const dropdownRef = useRef(null)
|
||||
const location = useLocation()
|
||||
|
||||
const pageTitle = pageTitles[location.pathname] ||
|
||||
(location.pathname.startsWith('/projects/') ? 'Project Details' :
|
||||
location.pathname.startsWith('/campaigns/') ? 'Campaign Details' : 'Page')
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (e) => {
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(e.target)) {
|
||||
setShowDropdown(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside)
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside)
|
||||
}, [])
|
||||
|
||||
const getInitials = (name) => {
|
||||
if (!name) return '?'
|
||||
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
const roleInfo = ROLE_INFO[user?.role] || ROLE_INFO.contributor
|
||||
|
||||
return (
|
||||
<header className="h-16 bg-white border-b border-border flex items-center justify-between px-6 shrink-0 sticky top-0 z-20">
|
||||
{/* Page title */}
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-text-primary">{pageTitle}</h2>
|
||||
</div>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Notifications */}
|
||||
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-2 h-2 bg-red-500 rounded-full"></span>
|
||||
</button>
|
||||
|
||||
{/* User menu */}
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
onClick={() => setShowDropdown(!showDropdown)}
|
||||
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-surface-tertiary transition-colors"
|
||||
>
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center text-white text-xs font-semibold ${
|
||||
user?.role === 'superadmin'
|
||||
? 'bg-gradient-to-br from-purple-500 to-pink-500'
|
||||
: 'bg-gradient-to-br from-blue-500 to-indigo-500'
|
||||
}`}>
|
||||
{getInitials(user?.name)}
|
||||
</div>
|
||||
<div className="text-left hidden sm:block">
|
||||
<p className="text-sm font-medium text-text-primary">
|
||||
{user?.name || 'User'}
|
||||
</p>
|
||||
<p className={`text-[10px] font-medium ${roleInfo.color.split(' ')[1]}`}>
|
||||
{roleInfo.icon} {roleInfo.label}
|
||||
</p>
|
||||
</div>
|
||||
<ChevronDown className={`w-4 h-4 text-text-tertiary transition-transform ${showDropdown ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showDropdown && (
|
||||
<div className="absolute right-0 top-full mt-2 w-64 bg-white rounded-xl shadow-lg border border-border overflow-hidden animate-scale-in">
|
||||
{/* User info */}
|
||||
<div className="px-4 py-3 border-b border-border-light bg-surface-secondary">
|
||||
<p className="text-sm font-semibold text-text-primary">{user?.name}</p>
|
||||
<p className="text-xs text-text-tertiary">{user?.email}</p>
|
||||
<div className={`inline-flex items-center gap-1 text-[10px] font-medium px-2 py-0.5 rounded-full mt-2 ${roleInfo.color}`}>
|
||||
<span>{roleInfo.icon}</span>
|
||||
{roleInfo.label}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Menu items */}
|
||||
<div className="py-2">
|
||||
{user?.role === 'superadmin' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
window.location.href = '/users'
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-surface-secondary transition-colors text-left"
|
||||
>
|
||||
<Shield className="w-4 h-4 text-text-tertiary" />
|
||||
<span className="text-sm text-text-primary">User Management</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDropdown(false)
|
||||
logout()
|
||||
}}
|
||||
className="w-full flex items-center gap-3 px-4 py-2.5 hover:bg-red-50 transition-colors text-left group"
|
||||
>
|
||||
<LogOut className="w-4 h-4 text-text-tertiary group-hover:text-red-500" />
|
||||
<span className="text-sm text-text-primary group-hover:text-red-500">Sign Out</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
111
client/src/components/KanbanBoard.jsx
Normal file
111
client/src/components/KanbanBoard.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import { useState } from 'react'
|
||||
import PostCard from './PostCard'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const COLUMNS = [
|
||||
{ id: 'draft', labelKey: 'posts.status.draft', color: 'bg-gray-400' },
|
||||
{ id: 'in_review', labelKey: 'posts.status.in_review', color: 'bg-amber-400' },
|
||||
{ id: 'approved', labelKey: 'posts.status.approved', color: 'bg-blue-400' },
|
||||
{ id: 'scheduled', labelKey: 'posts.status.scheduled', color: 'bg-purple-400' },
|
||||
{ id: 'published', labelKey: 'posts.status.published', color: 'bg-emerald-400' },
|
||||
]
|
||||
|
||||
export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
||||
const { t } = useLanguage()
|
||||
const [draggedPost, setDraggedPost] = useState(null)
|
||||
const [dragOverCol, setDragOverCol] = useState(null)
|
||||
|
||||
const handleDragStart = (e, post) => {
|
||||
setDraggedPost(post)
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Make the drag image slightly transparent
|
||||
if (e.target) {
|
||||
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDragEnd = (e) => {
|
||||
e.target.style.opacity = '1'
|
||||
setDraggedPost(null)
|
||||
setDragOverCol(null)
|
||||
}
|
||||
|
||||
const handleDragOver = (e, colId) => {
|
||||
e.preventDefault()
|
||||
e.dataTransfer.dropEffect = 'move'
|
||||
setDragOverCol(colId)
|
||||
}
|
||||
|
||||
const handleDragLeave = (e, colId) => {
|
||||
// Only clear if we're actually leaving the column (not entering a child)
|
||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||
setDragOverCol(null)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (e, colId) => {
|
||||
e.preventDefault()
|
||||
setDragOverCol(null)
|
||||
if (draggedPost && draggedPost.status !== colId) {
|
||||
onMovePost(draggedPost._id, colId)
|
||||
}
|
||||
setDraggedPost(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||
{COLUMNS.map((col) => {
|
||||
const colPosts = posts.filter((p) => p.status === col.id)
|
||||
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
|
||||
|
||||
return (
|
||||
<div key={col.id} className="flex-shrink-0 w-72">
|
||||
{/* Column header */}
|
||||
<div className="flex items-center gap-2 mb-3 px-1">
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
||||
<h4 className="text-sm font-semibold text-text-primary">{t(col.labelKey)}</h4>
|
||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full ml-auto">
|
||||
{colPosts.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Column body — drop zone */}
|
||||
<div
|
||||
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
|
||||
isOver
|
||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||
: 'bg-surface-secondary border-border-light border-solid'
|
||||
}`}
|
||||
onDragOver={(e) => handleDragOver(e, col.id)}
|
||||
onDragLeave={(e) => handleDragLeave(e, col.id)}
|
||||
onDrop={(e) => handleDrop(e, col.id)}
|
||||
>
|
||||
{colPosts.length === 0 ? (
|
||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
|
||||
</div>
|
||||
) : (
|
||||
colPosts.map((post) => (
|
||||
<div
|
||||
key={post._id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, post)}
|
||||
onDragEnd={handleDragEnd}
|
||||
className="cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<PostCard
|
||||
post={post}
|
||||
onClick={() => onPostClick(post)}
|
||||
onMove={onMovePost}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
24
client/src/components/Layout.jsx
Normal file
24
client/src/components/Layout.jsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useState } from 'react'
|
||||
import { Outlet } from 'react-router-dom'
|
||||
import Sidebar from './Sidebar'
|
||||
import Header from './Header'
|
||||
|
||||
export default function Layout() {
|
||||
const [collapsed, setCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-surface-secondary">
|
||||
<Sidebar collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
<div
|
||||
className={`transition-all duration-300 ${
|
||||
collapsed ? 'main-content-margin-collapsed' : 'main-content-margin'
|
||||
}`}
|
||||
>
|
||||
<Header />
|
||||
<main className="p-6">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
client/src/components/MemberCard.jsx
Normal file
68
client/src/components/MemberCard.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import BrandBadge from './BrandBadge'
|
||||
|
||||
const ROLE_BADGES = {
|
||||
manager: { bg: 'bg-indigo-50', text: 'text-indigo-700', label: 'Manager' },
|
||||
approver: { bg: 'bg-emerald-50', text: 'text-emerald-700', label: 'Approver' },
|
||||
publisher: { bg: 'bg-blue-50', text: 'text-blue-700', label: 'Publisher' },
|
||||
content_creator: { bg: 'bg-amber-50', text: 'text-amber-700', label: 'Content Creator' },
|
||||
producer: { bg: 'bg-purple-50', text: 'text-purple-700', label: 'Producer' },
|
||||
designer: { bg: 'bg-pink-50', text: 'text-pink-700', label: 'Designer' },
|
||||
content_writer: { bg: 'bg-orange-50', text: 'text-orange-700', label: 'Content Writer' },
|
||||
social_media_manager: { bg: 'bg-teal-50', text: 'text-teal-700', label: 'Social Media Manager' },
|
||||
photographer: { bg: 'bg-cyan-50', text: 'text-cyan-700', label: 'Photographer' },
|
||||
videographer: { bg: 'bg-sky-50', text: 'text-sky-700', label: 'Videographer' },
|
||||
strategist: { bg: 'bg-rose-50', text: 'text-rose-700', label: 'Strategist' },
|
||||
default: { bg: 'bg-gray-50', text: 'text-gray-700', label: 'Team Member' },
|
||||
}
|
||||
|
||||
export default function MemberCard({ member, onClick }) {
|
||||
const getInitials = (name) => {
|
||||
if (!name) return '?'
|
||||
return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
const role = ROLE_BADGES[member.team_role || member.role] || ROLE_BADGES.default
|
||||
|
||||
const avatarColors = [
|
||||
'from-indigo-400 to-purple-500',
|
||||
'from-pink-400 to-rose-500',
|
||||
'from-emerald-400 to-teal-500',
|
||||
'from-amber-400 to-orange-500',
|
||||
'from-cyan-400 to-blue-500',
|
||||
]
|
||||
const colorIndex = (member.name?.charCodeAt(0) || 0) % avatarColors.length
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => onClick?.(member)}
|
||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer text-center"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<div className={`w-16 h-16 rounded-full bg-gradient-to-br ${avatarColors[colorIndex]} flex items-center justify-center text-white text-xl font-bold mx-auto mb-3`}>
|
||||
{getInitials(member.name)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h4 className="text-base font-semibold text-text-primary">{member.name}</h4>
|
||||
|
||||
{/* Role badge */}
|
||||
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full mt-1 ${role.bg} ${role.text}`}>
|
||||
{role.label}
|
||||
</span>
|
||||
|
||||
{/* Email */}
|
||||
{member.email && (
|
||||
<p className="text-xs text-text-tertiary mt-2">{member.email}</p>
|
||||
)}
|
||||
|
||||
{/* Brands */}
|
||||
{member.brands && member.brands.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 justify-center mt-3 pt-3 border-t border-border-light">
|
||||
{member.brands.map((brand) => (
|
||||
<BrandBadge key={brand} brand={brand} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
122
client/src/components/Modal.jsx
Normal file
122
client/src/components/Modal.jsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { useEffect } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { X, AlertTriangle } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function Modal({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
size = 'md',
|
||||
// Confirmation mode props
|
||||
isConfirm = false,
|
||||
confirmText,
|
||||
cancelText,
|
||||
onConfirm,
|
||||
danger = false,
|
||||
}) {
|
||||
const { t } = useLanguage()
|
||||
|
||||
// Default translations
|
||||
const finalConfirmText = confirmText || (danger ? t('common.delete') : t('common.save'))
|
||||
const finalCancelText = cancelText || t('common.cancel')
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'max-w-md',
|
||||
md: 'max-w-lg',
|
||||
lg: 'max-w-2xl',
|
||||
xl: 'max-w-4xl',
|
||||
}
|
||||
|
||||
// Confirmation dialog
|
||||
if (isConfirm) {
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className="relative bg-white rounded-2xl shadow-2xl w-full max-w-md animate-scale-in">
|
||||
<div className="p-6">
|
||||
{danger && (
|
||||
<div className="w-12 h-12 rounded-full bg-red-100 flex items-center justify-center mx-auto mb-4">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-text-primary text-center mb-2">{title}</h3>
|
||||
<div className="text-sm text-text-secondary text-center mb-6">
|
||||
{children}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex-1 px-4 py-2.5 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
{finalCancelText}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onConfirm?.();
|
||||
onClose();
|
||||
}}
|
||||
className={`flex-1 px-4 py-2.5 text-sm font-medium text-white rounded-lg shadow-sm transition-colors ${
|
||||
danger
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-brand-primary hover:bg-brand-primary-light'
|
||||
}`}
|
||||
>
|
||||
{finalConfirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
|
||||
// Regular modal
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex items-start justify-center pt-[10vh] px-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal content */}
|
||||
<div className={`relative bg-white rounded-2xl shadow-2xl w-full ${sizeClasses[size]} max-h-[80vh] flex flex-col animate-scale-in`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-border shrink-0">
|
||||
<h3 className="text-lg font-semibold text-text-primary">{title}</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg hover:bg-surface-tertiary text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="px-6 py-4 overflow-y-auto flex-1">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)
|
||||
}
|
||||
30
client/src/components/PlatformIcon.jsx
Normal file
30
client/src/components/PlatformIcon.jsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { PLATFORMS } from '../utils/api'
|
||||
|
||||
export default function PlatformIcon({ platform, size = 16, showLabel = false, className = '' }) {
|
||||
const p = PLATFORMS[platform]
|
||||
if (!p) return <span className="text-[10px] text-text-tertiary capitalize">{platform}</span>
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 ${className}`} title={p.label}>
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill={p.color}
|
||||
className="shrink-0"
|
||||
>
|
||||
<path d={p.icon} />
|
||||
</svg>
|
||||
{showLabel && <span className="text-xs text-text-secondary">{p.label}</span>}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function PlatformIcons({ platforms = [], size = 14, gap = 'gap-1', className = '' }) {
|
||||
if (!platforms || platforms.length === 0) return null
|
||||
return (
|
||||
<span className={`inline-flex items-center ${gap} ${className}`}>
|
||||
{platforms.map(p => <PlatformIcon key={p} platform={p} size={size} />)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
121
client/src/components/PostCard.jsx
Normal file
121
client/src/components/PostCard.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
68
client/src/components/ProjectCard.jsx
Normal file
68
client/src/components/ProjectCard.jsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { format } from 'date-fns'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import BrandBadge from './BrandBadge'
|
||||
|
||||
export default function ProjectCard({ project }) {
|
||||
const navigate = useNavigate()
|
||||
|
||||
const completedTasks = project.tasks?.filter(t => t.status === 'done').length || 0
|
||||
const totalTasks = project.tasks?.length || 0
|
||||
const progress = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0
|
||||
|
||||
const ownerName = typeof project.owner === 'object' ? project.owner?.name : project.owner
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={() => navigate(`/projects/${project._id}`)}
|
||||
className="bg-white rounded-xl border border-border p-5 card-hover cursor-pointer"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3 mb-3">
|
||||
<h4 className="text-base font-semibold text-text-primary line-clamp-1">{project.name}</h4>
|
||||
<StatusBadge status={project.status} size="xs" />
|
||||
</div>
|
||||
|
||||
{project.brand && (
|
||||
<div className="mb-3">
|
||||
<BrandBadge brand={project.brand} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{project.description && (
|
||||
<p className="text-sm text-text-secondary line-clamp-2 mb-4">{project.description}</p>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="text-text-tertiary">Progress</span>
|
||||
<span className="font-medium text-text-secondary">{progress}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-brand-primary rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[10px] text-text-tertiary mt-1">{completedTasks}/{totalTasks} tasks</p>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-border-light">
|
||||
{ownerName && (
|
||||
<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">
|
||||
{ownerName.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()}
|
||||
</div>
|
||||
<span className="text-xs text-text-tertiary">{ownerName}</span>
|
||||
</div>
|
||||
)}
|
||||
{project.dueDate && (
|
||||
<span className="text-xs text-text-tertiary">
|
||||
Due {format(new Date(project.dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
169
client/src/components/Sidebar.jsx
Normal file
169
client/src/components/Sidebar.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { useContext } from 'react'
|
||||
import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, FileEdit, Image, Calendar, Wallet,
|
||||
FolderKanban, CheckSquare, Users, ChevronLeft, ChevronRight, Sparkles, Shield, LogOut, User, Settings, Languages
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', icon: LayoutDashboard, labelKey: 'nav.dashboard', end: true, tutorial: 'dashboard' },
|
||||
{ to: '/campaigns', icon: Calendar, labelKey: 'nav.campaigns', tutorial: 'campaigns' },
|
||||
{ to: '/finance', icon: Wallet, labelKey: 'nav.finance', minRole: 'manager' },
|
||||
{ to: '/posts', icon: FileEdit, labelKey: 'nav.posts', tutorial: 'posts' },
|
||||
{ to: '/assets', icon: Image, labelKey: 'nav.assets', tutorial: 'assets' },
|
||||
{ to: '/projects', icon: FolderKanban, labelKey: 'nav.projects' },
|
||||
{ to: '/tasks', icon: CheckSquare, labelKey: 'nav.tasks', tutorial: 'tasks' },
|
||||
{ to: '/team', icon: Users, labelKey: 'nav.team', tutorial: 'team' },
|
||||
]
|
||||
|
||||
const ROLE_LEVEL = { contributor: 0, manager: 1, superadmin: 2 }
|
||||
|
||||
export default function Sidebar({ collapsed, setCollapsed }) {
|
||||
const { user: currentUser, logout } = useAuth()
|
||||
const { t, lang, setLang } = useLanguage()
|
||||
const userLevel = ROLE_LEVEL[currentUser?.role] ?? 0
|
||||
|
||||
const visibleItems = navItems.filter(item => {
|
||||
if (!item.minRole) return true
|
||||
return userLevel >= (ROLE_LEVEL[item.minRole] ?? 0)
|
||||
})
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`sidebar fixed top-0 h-screen bg-sidebar flex flex-col z-30 transition-all duration-300 ${
|
||||
collapsed ? 'w-[68px]' : 'w-[260px]'
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex items-center gap-3 px-4 h-16 border-b border-white/10 shrink-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-gradient-to-br from-indigo-400 to-purple-500 flex items-center justify-center shrink-0">
|
||||
<Sparkles className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="animate-fade-in overflow-hidden">
|
||||
<h1 className="text-white font-bold text-sm leading-tight whitespace-nowrap">{t('app.name')}</h1>
|
||||
<p className="text-text-on-dark-muted text-xs whitespace-nowrap">{t('app.subtitle')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 py-4 px-3 space-y-1 overflow-y-auto">
|
||||
{visibleItems.map(({ to, icon: Icon, labelKey, end, tutorial }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end={end}
|
||||
data-tutorial={tutorial}
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Icon className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t(labelKey)}</span>}
|
||||
</NavLink>
|
||||
))}
|
||||
|
||||
{/* Superadmin Only: Users Management */}
|
||||
{currentUser?.role === 'superadmin' && (
|
||||
<NavLink
|
||||
to="/users"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Shield className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.users')}</span>}
|
||||
</NavLink>
|
||||
)}
|
||||
|
||||
{/* Settings (visible to all) */}
|
||||
<NavLink
|
||||
to="/settings"
|
||||
className={({ isActive }) =>
|
||||
`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-all duration-200 group ${
|
||||
isActive
|
||||
? 'bg-white/15 text-white shadow-sm'
|
||||
: 'text-text-on-dark-muted hover:bg-white/8 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
<Settings className="w-5 h-5 shrink-0" />
|
||||
{!collapsed && <span className="animate-fade-in whitespace-nowrap">{t('nav.settings')}</span>}
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Current User & Logout */}
|
||||
<div className="border-t border-white/10 shrink-0">
|
||||
{currentUser && !collapsed && (
|
||||
<div className="p-3 border-b border-white/10">
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-lg bg-white/5">
|
||||
<div className="w-8 h-8 rounded-full bg-brand-primary flex items-center justify-center shrink-0">
|
||||
{currentUser.avatar ? (
|
||||
<img src={currentUser.avatar} alt={currentUser.name} className="w-full h-full rounded-full object-cover" />
|
||||
) : (
|
||||
<User className="w-4 h-4 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 animate-fade-in">
|
||||
<p className="text-white text-sm font-medium truncate">{currentUser.name}</p>
|
||||
<p className="text-text-on-dark-muted text-xs truncate capitalize">{currentUser.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<>
|
||||
{/* Language Toggle */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={() => setLang(lang === 'en' ? 'ar' : 'en')}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<Languages className="w-4 h-4" />
|
||||
<span className="animate-fade-in">{lang === 'en' ? 'عربي' : 'English'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<div className="px-3 pb-3">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
<span className="animate-fade-in">{t('nav.logout')}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div className="p-3 border-t border-white/10">
|
||||
<button
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-text-on-dark-muted hover:bg-white/8 hover:text-white transition-colors text-sm"
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-4 h-4" /> : (
|
||||
<>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="animate-fade-in">{t('nav.collapse')}</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
42
client/src/components/StatCard.jsx
Normal file
42
client/src/components/StatCard.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
export default function StatCard({ icon: Icon, label, value, subtitle, color = 'brand-primary', trend }) {
|
||||
const colorMap = {
|
||||
'brand-primary': 'from-indigo-500 to-indigo-600',
|
||||
'brand-secondary': 'from-pink-500 to-pink-600',
|
||||
'brand-tertiary': 'from-amber-500 to-amber-600',
|
||||
'brand-quaternary': 'from-emerald-500 to-emerald-600',
|
||||
}
|
||||
|
||||
const iconBgMap = {
|
||||
'brand-primary': 'bg-indigo-50 text-indigo-600',
|
||||
'brand-secondary': 'bg-pink-50 text-pink-600',
|
||||
'brand-tertiary': 'bg-amber-50 text-amber-600',
|
||||
'brand-quaternary': 'bg-emerald-50 text-emerald-600',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-xl border border-border p-5 card-hover">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-text-tertiary">{label}</p>
|
||||
<p className="text-3xl font-bold text-text-primary mt-1">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-text-tertiary mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${iconBgMap[color] || iconBgMap['brand-primary']}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
</div>
|
||||
{trend && (
|
||||
<div className="mt-3 pt-3 border-t border-border-light">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={`text-xs font-medium ${trend > 0 ? 'text-emerald-600' : 'text-red-500'}`}>
|
||||
{trend > 0 ? '+' : ''}{trend}%
|
||||
</span>
|
||||
<span className="text-xs text-text-tertiary">vs last month</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
client/src/components/StatusBadge.jsx
Normal file
34
client/src/components/StatusBadge.jsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { getStatusConfig } from '../utils/api'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const STATUS_LABEL_MAP = {
|
||||
'draft': 'posts.status.draft',
|
||||
'in_review': 'posts.status.in_review',
|
||||
'approved': 'posts.status.approved',
|
||||
'scheduled': 'posts.status.scheduled',
|
||||
'published': 'posts.status.published',
|
||||
'todo': 'tasks.todo',
|
||||
'in_progress': 'tasks.in_progress',
|
||||
'done': 'tasks.done',
|
||||
}
|
||||
|
||||
export default function StatusBadge({ status, size = 'sm' }) {
|
||||
const { t } = useLanguage()
|
||||
const config = getStatusConfig(status)
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'text-[10px] px-1.5 py-0.5',
|
||||
sm: 'text-xs px-2 py-1',
|
||||
md: 'text-sm px-2.5 py-1',
|
||||
}
|
||||
|
||||
const labelKey = STATUS_LABEL_MAP[status]
|
||||
const label = labelKey ? t(labelKey) : config.label
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1.5 rounded-full font-medium ${config.bg} ${config.text} ${sizeClasses[size]}`}>
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${config.dot}`}></span>
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
93
client/src/components/TaskCard.jsx
Normal file
93
client/src/components/TaskCard.jsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { format } from 'date-fns'
|
||||
import { ArrowRight, Clock, User, UserCheck } from 'lucide-react'
|
||||
import { PRIORITY_CONFIG } from '../utils/api'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
export default function TaskCard({ task, onMove, showProject = true }) {
|
||||
const { t } = useLanguage()
|
||||
const { user: authUser } = useAuth()
|
||||
const priority = PRIORITY_CONFIG[task.priority] || PRIORITY_CONFIG.medium
|
||||
const projectName = typeof task.project === 'object' ? task.project?.name : task.projectName
|
||||
|
||||
const nextStatus = {
|
||||
todo: 'in_progress',
|
||||
in_progress: 'done',
|
||||
}
|
||||
|
||||
const nextLabel = {
|
||||
todo: t('tasks.start'),
|
||||
in_progress: t('tasks.complete'),
|
||||
}
|
||||
|
||||
const dueDate = task.due_date || task.dueDate
|
||||
const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
|
||||
const creatorName = task.creator_user_name || task.creatorUserName
|
||||
|
||||
// Determine if this task was assigned by someone else
|
||||
const createdByUserId = task.created_by_user_id || task.createdByUserId
|
||||
const isExternallyAssigned = authUser && createdByUserId && createdByUserId !== authUser.id
|
||||
const assignedName = task.assigned_name || task.assignedName
|
||||
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-border p-3 card-hover group ${isExternallyAssigned ? 'border-l-[3px] border-l-blue-400' : ''}`}>
|
||||
<div className="flex items-start gap-2.5">
|
||||
{/* Priority dot */}
|
||||
<div className={`w-2.5 h-2.5 rounded-full ${priority.color} mt-1.5 shrink-0`} title={priority.label} />
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h5 className={`text-sm font-medium leading-snug ${task.status === 'done' ? 'text-text-tertiary line-through' : 'text-text-primary'}`}>
|
||||
{task.title}
|
||||
</h5>
|
||||
|
||||
{/* Assigned by label for externally-assigned tasks */}
|
||||
{isExternallyAssigned && creatorName && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<UserCheck className="w-3 h-3 text-blue-400" />
|
||||
<span className="text-[10px] text-blue-500 font-medium">{t('tasks.from')} {creatorName}</span>
|
||||
</div>
|
||||
)}
|
||||
{/* Assigned to label for tasks you delegated */}
|
||||
{!isExternallyAssigned && assignedName && (
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<User className="w-3 h-3 text-emerald-400" />
|
||||
<span className="text-[10px] text-emerald-500 font-medium">{t('tasks.assignedTo')} {assignedName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
|
||||
{showProject && projectName && (
|
||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
|
||||
{projectName}
|
||||
</span>
|
||||
)}
|
||||
{dueDate && (
|
||||
<span className={`text-[10px] flex items-center gap-1 ${isOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||
<Clock className="w-3 h-3" />
|
||||
{format(new Date(dueDate), 'MMM d')}
|
||||
</span>
|
||||
)}
|
||||
{!isExternallyAssigned && creatorName && (
|
||||
<span className="text-[10px] flex items-center gap-1 text-text-tertiary">
|
||||
<User className="w-3 h-3" />
|
||||
{creatorName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick action */}
|
||||
{onMove && nextStatus[task.status] && (
|
||||
<div className="mt-2 pt-2 border-t border-border-light opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => onMove(task._id || task.id, nextStatus[task.status])}
|
||||
className="text-[11px] text-brand-primary hover:text-brand-primary-light font-medium flex items-center gap-1"
|
||||
>
|
||||
{nextLabel[task.status]} <ArrowRight className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
248
client/src/components/Tutorial.jsx
Normal file
248
client/src/components/Tutorial.jsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { X, ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
import { useLanguage } from '../i18n/LanguageContext'
|
||||
|
||||
const getTutorialSteps = (t) => [
|
||||
{
|
||||
target: '[data-tutorial="dashboard"]',
|
||||
title: t('tutorial.dashboard.title'),
|
||||
description: t('tutorial.dashboard.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="campaigns"]',
|
||||
title: t('tutorial.campaigns.title'),
|
||||
description: t('tutorial.campaigns.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="posts"]',
|
||||
title: t('tutorial.posts.title'),
|
||||
description: t('tutorial.posts.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="tasks"]',
|
||||
title: t('tutorial.tasks.title'),
|
||||
description: t('tutorial.tasks.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="team"]',
|
||||
title: t('tutorial.team.title'),
|
||||
description: t('tutorial.team.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="assets"]',
|
||||
title: t('tutorial.assets.title'),
|
||||
description: t('tutorial.assets.desc'),
|
||||
position: 'right',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="new-post"]',
|
||||
title: t('tutorial.newPost.title'),
|
||||
description: t('tutorial.newPost.desc'),
|
||||
position: 'bottom',
|
||||
},
|
||||
{
|
||||
target: '[data-tutorial="filters"]',
|
||||
title: t('tutorial.filters.title'),
|
||||
description: t('tutorial.filters.desc'),
|
||||
position: 'bottom',
|
||||
},
|
||||
]
|
||||
|
||||
export default function Tutorial({ onComplete }) {
|
||||
const { t } = useLanguage()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [targetRect, setTargetRect] = useState(null)
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ top: 0, left: 0 })
|
||||
|
||||
const TUTORIAL_STEPS = getTutorialSteps(t)
|
||||
const step = TUTORIAL_STEPS[currentStep]
|
||||
|
||||
useEffect(() => {
|
||||
updatePosition()
|
||||
window.addEventListener('resize', updatePosition)
|
||||
return () => window.removeEventListener('resize', updatePosition)
|
||||
}, [currentStep])
|
||||
|
||||
const updatePosition = () => {
|
||||
const target = document.querySelector(step.target)
|
||||
if (!target) {
|
||||
console.warn(`Tutorial target not found: ${step.target}`)
|
||||
return
|
||||
}
|
||||
|
||||
const rect = target.getBoundingClientRect()
|
||||
setTargetRect(rect)
|
||||
|
||||
// Calculate tooltip position
|
||||
const tooltipWidth = 360
|
||||
const tooltipHeight = 200
|
||||
const padding = 20
|
||||
|
||||
let top = rect.top
|
||||
let left = rect.right + padding
|
||||
|
||||
// Adjust based on position hint
|
||||
if (step.position === 'bottom') {
|
||||
top = rect.bottom + padding
|
||||
left = rect.left
|
||||
} else if (step.position === 'left') {
|
||||
top = rect.top
|
||||
left = rect.left - tooltipWidth - padding
|
||||
} else if (step.position === 'top') {
|
||||
top = rect.top - tooltipHeight - padding
|
||||
left = rect.left
|
||||
}
|
||||
|
||||
// Keep tooltip on screen
|
||||
if (left + tooltipWidth > window.innerWidth) {
|
||||
left = rect.left - tooltipWidth - padding
|
||||
}
|
||||
if (left < 0) {
|
||||
left = padding
|
||||
}
|
||||
if (top + tooltipHeight > window.innerHeight) {
|
||||
top = window.innerHeight - tooltipHeight - padding
|
||||
}
|
||||
if (top < 0) {
|
||||
top = padding
|
||||
}
|
||||
|
||||
setTooltipPosition({ top, left })
|
||||
}
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < TUTORIAL_STEPS.length - 1) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
} else {
|
||||
onComplete()
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrev = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onComplete()
|
||||
}
|
||||
|
||||
if (!targetRect) return null
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[9999] pointer-events-none">
|
||||
{/* Dark overlay with spotlight cutout */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-auto" style={{ pointerEvents: 'auto' }}>
|
||||
<defs>
|
||||
<mask id="tutorial-mask">
|
||||
<rect x="0" y="0" width="100%" height="100%" fill="white" />
|
||||
<rect
|
||||
x={targetRect.left - 4}
|
||||
y={targetRect.top - 4}
|
||||
width={targetRect.width + 8}
|
||||
height={targetRect.height + 8}
|
||||
rx="8"
|
||||
fill="black"
|
||||
/>
|
||||
</mask>
|
||||
</defs>
|
||||
<rect
|
||||
x="0"
|
||||
y="0"
|
||||
width="100%"
|
||||
height="100%"
|
||||
fill="rgba(0, 0, 0, 0.7)"
|
||||
mask="url(#tutorial-mask)"
|
||||
onClick={handleSkip}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* Highlight ring around target */}
|
||||
<div
|
||||
className="absolute border-4 border-brand-primary rounded-lg shadow-lg transition-all duration-300 pointer-events-none"
|
||||
style={{
|
||||
top: targetRect.top - 4,
|
||||
left: targetRect.left - 4,
|
||||
width: targetRect.width + 8,
|
||||
height: targetRect.height + 8,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tooltip card */}
|
||||
<div
|
||||
className="absolute bg-white rounded-xl shadow-2xl border border-border p-6 animate-fade-in pointer-events-auto"
|
||||
style={{
|
||||
top: tooltipPosition.top,
|
||||
left: tooltipPosition.left,
|
||||
width: 360,
|
||||
maxWidth: 'calc(100vw - 40px)',
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="absolute top-4 right-4 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-brand-primary mb-2">
|
||||
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-brand-primary/10 text-brand-primary text-xs font-bold">
|
||||
{currentStep + 1}
|
||||
</span>
|
||||
<span>{t('tutorial.step')} {currentStep + 1} {t('tutorial.of')} {TUTORIAL_STEPS.length}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-text-primary mb-2">{step.title}</h3>
|
||||
<p className="text-sm text-text-secondary leading-relaxed">{step.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full h-1.5 bg-surface-tertiary rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-brand-primary to-purple-500 transition-all duration-300"
|
||||
style={{ width: `${((currentStep + 1) / TUTORIAL_STEPS.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<button
|
||||
onClick={handleSkip}
|
||||
className="text-sm font-medium text-text-tertiary hover:text-text-primary transition-colors"
|
||||
>
|
||||
{t('tutorial.skip')}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentStep > 0 && (
|
||||
<button
|
||||
onClick={handlePrev}
|
||||
className="flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-text-secondary hover:bg-surface-tertiary rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" />
|
||||
{t('tutorial.prev')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="flex items-center gap-1.5 px-5 py-2 bg-brand-primary text-white rounded-lg text-sm font-medium hover:bg-brand-primary-light shadow-sm transition-colors"
|
||||
>
|
||||
{currentStep < TUTORIAL_STEPS.length - 1 ? t('tutorial.next') : t('tutorial.finish')}
|
||||
{currentStep < TUTORIAL_STEPS.length - 1 && <ArrowRight className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user