@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,538 @@
1
+ /**
2
+ * Productivity Sidebar - Board-Focused Design
3
+ * Clean, always-visible sidebar optimized for board navigation
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import Link from 'next/link'
9
+ import Image from 'next/image'
10
+ import { usePathname, useParams, useRouter } from 'next/navigation'
11
+ import { useState, useCallback, useEffect } from 'react'
12
+ import { cn } from '@nextsparkjs/core/lib/utils'
13
+ import { useAuth } from '@nextsparkjs/core/hooks/useAuth'
14
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
15
+ import { useTeamsConfig } from '@nextsparkjs/core/hooks/useTeamsConfig'
16
+ import { Button } from '@nextsparkjs/core/components/ui/button'
17
+ import {
18
+ DropdownMenu,
19
+ DropdownMenuContent,
20
+ DropdownMenuItem,
21
+ DropdownMenuSeparator,
22
+ DropdownMenuTrigger,
23
+ } from '@nextsparkjs/core/components/ui/dropdown-menu'
24
+ import {
25
+ Dialog,
26
+ DialogContent,
27
+ DialogHeader,
28
+ DialogTitle,
29
+ DialogTrigger,
30
+ } from '@nextsparkjs/core/components/ui/dialog'
31
+ import { Input } from '@nextsparkjs/core/components/ui/input'
32
+ import { Label } from '@nextsparkjs/core/components/ui/label'
33
+ import {
34
+ Kanban,
35
+ Plus,
36
+ ChevronDown,
37
+ LogOut,
38
+ Settings,
39
+ Users,
40
+ Check,
41
+ Loader2,
42
+ LayoutGrid,
43
+ Star,
44
+ MoreHorizontal,
45
+ Pencil,
46
+ Trash2,
47
+ Archive
48
+ } from 'lucide-react'
49
+
50
+ interface Board {
51
+ id: string
52
+ name: string
53
+ color: string
54
+ description?: string
55
+ }
56
+
57
+ interface Team {
58
+ id: string
59
+ name: string
60
+ type: string
61
+ }
62
+
63
+ // Board colors with CSS variables
64
+ const BOARD_COLORS = [
65
+ { id: 'blue', class: 'bg-[var(--board-blue)]', label: 'Blue' },
66
+ { id: 'green', class: 'bg-[var(--board-green)]', label: 'Green' },
67
+ { id: 'purple', class: 'bg-[var(--board-purple)]', label: 'Purple' },
68
+ { id: 'orange', class: 'bg-[var(--board-orange)]', label: 'Orange' },
69
+ { id: 'red', class: 'bg-[var(--board-red)]', label: 'Red' },
70
+ { id: 'pink', class: 'bg-[var(--board-pink)]', label: 'Pink' },
71
+ { id: 'gray', class: 'bg-[var(--board-gray)]', label: 'Gray' },
72
+ ]
73
+
74
+ const getColorClass = (color: string) => {
75
+ return BOARD_COLORS.find(c => c.id === color)?.class || BOARD_COLORS[0].class
76
+ }
77
+
78
+ // Quick Create Board Dialog
79
+ function CreateBoardDialog({
80
+ open,
81
+ onOpenChange,
82
+ onCreated
83
+ }: {
84
+ open: boolean
85
+ onOpenChange: (open: boolean) => void
86
+ onCreated: () => void
87
+ }) {
88
+ const { currentTeam } = useTeamContext()
89
+ const router = useRouter()
90
+ const [name, setName] = useState('')
91
+ const [color, setColor] = useState('blue')
92
+ const [isCreating, setIsCreating] = useState(false)
93
+
94
+ const handleCreate = async () => {
95
+ if (!name.trim() || !currentTeam?.id) return
96
+
97
+ setIsCreating(true)
98
+ try {
99
+ const response = await fetch('/api/v1/boards', {
100
+ method: 'POST',
101
+ headers: {
102
+ 'Content-Type': 'application/json',
103
+ 'x-team-id': currentTeam.id,
104
+ },
105
+ body: JSON.stringify({ name: name.trim(), color }),
106
+ })
107
+
108
+ if (response.ok) {
109
+ const data = await response.json()
110
+ setName('')
111
+ setColor('blue')
112
+ onOpenChange(false)
113
+ onCreated()
114
+ router.push(`/dashboard/boards/${data.data.id}`)
115
+ }
116
+ } catch (error) {
117
+ console.error('Failed to create board:', error)
118
+ } finally {
119
+ setIsCreating(false)
120
+ }
121
+ }
122
+
123
+ return (
124
+ <Dialog open={open} onOpenChange={onOpenChange}>
125
+ <DialogContent className="sm:max-w-md">
126
+ <DialogHeader>
127
+ <DialogTitle>Create new board</DialogTitle>
128
+ </DialogHeader>
129
+ <div className="space-y-4 pt-4">
130
+ <div className="space-y-2">
131
+ <Label htmlFor="board-name">Board name</Label>
132
+ <Input
133
+ id="board-name"
134
+ placeholder="My awesome board"
135
+ value={name}
136
+ onChange={(e) => setName(e.target.value)}
137
+ onKeyDown={(e) => e.key === 'Enter' && handleCreate()}
138
+ autoFocus
139
+ />
140
+ </div>
141
+ <div className="space-y-2">
142
+ <Label>Color</Label>
143
+ <div className="flex gap-2">
144
+ {BOARD_COLORS.map((c) => (
145
+ <button
146
+ key={c.id}
147
+ onClick={() => setColor(c.id)}
148
+ className={cn(
149
+ 'w-8 h-8 rounded-lg transition-all',
150
+ c.class,
151
+ color === c.id
152
+ ? 'ring-2 ring-offset-2 ring-primary scale-110'
153
+ : 'hover:scale-105'
154
+ )}
155
+ title={c.label}
156
+ />
157
+ ))}
158
+ </div>
159
+ </div>
160
+ <div className="flex justify-end gap-2 pt-2">
161
+ <Button variant="outline" onClick={() => onOpenChange(false)}>
162
+ Cancel
163
+ </Button>
164
+ <Button
165
+ onClick={handleCreate}
166
+ disabled={!name.trim() || isCreating}
167
+ >
168
+ {isCreating ? (
169
+ <Loader2 className="w-4 h-4 animate-spin mr-2" />
170
+ ) : (
171
+ <Plus className="w-4 h-4 mr-2" />
172
+ )}
173
+ Create
174
+ </Button>
175
+ </div>
176
+ </div>
177
+ </DialogContent>
178
+ </Dialog>
179
+ )
180
+ }
181
+
182
+ // Board Item with context menu
183
+ function BoardItem({
184
+ board,
185
+ isActive,
186
+ onRefresh
187
+ }: {
188
+ board: Board
189
+ isActive: boolean
190
+ onRefresh: () => void
191
+ }) {
192
+ const { currentTeam } = useTeamContext()
193
+ const colorClass = getColorClass(board.color)
194
+
195
+ const handleDelete = async () => {
196
+ if (!confirm('Delete this board? This cannot be undone.')) return
197
+
198
+ try {
199
+ await fetch(`/api/v1/boards/${board.id}`, {
200
+ method: 'DELETE',
201
+ headers: { 'x-team-id': currentTeam?.id || '' },
202
+ })
203
+ onRefresh()
204
+ } catch (error) {
205
+ console.error('Failed to delete board:', error)
206
+ }
207
+ }
208
+
209
+ return (
210
+ <div className="group relative">
211
+ <Link
212
+ href={`/dashboard/boards/${board.id}`}
213
+ className={cn(
214
+ 'flex items-center gap-3 px-3 py-2 rounded-lg transition-all text-sm',
215
+ isActive
216
+ ? 'bg-primary/10 text-primary font-medium'
217
+ : 'text-muted-foreground hover:bg-muted hover:text-foreground'
218
+ )}
219
+ >
220
+ <div className={cn('w-3 h-3 rounded-sm shrink-0', colorClass)} />
221
+ <span className="truncate flex-1">{board.name}</span>
222
+ </Link>
223
+
224
+ {/* Context menu on hover */}
225
+ <DropdownMenu>
226
+ <DropdownMenuTrigger asChild>
227
+ <button
228
+ className={cn(
229
+ 'absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded opacity-0 group-hover:opacity-100 transition-opacity',
230
+ 'hover:bg-muted text-muted-foreground hover:text-foreground'
231
+ )}
232
+ >
233
+ <MoreHorizontal className="w-4 h-4" />
234
+ </button>
235
+ </DropdownMenuTrigger>
236
+ <DropdownMenuContent align="end" className="w-40">
237
+ <DropdownMenuItem asChild>
238
+ <Link href={`/dashboard/boards/${board.id}/edit`}>
239
+ <Pencil className="w-4 h-4 mr-2" />
240
+ Edit
241
+ </Link>
242
+ </DropdownMenuItem>
243
+ <DropdownMenuSeparator />
244
+ <DropdownMenuItem
245
+ className="text-destructive focus:text-destructive"
246
+ onClick={handleDelete}
247
+ >
248
+ <Trash2 className="w-4 h-4 mr-2" />
249
+ Delete
250
+ </DropdownMenuItem>
251
+ </DropdownMenuContent>
252
+ </DropdownMenu>
253
+ </div>
254
+ )
255
+ }
256
+
257
+ export function ProductivitySidebar() {
258
+ const pathname = usePathname()
259
+ const params = useParams()
260
+ const router = useRouter()
261
+ const { user, signOut } = useAuth()
262
+ const { currentTeam, userTeams, switchTeam } = useTeamContext()
263
+ const { canSwitch } = useTeamsConfig()
264
+
265
+ const [boards, setBoards] = useState<Board[]>([])
266
+ const [isLoading, setIsLoading] = useState(true)
267
+ const [createDialogOpen, setCreateDialogOpen] = useState(false)
268
+
269
+ const currentBoardId = params?.id as string
270
+
271
+ // Get current user's role in the active team
272
+ const currentMembership = userTeams.find(m => m.team.id === currentTeam?.id)
273
+ const userRole = currentMembership?.role || 'member'
274
+
275
+ // Only owners and admins can create boards
276
+ const canCreateBoard = userRole === 'owner' || userRole === 'admin'
277
+
278
+ // Fetch boards
279
+ const fetchBoards = useCallback(async () => {
280
+ if (!currentTeam?.id) {
281
+ setIsLoading(false)
282
+ return
283
+ }
284
+ try {
285
+ setIsLoading(true)
286
+ const response = await fetch('/api/v1/boards?limit=100', {
287
+ headers: {
288
+ 'Content-Type': 'application/json',
289
+ 'x-team-id': currentTeam.id
290
+ }
291
+ })
292
+ if (response.ok) {
293
+ const data = await response.json()
294
+ setBoards(data.data || [])
295
+ }
296
+ } catch (error) {
297
+ console.error('Failed to fetch boards:', error)
298
+ } finally {
299
+ setIsLoading(false)
300
+ }
301
+ }, [currentTeam?.id])
302
+
303
+ useEffect(() => {
304
+ fetchBoards()
305
+ }, [fetchBoards])
306
+
307
+ const getUserInitials = useCallback(() => {
308
+ if (!user) return 'U'
309
+ if (user.firstName && user.lastName) {
310
+ return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
311
+ }
312
+ return user.email?.slice(0, 2).toUpperCase() || 'U'
313
+ }, [user])
314
+
315
+ const getTeamInitials = (name: string) => {
316
+ return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
317
+ }
318
+
319
+ const handleTeamSwitch = async (teamId: string) => {
320
+ try {
321
+ await switchTeam(teamId)
322
+ router.push('/dashboard/boards')
323
+ } catch (error) {
324
+ console.error('Failed to switch team:', error)
325
+ }
326
+ }
327
+
328
+ return (
329
+ <>
330
+ <aside className="hidden lg:flex flex-col fixed left-0 top-0 h-screen w-64 bg-sidebar border-r border-sidebar-border z-50">
331
+ {/* Team Header - Switcher only visible when canSwitch is true */}
332
+ <div className="p-3 border-b border-sidebar-border">
333
+ {canSwitch ? (
334
+ // Full team switcher dropdown (multi-tenant/hybrid modes)
335
+ <DropdownMenu>
336
+ <DropdownMenuTrigger asChild>
337
+ <button className="w-full flex items-center gap-3 p-2 rounded-lg hover:bg-sidebar-accent transition-colors text-left">
338
+ <div className="w-9 h-9 rounded-lg bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-sm font-bold text-primary-foreground shrink-0">
339
+ {currentTeam ? getTeamInitials(currentTeam.name) : 'T'}
340
+ </div>
341
+ <div className="flex-1 min-w-0">
342
+ <p className="font-semibold text-sm text-sidebar-foreground truncate">
343
+ {currentTeam?.name || 'Select Team'}
344
+ </p>
345
+ <p className="text-xs text-muted-foreground">
346
+ {boards.length} board{boards.length !== 1 ? 's' : ''}
347
+ </p>
348
+ </div>
349
+ <ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
350
+ </button>
351
+ </DropdownMenuTrigger>
352
+ <DropdownMenuContent align="start" className="w-56">
353
+ <div className="px-2 py-1.5 text-xs font-semibold text-muted-foreground">
354
+ Switch Team
355
+ </div>
356
+ {userTeams.map((membership) => (
357
+ <DropdownMenuItem
358
+ key={membership.team.id}
359
+ onClick={() => handleTeamSwitch(membership.team.id)}
360
+ className="flex items-center gap-2"
361
+ >
362
+ <div className="w-6 h-6 rounded bg-muted flex items-center justify-center text-[10px] font-bold">
363
+ {getTeamInitials(membership.team.name)}
364
+ </div>
365
+ <span className="flex-1 truncate">{membership.team.name}</span>
366
+ {currentTeam?.id === membership.team.id && (
367
+ <Check className="w-4 h-4 text-primary" />
368
+ )}
369
+ </DropdownMenuItem>
370
+ ))}
371
+ <DropdownMenuSeparator />
372
+ <DropdownMenuItem asChild>
373
+ <Link href="/dashboard/settings/teams" className="flex items-center gap-2">
374
+ <Users className="w-4 h-4" />
375
+ Manage Teams
376
+ </Link>
377
+ </DropdownMenuItem>
378
+ </DropdownMenuContent>
379
+ </DropdownMenu>
380
+ ) : (
381
+ // Static team display (single-user/collaborative/single-tenant modes)
382
+ <div className="flex items-center gap-3 p-2">
383
+ <div className="w-9 h-9 rounded-lg bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-sm font-bold text-primary-foreground shrink-0">
384
+ {currentTeam ? getTeamInitials(currentTeam.name) : 'T'}
385
+ </div>
386
+ <div className="flex-1 min-w-0">
387
+ <p className="font-semibold text-sm text-sidebar-foreground truncate">
388
+ {currentTeam?.name || 'Workspace'}
389
+ </p>
390
+ <p className="text-xs text-muted-foreground">
391
+ {boards.length} board{boards.length !== 1 ? 's' : ''}
392
+ </p>
393
+ </div>
394
+ </div>
395
+ )}
396
+ </div>
397
+
398
+ {/* Boards Section */}
399
+ <div className="flex-1 overflow-y-auto">
400
+ {/* Section Header */}
401
+ <div className="flex items-center justify-between px-4 py-3">
402
+ <span className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
403
+ Boards
404
+ </span>
405
+ {canCreateBoard && (
406
+ <button
407
+ onClick={() => setCreateDialogOpen(true)}
408
+ className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground transition-colors"
409
+ title="Create board"
410
+ >
411
+ <Plus className="w-4 h-4" />
412
+ </button>
413
+ )}
414
+ </div>
415
+
416
+ {/* Boards List */}
417
+ <nav className="px-2 pb-4">
418
+ {isLoading ? (
419
+ <div className="flex items-center justify-center py-8">
420
+ <Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
421
+ </div>
422
+ ) : boards.length === 0 ? (
423
+ <div className="text-center py-8 px-4">
424
+ <div className="w-12 h-12 rounded-xl bg-muted flex items-center justify-center mx-auto mb-3">
425
+ <Kanban className="w-6 h-6 text-muted-foreground" />
426
+ </div>
427
+ <p className="text-sm text-muted-foreground mb-3">
428
+ {canCreateBoard ? 'No boards yet' : 'No boards available'}
429
+ </p>
430
+ {canCreateBoard && (
431
+ <Button
432
+ size="sm"
433
+ onClick={() => setCreateDialogOpen(true)}
434
+ className="gap-1"
435
+ >
436
+ <Plus className="w-4 h-4" />
437
+ Create Board
438
+ </Button>
439
+ )}
440
+ </div>
441
+ ) : (
442
+ <div className="space-y-0.5">
443
+ {boards.map((board) => (
444
+ <BoardItem
445
+ key={board.id}
446
+ board={board}
447
+ isActive={currentBoardId === board.id}
448
+ onRefresh={fetchBoards}
449
+ />
450
+ ))}
451
+ </div>
452
+ )}
453
+ </nav>
454
+ </div>
455
+
456
+ {/* Bottom Section */}
457
+ <div className="border-t border-sidebar-border p-3 space-y-1">
458
+ {/* All Boards Link */}
459
+ <Link
460
+ href="/dashboard/boards"
461
+ className={cn(
462
+ 'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm',
463
+ pathname === '/dashboard/boards'
464
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
465
+ : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'
466
+ )}
467
+ >
468
+ <LayoutGrid className="w-4 h-4" />
469
+ All Boards
470
+ </Link>
471
+
472
+ {/* Settings Link */}
473
+ <Link
474
+ href="/dashboard/settings"
475
+ className={cn(
476
+ 'flex items-center gap-3 px-3 py-2 rounded-lg transition-colors text-sm',
477
+ pathname.startsWith('/dashboard/settings')
478
+ ? 'bg-sidebar-accent text-sidebar-accent-foreground font-medium'
479
+ : 'text-muted-foreground hover:bg-sidebar-accent/50 hover:text-sidebar-foreground'
480
+ )}
481
+ >
482
+ <Settings className="w-4 h-4" />
483
+ Settings
484
+ </Link>
485
+
486
+ {/* User */}
487
+ <DropdownMenu>
488
+ <DropdownMenuTrigger asChild>
489
+ <button className="w-full flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-sidebar-accent/50 transition-colors text-left">
490
+ {user?.image ? (
491
+ <Image
492
+ src={user.image}
493
+ alt=""
494
+ width={28}
495
+ height={28}
496
+ className="w-7 h-7 rounded-full object-cover"
497
+ />
498
+ ) : (
499
+ <div className="w-7 h-7 rounded-full bg-primary/10 flex items-center justify-center text-xs font-medium text-primary">
500
+ {getUserInitials()}
501
+ </div>
502
+ )}
503
+ <span className="flex-1 text-sm text-sidebar-foreground truncate">
504
+ {user?.firstName || user?.email?.split('@')[0] || 'User'}
505
+ </span>
506
+ </button>
507
+ </DropdownMenuTrigger>
508
+ <DropdownMenuContent align="start" className="w-48">
509
+ <DropdownMenuItem asChild>
510
+ <Link href="/dashboard/settings/profile">
511
+ <Settings className="w-4 h-4 mr-2" />
512
+ Profile Settings
513
+ </Link>
514
+ </DropdownMenuItem>
515
+ <DropdownMenuSeparator />
516
+ <DropdownMenuItem
517
+ onClick={() => signOut()}
518
+ className="text-destructive focus:text-destructive"
519
+ >
520
+ <LogOut className="w-4 h-4 mr-2" />
521
+ Sign Out
522
+ </DropdownMenuItem>
523
+ </DropdownMenuContent>
524
+ </DropdownMenu>
525
+ </div>
526
+ </aside>
527
+
528
+ {/* Create Board Dialog */}
529
+ <CreateBoardDialog
530
+ open={createDialogOpen}
531
+ onOpenChange={setCreateDialogOpen}
532
+ onCreated={fetchBoards}
533
+ />
534
+ </>
535
+ )
536
+ }
537
+
538
+ export default ProductivitySidebar