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 { 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" />
|
||||
|
||||
@@ -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>
|
||||
))
|
||||
)}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user