@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,410 @@
1
+ /**
2
+ * Productivity Mobile Navigation
3
+ * Bottom navigation bar for mobile with boards sheet
4
+ */
5
+
6
+ 'use client'
7
+
8
+ import Link from 'next/link'
9
+ import { usePathname, useParams, useRouter } from 'next/navigation'
10
+ import { useState, useEffect, useCallback } from 'react'
11
+ import { cn } from '@nextsparkjs/core/lib/utils'
12
+ import { useTeamContext } from '@nextsparkjs/core/contexts/TeamContext'
13
+ import { useTeamsConfig } from '@nextsparkjs/core/hooks/useTeamsConfig'
14
+ import { Button } from '@nextsparkjs/core/components/ui/button'
15
+ import {
16
+ Sheet,
17
+ SheetContent,
18
+ SheetHeader,
19
+ SheetTitle,
20
+ SheetTrigger,
21
+ } from '@nextsparkjs/core/components/ui/sheet'
22
+ import {
23
+ Dialog,
24
+ DialogContent,
25
+ DialogHeader,
26
+ DialogTitle,
27
+ } from '@nextsparkjs/core/components/ui/dialog'
28
+ import { Input } from '@nextsparkjs/core/components/ui/input'
29
+ import { Label } from '@nextsparkjs/core/components/ui/label'
30
+ import {
31
+ LayoutGrid,
32
+ Plus,
33
+ Settings,
34
+ Users,
35
+ Loader2,
36
+ Kanban,
37
+ Check,
38
+ ChevronRight
39
+ } from 'lucide-react'
40
+
41
+ interface Board {
42
+ id: string
43
+ name: string
44
+ color: string
45
+ }
46
+
47
+ // Board colors
48
+ const BOARD_COLORS = [
49
+ { id: 'blue', class: 'bg-[var(--board-blue)]' },
50
+ { id: 'green', class: 'bg-[var(--board-green)]' },
51
+ { id: 'purple', class: 'bg-[var(--board-purple)]' },
52
+ { id: 'orange', class: 'bg-[var(--board-orange)]' },
53
+ { id: 'red', class: 'bg-[var(--board-red)]' },
54
+ { id: 'pink', class: 'bg-[var(--board-pink)]' },
55
+ { id: 'gray', class: 'bg-[var(--board-gray)]' },
56
+ ]
57
+
58
+ const getColorClass = (color: string) => {
59
+ return BOARD_COLORS.find(c => c.id === color)?.class || BOARD_COLORS[0].class
60
+ }
61
+
62
+ export function ProductivityMobileNav() {
63
+ const pathname = usePathname()
64
+ const params = useParams()
65
+ const router = useRouter()
66
+ const { currentTeam, userTeams, switchTeam } = useTeamContext()
67
+ const { canSwitch } = useTeamsConfig()
68
+
69
+ const [boards, setBoards] = useState<Board[]>([])
70
+ const [isLoading, setIsLoading] = useState(true)
71
+ const [boardsSheetOpen, setBoardsSheetOpen] = useState(false)
72
+ const [teamsSheetOpen, setTeamsSheetOpen] = useState(false)
73
+ const [createDialogOpen, setCreateDialogOpen] = useState(false)
74
+ const [newBoardName, setNewBoardName] = useState('')
75
+ const [newBoardColor, setNewBoardColor] = useState('blue')
76
+ const [isCreating, setIsCreating] = useState(false)
77
+
78
+ const currentBoardId = params?.id as string
79
+
80
+ // Get current user's role in the active team
81
+ const currentMembership = userTeams.find(m => m.team.id === currentTeam?.id)
82
+ const userRole = currentMembership?.role || 'member'
83
+
84
+ // Only owners and admins can create boards
85
+ const canCreateBoard = userRole === 'owner' || userRole === 'admin'
86
+
87
+ // Fetch boards
88
+ const fetchBoards = useCallback(async () => {
89
+ if (!currentTeam?.id) {
90
+ setIsLoading(false)
91
+ return
92
+ }
93
+ try {
94
+ setIsLoading(true)
95
+ const response = await fetch('/api/v1/boards?limit=100', {
96
+ headers: {
97
+ 'Content-Type': 'application/json',
98
+ 'x-team-id': currentTeam.id
99
+ }
100
+ })
101
+ if (response.ok) {
102
+ const data = await response.json()
103
+ setBoards(data.data || [])
104
+ }
105
+ } catch (error) {
106
+ console.error('Failed to fetch boards:', error)
107
+ } finally {
108
+ setIsLoading(false)
109
+ }
110
+ }, [currentTeam?.id])
111
+
112
+ useEffect(() => {
113
+ fetchBoards()
114
+ }, [fetchBoards])
115
+
116
+ const handleCreateBoard = async () => {
117
+ if (!newBoardName.trim() || !currentTeam?.id) return
118
+
119
+ setIsCreating(true)
120
+ try {
121
+ const response = await fetch('/api/v1/boards', {
122
+ method: 'POST',
123
+ headers: {
124
+ 'Content-Type': 'application/json',
125
+ 'x-team-id': currentTeam.id,
126
+ },
127
+ body: JSON.stringify({ name: newBoardName.trim(), color: newBoardColor }),
128
+ })
129
+
130
+ if (response.ok) {
131
+ const data = await response.json()
132
+ setNewBoardName('')
133
+ setNewBoardColor('blue')
134
+ setCreateDialogOpen(false)
135
+ setBoardsSheetOpen(false)
136
+ fetchBoards()
137
+ router.push(`/dashboard/boards/${data.data.id}`)
138
+ }
139
+ } catch (error) {
140
+ console.error('Failed to create board:', error)
141
+ } finally {
142
+ setIsCreating(false)
143
+ }
144
+ }
145
+
146
+ const handleTeamSwitch = async (teamId: string) => {
147
+ try {
148
+ await switchTeam(teamId)
149
+ setTeamsSheetOpen(false)
150
+ router.push('/dashboard/boards')
151
+ } catch (error) {
152
+ console.error('Failed to switch team:', error)
153
+ }
154
+ }
155
+
156
+ const getTeamInitials = (name: string) => {
157
+ return name.split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase()
158
+ }
159
+
160
+ const isActive = (path: string) => {
161
+ if (path === '/dashboard/boards') {
162
+ return pathname.startsWith('/dashboard/boards')
163
+ }
164
+ return pathname === path || pathname.startsWith(path + '/')
165
+ }
166
+
167
+ return (
168
+ <>
169
+ <nav className="lg:hidden fixed bottom-0 left-0 right-0 z-50 bg-background/95 backdrop-blur-md border-t border-border safe-area-bottom">
170
+ <div className="flex items-center justify-around h-16 px-2">
171
+ {/* Boards Sheet */}
172
+ <Sheet open={boardsSheetOpen} onOpenChange={setBoardsSheetOpen}>
173
+ <SheetTrigger asChild>
174
+ <button
175
+ className={cn(
176
+ 'flex flex-col items-center gap-0.5 py-1 px-4 rounded-lg transition-colors',
177
+ isActive('/dashboard/boards')
178
+ ? 'text-primary'
179
+ : 'text-muted-foreground'
180
+ )}
181
+ >
182
+ <LayoutGrid className="w-5 h-5" />
183
+ <span className="text-[10px] font-medium">Boards</span>
184
+ </button>
185
+ </SheetTrigger>
186
+ <SheetContent side="bottom" className="h-[80vh] rounded-t-2xl">
187
+ <SheetHeader className="pb-4">
188
+ <SheetTitle className="flex items-center justify-between">
189
+ <span>Your Boards</span>
190
+ {canCreateBoard && (
191
+ <Button
192
+ size="sm"
193
+ onClick={() => setCreateDialogOpen(true)}
194
+ >
195
+ <Plus className="w-4 h-4 mr-1" />
196
+ New
197
+ </Button>
198
+ )}
199
+ </SheetTitle>
200
+ </SheetHeader>
201
+
202
+ <div className="overflow-y-auto flex-1 -mx-6 px-6">
203
+ {isLoading ? (
204
+ <div className="flex items-center justify-center py-12">
205
+ <Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
206
+ </div>
207
+ ) : boards.length === 0 ? (
208
+ <div className="text-center py-12">
209
+ <div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mx-auto mb-4">
210
+ <Kanban className="w-8 h-8 text-muted-foreground" />
211
+ </div>
212
+ <p className="text-muted-foreground mb-4">
213
+ {canCreateBoard ? 'No boards yet' : 'No boards available'}
214
+ </p>
215
+ {canCreateBoard && (
216
+ <Button onClick={() => setCreateDialogOpen(true)}>
217
+ <Plus className="w-4 h-4 mr-2" />
218
+ Create Board
219
+ </Button>
220
+ )}
221
+ </div>
222
+ ) : (
223
+ <div className="space-y-1">
224
+ {boards.map((board) => {
225
+ const colorClass = getColorClass(board.color)
226
+ const isCurrent = currentBoardId === board.id
227
+
228
+ return (
229
+ <Link
230
+ key={board.id}
231
+ href={`/dashboard/boards/${board.id}`}
232
+ onClick={() => setBoardsSheetOpen(false)}
233
+ className={cn(
234
+ 'flex items-center gap-3 p-3 rounded-xl transition-all',
235
+ isCurrent
236
+ ? 'bg-primary/10 text-primary'
237
+ : 'hover:bg-muted'
238
+ )}
239
+ >
240
+ <div className={cn('w-4 h-4 rounded', colorClass)} />
241
+ <span className="flex-1 font-medium">{board.name}</span>
242
+ {isCurrent && <Check className="w-4 h-4" />}
243
+ </Link>
244
+ )
245
+ })}
246
+ </div>
247
+ )}
248
+
249
+ {/* View All Link */}
250
+ {boards.length > 0 && (
251
+ <div className="mt-4 pt-4 border-t">
252
+ <Link
253
+ href="/dashboard/boards"
254
+ onClick={() => setBoardsSheetOpen(false)}
255
+ className="flex items-center justify-between p-3 rounded-xl hover:bg-muted text-muted-foreground"
256
+ >
257
+ <span>View All Boards</span>
258
+ <ChevronRight className="w-4 h-4" />
259
+ </Link>
260
+ </div>
261
+ )}
262
+ </div>
263
+ </SheetContent>
264
+ </Sheet>
265
+
266
+ {/* Create Button - Only shown for users with create permission */}
267
+ {canCreateBoard ? (
268
+ <button
269
+ onClick={() => setCreateDialogOpen(true)}
270
+ className="flex items-center justify-center w-14 h-14 -mt-6 rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30 transition-transform active:scale-95"
271
+ >
272
+ <Plus className="w-7 h-7" />
273
+ </button>
274
+ ) : (
275
+ <div className="w-14" /> // Spacer to maintain layout
276
+ )}
277
+
278
+ {/* Teams Sheet - Only visible when canSwitch is true (multi-tenant/hybrid modes) */}
279
+ {canSwitch ? (
280
+ <Sheet open={teamsSheetOpen} onOpenChange={setTeamsSheetOpen}>
281
+ <SheetTrigger asChild>
282
+ <button
283
+ className="flex flex-col items-center gap-0.5 py-1 px-4 rounded-lg transition-colors text-muted-foreground"
284
+ >
285
+ <Users className="w-5 h-5" />
286
+ <span className="text-[10px] font-medium">Teams</span>
287
+ </button>
288
+ </SheetTrigger>
289
+ <SheetContent side="bottom" className="rounded-t-2xl">
290
+ <SheetHeader className="pb-4">
291
+ <SheetTitle>Switch Team</SheetTitle>
292
+ </SheetHeader>
293
+
294
+ <div className="space-y-1">
295
+ {userTeams.map((membership) => (
296
+ <button
297
+ key={membership.team.id}
298
+ onClick={() => handleTeamSwitch(membership.team.id)}
299
+ className={cn(
300
+ 'w-full flex items-center gap-3 p-3 rounded-xl transition-all text-left',
301
+ currentTeam?.id === membership.team.id
302
+ ? 'bg-primary/10 text-primary'
303
+ : 'hover:bg-muted'
304
+ )}
305
+ >
306
+ <div className="w-10 h-10 rounded-lg bg-muted flex items-center justify-center text-sm font-bold">
307
+ {getTeamInitials(membership.team.name)}
308
+ </div>
309
+ <span className="flex-1 font-medium">{membership.team.name}</span>
310
+ {currentTeam?.id === membership.team.id && (
311
+ <Check className="w-5 h-5" />
312
+ )}
313
+ </button>
314
+ ))}
315
+ </div>
316
+
317
+ <div className="mt-4 pt-4 border-t">
318
+ <Link
319
+ href="/dashboard/settings/teams"
320
+ onClick={() => setTeamsSheetOpen(false)}
321
+ className="flex items-center justify-between p-3 rounded-xl hover:bg-muted text-muted-foreground"
322
+ >
323
+ <span>Manage Teams</span>
324
+ <ChevronRight className="w-4 h-4" />
325
+ </Link>
326
+ </div>
327
+ </SheetContent>
328
+ </Sheet>
329
+ ) : (
330
+ // Settings link when team switching is disabled (single-user/collaborative/single-tenant)
331
+ <Link
332
+ href="/dashboard/settings"
333
+ className={cn(
334
+ 'flex flex-col items-center gap-0.5 py-1 px-4 rounded-lg transition-colors',
335
+ pathname.startsWith('/dashboard/settings')
336
+ ? 'text-primary'
337
+ : 'text-muted-foreground'
338
+ )}
339
+ >
340
+ <Settings className="w-5 h-5" />
341
+ <span className="text-[10px] font-medium">Settings</span>
342
+ </Link>
343
+ )}
344
+ </div>
345
+ </nav>
346
+
347
+ {/* Create Board Dialog */}
348
+ <Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
349
+ <DialogContent className="sm:max-w-md">
350
+ <DialogHeader>
351
+ <DialogTitle>Create new board</DialogTitle>
352
+ </DialogHeader>
353
+ <div className="space-y-4 pt-4">
354
+ <div className="space-y-2">
355
+ <Label htmlFor="mobile-board-name">Board name</Label>
356
+ <Input
357
+ id="mobile-board-name"
358
+ placeholder="My awesome board"
359
+ value={newBoardName}
360
+ onChange={(e) => setNewBoardName(e.target.value)}
361
+ onKeyDown={(e) => e.key === 'Enter' && handleCreateBoard()}
362
+ autoFocus
363
+ />
364
+ </div>
365
+ <div className="space-y-2">
366
+ <Label>Color</Label>
367
+ <div className="flex gap-2">
368
+ {BOARD_COLORS.map((c) => (
369
+ <button
370
+ key={c.id}
371
+ onClick={() => setNewBoardColor(c.id)}
372
+ className={cn(
373
+ 'w-9 h-9 rounded-lg transition-all',
374
+ c.class,
375
+ newBoardColor === c.id
376
+ ? 'ring-2 ring-offset-2 ring-primary scale-110'
377
+ : 'hover:scale-105'
378
+ )}
379
+ />
380
+ ))}
381
+ </div>
382
+ </div>
383
+ <div className="flex gap-2 pt-2">
384
+ <Button
385
+ variant="outline"
386
+ className="flex-1"
387
+ onClick={() => setCreateDialogOpen(false)}
388
+ >
389
+ Cancel
390
+ </Button>
391
+ <Button
392
+ className="flex-1"
393
+ onClick={handleCreateBoard}
394
+ disabled={!newBoardName.trim() || isCreating}
395
+ >
396
+ {isCreating ? (
397
+ <Loader2 className="w-4 h-4 animate-spin" />
398
+ ) : (
399
+ 'Create'
400
+ )}
401
+ </Button>
402
+ </div>
403
+ </div>
404
+ </DialogContent>
405
+ </Dialog>
406
+ </>
407
+ )
408
+ }
409
+
410
+ export default ProductivityMobileNav