@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,236 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Productivity Theme - Board Detail Page (Kanban View)
5
+ *
6
+ * Shows a board with its lists and cards in a Kanban layout.
7
+ */
8
+
9
+ import { useState, useEffect, use } from 'react'
10
+ import Link from 'next/link'
11
+ import { Button } from '@nextsparkjs/core/components/ui/button'
12
+ import { Badge } from '@nextsparkjs/core/components/ui/badge'
13
+ import { Skeleton } from '@nextsparkjs/core/components/ui/skeleton'
14
+ import {
15
+ DropdownMenu,
16
+ DropdownMenuContent,
17
+ DropdownMenuItem,
18
+ DropdownMenuSeparator,
19
+ DropdownMenuTrigger,
20
+ } from '@nextsparkjs/core/components/ui/dropdown-menu'
21
+ import { ArrowLeft, MoreVertical, Settings, Archive, Trash2 } from 'lucide-react'
22
+ import { KanbanBoard } from '@/themes/productivity/components/KanbanBoard'
23
+ import { PermissionGate } from '@nextsparkjs/core/components/permissions/PermissionGate'
24
+ import { useToast } from '@nextsparkjs/core/hooks/useToast'
25
+ import { useRouter } from 'next/navigation'
26
+
27
+ /**
28
+ * Get headers with x-team-id for API calls
29
+ */
30
+ function getTeamHeaders(): HeadersInit {
31
+ const headers: HeadersInit = {
32
+ 'Content-Type': 'application/json',
33
+ }
34
+ if (typeof window !== 'undefined') {
35
+ const teamId = localStorage.getItem('activeTeamId')
36
+ console.log('[BoardPage] activeTeamId from localStorage:', teamId)
37
+ if (teamId) {
38
+ headers['x-team-id'] = teamId
39
+ }
40
+ }
41
+ return headers
42
+ }
43
+
44
+ interface Board {
45
+ id: string
46
+ name: string
47
+ description?: string | null
48
+ color?: string | null
49
+ status?: string | null
50
+ }
51
+
52
+ interface PageProps {
53
+ params: Promise<{ id: string }>
54
+ }
55
+
56
+ export default function BoardDetailPage({ params }: PageProps) {
57
+ const resolvedParams = use(params)
58
+ const boardId = resolvedParams.id
59
+
60
+ const [board, setBoard] = useState<Board | null>(null)
61
+ const [isLoading, setIsLoading] = useState(true)
62
+ const { toast } = useToast()
63
+ const router = useRouter()
64
+
65
+ useEffect(() => {
66
+ const fetchBoard = async () => {
67
+ try {
68
+ setIsLoading(true)
69
+ const response = await fetch(`/api/v1/boards/${boardId}`, {
70
+ headers: getTeamHeaders(),
71
+ })
72
+ if (!response.ok) {
73
+ throw new Error('Board not found')
74
+ }
75
+ const data = await response.json()
76
+ setBoard(data.data || data)
77
+ } catch (error) {
78
+ console.error('Error fetching board:', error)
79
+ toast({
80
+ title: 'Error',
81
+ description: 'Could not load board.',
82
+ variant: 'destructive',
83
+ })
84
+ } finally {
85
+ setIsLoading(false)
86
+ }
87
+ }
88
+
89
+ fetchBoard()
90
+ // eslint-disable-next-line react-hooks/exhaustive-deps
91
+ }, [boardId])
92
+
93
+ const handleArchive = async () => {
94
+ try {
95
+ await fetch(`/api/v1/boards/${boardId}`, {
96
+ method: 'PATCH',
97
+ headers: getTeamHeaders(),
98
+ body: JSON.stringify({ status: 'archived' }),
99
+ })
100
+ toast({ title: 'Board archived' })
101
+ router.push('/dashboard/boards')
102
+ } catch (error) {
103
+ toast({
104
+ title: 'Error',
105
+ description: 'Could not archive board.',
106
+ variant: 'destructive',
107
+ })
108
+ }
109
+ }
110
+
111
+ const handleDelete = async () => {
112
+ if (!confirm('Are you sure you want to delete this board? All lists and cards will be deleted.')) {
113
+ return
114
+ }
115
+
116
+ try {
117
+ await fetch(`/api/v1/boards/${boardId}`, {
118
+ method: 'DELETE',
119
+ headers: getTeamHeaders(),
120
+ })
121
+ toast({ title: 'Board deleted' })
122
+ router.push('/dashboard/boards')
123
+ } catch (error) {
124
+ toast({
125
+ title: 'Error',
126
+ description: 'Could not delete board.',
127
+ variant: 'destructive',
128
+ })
129
+ }
130
+ }
131
+
132
+ if (isLoading) {
133
+ return (
134
+ <div className="h-full flex flex-col">
135
+ <div className="flex items-center justify-between p-4 border-b">
136
+ <div className="flex items-center gap-4">
137
+ <Skeleton className="h-8 w-8" />
138
+ <Skeleton className="h-6 w-48" />
139
+ </div>
140
+ <Skeleton className="h-8 w-8" />
141
+ </div>
142
+ <div className="flex-1 p-4">
143
+ <div className="flex gap-4">
144
+ {[1, 2, 3].map((i) => (
145
+ <Skeleton key={i} className="w-72 h-96" />
146
+ ))}
147
+ </div>
148
+ </div>
149
+ </div>
150
+ )
151
+ }
152
+
153
+ if (!board) {
154
+ return (
155
+ <div className="flex flex-col items-center justify-center h-full">
156
+ <h2 className="text-xl font-semibold mb-2">Board not found</h2>
157
+ <p className="text-muted-foreground mb-4">
158
+ This board may have been deleted or you don&apos;t have access.
159
+ </p>
160
+ <Link href="/dashboard/boards">
161
+ <Button variant="outline">
162
+ <ArrowLeft className="h-4 w-4 mr-2" />
163
+ Back to Boards
164
+ </Button>
165
+ </Link>
166
+ </div>
167
+ )
168
+ }
169
+
170
+ return (
171
+ <div className="h-full flex flex-col">
172
+ {/* Header */}
173
+ <div className="flex items-center justify-between px-4 py-3 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
174
+ <div className="flex items-center gap-4">
175
+ <Link href="/dashboard/boards">
176
+ <Button variant="ghost" size="sm">
177
+ <ArrowLeft className="h-4 w-4" />
178
+ </Button>
179
+ </Link>
180
+
181
+ <div>
182
+ <div className="flex items-center gap-2">
183
+ <h1 className="text-xl font-semibold">{board.name}</h1>
184
+ {board.status && board.status !== 'active' && (
185
+ <Badge variant="secondary">{board.status}</Badge>
186
+ )}
187
+ </div>
188
+ {board.description && (
189
+ <p className="text-sm text-muted-foreground">{board.description}</p>
190
+ )}
191
+ </div>
192
+ </div>
193
+
194
+ <DropdownMenu>
195
+ <DropdownMenuTrigger asChild>
196
+ <Button variant="ghost" size="sm">
197
+ <MoreVertical className="h-4 w-4" />
198
+ </Button>
199
+ </DropdownMenuTrigger>
200
+ <DropdownMenuContent align="end">
201
+ <PermissionGate permission="boards.update">
202
+ <DropdownMenuItem asChild>
203
+ <Link href={`/dashboard/boards/${boardId}/edit`}>
204
+ <Settings className="h-4 w-4 mr-2" />
205
+ Settings
206
+ </Link>
207
+ </DropdownMenuItem>
208
+ </PermissionGate>
209
+ <DropdownMenuSeparator />
210
+ <PermissionGate permission="boards.archive">
211
+ <DropdownMenuItem onClick={handleArchive}>
212
+ <Archive className="h-4 w-4 mr-2" />
213
+ Archive Board
214
+ </DropdownMenuItem>
215
+ </PermissionGate>
216
+ <PermissionGate permission="boards.delete">
217
+ <DropdownMenuItem
218
+ className="text-destructive"
219
+ onClick={handleDelete}
220
+ >
221
+ <Trash2 className="h-4 w-4 mr-2" />
222
+ Delete Board
223
+ </DropdownMenuItem>
224
+ </PermissionGate>
225
+ </DropdownMenuContent>
226
+ </DropdownMenu>
227
+ </div>
228
+
229
+ {/* Kanban Board */}
230
+ <div className="flex-1 overflow-hidden">
231
+ <KanbanBoard boardId={boardId} />
232
+ </div>
233
+ </div>
234
+ )
235
+ }
236
+
@@ -0,0 +1,236 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * Productivity Theme - Create Board Page
5
+ *
6
+ * Form for creating a new board.
7
+ */
8
+
9
+ import { useState } from 'react'
10
+ import Link from 'next/link'
11
+ import { useRouter } from 'next/navigation'
12
+ import { Card, CardContent, CardHeader, CardTitle } from '@nextsparkjs/core/components/ui/card'
13
+ import { Button } from '@nextsparkjs/core/components/ui/button'
14
+ import { Input } from '@nextsparkjs/core/components/ui/input'
15
+ import { Textarea } from '@nextsparkjs/core/components/ui/textarea'
16
+ import { Label } from '@nextsparkjs/core/components/ui/label'
17
+ import {
18
+ Select,
19
+ SelectContent,
20
+ SelectItem,
21
+ SelectTrigger,
22
+ SelectValue,
23
+ } from '@nextsparkjs/core/components/ui/select'
24
+ import { ArrowLeft, Loader2 } from 'lucide-react'
25
+ import { useToast } from '@nextsparkjs/core/hooks/useToast'
26
+ import { usePermission } from '@nextsparkjs/core/lib/permissions/hooks'
27
+ import { NoPermission } from '@nextsparkjs/core/components/permissions/NoPermission'
28
+
29
+ /**
30
+ * Get headers with x-team-id for API calls
31
+ */
32
+ function getTeamHeaders(): HeadersInit {
33
+ const headers: HeadersInit = {
34
+ 'Content-Type': 'application/json',
35
+ }
36
+ if (typeof window !== 'undefined') {
37
+ const teamId = localStorage.getItem('activeTeamId')
38
+ if (teamId) {
39
+ headers['x-team-id'] = teamId
40
+ }
41
+ }
42
+ return headers
43
+ }
44
+
45
+ const colorOptions = [
46
+ { value: 'blue', label: 'Blue', class: 'bg-blue-500' },
47
+ { value: 'green', label: 'Green', class: 'bg-green-500' },
48
+ { value: 'purple', label: 'Purple', class: 'bg-purple-500' },
49
+ { value: 'orange', label: 'Orange', class: 'bg-orange-500' },
50
+ { value: 'red', label: 'Red', class: 'bg-red-500' },
51
+ { value: 'pink', label: 'Pink', class: 'bg-pink-500' },
52
+ { value: 'gray', label: 'Gray', class: 'bg-gray-500' },
53
+ ]
54
+
55
+ export default function CreateBoardPage() {
56
+ const router = useRouter()
57
+ const { toast } = useToast()
58
+ const [isSubmitting, setIsSubmitting] = useState(false)
59
+ const [formData, setFormData] = useState({
60
+ name: '',
61
+ description: '',
62
+ color: 'blue',
63
+ })
64
+
65
+ // Permission check
66
+ const hasCreatePermission = usePermission('boards.create')
67
+
68
+ // If user doesn't have permission, show NoPermission component
69
+ if (hasCreatePermission === false) {
70
+ return (
71
+ <NoPermission
72
+ entityName="Board"
73
+ action="create"
74
+ />
75
+ )
76
+ }
77
+
78
+ const handleSubmit = async (e: React.FormEvent) => {
79
+ e.preventDefault()
80
+
81
+ if (!formData.name.trim()) {
82
+ toast({
83
+ title: 'Validation Error',
84
+ description: 'Board name is required.',
85
+ variant: 'destructive',
86
+ })
87
+ return
88
+ }
89
+
90
+ setIsSubmitting(true)
91
+
92
+ try {
93
+ const response = await fetch('/api/v1/boards', {
94
+ method: 'POST',
95
+ headers: getTeamHeaders(),
96
+ body: JSON.stringify(formData),
97
+ })
98
+
99
+ if (!response.ok) {
100
+ const error = await response.json()
101
+ throw new Error(error.message || 'Failed to create board')
102
+ }
103
+
104
+ const data = await response.json()
105
+ const boardId = data.data?.id || data.id
106
+
107
+ toast({ title: 'Board created successfully!' })
108
+ router.push(`/dashboard/boards/${boardId}`)
109
+ } catch (error) {
110
+ console.error('Error creating board:', error)
111
+ toast({
112
+ title: 'Error',
113
+ description: error instanceof Error ? error.message : 'Could not create board.',
114
+ variant: 'destructive',
115
+ })
116
+ } finally {
117
+ setIsSubmitting(false)
118
+ }
119
+ }
120
+
121
+ return (
122
+ <div className="p-6 max-w-2xl mx-auto" data-cy="boards-create-page">
123
+ {/* Header */}
124
+ <div className="flex items-center gap-4 mb-6">
125
+ <Link href="/dashboard/boards">
126
+ <Button variant="ghost" size="sm">
127
+ <ArrowLeft className="h-4 w-4" />
128
+ </Button>
129
+ </Link>
130
+ <div>
131
+ <h1 className="text-2xl font-bold">Create Board</h1>
132
+ <p className="text-muted-foreground text-sm">
133
+ Set up a new board to organize your work
134
+ </p>
135
+ </div>
136
+ </div>
137
+
138
+ {/* Form */}
139
+ <Card>
140
+ <CardHeader>
141
+ <CardTitle>Board Details</CardTitle>
142
+ </CardHeader>
143
+ <CardContent>
144
+ <form onSubmit={handleSubmit} className="space-y-6" data-cy="boards-form">
145
+ {/* Name */}
146
+ <div className="space-y-2">
147
+ <Label htmlFor="name">Name *</Label>
148
+ <Input
149
+ id="name"
150
+ placeholder="Enter board name..."
151
+ value={formData.name}
152
+ onChange={(e) => setFormData({ ...formData, name: e.target.value })}
153
+ disabled={isSubmitting}
154
+ autoFocus
155
+ data-cy="boards-field-name"
156
+ />
157
+ </div>
158
+
159
+ {/* Description */}
160
+ <div className="space-y-2">
161
+ <Label htmlFor="description">Description</Label>
162
+ <Textarea
163
+ id="description"
164
+ placeholder="Describe what this board is for..."
165
+ value={formData.description}
166
+ onChange={(e) => setFormData({ ...formData, description: e.target.value })}
167
+ disabled={isSubmitting}
168
+ rows={3}
169
+ data-cy="boards-field-description"
170
+ />
171
+ </div>
172
+
173
+ {/* Color */}
174
+ <div className="space-y-2">
175
+ <Label htmlFor="color">Color</Label>
176
+ <Select
177
+ value={formData.color}
178
+ onValueChange={(value) => setFormData({ ...formData, color: value })}
179
+ disabled={isSubmitting}
180
+ >
181
+ <SelectTrigger id="color" data-cy="boards-field-color">
182
+ <SelectValue placeholder="Select a color" />
183
+ </SelectTrigger>
184
+ <SelectContent>
185
+ {colorOptions.map((color) => (
186
+ <SelectItem key={color.value} value={color.value}>
187
+ <div className="flex items-center gap-2">
188
+ <div className={`w-4 h-4 rounded ${color.class}`} />
189
+ {color.label}
190
+ </div>
191
+ </SelectItem>
192
+ ))}
193
+ </SelectContent>
194
+ </Select>
195
+ </div>
196
+
197
+ {/* Color Preview */}
198
+ <div className="space-y-2">
199
+ <Label>Preview</Label>
200
+ <div className="border rounded-lg overflow-hidden">
201
+ <div
202
+ className={`h-2 ${colorOptions.find(c => c.value === formData.color)?.class || 'bg-blue-500'}`}
203
+ />
204
+ <div className="p-4 bg-muted/30">
205
+ <p className="font-medium">{formData.name || 'Board Name'}</p>
206
+ {formData.description && (
207
+ <p className="text-sm text-muted-foreground mt-1">{formData.description}</p>
208
+ )}
209
+ </div>
210
+ </div>
211
+ </div>
212
+
213
+ {/* Actions */}
214
+ <div className="flex items-center gap-4 pt-4">
215
+ <Button type="submit" disabled={isSubmitting} data-cy="boards-form-submit">
216
+ {isSubmitting ? (
217
+ <>
218
+ <Loader2 className="h-4 w-4 mr-2 animate-spin" />
219
+ Creating...
220
+ </>
221
+ ) : (
222
+ 'Create Board'
223
+ )}
224
+ </Button>
225
+ <Link href="/dashboard/boards">
226
+ <Button type="button" variant="outline" disabled={isSubmitting} data-cy="boards-form-cancel">
227
+ Cancel
228
+ </Button>
229
+ </Link>
230
+ </div>
231
+ </form>
232
+ </CardContent>
233
+ </Card>
234
+ </div>
235
+ )
236
+ }