@nextsparkjs/theme-productivity 0.1.0-beta.1
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.
- package/README.md +76 -0
- package/about.md +123 -0
- package/components/CardDetailModal.tsx +318 -0
- package/components/KanbanBoard.tsx +612 -0
- package/components/KanbanCard.tsx +218 -0
- package/components/KanbanColumn.tsx +264 -0
- package/components/SortableList.tsx +46 -0
- package/components/index.ts +4 -0
- package/config/app.config.ts +172 -0
- package/config/billing.config.ts +187 -0
- package/config/dashboard.config.ts +357 -0
- package/config/dev.config.ts +55 -0
- package/config/features.config.ts +256 -0
- package/config/flows.config.ts +484 -0
- package/config/permissions.config.ts +167 -0
- package/config/theme.config.ts +106 -0
- package/entities/boards/boards.config.ts +61 -0
- package/entities/boards/boards.fields.ts +154 -0
- package/entities/boards/boards.service.ts +256 -0
- package/entities/boards/boards.types.ts +57 -0
- package/entities/boards/messages/en.json +80 -0
- package/entities/boards/messages/es.json +80 -0
- package/entities/boards/migrations/001_boards_table.sql +83 -0
- package/entities/cards/cards.config.ts +61 -0
- package/entities/cards/cards.fields.ts +242 -0
- package/entities/cards/cards.service.ts +336 -0
- package/entities/cards/cards.types.ts +79 -0
- package/entities/cards/messages/en.json +114 -0
- package/entities/cards/messages/es.json +114 -0
- package/entities/cards/migrations/020_cards_table.sql +92 -0
- package/entities/lists/lists.config.ts +61 -0
- package/entities/lists/lists.fields.ts +105 -0
- package/entities/lists/lists.service.ts +252 -0
- package/entities/lists/lists.types.ts +55 -0
- package/entities/lists/messages/en.json +60 -0
- package/entities/lists/messages/es.json +60 -0
- package/entities/lists/migrations/010_lists_table.sql +79 -0
- package/lib/selectors.ts +206 -0
- package/messages/en.json +79 -0
- package/messages/es.json +79 -0
- package/migrations/999_theme_sample_data.sql +922 -0
- package/migrations/999a_initial_sample_data.sql +377 -0
- package/migrations/999b_abundant_sample_data.sql +346 -0
- package/package.json +17 -0
- package/permissions-matrix.md +122 -0
- package/styles/components.css +460 -0
- package/styles/globals.css +560 -0
- package/templates/dashboard/(main)/boards/[id]/[cardId]/page.tsx +238 -0
- package/templates/dashboard/(main)/boards/[id]/edit/page.tsx +390 -0
- package/templates/dashboard/(main)/boards/[id]/page.tsx +236 -0
- package/templates/dashboard/(main)/boards/create/page.tsx +236 -0
- package/templates/dashboard/(main)/boards/page.tsx +335 -0
- package/templates/dashboard/(main)/layout.tsx +32 -0
- package/templates/dashboard/(main)/page.tsx +592 -0
- package/templates/shared/ProductivityMobileNav.tsx +410 -0
- package/templates/shared/ProductivitySidebar.tsx +538 -0
- package/templates/shared/ProductivityTopBar.tsx +317 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSortable } from '@dnd-kit/sortable'
|
|
4
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
5
|
+
import { Calendar, Clock, MessageSquare, Paperclip, CheckSquare, AlertCircle } from 'lucide-react'
|
|
6
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
7
|
+
|
|
8
|
+
export interface CardData {
|
|
9
|
+
id: string
|
|
10
|
+
title: string
|
|
11
|
+
description?: string | null
|
|
12
|
+
priority?: 'low' | 'medium' | 'high' | 'urgent' | null
|
|
13
|
+
status?: string | null
|
|
14
|
+
dueDate?: string | null
|
|
15
|
+
position?: number
|
|
16
|
+
listId: string
|
|
17
|
+
labels?: string[]
|
|
18
|
+
assigneeId?: string | null
|
|
19
|
+
commentsCount?: number
|
|
20
|
+
attachmentsCount?: number
|
|
21
|
+
checklistProgress?: { completed: number; total: number } | null
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface KanbanCardProps {
|
|
25
|
+
card: CardData
|
|
26
|
+
onClick?: () => void
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Priority color bar classes
|
|
30
|
+
const priorityBarColors: Record<string, string> = {
|
|
31
|
+
low: 'bg-slate-400',
|
|
32
|
+
medium: 'bg-blue-500',
|
|
33
|
+
high: 'bg-orange-500',
|
|
34
|
+
urgent: 'bg-red-500',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Label color mapping
|
|
38
|
+
const labelColors: Record<string, { bg: string; text: string }> = {
|
|
39
|
+
bug: { bg: 'bg-red-500', text: 'text-white' },
|
|
40
|
+
feature: { bg: 'bg-blue-500', text: 'text-white' },
|
|
41
|
+
enhancement: { bg: 'bg-green-500', text: 'text-white' },
|
|
42
|
+
documentation: { bg: 'bg-purple-500', text: 'text-white' },
|
|
43
|
+
urgent: { bg: 'bg-red-600', text: 'text-white' },
|
|
44
|
+
important: { bg: 'bg-orange-500', text: 'text-white' },
|
|
45
|
+
design: { bg: 'bg-pink-500', text: 'text-white' },
|
|
46
|
+
backend: { bg: 'bg-indigo-500', text: 'text-white' },
|
|
47
|
+
frontend: { bg: 'bg-cyan-500', text: 'text-white' },
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function KanbanCard({ card, onClick }: KanbanCardProps) {
|
|
51
|
+
const {
|
|
52
|
+
attributes,
|
|
53
|
+
listeners,
|
|
54
|
+
setNodeRef,
|
|
55
|
+
transform,
|
|
56
|
+
transition,
|
|
57
|
+
isDragging,
|
|
58
|
+
} = useSortable({
|
|
59
|
+
id: card.id,
|
|
60
|
+
data: {
|
|
61
|
+
type: 'card',
|
|
62
|
+
card,
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const style = {
|
|
67
|
+
transform: CSS.Transform.toString(transform),
|
|
68
|
+
transition,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const formatDate = (dateString: string) => {
|
|
72
|
+
const date = new Date(dateString)
|
|
73
|
+
const today = new Date()
|
|
74
|
+
const tomorrow = new Date(today)
|
|
75
|
+
tomorrow.setDate(tomorrow.getDate() + 1)
|
|
76
|
+
|
|
77
|
+
// Check if it's today
|
|
78
|
+
if (date.toDateString() === today.toDateString()) {
|
|
79
|
+
return 'Today'
|
|
80
|
+
}
|
|
81
|
+
// Check if it's tomorrow
|
|
82
|
+
if (date.toDateString() === tomorrow.toDateString()) {
|
|
83
|
+
return 'Tomorrow'
|
|
84
|
+
}
|
|
85
|
+
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const getDueDateStatus = () => {
|
|
89
|
+
if (!card.dueDate) return null
|
|
90
|
+
const now = new Date()
|
|
91
|
+
const dueDate = new Date(card.dueDate)
|
|
92
|
+
const diffTime = dueDate.getTime() - now.getTime()
|
|
93
|
+
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
|
|
94
|
+
|
|
95
|
+
if (diffDays < 0) return 'overdue'
|
|
96
|
+
if (diffDays === 0) return 'today'
|
|
97
|
+
if (diffDays <= 2) return 'soon'
|
|
98
|
+
return 'normal'
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const dueDateStatus = getDueDateStatus()
|
|
102
|
+
const hasLabels = card.labels && card.labels.length > 0
|
|
103
|
+
const hasMetadata = card.dueDate || card.commentsCount || card.attachmentsCount || card.checklistProgress
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<div
|
|
107
|
+
ref={setNodeRef}
|
|
108
|
+
style={style}
|
|
109
|
+
className={cn(
|
|
110
|
+
'group relative bg-card rounded-lg border border-border/50 overflow-hidden',
|
|
111
|
+
'cursor-pointer transition-all duration-200',
|
|
112
|
+
'hover:border-border hover:shadow-md hover:-translate-y-0.5',
|
|
113
|
+
isDragging && 'opacity-60 shadow-xl rotate-2 scale-105 z-50'
|
|
114
|
+
)}
|
|
115
|
+
onClick={onClick}
|
|
116
|
+
data-cy={`cards-item-${card.id}`}
|
|
117
|
+
{...attributes}
|
|
118
|
+
{...listeners}
|
|
119
|
+
>
|
|
120
|
+
{/* Priority color bar at top */}
|
|
121
|
+
{card.priority && (
|
|
122
|
+
<div className={cn('h-1 w-full', priorityBarColors[card.priority])} />
|
|
123
|
+
)}
|
|
124
|
+
|
|
125
|
+
<div className="p-3 space-y-2">
|
|
126
|
+
{/* Labels row */}
|
|
127
|
+
{hasLabels && (
|
|
128
|
+
<div className="flex flex-wrap gap-1">
|
|
129
|
+
{card.labels!.slice(0, 4).map((label) => {
|
|
130
|
+
const colors = labelColors[label.toLowerCase()] || { bg: 'bg-gray-500', text: 'text-white' }
|
|
131
|
+
return (
|
|
132
|
+
<span
|
|
133
|
+
key={label}
|
|
134
|
+
className={cn(
|
|
135
|
+
'px-2 py-0.5 text-[10px] font-semibold rounded-full uppercase tracking-wide',
|
|
136
|
+
colors.bg, colors.text
|
|
137
|
+
)}
|
|
138
|
+
>
|
|
139
|
+
{label}
|
|
140
|
+
</span>
|
|
141
|
+
)
|
|
142
|
+
})}
|
|
143
|
+
{card.labels!.length > 4 && (
|
|
144
|
+
<span className="px-2 py-0.5 text-[10px] font-medium text-muted-foreground bg-muted rounded-full">
|
|
145
|
+
+{card.labels!.length - 4}
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
</div>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* Title */}
|
|
152
|
+
<h4 className="font-medium text-sm leading-snug text-card-foreground">
|
|
153
|
+
{card.title}
|
|
154
|
+
</h4>
|
|
155
|
+
|
|
156
|
+
{/* Description preview */}
|
|
157
|
+
{card.description && (
|
|
158
|
+
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
159
|
+
{card.description}
|
|
160
|
+
</p>
|
|
161
|
+
)}
|
|
162
|
+
|
|
163
|
+
{/* Footer badges row */}
|
|
164
|
+
{hasMetadata && (
|
|
165
|
+
<div className="flex items-center gap-3 pt-1 flex-wrap">
|
|
166
|
+
{/* Due date badge */}
|
|
167
|
+
{card.dueDate && (
|
|
168
|
+
<div
|
|
169
|
+
className={cn(
|
|
170
|
+
'flex items-center gap-1 text-xs px-1.5 py-0.5 rounded',
|
|
171
|
+
dueDateStatus === 'overdue' && 'bg-red-100 text-red-700 dark:bg-red-900/50 dark:text-red-300',
|
|
172
|
+
dueDateStatus === 'today' && 'bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300',
|
|
173
|
+
dueDateStatus === 'soon' && 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/50 dark:text-yellow-300',
|
|
174
|
+
dueDateStatus === 'normal' && 'text-muted-foreground'
|
|
175
|
+
)}
|
|
176
|
+
>
|
|
177
|
+
{dueDateStatus === 'overdue' ? (
|
|
178
|
+
<AlertCircle className="h-3 w-3" />
|
|
179
|
+
) : (
|
|
180
|
+
<Clock className="h-3 w-3" />
|
|
181
|
+
)}
|
|
182
|
+
<span className="font-medium">{formatDate(card.dueDate)}</span>
|
|
183
|
+
</div>
|
|
184
|
+
)}
|
|
185
|
+
|
|
186
|
+
{/* Checklist progress */}
|
|
187
|
+
{card.checklistProgress && (
|
|
188
|
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
189
|
+
<CheckSquare className="h-3 w-3" />
|
|
190
|
+
<span>{card.checklistProgress.completed}/{card.checklistProgress.total}</span>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
|
|
194
|
+
{/* Comments count */}
|
|
195
|
+
{card.commentsCount && card.commentsCount > 0 && (
|
|
196
|
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
197
|
+
<MessageSquare className="h-3 w-3" />
|
|
198
|
+
<span>{card.commentsCount}</span>
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{/* Attachments count */}
|
|
203
|
+
{card.attachmentsCount && card.attachmentsCount > 0 && (
|
|
204
|
+
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
205
|
+
<Paperclip className="h-3 w-3" />
|
|
206
|
+
<span>{card.attachmentsCount}</span>
|
|
207
|
+
</div>
|
|
208
|
+
)}
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
</div>
|
|
212
|
+
|
|
213
|
+
{/* Hover overlay for quick actions hint */}
|
|
214
|
+
<div className="absolute inset-0 bg-primary/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none" />
|
|
215
|
+
</div>
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { useDroppable } from '@dnd-kit/core'
|
|
5
|
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'
|
|
6
|
+
import { Button } from '@nextsparkjs/core/components/ui/button'
|
|
7
|
+
import { Input } from '@nextsparkjs/core/components/ui/input'
|
|
8
|
+
import { GripVertical, Plus, X, MoreHorizontal } from 'lucide-react'
|
|
9
|
+
import { KanbanCard, type CardData } from './KanbanCard'
|
|
10
|
+
import { cn } from '@nextsparkjs/core/lib/utils'
|
|
11
|
+
import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
|
|
12
|
+
import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
|
|
13
|
+
import {
|
|
14
|
+
DropdownMenu,
|
|
15
|
+
DropdownMenuContent,
|
|
16
|
+
DropdownMenuItem,
|
|
17
|
+
DropdownMenuSeparator,
|
|
18
|
+
DropdownMenuTrigger,
|
|
19
|
+
} from '@nextsparkjs/core/components/ui/dropdown-menu'
|
|
20
|
+
import type { DragHandleProps } from './SortableList'
|
|
21
|
+
|
|
22
|
+
export interface ListData {
|
|
23
|
+
id: string
|
|
24
|
+
name: string
|
|
25
|
+
position: number
|
|
26
|
+
boardId: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface KanbanColumnProps {
|
|
30
|
+
list: ListData
|
|
31
|
+
cards: CardData[]
|
|
32
|
+
onAddCard?: (listId: string, title: string) => void
|
|
33
|
+
onCardClick?: (card: CardData) => void
|
|
34
|
+
dragHandleProps?: DragHandleProps
|
|
35
|
+
onDeleteList?: (listId: string) => void
|
|
36
|
+
onRenameList?: (listId: string, newName: string) => void
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function KanbanColumn({
|
|
40
|
+
list,
|
|
41
|
+
cards,
|
|
42
|
+
onAddCard,
|
|
43
|
+
onCardClick,
|
|
44
|
+
dragHandleProps,
|
|
45
|
+
onDeleteList,
|
|
46
|
+
onRenameList,
|
|
47
|
+
}: KanbanColumnProps) {
|
|
48
|
+
const [isAddingCard, setIsAddingCard] = useState(false)
|
|
49
|
+
const [newCardTitle, setNewCardTitle] = useState('')
|
|
50
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
51
|
+
const [editedName, setEditedName] = useState(list.name)
|
|
52
|
+
|
|
53
|
+
// Permission check for list editing
|
|
54
|
+
const canUpdateList = usePermission('lists.update')
|
|
55
|
+
|
|
56
|
+
const { setNodeRef, isOver } = useDroppable({
|
|
57
|
+
id: list.id,
|
|
58
|
+
data: {
|
|
59
|
+
type: 'column',
|
|
60
|
+
list,
|
|
61
|
+
},
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
const handleAddCard = () => {
|
|
65
|
+
if (newCardTitle.trim() && onAddCard) {
|
|
66
|
+
onAddCard(list.id, newCardTitle.trim())
|
|
67
|
+
setNewCardTitle('')
|
|
68
|
+
setIsAddingCard(false)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
73
|
+
if (e.key === 'Enter') {
|
|
74
|
+
handleAddCard()
|
|
75
|
+
} else if (e.key === 'Escape') {
|
|
76
|
+
setIsAddingCard(false)
|
|
77
|
+
setNewCardTitle('')
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const handleRename = () => {
|
|
82
|
+
if (editedName.trim() && editedName !== list.name && onRenameList) {
|
|
83
|
+
onRenameList(list.id, editedName.trim())
|
|
84
|
+
}
|
|
85
|
+
setIsEditing(false)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cardIds = cards.map((c) => c.id)
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
ref={setNodeRef}
|
|
93
|
+
className={cn(
|
|
94
|
+
'flex flex-col w-[280px] flex-shrink-0 rounded-xl transition-all duration-200',
|
|
95
|
+
'bg-muted/40 dark:bg-muted/20 backdrop-blur-sm',
|
|
96
|
+
'border border-border/30',
|
|
97
|
+
isOver && 'ring-2 ring-primary/50 bg-primary/5'
|
|
98
|
+
)}
|
|
99
|
+
style={{
|
|
100
|
+
maxHeight: 'calc(100vh - 180px)',
|
|
101
|
+
}}
|
|
102
|
+
data-cy={`lists-column-${list.id}`}
|
|
103
|
+
>
|
|
104
|
+
{/* Column Header */}
|
|
105
|
+
<div className="px-3 pt-3 pb-2" data-cy={`lists-column-header-${list.id}`}>
|
|
106
|
+
<div className="flex items-center justify-between gap-2">
|
|
107
|
+
<div className="flex items-center gap-1.5 flex-1 min-w-0">
|
|
108
|
+
{dragHandleProps && (
|
|
109
|
+
<button
|
|
110
|
+
{...dragHandleProps}
|
|
111
|
+
className="p-1 -ml-1 cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground transition-colors rounded-md hover:bg-background/80 shrink-0"
|
|
112
|
+
aria-label="Drag to reorder list"
|
|
113
|
+
>
|
|
114
|
+
<GripVertical className="h-4 w-4" />
|
|
115
|
+
</button>
|
|
116
|
+
)}
|
|
117
|
+
{isEditing ? (
|
|
118
|
+
<Input
|
|
119
|
+
autoFocus
|
|
120
|
+
value={editedName}
|
|
121
|
+
onChange={(e) => setEditedName(e.target.value)}
|
|
122
|
+
onBlur={handleRename}
|
|
123
|
+
onKeyDown={(e) => {
|
|
124
|
+
if (e.key === 'Enter') handleRename()
|
|
125
|
+
if (e.key === 'Escape') {
|
|
126
|
+
setEditedName(list.name)
|
|
127
|
+
setIsEditing(false)
|
|
128
|
+
}
|
|
129
|
+
}}
|
|
130
|
+
className="h-7 text-sm font-semibold px-1.5 bg-background"
|
|
131
|
+
/>
|
|
132
|
+
) : (
|
|
133
|
+
<h3
|
|
134
|
+
className={cn(
|
|
135
|
+
"font-semibold text-sm text-foreground truncate transition-colors",
|
|
136
|
+
canUpdateList && "cursor-pointer hover:text-primary"
|
|
137
|
+
)}
|
|
138
|
+
onClick={canUpdateList ? () => setIsEditing(true) : undefined}
|
|
139
|
+
data-cy={`lists-column-title-${list.id}`}
|
|
140
|
+
>
|
|
141
|
+
{list.name}
|
|
142
|
+
</h3>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
<div className="flex items-center gap-1 shrink-0">
|
|
146
|
+
<span className="text-xs font-medium text-muted-foreground bg-background/80 px-2 py-0.5 rounded-md">
|
|
147
|
+
{cards.length}
|
|
148
|
+
</span>
|
|
149
|
+
<DropdownMenu>
|
|
150
|
+
<DropdownMenuTrigger asChild>
|
|
151
|
+
<Button variant="ghost" size="icon" className="h-7 w-7 text-muted-foreground hover:text-foreground" data-cy={`lists-column-menu-trigger-${list.id}`}>
|
|
152
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
153
|
+
</Button>
|
|
154
|
+
</DropdownMenuTrigger>
|
|
155
|
+
<DropdownMenuContent align="end" className="w-48" data-cy={`lists-column-menu-${list.id}`}>
|
|
156
|
+
<PermissionGate permission="lists.update">
|
|
157
|
+
<DropdownMenuItem onClick={() => setIsEditing(true)}>
|
|
158
|
+
Rename list
|
|
159
|
+
</DropdownMenuItem>
|
|
160
|
+
</PermissionGate>
|
|
161
|
+
<PermissionGate permission="cards.create">
|
|
162
|
+
<DropdownMenuItem onClick={() => setIsAddingCard(true)}>
|
|
163
|
+
Add card
|
|
164
|
+
</DropdownMenuItem>
|
|
165
|
+
</PermissionGate>
|
|
166
|
+
<PermissionGate permission="lists.delete">
|
|
167
|
+
<DropdownMenuSeparator />
|
|
168
|
+
<DropdownMenuItem
|
|
169
|
+
className="text-destructive focus:text-destructive"
|
|
170
|
+
onClick={() => onDeleteList?.(list.id)}
|
|
171
|
+
>
|
|
172
|
+
Delete list
|
|
173
|
+
</DropdownMenuItem>
|
|
174
|
+
</PermissionGate>
|
|
175
|
+
</DropdownMenuContent>
|
|
176
|
+
</DropdownMenu>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
{/* Cards Container with custom scrollbar */}
|
|
182
|
+
<div className="flex-1 overflow-y-auto px-2 custom-scrollbar">
|
|
183
|
+
<div className="min-h-[60px] pb-2 px-1">
|
|
184
|
+
<SortableContext items={cardIds} strategy={verticalListSortingStrategy}>
|
|
185
|
+
<div className="space-y-2">
|
|
186
|
+
{cards.length === 0 && !isAddingCard && (
|
|
187
|
+
<div className="text-center py-6 text-muted-foreground text-xs">
|
|
188
|
+
No cards yet
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
{cards.map((card) => (
|
|
192
|
+
<KanbanCard
|
|
193
|
+
key={card.id}
|
|
194
|
+
card={card}
|
|
195
|
+
onClick={() => onCardClick?.(card)}
|
|
196
|
+
/>
|
|
197
|
+
))}
|
|
198
|
+
</div>
|
|
199
|
+
</SortableContext>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
{/* Add Card Section - Fixed at bottom */}
|
|
204
|
+
<PermissionGate permission="cards.create">
|
|
205
|
+
<div className="px-3 py-2 border-t border-border/30">
|
|
206
|
+
{isAddingCard ? (
|
|
207
|
+
<div className="space-y-2" data-cy={`cards-add-form-${list.id}`}>
|
|
208
|
+
<textarea
|
|
209
|
+
autoFocus
|
|
210
|
+
placeholder="Enter a title for this card..."
|
|
211
|
+
value={newCardTitle}
|
|
212
|
+
onChange={(e) => setNewCardTitle(e.target.value)}
|
|
213
|
+
onKeyDown={(e) => {
|
|
214
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
215
|
+
e.preventDefault()
|
|
216
|
+
handleAddCard()
|
|
217
|
+
}
|
|
218
|
+
if (e.key === 'Escape') {
|
|
219
|
+
setIsAddingCard(false)
|
|
220
|
+
setNewCardTitle('')
|
|
221
|
+
}
|
|
222
|
+
}}
|
|
223
|
+
className="w-full min-h-[60px] p-2 text-sm bg-card border border-border rounded-lg resize-none focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary"
|
|
224
|
+
data-cy={`cards-field-title-${list.id}`}
|
|
225
|
+
/>
|
|
226
|
+
<div className="flex items-center gap-2">
|
|
227
|
+
<Button size="sm" onClick={handleAddCard} disabled={!newCardTitle.trim()} data-cy={`cards-form-submit-${list.id}`}>
|
|
228
|
+
Add Card
|
|
229
|
+
</Button>
|
|
230
|
+
<Button
|
|
231
|
+
size="sm"
|
|
232
|
+
variant="ghost"
|
|
233
|
+
onClick={() => {
|
|
234
|
+
setIsAddingCard(false)
|
|
235
|
+
setNewCardTitle('')
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<X className="h-4 w-4" />
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
</div>
|
|
242
|
+
) : (
|
|
243
|
+
<Button
|
|
244
|
+
variant="ghost"
|
|
245
|
+
size="sm"
|
|
246
|
+
className="w-full justify-start text-muted-foreground hover:text-foreground hover:bg-background/80 rounded-lg"
|
|
247
|
+
onClick={() => setIsAddingCard(true)}
|
|
248
|
+
data-cy={`lists-add-card-${list.id}`}
|
|
249
|
+
>
|
|
250
|
+
<Plus className="h-4 w-4 mr-2" />
|
|
251
|
+
Add a card
|
|
252
|
+
</Button>
|
|
253
|
+
)}
|
|
254
|
+
</div>
|
|
255
|
+
</PermissionGate>
|
|
256
|
+
|
|
257
|
+
{/* Drop indicator line when dragging over */}
|
|
258
|
+
{isOver && (
|
|
259
|
+
<div className="absolute inset-x-2 bottom-14 h-0.5 bg-primary rounded-full animate-pulse" />
|
|
260
|
+
)}
|
|
261
|
+
</div>
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useSortable } from '@dnd-kit/sortable'
|
|
4
|
+
import { CSS } from '@dnd-kit/utilities'
|
|
5
|
+
import type { ListData } from './KanbanColumn'
|
|
6
|
+
|
|
7
|
+
// Export drag handle props type for KanbanColumn
|
|
8
|
+
export type DragHandleProps = ReturnType<typeof useSortable>['listeners']
|
|
9
|
+
|
|
10
|
+
interface SortableListProps {
|
|
11
|
+
list: ListData
|
|
12
|
+
children: (props: { dragHandleProps: DragHandleProps }) => React.ReactNode
|
|
13
|
+
disabled?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function SortableList({ list, children, disabled = false }: SortableListProps) {
|
|
17
|
+
const {
|
|
18
|
+
attributes,
|
|
19
|
+
listeners,
|
|
20
|
+
setNodeRef,
|
|
21
|
+
transform,
|
|
22
|
+
transition,
|
|
23
|
+
isDragging,
|
|
24
|
+
} = useSortable({
|
|
25
|
+
id: list.id,
|
|
26
|
+
data: {
|
|
27
|
+
type: 'list',
|
|
28
|
+
list,
|
|
29
|
+
},
|
|
30
|
+
disabled,
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const style = {
|
|
34
|
+
transform: CSS.Transform.toString(transform),
|
|
35
|
+
transition,
|
|
36
|
+
opacity: isDragging ? 0.5 : 1,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div ref={setNodeRef} style={style} {...attributes}>
|
|
41
|
+
<div className={isDragging ? 'ring-2 ring-primary/50 rounded-lg' : ''}>
|
|
42
|
+
{children({ dragHandleProps: listeners })}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
)
|
|
46
|
+
}
|