feat: add theme toggle, shared KanbanCard, keyboard shortcuts
All checks were successful
Deploy / deploy (push) Successful in 11s
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:
@@ -4,6 +4,7 @@ import { Bell, ChevronDown, LogOut, Shield, Lock, AlertCircle, CheckCircle } fro
|
|||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { getInitials, api } from '../utils/api'
|
import { getInitials, api } from '../utils/api'
|
||||||
import Modal from './Modal'
|
import Modal from './Modal'
|
||||||
|
import ThemeToggle from './ThemeToggle'
|
||||||
|
|
||||||
const pageTitles = {
|
const pageTitles = {
|
||||||
'/': 'Dashboard',
|
'/': 'Dashboard',
|
||||||
@@ -98,7 +99,10 @@ export default function Header() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Theme toggle */}
|
||||||
|
<ThemeToggle />
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications */}
|
||||||
<button className="relative p-2 rounded-lg hover:bg-surface-tertiary text-text-secondary hover:text-text-primary transition-colors">
|
<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" />
|
<Bell className="w-5 h-5" />
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import PostCard from './PostCard'
|
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
|
|
||||||
const COLUMNS = [
|
export default function KanbanBoard({ columns, items, renderCard, getItemId, onMove, emptyLabel }) {
|
||||||
{ 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 { t } = useLanguage()
|
||||||
const [draggedPost, setDraggedPost] = useState(null)
|
const [draggedItem, setDraggedItem] = useState(null)
|
||||||
const [dragOverCol, setDragOverCol] = useState(null)
|
const [dragOverCol, setDragOverCol] = useState(null)
|
||||||
|
|
||||||
const handleDragStart = (e, post) => {
|
const handleDragStart = (e, item) => {
|
||||||
setDraggedPost(post)
|
setDraggedItem(item)
|
||||||
e.dataTransfer.effectAllowed = 'move'
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
// Make the drag image slightly transparent
|
|
||||||
if (e.target) {
|
if (e.target) {
|
||||||
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
setTimeout(() => e.target.style.opacity = '0.4', 0)
|
||||||
}
|
}
|
||||||
@@ -26,7 +16,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
|||||||
|
|
||||||
const handleDragEnd = (e) => {
|
const handleDragEnd = (e) => {
|
||||||
e.target.style.opacity = '1'
|
e.target.style.opacity = '1'
|
||||||
setDraggedPost(null)
|
setDraggedItem(null)
|
||||||
setDragOverCol(null)
|
setDragOverCol(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,8 +26,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
|||||||
setDragOverCol(colId)
|
setDragOverCol(colId)
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleDragLeave = (e, colId) => {
|
const handleDragLeave = (e) => {
|
||||||
// Only clear if we're actually leaving the column (not entering a child)
|
|
||||||
if (!e.currentTarget.contains(e.relatedTarget)) {
|
if (!e.currentTarget.contains(e.relatedTarget)) {
|
||||||
setDragOverCol(null)
|
setDragOverCol(null)
|
||||||
}
|
}
|
||||||
@@ -46,59 +35,54 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
|
|||||||
const handleDrop = (e, colId) => {
|
const handleDrop = (e, colId) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setDragOverCol(null)
|
setDragOverCol(null)
|
||||||
if (draggedPost && draggedPost.status !== colId) {
|
if (draggedItem && draggedItem.status !== colId) {
|
||||||
onMovePost(draggedPost._id, colId)
|
onMove(getItemId(draggedItem), colId)
|
||||||
}
|
}
|
||||||
setDraggedPost(null)
|
setDraggedItem(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{COLUMNS.map((col) => {
|
{columns.map((col) => {
|
||||||
const colPosts = posts.filter((p) => p.status === col.id)
|
const colItems = items.filter((item) => item.status === col.id)
|
||||||
const isOver = dragOverCol === col.id && draggedPost?.status !== col.id
|
const isOver = dragOverCol === col.id && draggedItem?.status !== col.id
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={col.id} className="flex-shrink-0 w-72">
|
<div key={col.id} className="min-w-[240px] flex-1">
|
||||||
{/* Column header */}
|
{/* Column header */}
|
||||||
<div className="flex items-center gap-2 mb-3 px-1">
|
<div className="flex items-center gap-2 mb-3">
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
<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>
|
<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 ml-auto">
|
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
||||||
{colPosts.length}
|
{colItems.length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column body — drop zone */}
|
{/* Column body — drop zone */}
|
||||||
<div
|
<div
|
||||||
className={`kanban-column rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[120px] ${
|
className={`rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[200px] ${
|
||||||
isOver
|
isOver
|
||||||
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
? 'bg-brand-primary/5 border-brand-primary/40 border-dashed'
|
||||||
: 'bg-surface-secondary border-border-light border-solid'
|
: 'bg-surface-secondary border-border-light border-solid'
|
||||||
}`}
|
}`}
|
||||||
onDragOver={(e) => handleDragOver(e, col.id)}
|
onDragOver={(e) => handleDragOver(e, col.id)}
|
||||||
onDragLeave={(e) => handleDragLeave(e, col.id)}
|
onDragLeave={handleDragLeave}
|
||||||
onDrop={(e) => handleDrop(e, col.id)}
|
onDrop={(e) => handleDrop(e, col.id)}
|
||||||
>
|
>
|
||||||
{colPosts.length === 0 ? (
|
{colItems.length === 0 ? (
|
||||||
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
<div className={`py-8 text-center text-xs ${isOver ? 'text-brand-primary font-medium' : 'text-text-tertiary'}`}>
|
||||||
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
|
{isOver ? t('posts.dropHere') : (emptyLabel || t('posts.noPosts'))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
colPosts.map((post) => (
|
colItems.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={post._id}
|
key={getItemId(item)}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => handleDragStart(e, post)}
|
onDragStart={(e) => handleDragStart(e, item)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
className="cursor-grab active:cursor-grabbing"
|
className="cursor-grab active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<PostCard
|
{renderCard(item)}
|
||||||
post={post}
|
|
||||||
onClick={() => onPostClick(post)}
|
|
||||||
onMove={onMovePost}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
56
client/src/components/KanbanCard.jsx
Normal file
56
client/src/components/KanbanCard.jsx
Normal file
@@ -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 (
|
||||||
|
<div
|
||||||
|
onClick={onClick}
|
||||||
|
className="bg-white rounded-lg p-3 border border-border hover:border-brand-primary/30 cursor-pointer transition-all group overflow-hidden"
|
||||||
|
>
|
||||||
|
{/* Thumbnail */}
|
||||||
|
{thumbnail && (
|
||||||
|
<div className="w-[calc(100%+1.5rem)] h-32 -mx-3 -mt-3 mb-2 rounded-t-lg overflow-hidden">
|
||||||
|
<img src={thumbnail} alt="" className="w-full h-full object-cover" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<h5 className="text-sm font-medium text-text-primary line-clamp-2 leading-snug mb-2">{title}</h5>
|
||||||
|
|
||||||
|
{/* Tags row: brand + extra tags */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{brandName && <BrandBadge brand={brandName} />}
|
||||||
|
{tags}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer: assignee + date */}
|
||||||
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{date && (
|
||||||
|
<span className={`text-[10px] flex items-center gap-1 ${dateOverdue ? 'text-red-500 font-medium' : 'text-text-tertiary'}`}>
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{format(new Date(date), 'MMM d')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Optional extra content (quick actions, delete overlay, etc.) */}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
21
client/src/components/ThemeToggle.jsx
Normal file
21
client/src/components/ThemeToggle.jsx
Normal file
@@ -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 (
|
||||||
|
<button
|
||||||
|
onClick={toggleDarkMode}
|
||||||
|
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors ${className}`}
|
||||||
|
title={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
aria-label={darkMode ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{darkMode ? (
|
||||||
|
<Sun className="w-5 h-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-5 h-5 text-gray-600" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
38
client/src/contexts/ThemeContext.jsx
Normal file
38
client/src/contexts/ThemeContext.jsx
Normal file
@@ -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 (
|
||||||
|
<ThemeContext.Provider value={{ darkMode, toggleDarkMode }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useTheme must be used within ThemeProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
59
client/src/hooks/useKeyboardShortcuts.js
Normal file
59
client/src/hooks/useKeyboardShortcuts.js
Normal file
@@ -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()
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -52,12 +52,10 @@
|
|||||||
background: #94a3b8;
|
background: #94a3b8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth transitions */
|
/* Smooth transitions — scoped to interactive elements only.
|
||||||
* {
|
Do NOT use * selector — it causes every element to re-animate
|
||||||
transition-property: background-color, border-color, color, opacity, box-shadow, transform;
|
on any React state change (e.g. drag-and-drop). Components should
|
||||||
transition-duration: 200ms;
|
use Tailwind transition-colors / transition-all where needed. */
|
||||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Arabic text support */
|
/* Arabic text support */
|
||||||
[dir="rtl"] {
|
[dir="rtl"] {
|
||||||
@@ -334,14 +332,7 @@ textarea {
|
|||||||
/* Refined button styles */
|
/* Refined button styles */
|
||||||
button {
|
button {
|
||||||
border-radius: 0.625rem;
|
border-radius: 0.625rem;
|
||||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: background-color 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
button:disabled {
|
button:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
@@ -386,11 +377,6 @@ select:not(:disabled):hover {
|
|||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Kanban column */
|
|
||||||
.kanban-column {
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Calendar grid */
|
/* Calendar grid */
|
||||||
.calendar-grid {
|
.calendar-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { useLanguage } from '../i18n/LanguageContext'
|
|||||||
import { useToast } from '../components/ToastContainer'
|
import { useToast } from '../components/ToastContainer'
|
||||||
import IssueDetailPanel from '../components/IssueDetailPanel'
|
import IssueDetailPanel from '../components/IssueDetailPanel'
|
||||||
import IssueCard from '../components/IssueCard'
|
import IssueCard from '../components/IssueCard'
|
||||||
|
import KanbanBoard from '../components/KanbanBoard'
|
||||||
|
import KanbanCard from '../components/KanbanCard'
|
||||||
import EmptyState from '../components/EmptyState'
|
import EmptyState from '../components/EmptyState'
|
||||||
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
|
import { SkeletonTable, SkeletonKanbanBoard } from '../components/SkeletonLoader'
|
||||||
import BulkSelectBar from '../components/BulkSelectBar'
|
import BulkSelectBar from '../components/BulkSelectBar'
|
||||||
@@ -28,8 +30,6 @@ const ISSUE_STATUS_CONFIG = {
|
|||||||
declined: STATUS_CONFIG.declined,
|
declined: STATUS_CONFIG.declined,
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ORDER = ['new', 'acknowledged', 'in_progress', 'resolved', 'declined']
|
|
||||||
|
|
||||||
export default function Issues() {
|
export default function Issues() {
|
||||||
const { t } = useLanguage()
|
const { t } = useLanguage()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
@@ -47,10 +47,6 @@ export default function Issues() {
|
|||||||
// View mode
|
// View mode
|
||||||
const [viewMode, setViewMode] = useState('board')
|
const [viewMode, setViewMode] = useState('board')
|
||||||
|
|
||||||
// Drag and drop
|
|
||||||
const [draggedIssue, setDraggedIssue] = useState(null)
|
|
||||||
const [dragOverCol, setDragOverCol] = useState(null)
|
|
||||||
|
|
||||||
// List sorting
|
// List sorting
|
||||||
const [sortBy, setSortBy] = useState('created_at')
|
const [sortBy, setSortBy] = useState('created_at')
|
||||||
const [sortDir, setSortDir] = useState('desc')
|
const [sortDir, setSortDir] = useState('desc')
|
||||||
@@ -139,13 +135,22 @@ export default function Issues() {
|
|||||||
|
|
||||||
// Drag and drop handlers
|
// Drag and drop handlers
|
||||||
const handleMoveIssue = async (issueId, newStatus) => {
|
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 {
|
try {
|
||||||
await api.patch(`/issues/${issueId}`, { status: newStatus })
|
await api.patch(`/issues/${issueId}`, { status: newStatus })
|
||||||
toast.success(t('issues.statusUpdated'))
|
toast.success(t('issues.statusUpdated'))
|
||||||
loadData()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Move issue failed:', err)
|
console.error('Move issue failed:', err)
|
||||||
toast.error('Failed to update status')
|
toast.error('Failed to update status')
|
||||||
|
// Rollback on error
|
||||||
|
setIssues(prev)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,33 +188,6 @@ export default function Issues() {
|
|||||||
toast.success(t('issues.linkCopied'))
|
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) => {
|
const toggleSort = (col) => {
|
||||||
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
if (sortBy === col) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
|
||||||
else { setSortBy(col); setSortDir('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'}
|
description={hasActiveFilters ? 'Try adjusting your filters' : 'No issues have been submitted yet'}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex gap-4 overflow-x-auto pb-4">
|
<KanbanBoard
|
||||||
{STATUS_ORDER.map(status => {
|
columns={Object.entries(ISSUE_STATUS_CONFIG).map(([id, cfg]) => ({ id, label: cfg.label, color: cfg.dot }))}
|
||||||
const config = ISSUE_STATUS_CONFIG[status]
|
items={filteredIssues}
|
||||||
const columnIssues = filteredIssues.filter(i => i.status === status)
|
getItemId={(i) => i.Id || i.id}
|
||||||
return (
|
onMove={handleMoveIssue}
|
||||||
<div
|
emptyLabel={t('issues.noIssuesInColumn')}
|
||||||
key={status}
|
renderCard={(issue) => (
|
||||||
className={`flex-shrink-0 w-72 rounded-xl border transition-colors ${
|
<KanbanCard
|
||||||
dragOverCol === status ? 'border-brand-primary bg-brand-primary/5' : 'border-border bg-surface-secondary/50'
|
title={issue.title}
|
||||||
}`}
|
thumbnail={issue.thumbnail_url}
|
||||||
onDragOver={e => handleDragOver(e, status)}
|
brandName={issue.brand_name}
|
||||||
onDragLeave={handleDragLeave}
|
assigneeName={issue.submitter_name}
|
||||||
onDrop={e => handleDrop(e, status)}
|
date={issue.created_at || issue.CreatedAt}
|
||||||
>
|
onClick={() => setSelectedIssue(issue)}
|
||||||
{/* Column header */}
|
tags={issue.category && (
|
||||||
<div className="px-3 py-3 border-b border-border">
|
<span className="text-[10px] px-1.5 py-0.5 rounded bg-surface-tertiary text-text-secondary font-medium">
|
||||||
<div className="flex items-center gap-2">
|
{issue.category}
|
||||||
<span className={`w-2.5 h-2.5 rounded-full ${config.dot}`} />
|
</span>
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useAuth } from '../contexts/AuthContext'
|
|||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PLATFORMS } from '../utils/api'
|
import { api, PLATFORMS } from '../utils/api'
|
||||||
import KanbanBoard from '../components/KanbanBoard'
|
import KanbanBoard from '../components/KanbanBoard'
|
||||||
|
import KanbanCard from '../components/KanbanCard'
|
||||||
import PostCard from '../components/PostCard'
|
import PostCard from '../components/PostCard'
|
||||||
import PostDetailPanel from '../components/PostDetailPanel'
|
import PostDetailPanel from '../components/PostDetailPanel'
|
||||||
import DatePresetPicker from '../components/DatePresetPicker'
|
import DatePresetPicker from '../components/DatePresetPicker'
|
||||||
@@ -22,7 +23,7 @@ const EMPTY_POST = {
|
|||||||
|
|
||||||
export default function PostProduction() {
|
export default function PostProduction() {
|
||||||
const { t, lang } = useLanguage()
|
const { t, lang } = useLanguage()
|
||||||
const { teamMembers, brands } = useContext(AppContext)
|
const { teamMembers, brands, getBrandName } = useContext(AppContext)
|
||||||
const { canEditResource } = useAuth()
|
const { canEditResource } = useAuth()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const [posts, setPosts] = useState([])
|
const [posts, setPosts] = useState([])
|
||||||
@@ -54,12 +55,15 @@ export default function PostProduction() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMovePost = async (postId, newStatus) => {
|
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 {
|
try {
|
||||||
await api.patch(`/posts/${postId}`, { status: newStatus })
|
await api.patch(`/posts/${postId}`, { status: newStatus })
|
||||||
toast.success(t('posts.statusUpdated'))
|
toast.success(t('posts.statusUpdated'))
|
||||||
loadPosts()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Move failed:', err)
|
console.error('Move failed:', err)
|
||||||
|
setPosts(prev)
|
||||||
if (err.message?.includes('Cannot publish')) {
|
if (err.message?.includes('Cannot publish')) {
|
||||||
setMoveError(t('posts.publishRequired'))
|
setMoveError(t('posts.publishRequired'))
|
||||||
setTimeout(() => setMoveError(''), 5000)
|
setTimeout(() => setMoveError(''), 5000)
|
||||||
@@ -258,7 +262,32 @@ export default function PostProduction() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{view === 'kanban' ? (
|
{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">
|
<div className="bg-white rounded-xl border border-border overflow-hidden">
|
||||||
{filteredPosts.length === 0 ? (
|
{filteredPosts.length === 0 ? (
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { useState, useEffect, useContext, useMemo } from 'react'
|
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 { AppContext } from '../App'
|
||||||
import { useAuth } from '../contexts/AuthContext'
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
import { useLanguage } from '../i18n/LanguageContext'
|
import { useLanguage } from '../i18n/LanguageContext'
|
||||||
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
import { api, PRIORITY_CONFIG, getBrandColor } from '../utils/api'
|
||||||
import TaskCard from '../components/TaskCard'
|
import TaskCard from '../components/TaskCard'
|
||||||
|
import KanbanBoard from '../components/KanbanBoard'
|
||||||
|
import KanbanCard from '../components/KanbanCard'
|
||||||
import TaskDetailPanel from '../components/TaskDetailPanel'
|
import TaskDetailPanel from '../components/TaskDetailPanel'
|
||||||
import BulkSelectBar from '../components/BulkSelectBar'
|
import BulkSelectBar from '../components/BulkSelectBar'
|
||||||
import Modal from '../components/Modal'
|
import Modal from '../components/Modal'
|
||||||
@@ -31,8 +33,6 @@ export default function Tasks() {
|
|||||||
// UI state
|
// UI state
|
||||||
const [viewMode, setViewMode] = useState('board')
|
const [viewMode, setViewMode] = useState('board')
|
||||||
const [selectedTask, setSelectedTask] = useState(null)
|
const [selectedTask, setSelectedTask] = useState(null)
|
||||||
const [draggedTask, setDraggedTask] = useState(null)
|
|
||||||
const [dragOverCol, setDragOverCol] = useState(null)
|
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
@@ -209,11 +209,14 @@ export default function Tasks() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = async (taskId, newStatus) => {
|
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 {
|
try {
|
||||||
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
await api.patch(`/tasks/${taskId}`, { status: newStatus })
|
||||||
toast.success(t('tasks.statusUpdated'))
|
toast.success(t('tasks.statusUpdated'))
|
||||||
loadTasks()
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
setTasks(prev)
|
||||||
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
|
if (err.message?.includes('403')) toast.error(t('tasks.canOnlyEditOwn'))
|
||||||
else toast.error(t('common.updateFailed'))
|
else toast.error(t('common.updateFailed'))
|
||||||
}
|
}
|
||||||
@@ -223,45 +226,6 @@ export default function Tasks() {
|
|||||||
setSelectedTask(task)
|
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 ────────────────────────────────
|
// ─── List view sorting ────────────────────────────────
|
||||||
const [sortBy, setSortBy] = useState('due_date')
|
const [sortBy, setSortBy] = useState('due_date')
|
||||||
const [sortDir, setSortDir] = useState('asc')
|
const [sortDir, setSortDir] = useState('asc')
|
||||||
@@ -560,67 +524,40 @@ export default function Tasks() {
|
|||||||
<>
|
<>
|
||||||
{/* ─── Board View ──────────────────────── */}
|
{/* ─── Board View ──────────────────────── */}
|
||||||
{viewMode === 'board' && (
|
{viewMode === 'board' && (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
<KanbanBoard
|
||||||
{columns.map(col => {
|
columns={[
|
||||||
const isOver = dragOverCol === col.status && draggedTask?.status !== col.status
|
{ 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 (
|
return (
|
||||||
<div key={col.status}>
|
<KanbanCard
|
||||||
<div className="flex items-center gap-2 mb-3">
|
title={task.title}
|
||||||
<div className={`w-2.5 h-2.5 rounded-full ${col.color}`} />
|
thumbnail={task.thumbnail_url}
|
||||||
<h4 className="text-sm font-semibold text-text-primary">{col.label}</h4>
|
brandName={brandName}
|
||||||
<span className="text-xs font-medium text-text-tertiary bg-surface-tertiary px-2 py-0.5 rounded-full">
|
assigneeName={assignee}
|
||||||
{col.items.length}
|
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>
|
</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 ───────────────────────── */}
|
{/* ─── List View ───────────────────────── */}
|
||||||
|
|||||||
Reference in New Issue
Block a user