@moontra/moonui-pro 2.11.4 → 2.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,22 @@
1
1
  "use client"
2
2
 
3
- import React from 'react'
3
+ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
4
+ import { motion, AnimatePresence, Reorder, useDragControls } from 'framer-motion'
4
5
  import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../ui/card'
5
6
  import { Button } from '../ui/button'
6
7
  import { MoonUIBadgePro as Badge } from '../ui/badge'
7
- import { MoonUIAvatarPro, MoonUIAvatarFallbackPro, MoonUIAvatarImagePro } from '../ui/avatar'
8
+ import { MoonUIAvatarPro, MoonUIAvatarFallbackPro, MoonUIAvatarImagePro, AvatarGroup as MoonUIAvatarGroupPro } from '../ui/avatar'
9
+ import { Input } from '../ui/input'
10
+ import { Textarea } from '../ui/textarea'
11
+ import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'
12
+ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '../ui/dropdown-menu'
13
+ import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from '../ui/command'
14
+ import { ScrollArea } from '../ui/scroll-area'
15
+ import { Skeleton } from '../ui/skeleton'
16
+ import { Switch } from '../ui/switch'
17
+ import { Label } from '../ui/label'
18
+ import { Progress } from '../ui/progress'
19
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '../ui/tabs'
8
20
  import {
9
21
  Plus,
10
22
  MoreHorizontal,
@@ -16,27 +28,101 @@ import {
16
28
  Trash2,
17
29
  GripVertical,
18
30
  Lock,
19
- Sparkles
31
+ Sparkles,
32
+ Search,
33
+ Filter,
34
+ Download,
35
+ Upload,
36
+ Copy,
37
+ Move,
38
+ Archive,
39
+ Eye,
40
+ EyeOff,
41
+ ChevronDown,
42
+ ChevronRight,
43
+ X,
44
+ Check,
45
+ Clock,
46
+ AlertCircle,
47
+ Tag,
48
+ Users,
49
+ FileText,
50
+ Image,
51
+ Link2,
52
+ Activity,
53
+ Settings,
54
+ Palette,
55
+ Star,
56
+ Flag,
57
+ CheckSquare,
58
+ Square,
59
+ MoreVertical,
60
+ ArrowUpDown,
61
+ ArrowUp,
62
+ ArrowDown,
63
+ Zap,
64
+ Timer
20
65
  } from 'lucide-react'
21
66
  import { cn } from '../../lib/utils'
22
- // Note: DocsProAccess should be handled by consuming application
23
67
  import { useSubscription } from '../../hooks/use-subscription'
24
68
 
69
+ // Enhanced types
70
+ interface KanbanAssignee {
71
+ id: string
72
+ name: string
73
+ avatar?: string
74
+ email?: string
75
+ }
76
+
77
+ interface KanbanLabel {
78
+ id: string
79
+ name: string
80
+ color: string
81
+ }
82
+
83
+ interface KanbanChecklist {
84
+ id: string
85
+ title: string
86
+ items: {
87
+ id: string
88
+ text: string
89
+ completed: boolean
90
+ }[]
91
+ }
92
+
93
+ interface KanbanActivity {
94
+ id: string
95
+ user: KanbanAssignee
96
+ action: string
97
+ timestamp: Date
98
+ details?: string
99
+ }
100
+
25
101
  interface KanbanCard {
26
102
  id: string
27
103
  title: string
28
104
  description?: string
29
- assignee?: {
30
- name: string
31
- avatar?: string
32
- email?: string
33
- }
105
+ coverImage?: string
106
+ assignees?: KanbanAssignee[]
34
107
  dueDate?: Date
108
+ startDate?: Date
35
109
  priority?: 'low' | 'medium' | 'high' | 'urgent'
36
110
  tags?: string[]
37
- attachments?: number
111
+ labels?: KanbanLabel[]
112
+ attachments?: {
113
+ id: string
114
+ name: string
115
+ type: string
116
+ url: string
117
+ size: number
118
+ }[]
38
119
  comments?: number
39
120
  completed?: boolean
121
+ progress?: number
122
+ checklist?: KanbanChecklist
123
+ activities?: KanbanActivity[]
124
+ customFields?: Record<string, any>
125
+ position: number
40
126
  }
41
127
 
42
128
  interface KanbanColumn {
@@ -45,55 +131,422 @@ interface KanbanColumn {
45
131
  color?: string
46
132
  cards: KanbanCard[]
47
133
  limit?: number
134
+ collapsed?: boolean
135
+ locked?: boolean
136
+ template?: 'todo' | 'inProgress' | 'done' | 'custom'
137
+ }
138
+
139
+ interface KanbanFilter {
140
+ id: string
141
+ name: string
142
+ query: string
143
+ assignees?: string[]
144
+ labels?: string[]
145
+ priority?: string[]
146
+ tags?: string[]
147
+ dueDate?: {
148
+ from?: Date
149
+ to?: Date
150
+ }
48
151
  }
49
152
 
50
153
  interface KanbanProps {
51
154
  columns: KanbanColumn[]
52
- onCardMove?: (cardId: string, fromColumn: string, toColumn: string, newIndex: number) => void
155
+ onCardMove?: (cardId: string, fromColumn: string, toColumn: string, newPosition: number) => void
53
156
  onCardClick?: (card: KanbanCard) => void
54
157
  onCardEdit?: (card: KanbanCard) => void
55
158
  onCardDelete?: (card: KanbanCard) => void
56
- onAddCard?: (columnId: string) => void
57
- onAddColumn?: () => void
159
+ onCardUpdate?: (card: KanbanCard) => void
160
+ onAddCard?: (columnId: string, card?: Partial<KanbanCard>) => void
161
+ onAddColumn?: (column?: Partial<KanbanColumn>) => void
162
+ onColumnUpdate?: (column: KanbanColumn) => void
163
+ onColumnDelete?: (columnId: string) => void
164
+ onBulkAction?: (action: string, cardIds: string[]) => void
165
+ onExport?: (format: 'json' | 'csv') => void
58
166
  className?: string
59
167
  showAddColumn?: boolean
60
168
  showCardDetails?: boolean
169
+ showFilters?: boolean
170
+ showSearch?: boolean
171
+ enableKeyboardShortcuts?: boolean
172
+ cardTemplates?: Partial<KanbanCard>[]
173
+ columnTemplates?: Partial<KanbanColumn>[]
174
+ filters?: KanbanFilter[]
175
+ defaultFilter?: string
176
+ loading?: boolean
61
177
  disabled?: boolean
178
+ labels?: KanbanLabel[]
179
+ users?: KanbanAssignee[]
180
+ }
181
+
182
+ // Constants
183
+ const PRIORITY_CONFIG = {
184
+ low: {
185
+ color: 'bg-green-100 text-green-800 border-green-200',
186
+ dot: 'bg-green-500',
187
+ icon: ArrowDown
188
+ },
189
+ medium: {
190
+ color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
191
+ dot: 'bg-yellow-500',
192
+ icon: ArrowUp
193
+ },
194
+ high: {
195
+ color: 'bg-orange-100 text-orange-800 border-orange-200',
196
+ dot: 'bg-orange-500',
197
+ icon: Zap
198
+ },
199
+ urgent: {
200
+ color: 'bg-red-100 text-red-800 border-red-200',
201
+ dot: 'bg-red-500',
202
+ icon: AlertCircle
203
+ }
62
204
  }
63
205
 
64
- const PRIORITY_COLORS = {
65
- low: 'bg-green-100 text-green-800 border-green-200',
66
- medium: 'bg-yellow-100 text-yellow-800 border-yellow-200',
67
- high: 'bg-orange-100 text-orange-800 border-orange-200',
68
- urgent: 'bg-red-100 text-red-800 border-red-200'
206
+ const COLUMN_TEMPLATES = {
207
+ todo: { title: 'To Do', color: '#6B7280' },
208
+ inProgress: { title: 'In Progress', color: '#3B82F6' },
209
+ done: { title: 'Done', color: '#10B981' }
69
210
  }
70
211
 
71
- const PRIORITY_DOTS = {
72
- low: 'bg-green-500',
73
- medium: 'bg-yellow-500',
74
- high: 'bg-orange-500',
75
- urgent: 'bg-red-500'
212
+ // Helper functions
213
+ const formatDate = (date: Date) => {
214
+ return date.toLocaleDateString('en-US', {
215
+ month: 'short',
216
+ day: 'numeric',
217
+ year: date.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined
218
+ })
219
+ }
220
+
221
+ const isOverdue = (dueDate: Date) => {
222
+ return dueDate < new Date()
223
+ }
224
+
225
+ const getInitials = (name: string) => {
226
+ return name.split(' ').map(n => n[0]).join('').toUpperCase()
227
+ }
228
+
229
+ const formatFileSize = (bytes: number) => {
230
+ if (bytes === 0) return '0 Bytes'
231
+ const k = 1024
232
+ const sizes = ['Bytes', 'KB', 'MB', 'GB']
233
+ const i = Math.floor(Math.log(bytes) / Math.log(k))
234
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
235
+ }
236
+
237
+ // Custom hook for auto-scroll while dragging
238
+ const useAutoScroll = () => {
239
+ const scrollRef = useRef<HTMLDivElement>(null)
240
+ const scrollIntervalRef = useRef<NodeJS.Timeout | null>(null)
241
+
242
+ const startAutoScroll = useCallback((direction: 'left' | 'right') => {
243
+ if (scrollIntervalRef.current) return
244
+
245
+ scrollIntervalRef.current = setInterval(() => {
246
+ if (scrollRef.current) {
247
+ const scrollAmount = direction === 'left' ? -10 : 10
248
+ scrollRef.current.scrollLeft += scrollAmount
249
+ }
250
+ }, 20)
251
+ }, [])
252
+
253
+ const stopAutoScroll = useCallback(() => {
254
+ if (scrollIntervalRef.current) {
255
+ clearInterval(scrollIntervalRef.current)
256
+ scrollIntervalRef.current = null
257
+ }
258
+ }, [])
259
+
260
+ useEffect(() => {
261
+ return () => stopAutoScroll()
262
+ }, [stopAutoScroll])
263
+
264
+ return { scrollRef, startAutoScroll, stopAutoScroll }
265
+ }
266
+
267
+ // Card component
268
+ const KanbanCardComponent = ({
269
+ card,
270
+ isDragging,
271
+ onEdit,
272
+ onDelete,
273
+ onClick,
274
+ showDetails,
275
+ disabled
276
+ }: {
277
+ card: KanbanCard
278
+ isDragging: boolean
279
+ onEdit?: (e: React.MouseEvent) => void
280
+ onDelete?: (e: React.MouseEvent) => void
281
+ onClick?: () => void
282
+ showDetails: boolean
283
+ disabled: boolean
284
+ }) => {
285
+ const [isEditingTitle, setIsEditingTitle] = useState(false)
286
+ const [title, setTitle] = useState(card.title)
287
+ const dragControls = useDragControls()
288
+
289
+ const completedChecklistItems = card.checklist?.items.filter(item => item.completed).length || 0
290
+ const totalChecklistItems = card.checklist?.items.length || 0
291
+ const checklistProgress = totalChecklistItems > 0 ? (completedChecklistItems / totalChecklistItems) * 100 : 0
292
+
293
+ return (
294
+ <motion.div
295
+ layout
296
+ initial={{ opacity: 0, y: 20 }}
297
+ animate={{ opacity: 1, y: 0 }}
298
+ exit={{ opacity: 0, y: -20 }}
299
+ whileHover={{ scale: 1.02 }}
300
+ whileDrag={{ scale: 1.05, rotate: 3 }}
301
+ transition={{ duration: 0.2 }}
302
+ className={cn(
303
+ "relative group cursor-pointer select-none",
304
+ isDragging && "z-50"
305
+ )}
306
+ >
307
+ <Card
308
+ className={cn(
309
+ "border hover:shadow-md transition-all duration-200",
310
+ isDragging && "shadow-2xl ring-2 ring-primary ring-offset-2",
311
+ disabled && "cursor-not-allowed opacity-50"
312
+ )}
313
+ onClick={onClick}
314
+ >
315
+ {/* Drag handle */}
316
+ <div
317
+ className="absolute left-0 top-0 bottom-0 w-1 bg-gradient-to-b from-primary/20 to-primary/10 opacity-0 group-hover:opacity-100 transition-opacity cursor-move"
318
+ onPointerDown={(e) => dragControls.start(e)}
319
+ />
320
+
321
+ {/* Cover image */}
322
+ {card.coverImage && (
323
+ <div className="relative h-32 -mx-px -mt-px rounded-t-lg overflow-hidden">
324
+ <img
325
+ src={card.coverImage}
326
+ alt=""
327
+ className="w-full h-full object-cover"
328
+ />
329
+ <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent" />
330
+ </div>
331
+ )}
332
+
333
+ <CardContent className="p-3">
334
+ {/* Labels */}
335
+ {card.labels && card.labels.length > 0 && (
336
+ <div className="flex flex-wrap gap-1 mb-2">
337
+ {card.labels.map((label) => (
338
+ <div
339
+ key={label.id}
340
+ className="h-2 w-12 rounded-full"
341
+ style={{ backgroundColor: label.color }}
342
+ title={label.name}
343
+ />
344
+ ))}
345
+ </div>
346
+ )}
347
+
348
+ {/* Title and actions */}
349
+ <div className="flex items-start justify-between gap-2 mb-2">
350
+ <div className="flex-1">
351
+ {isEditingTitle ? (
352
+ <Input
353
+ value={title}
354
+ onChange={(e) => setTitle(e.target.value)}
355
+ onBlur={() => setIsEditingTitle(false)}
356
+ onKeyDown={(e) => {
357
+ if (e.key === 'Enter') {
358
+ setIsEditingTitle(false)
359
+ // Call update handler
360
+ }
361
+ if (e.key === 'Escape') {
362
+ setTitle(card.title)
363
+ setIsEditingTitle(false)
364
+ }
365
+ }}
366
+ className="h-6 px-1 py-0 text-sm font-medium"
367
+ autoFocus
368
+ onClick={(e) => e.stopPropagation()}
369
+ />
370
+ ) : (
371
+ <h4 className="font-medium text-sm line-clamp-2">{card.title}</h4>
372
+ )}
373
+ </div>
374
+
375
+ {/* Quick actions */}
376
+ <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
377
+ <DropdownMenu>
378
+ <DropdownMenuTrigger asChild>
379
+ <Button
380
+ variant="ghost"
381
+ size="sm"
382
+ className="h-6 w-6 p-0"
383
+ onClick={(e) => e.stopPropagation()}
384
+ >
385
+ <MoreVertical className="h-3 w-3" />
386
+ </Button>
387
+ </DropdownMenuTrigger>
388
+ <DropdownMenuContent align="end" className="w-48">
389
+ <DropdownMenuItem onClick={onEdit}>
390
+ <Edit className="mr-2 h-4 w-4" />
391
+ Edit
392
+ </DropdownMenuItem>
393
+ <DropdownMenuItem>
394
+ <Copy className="mr-2 h-4 w-4" />
395
+ Duplicate
396
+ </DropdownMenuItem>
397
+ <DropdownMenuItem>
398
+ <Move className="mr-2 h-4 w-4" />
399
+ Move
400
+ </DropdownMenuItem>
401
+ <DropdownMenuItem>
402
+ <Archive className="mr-2 h-4 w-4" />
403
+ Archive
404
+ </DropdownMenuItem>
405
+ <DropdownMenuSeparator />
406
+ <DropdownMenuItem
407
+ onClick={onDelete}
408
+ className="text-destructive"
409
+ >
410
+ <Trash2 className="mr-2 h-4 w-4" />
411
+ Delete
412
+ </DropdownMenuItem>
413
+ </DropdownMenuContent>
414
+ </DropdownMenu>
415
+ </div>
416
+ </div>
417
+
418
+ {/* Description */}
419
+ {card.description && (
420
+ <p className="text-xs text-muted-foreground mb-3 line-clamp-2">
421
+ {card.description}
422
+ </p>
423
+ )}
424
+
425
+ {/* Progress bar */}
426
+ {(card.progress !== undefined || card.checklist) && (
427
+ <div className="mb-3">
428
+ <div className="flex justify-between text-xs text-muted-foreground mb-1">
429
+ <span>Progress</span>
430
+ <span>{Math.round(card.progress || checklistProgress)}%</span>
431
+ </div>
432
+ <Progress value={card.progress || checklistProgress} className="h-1" />
433
+ </div>
434
+ )}
435
+
436
+ {/* Tags */}
437
+ {card.tags && card.tags.length > 0 && (
438
+ <div className="flex flex-wrap gap-1 mb-3">
439
+ {card.tags.map((tag, index) => (
440
+ <Badge key={index} variant="secondary" className="text-xs px-1.5 py-0">
441
+ {tag}
442
+ </Badge>
443
+ ))}
444
+ </div>
445
+ )}
446
+
447
+ {/* Card details */}
448
+ {showDetails && (
449
+ <div className="flex items-center justify-between text-xs">
450
+ <div className="flex items-center gap-2">
451
+ {/* Priority */}
452
+ {card.priority && (
453
+ <div className="flex items-center gap-1">
454
+ <div className={cn("w-2 h-2 rounded-full", PRIORITY_CONFIG[card.priority].dot)} />
455
+ <span className="capitalize">{card.priority}</span>
456
+ </div>
457
+ )}
458
+
459
+ {/* Due date */}
460
+ {card.dueDate && (
461
+ <div className={cn(
462
+ "flex items-center gap-1",
463
+ isOverdue(card.dueDate) && "text-destructive"
464
+ )}>
465
+ <Calendar className="h-3 w-3" />
466
+ <span>{formatDate(card.dueDate)}</span>
467
+ </div>
468
+ )}
469
+
470
+ {/* Checklist */}
471
+ {card.checklist && (
472
+ <div className="flex items-center gap-1">
473
+ <CheckSquare className="h-3 w-3" />
474
+ <span>{completedChecklistItems}/{totalChecklistItems}</span>
475
+ </div>
476
+ )}
477
+ </div>
478
+
479
+ <div className="flex items-center gap-2">
480
+ {/* Comments */}
481
+ {card.comments && card.comments > 0 && (
482
+ <div className="flex items-center gap-1">
483
+ <MessageCircle className="h-3 w-3" />
484
+ <span>{card.comments}</span>
485
+ </div>
486
+ )}
487
+
488
+ {/* Attachments */}
489
+ {card.attachments && card.attachments.length > 0 && (
490
+ <div className="flex items-center gap-1">
491
+ <Paperclip className="h-3 w-3" />
492
+ <span>{card.attachments.length}</span>
493
+ </div>
494
+ )}
495
+
496
+ {/* Assignees */}
497
+ {card.assignees && card.assignees.length > 0 && (
498
+ <MoonUIAvatarGroupPro max={3} size="xs">
499
+ {card.assignees.map((assignee) => (
500
+ <MoonUIAvatarPro key={assignee.id} className="h-5 w-5">
501
+ <MoonUIAvatarImagePro src={assignee.avatar} />
502
+ <MoonUIAvatarFallbackPro className="text-xs">
503
+ {getInitials(assignee.name)}
504
+ </MoonUIAvatarFallbackPro>
505
+ </MoonUIAvatarPro>
506
+ ))}
507
+ </MoonUIAvatarGroupPro>
508
+ )}
509
+ </div>
510
+ </div>
511
+ )}
512
+ </CardContent>
513
+ </Card>
514
+ </motion.div>
515
+ )
76
516
  }
77
517
 
518
+ // Main Kanban component
78
519
  export function Kanban({
79
- columns,
520
+ columns: initialColumns,
80
521
  onCardMove,
81
522
  onCardClick,
82
523
  onCardEdit,
83
524
  onCardDelete,
525
+ onCardUpdate,
84
526
  onAddCard,
85
527
  onAddColumn,
528
+ onColumnUpdate,
529
+ onColumnDelete,
530
+ onBulkAction,
531
+ onExport,
86
532
  className,
87
533
  showAddColumn = true,
88
534
  showCardDetails = true,
89
- disabled = false
535
+ showFilters = true,
536
+ showSearch = true,
537
+ enableKeyboardShortcuts = true,
538
+ cardTemplates = [],
539
+ columnTemplates = [],
540
+ filters = [],
541
+ defaultFilter,
542
+ loading = false,
543
+ disabled = false,
544
+ labels = [],
545
+ users = []
90
546
  }: KanbanProps) {
91
- // Check if we're in docs mode or have pro access
547
+ // Check pro access
92
548
  const { hasProAccess, isLoading } = useSubscription()
93
549
 
94
- // In docs mode, always show the component
95
-
96
- // If not in docs mode and no pro access, show upgrade prompt
97
550
  if (!isLoading && !hasProAccess) {
98
551
  return (
99
552
  <Card className={cn("w-full", className)}>
@@ -121,310 +574,637 @@ export function Kanban({
121
574
  </Card>
122
575
  )
123
576
  }
124
-
125
- const [draggedCard, setDraggedCard] = React.useState<string | null>(null)
126
- const [draggedOverColumn, setDraggedOverColumn] = React.useState<string | null>(null)
127
- const [draggedFromColumn, setDraggedFromColumn] = React.useState<string | null>(null)
128
577
 
129
- const handleDragStart = (e: React.DragEvent, cardId: string) => {
130
- if (disabled) return
131
-
132
- // Find which column this card belongs to
133
- const sourceColumn = columns.find(col =>
134
- col.cards.some(card => card.id === cardId)
135
- )
136
-
137
- setDraggedCard(cardId)
138
- setDraggedFromColumn(sourceColumn?.id || null)
139
- e.dataTransfer.effectAllowed = 'move'
140
- e.dataTransfer.setData('text/plain', cardId)
141
-
142
- // Add visual feedback
143
- e.currentTarget.classList.add('opacity-50')
144
- }
578
+ // State
579
+ const [columns, setColumns] = useState(initialColumns)
580
+ const [searchQuery, setSearchQuery] = useState('')
581
+ const [activeFilter, setActiveFilter] = useState(defaultFilter)
582
+ const [selectedCards, setSelectedCards] = useState<string[]>([])
583
+ const [draggedCard, setDraggedCard] = useState<string | null>(null)
584
+ const [draggedOverColumn, setDraggedOverColumn] = useState<string | null>(null)
585
+ const [isCreatingColumn, setIsCreatingColumn] = useState(false)
586
+ const [newColumnTitle, setNewColumnTitle] = useState('')
587
+
588
+ const { scrollRef, startAutoScroll, stopAutoScroll } = useAutoScroll()
145
589
 
146
- const handleDragEnd = (e: React.DragEvent) => {
590
+ // Filter cards based on search and filters
591
+ const filteredColumns = useMemo(() => {
592
+ if (!searchQuery && !activeFilter) return columns
593
+
594
+ return columns.map(column => ({
595
+ ...column,
596
+ cards: column.cards.filter(card => {
597
+ // Search filter
598
+ if (searchQuery) {
599
+ const query = searchQuery.toLowerCase()
600
+ const matchesSearch =
601
+ card.title.toLowerCase().includes(query) ||
602
+ card.description?.toLowerCase().includes(query) ||
603
+ card.tags?.some(tag => tag.toLowerCase().includes(query)) ||
604
+ card.assignees?.some(a => a.name.toLowerCase().includes(query))
605
+
606
+ if (!matchesSearch) return false
607
+ }
608
+
609
+ // Active filter
610
+ if (activeFilter) {
611
+ const filter = filters.find(f => f.id === activeFilter)
612
+ if (filter) {
613
+ // Apply filter logic here
614
+ // This is a simplified example
615
+ if (filter.assignees?.length && !card.assignees?.some(a => filter.assignees!.includes(a.id))) {
616
+ return false
617
+ }
618
+ if (filter.priority?.length && !filter.priority.includes(card.priority || '')) {
619
+ return false
620
+ }
621
+ if (filter.labels?.length && !card.labels?.some(l => filter.labels!.includes(l.id))) {
622
+ return false
623
+ }
624
+ }
625
+ }
626
+
627
+ return true
628
+ })
629
+ }))
630
+ }, [columns, searchQuery, activeFilter, filters])
631
+
632
+ // Keyboard shortcuts
633
+ useEffect(() => {
634
+ if (!enableKeyboardShortcuts) return
635
+
636
+ const handleKeyDown = (e: KeyboardEvent) => {
637
+ // Cmd/Ctrl + F: Focus search
638
+ if ((e.metaKey || e.ctrlKey) && e.key === 'f') {
639
+ e.preventDefault()
640
+ document.getElementById('kanban-search')?.focus()
641
+ }
642
+
643
+ // Cmd/Ctrl + N: Add new card
644
+ if ((e.metaKey || e.ctrlKey) && e.key === 'n') {
645
+ e.preventDefault()
646
+ // Add to first column by default
647
+ const firstColumn = columns[0]
648
+ if (firstColumn && onAddCard) {
649
+ onAddCard(firstColumn.id)
650
+ }
651
+ }
652
+
653
+ // Escape: Clear selection
654
+ if (e.key === 'Escape') {
655
+ setSelectedCards([])
656
+ }
657
+ }
658
+
659
+ window.addEventListener('keydown', handleKeyDown)
660
+ return () => window.removeEventListener('keydown', handleKeyDown)
661
+ }, [enableKeyboardShortcuts, columns, onAddCard])
662
+
663
+ // Drag handlers
664
+ const handleDragStart = (card: KanbanCard, columnId: string) => {
147
665
  if (disabled) return
148
-
149
- // Reset all states
150
- setDraggedCard(null)
151
- setDraggedOverColumn(null)
152
- setDraggedFromColumn(null)
153
-
154
- // Remove visual feedback
155
- e.currentTarget.classList.remove('opacity-50')
666
+ setDraggedCard(card.id)
156
667
  }
157
668
 
158
669
  const handleDragOver = (e: React.DragEvent, columnId: string) => {
159
670
  if (disabled) return
160
671
  e.preventDefault()
161
- e.dataTransfer.dropEffect = 'move'
162
672
  setDraggedOverColumn(columnId)
163
- }
164
673
 
165
- const handleDragEnter = (e: React.DragEvent, columnId: string) => {
166
- if (disabled) return
167
- e.preventDefault()
168
- setDraggedOverColumn(columnId)
169
- }
674
+ // Auto-scroll logic
675
+ const container = scrollRef.current
676
+ if (!container) return
170
677
 
171
- const handleDragLeave = (e: React.DragEvent, columnId: string) => {
172
- if (disabled) return
173
- e.preventDefault()
174
-
175
- // Only clear if we're leaving the column entirely
176
- const rect = e.currentTarget.getBoundingClientRect()
177
- const isLeavingColumn = (
178
- e.clientX < rect.left ||
179
- e.clientX > rect.right ||
180
- e.clientY < rect.top ||
181
- e.clientY > rect.bottom
182
- )
183
-
184
- if (isLeavingColumn) {
185
- setDraggedOverColumn(null)
678
+ const rect = container.getBoundingClientRect()
679
+ const x = e.clientX
680
+
681
+ if (x < rect.left + 100) {
682
+ startAutoScroll('left')
683
+ } else if (x > rect.right - 100) {
684
+ startAutoScroll('right')
685
+ } else {
686
+ stopAutoScroll()
186
687
  }
187
688
  }
188
689
 
189
- const handleDrop = (e: React.DragEvent, columnId: string) => {
190
- if (disabled) return
191
- e.preventDefault()
192
-
193
- const cardId = e.dataTransfer.getData('text/plain') || draggedCard
194
-
195
- if (cardId && onCardMove && draggedFromColumn && draggedFromColumn !== columnId) {
196
- const targetColumn = columns.find(col => col.id === columnId)
197
- const newIndex = targetColumn?.cards.length || 0
198
- onCardMove(cardId, draggedFromColumn, columnId, newIndex)
199
- }
200
-
201
- // Reset all states
690
+ const handleDragEnd = () => {
202
691
  setDraggedCard(null)
203
692
  setDraggedOverColumn(null)
204
- setDraggedFromColumn(null)
693
+ stopAutoScroll()
205
694
  }
206
695
 
207
- const handleCardClick = (card: KanbanCard) => {
208
- if (disabled) return
209
- onCardClick?.(card)
210
- }
696
+ const handleDrop = (e: React.DragEvent, targetColumnId: string, targetIndex: number) => {
697
+ if (disabled || !draggedCard || !onCardMove) return
698
+ e.preventDefault()
211
699
 
212
- const handleCardEdit = (card: KanbanCard, e: React.MouseEvent) => {
213
- if (disabled) return
214
- e.stopPropagation()
215
- onCardEdit?.(card)
216
- }
700
+ // Find source column and card
701
+ let sourceColumnId: string | null = null
702
+ let sourceCard: KanbanCard | null = null
217
703
 
218
- const handleCardDelete = (card: KanbanCard, e: React.MouseEvent) => {
219
- if (disabled) return
220
- e.stopPropagation()
221
- onCardDelete?.(card)
704
+ for (const column of columns) {
705
+ const card = column.cards.find(c => c.id === draggedCard)
706
+ if (card) {
707
+ sourceColumnId = column.id
708
+ sourceCard = card
709
+ break
710
+ }
711
+ }
712
+
713
+ if (sourceColumnId && sourceCard) {
714
+ onCardMove(draggedCard, sourceColumnId, targetColumnId, targetIndex)
715
+ }
716
+
717
+ handleDragEnd()
222
718
  }
223
719
 
224
- const formatDate = (date: Date) => {
225
- return date.toLocaleDateString('en-US', {
226
- month: 'short',
227
- day: 'numeric'
228
- })
720
+ // Bulk actions
721
+ const handleBulkAction = (action: string) => {
722
+ if (onBulkAction && selectedCards.length > 0) {
723
+ onBulkAction(action, selectedCards)
724
+ setSelectedCards([])
725
+ }
229
726
  }
230
727
 
231
- const isOverdue = (dueDate: Date) => {
232
- return dueDate < new Date()
728
+ // Column actions
729
+ const handleColumnAction = (column: KanbanColumn, action: string) => {
730
+ switch (action) {
731
+ case 'rename':
732
+ // Implement inline rename
733
+ break
734
+ case 'delete':
735
+ onColumnDelete?.(column.id)
736
+ break
737
+ case 'collapse':
738
+ onColumnUpdate?.({ ...column, collapsed: !column.collapsed })
739
+ break
740
+ case 'setLimit':
741
+ // Implement WIP limit dialog
742
+ break
743
+ }
233
744
  }
234
745
 
235
- const getInitials = (name: string) => {
236
- return name.split(' ').map(n => n[0]).join('').toUpperCase()
746
+ // Loading state
747
+ if (loading) {
748
+ return (
749
+ <div className={cn("w-full", className)}>
750
+ <div className="flex gap-6 overflow-x-auto pb-4">
751
+ {[1, 2, 3].map((i) => (
752
+ <div key={i} className="flex-shrink-0 w-80">
753
+ <Card>
754
+ <CardHeader>
755
+ <Skeleton className="h-4 w-24" />
756
+ </CardHeader>
757
+ <CardContent className="space-y-3">
758
+ {[1, 2, 3].map((j) => (
759
+ <Skeleton key={j} className="h-24 w-full" />
760
+ ))}
761
+ </CardContent>
762
+ </Card>
763
+ </div>
764
+ ))}
765
+ </div>
766
+ </div>
767
+ )
237
768
  }
238
769
 
239
770
  return (
240
771
  <div className={cn("w-full", className)}>
241
- <div className="flex gap-6 overflow-x-auto pb-4">
242
- {columns.map((column) => {
243
- const isOverLimit = column.limit && column.cards.length > column.limit
244
- const isDraggedOver = draggedOverColumn === column.id
245
-
246
- return (
247
- <div
248
- key={column.id}
249
- className={cn(
250
- "flex-shrink-0 w-80 transition-colors duration-200",
251
- isDraggedOver && "bg-primary/5 rounded-lg border-2 border-primary/20"
252
- )}
253
- onDragOver={(e) => handleDragOver(e, column.id)}
254
- onDragEnter={(e) => handleDragEnter(e, column.id)}
255
- onDragLeave={(e) => handleDragLeave(e, column.id)}
256
- onDrop={(e) => handleDrop(e, column.id)}
257
- >
258
- <Card className="h-full">
259
- <CardHeader className="pb-3">
260
- <div className="flex items-center justify-between">
261
- <div className="flex items-center gap-2">
262
- {column.color && (
263
- <div
264
- className="w-3 h-3 rounded-full"
265
- style={{ backgroundColor: column.color }}
266
- />
772
+ {/* Header with search and filters */}
773
+ {(showSearch || showFilters) && (
774
+ <div className="mb-6 space-y-4">
775
+ <div className="flex items-center justify-between gap-4">
776
+ {/* Search */}
777
+ {showSearch && (
778
+ <div className="relative flex-1 max-w-md">
779
+ <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
780
+ <Input
781
+ id="kanban-search"
782
+ placeholder="Search cards..."
783
+ value={searchQuery}
784
+ onChange={(e) => setSearchQuery(e.target.value)}
785
+ className="pl-9"
786
+ />
787
+ </div>
788
+ )}
789
+
790
+ {/* Actions */}
791
+ <div className="flex items-center gap-2">
792
+ {/* Filters */}
793
+ {showFilters && filters.length > 0 && (
794
+ <DropdownMenu>
795
+ <DropdownMenuTrigger asChild>
796
+ <Button variant="outline" size="sm">
797
+ <Filter className="mr-2 h-4 w-4" />
798
+ Filter
799
+ {activeFilter && (
800
+ <Badge variant="secondary" className="ml-2">
801
+ {filters.find(f => f.id === activeFilter)?.name}
802
+ </Badge>
267
803
  )}
268
- <CardTitle className="text-sm font-medium">
269
- {column.title}
270
- </CardTitle>
271
- <Badge variant="secondary" className="text-xs">
272
- {column.cards.length}
273
- </Badge>
274
- </div>
275
- <Button variant="ghost" size="sm">
276
- <MoreHorizontal className="h-4 w-4" />
277
804
  </Button>
278
- </div>
279
- {column.limit && (
280
- <CardDescription className={cn(
281
- "text-xs",
282
- isOverLimit && "text-destructive"
283
- )}>
284
- {isOverLimit ? 'Over limit' : `${column.cards.length}/${column.limit} cards`}
285
- </CardDescription>
286
- )}
287
- </CardHeader>
288
-
289
- <CardContent className="space-y-3">
290
- {/* Cards */}
291
- {column.cards.map((card) => (
292
- <div
293
- key={card.id}
294
- draggable={!disabled}
295
- onDragStart={(e) => handleDragStart(e, card.id)}
296
- onDragEnd={handleDragEnd}
297
- onClick={() => handleCardClick(card)}
298
- className={cn(
299
- "p-3 bg-background border rounded-lg cursor-pointer hover:shadow-md transition-all duration-200",
300
- "group relative select-none",
301
- draggedCard === card.id && "opacity-50 scale-95",
302
- disabled && "cursor-not-allowed"
303
- )}
304
- >
305
- <div className="flex items-start justify-between gap-2">
306
- <div className="flex-1">
307
- <h4 className="font-medium text-sm mb-1">{card.title}</h4>
308
- {card.description && (
309
- <p className="text-xs text-muted-foreground mb-2 line-clamp-2">
310
- {card.description}
311
- </p>
805
+ </DropdownMenuTrigger>
806
+ <DropdownMenuContent align="end" className="w-48">
807
+ <DropdownMenuLabel>Quick Filters</DropdownMenuLabel>
808
+ <DropdownMenuSeparator />
809
+ <DropdownMenuItem onClick={() => setActiveFilter(undefined)}>
810
+ <X className="mr-2 h-4 w-4" />
811
+ Clear filter
812
+ </DropdownMenuItem>
813
+ {filters.map((filter) => (
814
+ <DropdownMenuItem
815
+ key={filter.id}
816
+ onClick={() => setActiveFilter(filter.id)}
817
+ >
818
+ <Check
819
+ className={cn(
820
+ "mr-2 h-4 w-4",
821
+ activeFilter === filter.id ? "opacity-100" : "opacity-0"
312
822
  )}
313
- </div>
314
- <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
315
- <Button
316
- variant="ghost"
317
- size="sm"
318
- className="h-6 w-6 p-0"
319
- onClick={(e) => handleCardEdit(card, e)}
320
- >
321
- <Edit className="h-3 w-3" />
322
- </Button>
323
- <Button
324
- variant="ghost"
325
- size="sm"
326
- className="h-6 w-6 p-0"
327
- onClick={(e) => handleCardDelete(card, e)}
328
- >
329
- <Trash2 className="h-3 w-3" />
330
- </Button>
331
- <div className="cursor-move">
332
- <GripVertical className="h-3 w-3 text-muted-foreground" />
333
- </div>
334
- </div>
335
- </div>
823
+ />
824
+ {filter.name}
825
+ </DropdownMenuItem>
826
+ ))}
827
+ </DropdownMenuContent>
828
+ </DropdownMenu>
829
+ )}
336
830
 
337
- {/* Tags */}
338
- {card.tags && card.tags.length > 0 && (
339
- <div className="flex flex-wrap gap-1 mb-2">
340
- {card.tags.map((tag, index) => (
341
- <Badge key={index} variant="outline" className="text-xs px-1 py-0">
342
- {tag}
343
- </Badge>
344
- ))}
345
- </div>
346
- )}
831
+ {/* Bulk actions */}
832
+ {selectedCards.length > 0 && (
833
+ <DropdownMenu>
834
+ <DropdownMenuTrigger asChild>
835
+ <Button variant="outline" size="sm">
836
+ <span className="mr-2">{selectedCards.length} selected</span>
837
+ <ChevronDown className="h-4 w-4" />
838
+ </Button>
839
+ </DropdownMenuTrigger>
840
+ <DropdownMenuContent align="end">
841
+ <DropdownMenuItem onClick={() => handleBulkAction('move')}>
842
+ <Move className="mr-2 h-4 w-4" />
843
+ Move cards
844
+ </DropdownMenuItem>
845
+ <DropdownMenuItem onClick={() => handleBulkAction('archive')}>
846
+ <Archive className="mr-2 h-4 w-4" />
847
+ Archive cards
848
+ </DropdownMenuItem>
849
+ <DropdownMenuItem onClick={() => handleBulkAction('delete')}>
850
+ <Trash2 className="mr-2 h-4 w-4" />
851
+ Delete cards
852
+ </DropdownMenuItem>
853
+ </DropdownMenuContent>
854
+ </DropdownMenu>
855
+ )}
347
856
 
348
- {/* Card Details */}
349
- {showCardDetails && (
350
- <div className="flex items-center justify-between text-xs text-muted-foreground">
351
- <div className="flex items-center gap-2">
352
- {card.priority && (
353
- <div className="flex items-center gap-1">
354
- <div className={cn("w-2 h-2 rounded-full", PRIORITY_DOTS[card.priority])} />
355
- <span className="capitalize">{card.priority}</span>
356
- </div>
357
- )}
358
- {card.dueDate && (
359
- <div className={cn(
360
- "flex items-center gap-1",
361
- isOverdue(card.dueDate) && "text-destructive"
362
- )}>
363
- <Calendar className="h-3 w-3" />
364
- <span>{formatDate(card.dueDate)}</span>
365
- </div>
366
- )}
367
- </div>
368
-
369
- <div className="flex items-center gap-2">
370
- {card.comments && card.comments > 0 && (
371
- <div className="flex items-center gap-1">
372
- <MessageCircle className="h-3 w-3" />
373
- <span>{card.comments}</span>
374
- </div>
857
+ {/* Export */}
858
+ {onExport && (
859
+ <DropdownMenu>
860
+ <DropdownMenuTrigger asChild>
861
+ <Button variant="outline" size="sm">
862
+ <Download className="mr-2 h-4 w-4" />
863
+ Export
864
+ </Button>
865
+ </DropdownMenuTrigger>
866
+ <DropdownMenuContent align="end">
867
+ <DropdownMenuItem onClick={() => onExport('json')}>
868
+ Export as JSON
869
+ </DropdownMenuItem>
870
+ <DropdownMenuItem onClick={() => onExport('csv')}>
871
+ Export as CSV
872
+ </DropdownMenuItem>
873
+ </DropdownMenuContent>
874
+ </DropdownMenu>
875
+ )}
876
+ </div>
877
+ </div>
878
+
879
+ {/* Active filters display */}
880
+ {activeFilter && (
881
+ <div className="flex items-center gap-2">
882
+ <span className="text-sm text-muted-foreground">Active filters:</span>
883
+ <Badge variant="secondary">
884
+ {filters.find(f => f.id === activeFilter)?.name}
885
+ <Button
886
+ variant="ghost"
887
+ size="sm"
888
+ className="ml-1 h-auto p-0"
889
+ onClick={() => setActiveFilter(undefined)}
890
+ >
891
+ <X className="h-3 w-3" />
892
+ </Button>
893
+ </Badge>
894
+ </div>
895
+ )}
896
+ </div>
897
+ )}
898
+
899
+ {/* Kanban board */}
900
+ <div
901
+ ref={scrollRef}
902
+ className="flex gap-6 overflow-x-auto pb-4"
903
+ onDragOver={(e) => e.preventDefault()}
904
+ >
905
+ <AnimatePresence mode="sync">
906
+ {filteredColumns.map((column) => {
907
+ const isOverLimit = column.limit && column.cards.length >= column.limit
908
+ const isDraggedOver = draggedOverColumn === column.id
909
+
910
+ return (
911
+ <motion.div
912
+ key={column.id}
913
+ layout
914
+ initial={{ opacity: 0, x: -20 }}
915
+ animate={{ opacity: 1, x: 0 }}
916
+ exit={{ opacity: 0, x: 20 }}
917
+ className={cn(
918
+ "flex-shrink-0 w-80 transition-all duration-200",
919
+ isDraggedOver && "scale-105"
920
+ )}
921
+ onDragOver={(e) => handleDragOver(e, column.id)}
922
+ onDragLeave={() => setDraggedOverColumn(null)}
923
+ onDrop={(e) => handleDrop(e, column.id, column.cards.length)}
924
+ >
925
+ <Card className={cn(
926
+ "h-full transition-all duration-200",
927
+ isDraggedOver && "ring-2 ring-primary ring-offset-2 bg-primary/5",
928
+ column.collapsed && "opacity-60"
929
+ )}>
930
+ <CardHeader className="pb-3">
931
+ <div className="flex items-center justify-between">
932
+ <div className="flex items-center gap-2">
933
+ {/* Column color indicator */}
934
+ {column.color && (
935
+ <div
936
+ className="w-3 h-3 rounded-full"
937
+ style={{ backgroundColor: column.color }}
938
+ />
939
+ )}
940
+
941
+ {/* Column title */}
942
+ <CardTitle className="text-sm font-medium flex items-center gap-2">
943
+ {column.title}
944
+ {column.locked && <Lock className="h-3 w-3" />}
945
+ </CardTitle>
946
+
947
+ {/* Card count */}
948
+ <Badge variant="secondary" className="text-xs">
949
+ {column.cards.length}
950
+ </Badge>
951
+ </div>
952
+
953
+ {/* Column actions */}
954
+ <DropdownMenu>
955
+ <DropdownMenuTrigger asChild>
956
+ <Button variant="ghost" size="sm" className="h-6 w-6 p-0">
957
+ <MoreHorizontal className="h-4 w-4" />
958
+ </Button>
959
+ </DropdownMenuTrigger>
960
+ <DropdownMenuContent align="end">
961
+ <DropdownMenuItem onClick={() => handleColumnAction(column, 'rename')}>
962
+ <Edit className="mr-2 h-4 w-4" />
963
+ Rename
964
+ </DropdownMenuItem>
965
+ <DropdownMenuItem onClick={() => handleColumnAction(column, 'collapse')}>
966
+ {column.collapsed ? (
967
+ <>
968
+ <Eye className="mr-2 h-4 w-4" />
969
+ Expand
970
+ </>
971
+ ) : (
972
+ <>
973
+ <EyeOff className="mr-2 h-4 w-4" />
974
+ Collapse
975
+ </>
375
976
  )}
376
- {card.attachments && card.attachments > 0 && (
377
- <div className="flex items-center gap-1">
378
- <Paperclip className="h-3 w-3" />
379
- <span>{card.attachments}</span>
977
+ </DropdownMenuItem>
978
+ <DropdownMenuSeparator />
979
+ <DropdownMenuSub>
980
+ <DropdownMenuSubTrigger>
981
+ <Settings className="mr-2 h-4 w-4" />
982
+ Settings
983
+ </DropdownMenuSubTrigger>
984
+ <DropdownMenuSubContent>
985
+ <DropdownMenuItem onClick={() => handleColumnAction(column, 'setLimit')}>
986
+ <Timer className="mr-2 h-4 w-4" />
987
+ Set WIP limit
988
+ </DropdownMenuItem>
989
+ <DropdownMenuItem>
990
+ <Palette className="mr-2 h-4 w-4" />
991
+ Change color
992
+ </DropdownMenuItem>
993
+ <DropdownMenuItem>
994
+ <ArrowUpDown className="mr-2 h-4 w-4" />
995
+ Sort cards
996
+ </DropdownMenuItem>
997
+ </DropdownMenuSubContent>
998
+ </DropdownMenuSub>
999
+ <DropdownMenuSeparator />
1000
+ <DropdownMenuItem
1001
+ onClick={() => handleColumnAction(column, 'delete')}
1002
+ className="text-destructive"
1003
+ >
1004
+ <Trash2 className="mr-2 h-4 w-4" />
1005
+ Delete column
1006
+ </DropdownMenuItem>
1007
+ </DropdownMenuContent>
1008
+ </DropdownMenu>
1009
+ </div>
1010
+
1011
+ {/* WIP limit warning */}
1012
+ {column.limit && (
1013
+ <CardDescription className={cn(
1014
+ "text-xs flex items-center gap-1 mt-1",
1015
+ isOverLimit && "text-destructive"
1016
+ )}>
1017
+ {isOverLimit ? (
1018
+ <>
1019
+ <AlertCircle className="h-3 w-3" />
1020
+ Over WIP limit ({column.cards.length}/{column.limit})
1021
+ </>
1022
+ ) : (
1023
+ `WIP limit: ${column.cards.length}/${column.limit}`
1024
+ )}
1025
+ </CardDescription>
1026
+ )}
1027
+ </CardHeader>
1028
+
1029
+ {!column.collapsed && (
1030
+ <CardContent className="space-y-3">
1031
+ <ScrollArea className="h-[calc(100vh-300px)]">
1032
+ <AnimatePresence mode="popLayout">
1033
+ {column.cards
1034
+ .sort((a, b) => a.position - b.position)
1035
+ .map((card, index) => (
1036
+ <div
1037
+ key={card.id}
1038
+ draggable={!disabled}
1039
+ onDragStart={() => handleDragStart(card, column.id)}
1040
+ onDragEnd={handleDragEnd}
1041
+ onDrop={(e) => {
1042
+ e.preventDefault()
1043
+ e.stopPropagation()
1044
+ handleDrop(e, column.id, index)
1045
+ }}
1046
+ className="mb-3"
1047
+ >
1048
+ <KanbanCardComponent
1049
+ card={card}
1050
+ isDragging={draggedCard === card.id}
1051
+ onEdit={(e) => {
1052
+ e.stopPropagation()
1053
+ onCardEdit?.(card)
1054
+ }}
1055
+ onDelete={(e) => {
1056
+ e.stopPropagation()
1057
+ onCardDelete?.(card)
1058
+ }}
1059
+ onClick={() => onCardClick?.(card)}
1060
+ showDetails={showCardDetails}
1061
+ disabled={disabled}
1062
+ />
380
1063
  </div>
381
- )}
382
- {card.assignee && (
383
- <MoonUIAvatarPro className="h-5 w-5">
384
- <MoonUIAvatarImagePro src={card.assignee.avatar} />
385
- <MoonUIAvatarFallbackPro className="text-xs">
386
- {getInitials(card.assignee.name)}
387
- </MoonUIAvatarFallbackPro>
388
- </MoonUIAvatarPro>
389
- )}
390
- </div>
391
- </div>
1064
+ ))}
1065
+ </AnimatePresence>
1066
+ </ScrollArea>
1067
+
1068
+ {/* Add card button */}
1069
+ {onAddCard && !column.locked && !isOverLimit && (
1070
+ <DropdownMenu>
1071
+ <DropdownMenuTrigger asChild>
1072
+ <Button
1073
+ variant="ghost"
1074
+ size="sm"
1075
+ className="w-full justify-start text-muted-foreground hover:text-foreground"
1076
+ disabled={disabled}
1077
+ >
1078
+ <Plus className="h-4 w-4 mr-2" />
1079
+ Add card
1080
+ </Button>
1081
+ </DropdownMenuTrigger>
1082
+ <DropdownMenuContent align="start" className="w-48">
1083
+ <DropdownMenuLabel>Card Templates</DropdownMenuLabel>
1084
+ <DropdownMenuSeparator />
1085
+ <DropdownMenuItem onClick={() => onAddCard(column.id)}>
1086
+ <FileText className="mr-2 h-4 w-4" />
1087
+ Blank card
1088
+ </DropdownMenuItem>
1089
+ {cardTemplates.map((template, index) => (
1090
+ <DropdownMenuItem
1091
+ key={index}
1092
+ onClick={() => onAddCard(column.id, template)}
1093
+ >
1094
+ <Star className="mr-2 h-4 w-4" />
1095
+ {template.title || `Template ${index + 1}`}
1096
+ </DropdownMenuItem>
1097
+ ))}
1098
+ </DropdownMenuContent>
1099
+ </DropdownMenu>
392
1100
  )}
393
- </div>
394
- ))}
1101
+ </CardContent>
1102
+ )}
1103
+ </Card>
1104
+ </motion.div>
1105
+ )
1106
+ })}
1107
+ </AnimatePresence>
395
1108
 
396
- {/* Add Card Button */}
397
- {onAddCard && (
1109
+ {/* Add column */}
1110
+ {showAddColumn && onAddColumn && (
1111
+ <motion.div
1112
+ initial={{ opacity: 0 }}
1113
+ animate={{ opacity: 1 }}
1114
+ className="flex-shrink-0 w-80"
1115
+ >
1116
+ {isCreatingColumn ? (
1117
+ <Card>
1118
+ <CardHeader>
1119
+ <Input
1120
+ placeholder="Enter column title..."
1121
+ value={newColumnTitle}
1122
+ onChange={(e) => setNewColumnTitle(e.target.value)}
1123
+ onKeyDown={(e) => {
1124
+ if (e.key === 'Enter' && newColumnTitle) {
1125
+ onAddColumn({ title: newColumnTitle })
1126
+ setNewColumnTitle('')
1127
+ setIsCreatingColumn(false)
1128
+ }
1129
+ if (e.key === 'Escape') {
1130
+ setNewColumnTitle('')
1131
+ setIsCreatingColumn(false)
1132
+ }
1133
+ }}
1134
+ autoFocus
1135
+ />
1136
+ </CardHeader>
1137
+ <CardContent>
1138
+ <div className="flex gap-2">
398
1139
  <Button
399
- variant="ghost"
400
1140
  size="sm"
401
- onClick={() => onAddCard(column.id)}
402
- className="w-full justify-start text-muted-foreground hover:text-foreground"
403
- disabled={disabled}
1141
+ onClick={() => {
1142
+ if (newColumnTitle) {
1143
+ onAddColumn({ title: newColumnTitle })
1144
+ setNewColumnTitle('')
1145
+ setIsCreatingColumn(false)
1146
+ }
1147
+ }}
404
1148
  >
405
- <Plus className="h-4 w-4 mr-2" />
406
- Add card
1149
+ Add column
407
1150
  </Button>
408
- )}
1151
+ <Button
1152
+ size="sm"
1153
+ variant="ghost"
1154
+ onClick={() => {
1155
+ setNewColumnTitle('')
1156
+ setIsCreatingColumn(false)
1157
+ }}
1158
+ >
1159
+ Cancel
1160
+ </Button>
1161
+ </div>
409
1162
  </CardContent>
410
1163
  </Card>
411
- </div>
412
- )
413
- })}
414
-
415
- {/* Add Column Button */}
416
- {showAddColumn && onAddColumn && (
417
- <div className="flex-shrink-0 w-80">
418
- <Button
419
- variant="outline"
420
- onClick={onAddColumn}
421
- className="w-full h-full min-h-[200px] border-dashed justify-center items-center"
422
- disabled={disabled}
423
- >
424
- <Plus className="h-6 w-6 mr-2" />
425
- Add column
426
- </Button>
427
- </div>
1164
+ ) : (
1165
+ <DropdownMenu>
1166
+ <DropdownMenuTrigger asChild>
1167
+ <Button
1168
+ variant="outline"
1169
+ className="w-full h-full min-h-[200px] border-dashed justify-center items-center"
1170
+ disabled={disabled}
1171
+ >
1172
+ <Plus className="h-6 w-6 mr-2" />
1173
+ Add column
1174
+ </Button>
1175
+ </DropdownMenuTrigger>
1176
+ <DropdownMenuContent align="start" className="w-48">
1177
+ <DropdownMenuLabel>Column Templates</DropdownMenuLabel>
1178
+ <DropdownMenuSeparator />
1179
+ <DropdownMenuItem onClick={() => setIsCreatingColumn(true)}>
1180
+ <Plus className="mr-2 h-4 w-4" />
1181
+ Blank column
1182
+ </DropdownMenuItem>
1183
+ <DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.todo)}>
1184
+ <Square className="mr-2 h-4 w-4" />
1185
+ To Do
1186
+ </DropdownMenuItem>
1187
+ <DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.inProgress)}>
1188
+ <Clock className="mr-2 h-4 w-4" />
1189
+ In Progress
1190
+ </DropdownMenuItem>
1191
+ <DropdownMenuItem onClick={() => onAddColumn(COLUMN_TEMPLATES.done)}>
1192
+ <CheckSquare className="mr-2 h-4 w-4" />
1193
+ Done
1194
+ </DropdownMenuItem>
1195
+ {columnTemplates.map((template, index) => (
1196
+ <DropdownMenuItem
1197
+ key={index}
1198
+ onClick={() => onAddColumn(template)}
1199
+ >
1200
+ <Star className="mr-2 h-4 w-4" />
1201
+ {template.title || `Template ${index + 1}`}
1202
+ </DropdownMenuItem>
1203
+ ))}
1204
+ </DropdownMenuContent>
1205
+ </DropdownMenu>
1206
+ )}
1207
+ </motion.div>
428
1208
  )}
429
1209
  </div>
430
1210
  </div>