handleDragOver(e, col.id)}
- onDragLeave={(e) => handleDragLeave(e, col.id)}
+ onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.id)}
>
- {colPosts.length === 0 ? (
+ {colItems.length === 0 ? (
- {isOver ? t('posts.dropHere') : t('posts.noPosts')}
+ {isOver ? t('posts.dropHere') : (emptyLabel || t('posts.noPosts'))}
) : (
- colPosts.map((post) => (
+ colItems.map((item) => (
handleDragStart(e, post)}
+ onDragStart={(e) => handleDragStart(e, item)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
-
onPostClick(post)}
- onMove={onMovePost}
- compact
- />
+ {renderCard(item)}
))
)}
diff --git a/client/src/components/KanbanCard.jsx b/client/src/components/KanbanCard.jsx
new file mode 100644
index 0000000..1b08306
--- /dev/null
+++ b/client/src/components/KanbanCard.jsx
@@ -0,0 +1,56 @@
+import { format } from 'date-fns'
+import { Clock } from 'lucide-react'
+import { getInitials } from '../utils/api'
+import BrandBadge from './BrandBadge'
+import { useLanguage } from '../i18n/LanguageContext'
+
+export default function KanbanCard({ title, thumbnail, brandName, tags, assigneeName, date, dateOverdue, onClick, children }) {
+ const { t } = useLanguage()
+
+ return (
+
+ {/* Thumbnail */}
+ {thumbnail && (
+
+

+
+ )}
+
+ {/* Title */}
+
{title}
+
+ {/* Tags row: brand + extra tags */}
+
+ {brandName && }
+ {tags}
+
+
+ {/* Footer: assignee + date */}
+
+ {assigneeName ? (
+
+
+ {getInitials(assigneeName)}
+
+
{assigneeName}
+
+ ) : (
+
{t('common.unassigned')}
+ )}
+
+ {date && (
+
+
+ {format(new Date(date), 'MMM d')}
+
+ )}
+
+
+ {/* Optional extra content (quick actions, delete overlay, etc.) */}
+ {children}
+
+ )
+}
diff --git a/client/src/components/ThemeToggle.jsx b/client/src/components/ThemeToggle.jsx
new file mode 100644
index 0000000..1df44f0
--- /dev/null
+++ b/client/src/components/ThemeToggle.jsx
@@ -0,0 +1,21 @@
+import { useTheme } from '../contexts/ThemeContext'
+import { Moon, Sun } from 'lucide-react'
+
+export default function ThemeToggle({ className = '' }) {
+ const { darkMode, toggleDarkMode } = useTheme()
+
+ return (
+
+ )
+}
diff --git a/client/src/contexts/ThemeContext.jsx b/client/src/contexts/ThemeContext.jsx
new file mode 100644
index 0000000..4eb9212
--- /dev/null
+++ b/client/src/contexts/ThemeContext.jsx
@@ -0,0 +1,38 @@
+import { createContext, useContext, useState, useEffect } from 'react'
+
+const ThemeContext = createContext()
+
+export function ThemeProvider({ children }) {
+ const [darkMode, setDarkMode] = useState(() => {
+ // Check localStorage or system preference
+ const stored = localStorage.getItem('darkMode')
+ if (stored !== null) return stored === 'true'
+ return window.matchMedia('(prefers-color-scheme: dark)').matches
+ })
+
+ useEffect(() => {
+ // Apply dark mode class to document
+ if (darkMode) {
+ document.documentElement.classList.add('dark')
+ } else {
+ document.documentElement.classList.remove('dark')
+ }
+ localStorage.setItem('darkMode', String(darkMode))
+ }, [darkMode])
+
+ const toggleDarkMode = () => setDarkMode(prev => !prev)
+
+ return (
+
+ {children}
+
+ )
+}
+
+export function useTheme() {
+ const context = useContext(ThemeContext)
+ if (!context) {
+ throw new Error('useTheme must be used within ThemeProvider')
+ }
+ return context
+}
diff --git a/client/src/hooks/useKeyboardShortcuts.js b/client/src/hooks/useKeyboardShortcuts.js
new file mode 100644
index 0000000..dc8b32e
--- /dev/null
+++ b/client/src/hooks/useKeyboardShortcuts.js
@@ -0,0 +1,59 @@
+import { useEffect } from 'react'
+
+export function useKeyboardShortcuts(shortcuts = {}) {
+ useEffect(() => {
+ const handleKeyDown = (e) => {
+ // Ignore if user is typing in an input/textarea
+ if (
+ e.target.tagName === 'INPUT' ||
+ e.target.tagName === 'TEXTAREA' ||
+ e.target.isContentEditable
+ ) {
+ return
+ }
+
+ // Check for modifier + key
+ const key = e.key.toLowerCase()
+ const ctrl = e.ctrlKey || e.metaKey
+ const shift = e.shiftKey
+
+ for (const [combination, callback] of Object.entries(shortcuts)) {
+ const parts = combination.toLowerCase().split('+')
+ const needsCtrl = parts.includes('ctrl') || parts.includes('cmd')
+ const needsShift = parts.includes('shift')
+ const keyPart = parts.find(p => !['ctrl', 'cmd', 'shift'].includes(p))
+
+ if (
+ key === keyPart &&
+ needsCtrl === ctrl &&
+ needsShift === shift
+ ) {
+ e.preventDefault()
+ callback()
+ return
+ }
+ }
+ }
+
+ window.addEventListener('keydown', handleKeyDown)
+ return () => window.removeEventListener('keydown', handleKeyDown)
+ }, [shortcuts])
+}
+
+// Default keyboard shortcuts
+export const DEFAULT_SHORTCUTS = {
+ '?': () => {
+ // Show help (could implement a shortcuts modal)
+ console.log('Keyboard shortcuts: ? to show help')
+ },
+ 'g d': () => window.location.hash = '#/dashboard',
+ 'g p': () => window.location.hash = '#/posts',
+ 'g c': () => window.location.hash = '#/campaigns',
+ 'g t': () => window.location.hash = '#/tasks',
+ 'g a': () => window.location.hash = '#/artefacts',
+ '/': () => {
+ // Focus search - implement based on your search component
+ const searchInput = document.querySelector('[data-search-input]')
+ if (searchInput) searchInput.focus()
+ },
+}
diff --git a/client/src/index.css b/client/src/index.css
index ad8953e..c23ce18 100644
--- a/client/src/index.css
+++ b/client/src/index.css
@@ -52,12 +52,10 @@
background: #94a3b8;
}
-/* Smooth transitions */
-* {
- transition-property: background-color, border-color, color, opacity, box-shadow, transform;
- transition-duration: 200ms;
- transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
-}
+/* Smooth transitions — scoped to interactive elements only.
+ Do NOT use * selector — it causes every element to re-animate
+ on any React state change (e.g. drag-and-drop). Components should
+ use Tailwind transition-colors / transition-all where needed. */
/* Arabic text support */
[dir="rtl"] {
@@ -334,14 +332,7 @@ textarea {
/* Refined button styles */
button {
border-radius: 0.625rem;
- transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
-}
-button:hover:not(:disabled) {
- transform: translateY(-1px);
- box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.12);
-}
-button:active:not(:disabled) {
- transform: translateY(0) scale(0.98);
+ transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
button:disabled {
cursor: not-allowed;
@@ -386,11 +377,6 @@ select:not(:disabled):hover {
color: white;
}
-/* Kanban column */
-.kanban-column {
- min-height: 200px;
-}
-
/* Calendar grid */
.calendar-grid {
display: grid;
diff --git a/client/src/pages/Issues.jsx b/client/src/pages/Issues.jsx
index 5fdd61f..8d60655 100644
--- a/client/src/pages/Issues.jsx
+++ b/client/src/pages/Issues.jsx
@@ -6,6 +6,8 @@ import { useLanguage } from '../i18n/LanguageContext'
import { useToast } from '../components/ToastContainer'
import IssueDetailPanel from '../components/IssueDetailPanel'
import IssueCard from '../components/IssueCard'
+import KanbanBoard from '../components/KanbanBoard'
+import KanbanCard from '../components/KanbanCard'
import EmptyState from '../components/EmptyState'
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
import BulkSelectBar from '../components/BulkSelectBar'
@@ -28,8 +30,6 @@ const ISSUE_STATUS_CONFIG = {
declined: STATUS_CONFIG.declined,
}
-const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
-
export default function Issues() {
const { t } = useLanguage()
const toast = useToast()
@@ -47,10 +47,6 @@ export default function Issues() {
// View mode
const [viewMode, setViewMode] = useState('board')
- // Drag and drop
- const [draggedIssue, setDraggedIssue] = useState(null)
- const [dragOverCol, setDragOverCol] = useState(null)
-
// List sorting
const [sortBy, setSortBy] = useState('created_at')
const [sortDir, setSortDir] = useState('desc')
@@ -139,13 +135,22 @@ export default function Issues() {
// Drag and drop handlers
const handleMoveIssue = async (issueId, newStatus) => {
+ // Optimistic update — move the card instantly
+ const prev = issues
+ setIssues(issues.map(i => (i.Id || i.id) === issueId ? { ...i, status: newStatus } : i))
+ setCounts(c => {
+ const old = prev.find(i => (i.Id || i.id) === issueId)
+ if (!old || old.status === newStatus) return c
+ return { ...c, [old.status]: (c[old.status] || 1) - 1, [newStatus]: (c[newStatus] || 0) + 1 }
+ })
try {
await api.patch(`/issues/${issueId}`, { status: newStatus })
toast.success(t('issues.statusUpdated'))
- loadData()
} catch (err) {
console.error('Move issue failed:', err)
toast.error('Failed to update status')
+ // Rollback on error
+ setIssues(prev)
}
}
@@ -183,33 +188,6 @@ export default function Issues() {
toast.success(t('issues.linkCopied'))
}
- const handleDragStart = (e, issue) => {
- setDraggedIssue(issue)
- e.dataTransfer.effectAllowed = 'move'
- setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
- }
- const handleDragEnd = (e) => {
- e.target.style.opacity = '1'
- setDraggedIssue(null)
- setDragOverCol(null)
- }
- const handleDragOver = (e, colStatus) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- setDragOverCol(colStatus)
- }
- const handleDragLeave = (e) => {
- if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
- }
- const handleDrop = (e, colStatus) => {
- e.preventDefault()
- setDragOverCol(null)
- if (draggedIssue && draggedIssue.status !== colStatus) {
- handleMoveIssue(draggedIssue.Id || draggedIssue.id, colStatus)
- }
- setDraggedIssue(null)
- }
-
const toggleSort = (col) => {
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortBy(col); setSortDir('asc') }
@@ -386,54 +364,28 @@ export default function Issues() {
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
/>
) : (
-
- {STATUS_ORDER.map(status => {
- const config = ISSUE_STATUS_CONFIG[status]
- const columnIssues = filteredIssues.filter(i => i.status === status)
- return (
-
handleDragOver(e, status)}
- onDragLeave={handleDragLeave}
- onDrop={e => handleDrop(e, status)}
- >
- {/* Column header */}
-
-
-
- {config.label}
-
- {columnIssues.length}
-
-
-
-
- {/* Cards */}
-
- {columnIssues.length === 0 ? (
-
- {t('issues.noIssuesInColumn')}
-
- ) : (
- columnIssues.map(issue => (
-
handleDragStart(e, issue)}
- onDragEnd={handleDragEnd}
- >
-
-
- ))
- )}
-
-
- )
- })}
-
+
({ id, label: cfg.label, color: cfg.dot }))}
+ items={filteredIssues}
+ getItemId={(i) => i.Id || i.id}
+ onMove={handleMoveIssue}
+ emptyLabel={t('issues.noIssuesInColumn')}
+ renderCard={(issue) => (
+ setSelectedIssue(issue)}
+ tags={issue.category && (
+
+ {issue.category}
+
+ )}
+ />
+ )}
+ />
)
)}
diff --git a/client/src/pages/PostProduction.jsx b/client/src/pages/PostProduction.jsx
index 80082d8..b898697 100644
--- a/client/src/pages/PostProduction.jsx
+++ b/client/src/pages/PostProduction.jsx
@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PLATFORMS } from '../utils/api'
import KanbanBoard from '../components/KanbanBoard'
+import KanbanCard from '../components/KanbanCard'
import PostCard from '../components/PostCard'
import PostDetailPanel from '../components/PostDetailPanel'
import DatePresetPicker from '../components/DatePresetPicker'
@@ -22,7 +23,7 @@ const EMPTY_POST = {
export default function PostProduction() {
const { t, lang } = useLanguage()
- const { teamMembers, brands } = useContext(AppContext)
+ const { teamMembers, brands, getBrandName } = useContext(AppContext)
const { canEditResource } = useAuth()
const toast = useToast()
const [posts, setPosts] = useState([])
@@ -54,12 +55,15 @@ export default function PostProduction() {
}
const handleMovePost = async (postId, newStatus) => {
+ // Optimistic update — move the card instantly
+ const prev = posts
+ setPosts(posts.map(p => p._id === postId ? { ...p, status: newStatus } : p))
try {
await api.patch(`/posts/${postId}`, { status: newStatus })
toast.success(t('posts.statusUpdated'))
- loadPosts()
} catch (err) {
console.error('Move failed:', err)
+ setPosts(prev)
if (err.message?.includes('Cannot publish')) {
setMoveError(t('posts.publishRequired'))
setTimeout(() => setMoveError(''), 5000)
@@ -258,7 +262,32 @@ export default function PostProduction() {
)}
{view === 'kanban' ? (
-
+ p._id}
+ onMove={(id, status) => handleMovePost(id, status)}
+ renderCard={(post) => {
+ const brandName = getBrandName(post.brand_id || post.brandId) || post.brand_name || post.brand
+ const assignee = post.assignedToName || post.assignedName || post.assigned_name
+ return (
+ openEdit(post)}
+ />
+ )
+ }}
+ />
) : (
{filteredPosts.length === 0 ? (
diff --git a/client/src/pages/Tasks.jsx b/client/src/pages/Tasks.jsx
index 019d6fd..bd33810 100644
--- a/client/src/pages/Tasks.jsx
+++ b/client/src/pages/Tasks.jsx
@@ -1,10 +1,12 @@
import { useState, useEffect, useContext, useMemo } from 'react'
-import { Plus, CheckSquare, Trash2, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
+import { Plus, CheckSquare, Search, LayoutGrid, List, CalendarDays, X, SlidersHorizontal } from 'lucide-react'
import { AppContext } from '../App'
import { useAuth } from '../contexts/AuthContext'
import { useLanguage } from '../i18n/LanguageContext'
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
import TaskCard from '../components/TaskCard'
+import KanbanBoard from '../components/KanbanBoard'
+import KanbanCard from '../components/KanbanCard'
import TaskDetailPanel from '../components/TaskDetailPanel'
import BulkSelectBar from '../components/BulkSelectBar'
import Modal from '../components/Modal'
@@ -31,8 +33,6 @@ export default function Tasks() {
// UI state
const [viewMode, setViewMode] = useState('board')
const [selectedTask, setSelectedTask] = useState(null)
- const [draggedTask, setDraggedTask] = useState(null)
- const [dragOverCol, setDragOverCol] = useState(null)
// Filters
const [searchQuery, setSearchQuery] = useState('')
@@ -209,11 +209,14 @@ export default function Tasks() {
}
const handleMove = async (taskId, newStatus) => {
+ // Optimistic update — move the card instantly
+ const prev = tasks
+ setTasks(tasks.map(t => (t._id || t.id) === taskId ? { ...t, status: newStatus } : t))
try {
await api.patch(`/tasks/${taskId}`, { status: newStatus })
toast.success(t('tasks.statusUpdated'))
- loadTasks()
} catch (err) {
+ setTasks(prev)
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
else toast.error(t('common.updateFailed'))
}
@@ -223,45 +226,6 @@ export default function Tasks() {
setSelectedTask(task)
}
- // ─── Drag and drop (Kanban) ─────────────────────────
- const handleDragStart = (e, task) => {
- setDraggedTask(task)
- e.dataTransfer.effectAllowed = 'move'
- setTimeout(() => { if (e.target) e.target.style.opacity = '0.4' }, 0)
- }
- const handleDragEnd = (e) => {
- e.target.style.opacity = '1'
- setDraggedTask(null)
- setDragOverCol(null)
- }
- const handleDragOver = (e, colStatus) => {
- e.preventDefault()
- e.dataTransfer.dropEffect = 'move'
- setDragOverCol(colStatus)
- }
- const handleDragLeave = (e) => {
- if (!e.currentTarget.contains(e.relatedTarget)) setDragOverCol(null)
- }
- const handleDrop = (e, colStatus) => {
- e.preventDefault()
- setDragOverCol(null)
- if (draggedTask && draggedTask.status !== colStatus) {
- handleMove(draggedTask._id || draggedTask.id, colStatus)
- }
- setDraggedTask(null)
- }
-
- // ─── Kanban columns ──────────────────────────────────
- const todoTasks = filteredTasks.filter(t => t.status === 'todo')
- const inProgressTasks = filteredTasks.filter(t => t.status === 'in_progress')
- const doneTasks = filteredTasks.filter(t => t.status === 'done')
-
- const columns = [
- { label: t('tasks.todo'), status: 'todo', items: todoTasks, color: 'bg-gray-400' },
- { label: t('tasks.in_progress'), status: 'in_progress', items: inProgressTasks, color: 'bg-blue-400' },
- { label: t('tasks.done'), status: 'done', items: doneTasks, color: 'bg-emerald-400' },
- ]
-
// ─── List view sorting ────────────────────────────────
const [sortBy, setSortBy] = useState('due_date')
const [sortDir, setSortDir] = useState('asc')
@@ -560,67 +524,40 @@ export default function Tasks() {
<>
{/* ─── Board View ──────────────────────── */}
{viewMode === 'board' && (
-
- {columns.map(col => {
- const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
+
t._id || t.id}
+ onMove={handleMove}
+ emptyLabel={t('tasks.noTasks')}
+ renderCard={(task) => {
+ const dueDate = task.due_date || task.dueDate
+ const isOverdue = dueDate && new Date(dueDate) < new Date() && task.status !== 'done'
+ const assignee = task.assigned_name || task.assignedName
+ const brandName = task.brand_name || task.brandName
+ const projectName = task.project_name || task.projectName
return (
-
-
-
-
{col.label}
-
- {col.items.length}
+ openTask(task)}
+ tags={projectName && (
+
+ {projectName}
-
-
handleDragOver(e, col.status)}
- onDragLeave={handleDragLeave}
- onDrop={(e) => handleDrop(e, col.status)}
- >
- {col.items.length === 0 ? (
-
- {isOver ? t('posts.dropHere') : t('tasks.noTasks')}
-
- ) : (
- col.items.map(task => {
- const canEdit = canEditResource('task', task)
- const canDelete = canDeleteResource('task', task)
- return (
-
canEdit && handleDragStart(e, task)}
- onDragEnd={handleDragEnd}
- className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
- >
-
openTask(task)}>
-
- {canDelete && (
-
-
-
- )}
-
-
- )
- })
- )}
-
-
+ )}
+ />
)
- })}
-
+ }}
+ />
)}
{/* ─── List View ───────────────────────── */}