@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.
Files changed (57) hide show
  1. package/README.md +76 -0
  2. package/about.md +123 -0
  3. package/components/CardDetailModal.tsx +318 -0
  4. package/components/KanbanBoard.tsx +612 -0
  5. package/components/KanbanCard.tsx +218 -0
  6. package/components/KanbanColumn.tsx +264 -0
  7. package/components/SortableList.tsx +46 -0
  8. package/components/index.ts +4 -0
  9. package/config/app.config.ts +172 -0
  10. package/config/billing.config.ts +187 -0
  11. package/config/dashboard.config.ts +357 -0
  12. package/config/dev.config.ts +55 -0
  13. package/config/features.config.ts +256 -0
  14. package/config/flows.config.ts +484 -0
  15. package/config/permissions.config.ts +167 -0
  16. package/config/theme.config.ts +106 -0
  17. package/entities/boards/boards.config.ts +61 -0
  18. package/entities/boards/boards.fields.ts +154 -0
  19. package/entities/boards/boards.service.ts +256 -0
  20. package/entities/boards/boards.types.ts +57 -0
  21. package/entities/boards/messages/en.json +80 -0
  22. package/entities/boards/messages/es.json +80 -0
  23. package/entities/boards/migrations/001_boards_table.sql +83 -0
  24. package/entities/cards/cards.config.ts +61 -0
  25. package/entities/cards/cards.fields.ts +242 -0
  26. package/entities/cards/cards.service.ts +336 -0
  27. package/entities/cards/cards.types.ts +79 -0
  28. package/entities/cards/messages/en.json +114 -0
  29. package/entities/cards/messages/es.json +114 -0
  30. package/entities/cards/migrations/020_cards_table.sql +92 -0
  31. package/entities/lists/lists.config.ts +61 -0
  32. package/entities/lists/lists.fields.ts +105 -0
  33. package/entities/lists/lists.service.ts +252 -0
  34. package/entities/lists/lists.types.ts +55 -0
  35. package/entities/lists/messages/en.json +60 -0
  36. package/entities/lists/messages/es.json +60 -0
  37. package/entities/lists/migrations/010_lists_table.sql +79 -0
  38. package/lib/selectors.ts +206 -0
  39. package/messages/en.json +79 -0
  40. package/messages/es.json +79 -0
  41. package/migrations/999_theme_sample_data.sql +922 -0
  42. package/migrations/999a_initial_sample_data.sql +377 -0
  43. package/migrations/999b_abundant_sample_data.sql +346 -0
  44. package/package.json +17 -0
  45. package/permissions-matrix.md +122 -0
  46. package/styles/components.css +460 -0
  47. package/styles/globals.css +560 -0
  48. package/templates/dashboard/(main)/boards/[id]/[cardId]/page.tsx +238 -0
  49. package/templates/dashboard/(main)/boards/[id]/edit/page.tsx +390 -0
  50. package/templates/dashboard/(main)/boards/[id]/page.tsx +236 -0
  51. package/templates/dashboard/(main)/boards/create/page.tsx +236 -0
  52. package/templates/dashboard/(main)/boards/page.tsx +335 -0
  53. package/templates/dashboard/(main)/layout.tsx +32 -0
  54. package/templates/dashboard/(main)/page.tsx +592 -0
  55. package/templates/shared/ProductivityMobileNav.tsx +410 -0
  56. package/templates/shared/ProductivitySidebar.tsx +538 -0
  57. 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
+ }
@@ -0,0 +1,4 @@
1
+ export { KanbanCard, type CardData } from './KanbanCard'
2
+ export { KanbanColumn, type ListData } from './KanbanColumn'
3
+ export { KanbanBoard } from './KanbanBoard'
4
+