diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index 956dbb1..611482a 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -4,6 +4,7 @@ import { Bell, ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } fro import { useAuth } from '../contexts/AuthContext' import { getInitials, api } from '../utils/api' import Modal from './Modal' +import ThemeToggle from './ThemeToggle' const pageTitles = { '/': 'Dashboard', @@ -98,7 +99,10 @@ export default function Header() { {/* Right side */} -
+
+ {/* Theme toggle */} + + {/* Notifications */} + ) +} 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 ───────────────────────── */}