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

@@ -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() {
</div>
{/* Right side */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
{/* Theme toggle */}
<ThemeToggle />
{/* 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" />

View File

@@ -1,24 +1,14 @@
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 }) {
export default function KanbanBoard({ columns, items, renderCard, getItemId, onMove, emptyLabel }) {
const { t } = useLanguage()
const [draggedPost, setDraggedPost] = useState(null)
const [draggedItem, setDraggedItem] = useState(null)
const [dragOverCol, setDragOverCol] = useState(null)
const handleDragStart = (e, post) => {
setDraggedPost(post)
const handleDragStart = (e, item) => {
setDraggedItem(item)
e.dataTransfer.effectAllowed = 'move'
// Make the drag image slightly transparent
if (e.target) {
setTimeout(() => e.target.style.opacity = '0.4', 0)
}
@@ -26,7 +16,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
const handleDragEnd = (e) => {
e.target.style.opacity = '1'
setDraggedPost(null)
setDraggedItem(null)
setDragOverCol(null)
}
@@ -36,8 +26,7 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
setDragOverCol(colId)
}
const handleDragLeave = (e, colId) => {
// Only clear if we're actually leaving the column (not entering a child)
const handleDragLeave = (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setDragOverCol(null)
}
@@ -46,59 +35,54 @@ export default function KanbanBoard({ posts, onPostClick, onMovePost }) {
const handleDrop = (e, colId) => {
e.preventDefault()
setDragOverCol(null)
if (draggedPost && draggedPost.status !== colId) {
onMovePost(draggedPost._id, colId)
if (draggedItem && draggedItem.status !== colId) {
onMove(getItemId(draggedItem), colId)
}
setDraggedPost(null)
setDraggedItem(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
{columns.map((col) => {
const colItems = items.filter((item) => item.status === col.id)
const isOver = dragOverCol === col.id && draggedItem?.status !== col.id
return (
<div key={col.id} className="flex-shrink-0 w-72">
<div key={col.id} className="min-w-[240px] flex-1">
{/* 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}`} />
<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}
<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">
{colItems.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] ${
className={`rounded-xl p-2 space-y-2 border-2 transition-colors min-h-[200px] ${
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)}
onDragLeave={handleDragLeave}
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'}`}>
{isOver ? t('posts.dropHere') : t('posts.noPosts')}
{isOver ? t('posts.dropHere') : (emptyLabel || t('posts.noPosts'))}
</div>
) : (
colPosts.map((post) => (
colItems.map((item) => (
<div
key={post._id}
key={getItemId(item)}
draggable
onDragStart={(e) => handleDragStart(e, post)}
onDragStart={(e) => handleDragStart(e, item)}
onDragEnd={handleDragEnd}
className="cursor-grab active:cursor-grabbing"
>
<PostCard
post={post}
onClick={() => onPostClick(post)}
onMove={onMovePost}
compact
/>
{renderCard(item)}
</div>
))
)}

View 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>
)
}

View 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>
)
}