feat: add theme toggle, shared KanbanCard, keyboard shortcuts
All checks were successful
Deploy / deploy (push) Successful in 11s

Previously unstaged files from prior sessions: ThemeContext,
ThemeToggle, KanbanCard, useKeyboardShortcuts hook, and updated
Header, KanbanBoard, Issues, Tasks, PostProduction, index.css.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
fahed
2026-03-04 12:12:34 +03:00
parent c31e6222d7
commit fa6345f63e
10 changed files with 313 additions and 247 deletions

View File

@@ -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'}
/>
) : (
<div className="flex gap-4 overflow-x-auto pb-4">
{STATUS_ORDER.map(status => {
const config = ISSUE_STATUS_CONFIG[status]
const columnIssues = filteredIssues.filter(i => i.status === status)
return (
<div
key={status}
className={`flex-shrink-0 w-72 rounded-xl border transition-colors ${
dragOverCol === status ? 'border-brand-primary bg-brand-primary/5' : 'border-border bg-surface-secondary/50'
}`}
onDragOver={e => handleDragOver(e, status)}
onDragLeave={handleDragLeave}
onDrop={e => handleDrop(e, status)}
>
{/* Column header */}
<div className="px-3 py-3 border-b border-border">
<div className="flex items-center gap-2">
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
<span className="text-sm font-semibold text-text-primary">{config.label}</span>
<span className="text-xs bg-surface-tertiary text-text-tertiary px-1.5 py-0.5 rounded-full font-medium">
{columnIssues.length}
</span>
</div>
</div>
{/* Cards */}
<div className="p-2 space-y-2 min-h-[120px]">
{columnIssues.length === 0 ? (
<div className="text-center py-6 text-xs text-text-tertiary">
{t('issues.noIssuesInColumn')}
</div>
) : (
columnIssues.map(issue => (
<div
key={issue.Id || issue.id}
draggable
onDragStart={e => handleDragStart(e, issue)}
onDragEnd={handleDragEnd}
>
<IssueCard issue={issue} onClick={setSelectedIssue} />
</div>
))
)}
</div>
</div>
)
})}
</div>
<KanbanBoard
columns={Object.entries(ISSUE_STATUS_CONFIG).map(([id, cfg]) => ({ id, label: cfg.label, color: cfg.dot }))}
items={filteredIssues}
getItemId={(i) => i.Id || i.id}
onMove={handleMoveIssue}
emptyLabel={t('issues.noIssuesInColumn')}
renderCard={(issue) => (
<KanbanCard
title={issue.title}
thumbnail={issue.thumbnail_url}
brandName={issue.brand_name}
assigneeName={issue.submitter_name}
date={issue.created_at || issue.CreatedAt}
onClick={() => setSelectedIssue(issue)}
tags={issue.category && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">
{issue.category}
</span>
)}
/>
)}
/>
)
)}

View File

@@ -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' ? (
<KanbanBoard posts={filteredPosts} onPostClick={openEdit} onMovePost={handleMovePost} />
<KanbanBoard
columns={[
{ id: 'draft', label: t('posts.status.draft'), color: 'bg-gray-400' },
{ id: 'in_review', label: t('posts.status.in_review'), color: 'bg-amber-400' },
{ id: 'approved', label: t('posts.status.approved'), color: 'bg-blue-400' },
{ id: 'scheduled', label: t('posts.status.scheduled'), color: 'bg-purple-400' },
{ id: 'published', label: t('posts.status.published'), color: 'bg-emerald-400' },
]}
items={filteredPosts}
getItemId={(p) => 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 (
<KanbanCard
title={post.title}
thumbnail={post.thumbnail_url}
brandName={brandName}
assigneeName={assignee}
date={post.scheduledDate}
onClick={() => openEdit(post)}
/>
)
}}
/>
) : (
<div className="bg-white rounded-xl border border-border overflow-hidden">
{filteredPosts.length === 0 ? (

View File

@@ -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' && (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{columns.map(col => {
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
<KanbanBoard
columns={[
{ id: 'todo', label: t('tasks.todo'), color: 'bg-gray-400' },
{ id: 'in_progress', label: t('tasks.in_progress'), color: 'bg-blue-400' },
{ id: 'done', label: t('tasks.done'), color: 'bg-emerald-400' },
]}
items={filteredTasks}
getItemId={(t) => 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 (
<div key={col.status}>
<div className="flex items-center gap-2 mb-3">
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
{col.items.length}
<KanbanCard
title={task.title}
thumbnail={task.thumbnail_url}
brandName={brandName}
assigneeName={assignee}
date={dueDate}
dateOverdue={isOverdue}
onClick={() => openTask(task)}
tags={projectName && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-tertiary">
{projectName}
</span>
</div>
<div
className={`kanban-column rounded-xl p-2 space-y-2 min-h-[200px] border-2 transition-colors ${
isOver
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
: 'bg-surface-secondary border-border-light border-solid'
}`}
onDragOver={(e) => handleDragOver(e, col.status)}
onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, col.status)}
>
{col.items.length === 0 ? (
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
{isOver ? t('posts.dropHere') : t('tasks.noTasks')}
</div>
) : (
col.items.map(task => {
const canEdit = canEditResource('task', task)
const canDelete = canDeleteResource('task', task)
return (
<div
key={task._id || task.id}
draggable={canEdit}
onDragStart={(e) => canEdit && handleDragStart(e, task)}
onDragEnd={handleDragEnd}
className={canEdit ? 'cursor-grab active:cursor-grabbing' : ''}
>
<div className="relative group" onClick={() => openTask(task)}>
<TaskCard task={task} onMove={canEdit ? handleMove : undefined} showProject />
{canDelete && (
<div className="absolute top-2 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); handlePanelDelete(task._id || task.id) }}
className="p-1 hover:bg-red-50 rounded text-text-tertiary hover:text-red-500"
title={t('common.delete')}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
</div>
)
})
)}
</div>
</div>
)}
/>
)
})}
</div>
}}
/>
)}
{/* ─── List View ───────────────────────── */}